[
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: \"[BUG] Enter a short bug description here\"\nlabels: bug\nassignees: fdev31\n\n---\n\n**Pyprland version**\nWhich version did you use? (copy & paste the string returned by `pypr version`)\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Configuration (provide following files/samples when relevant):**\n- pyprland.toml\n- hyprland.conf\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: \"[FEAT] Description of the feature\"\nlabels: enhancement\nassignees: fdev31\n\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/wiki_improvement.md",
    "content": "---\nname: Wiki improvement\nabout: Suggest a fix or improvement in the documentation\ntitle: \"[WIKI] Description of the problem\"\nlabels: documentation\nassignees: fdev31\n\n---\n\n**Is your feature request related to a problem or an improvement? Please describe.**\nA clear and concise description of what the problem is. Ex. There is a link broken in...\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE/pull_request_template.md",
    "content": "---\nname: Default PR template\nabout: Improve the code\ntitle: \"Change description here\"\n---\n\n# Description of the pull request content & goal\n\nIn order to ... I have implemented ...\n\n# Relevant wiki content to be added/updated\n\n## In \"Wiki page XXX\"\n\nAdd configuration details for ...eg:\n...\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: github-actions\n    directory: \"/\"\n    schedule:\n      interval: weekly\n      time: \"01:00\"\n    open-pull-requests-limit: 10\n    reviewers:\n      - NotAShelf\n      - fdev31\n    assignees:\n      - NotAShelf\n      - fdev31\n"
  },
  {
    "path": ".github/workflows/aur.yml",
    "content": "name: AUR Package Validation\n\non:\n  workflow_dispatch:\n  pull_request:\n    paths:\n      - \"pyprland/**\"\n      - \"pyproject.toml\"\n      - \"uv.lock\"\n      - \"client/**\"\n      - \".github/workflows/aur.yml\"\n  push:\n    paths:\n      - \"pyprland/**\"\n      - \"pyproject.toml\"\n      - \"uv.lock\"\n      - \"client/**\"\n      - \".github/workflows/aur.yml\"\n\njobs:\n  makepkg:\n    runs-on: ubuntu-latest\n    container:\n      image: archlinux:latest\n    steps:\n      - name: Install base dependencies\n        run: pacman -Syu --noconfirm base-devel git python python-build python-installer python-hatchling python-aiofiles python-aiohttp python-pillow gcc\n\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n\n      - name: Fetch PKGBUILD from AUR\n        run: |\n          mkdir -p /tmp/build\n          curl -sL \"https://aur.archlinux.org/cgit/aur.git/plain/PKGBUILD?h=pyprland-git\" -o /tmp/build/PKGBUILD\n\n      - name: Patch PKGBUILD to use local source\n        run: |\n          # Point the PKGBUILD source at the checked-out repo instead of remote\n          sed -i \"s|source=(git+https://github.com/fdev31/pyprland#branch=main)|source=(\\\"git+file://${GITHUB_WORKSPACE}\\\")|\" /tmp/build/PKGBUILD\n          # Show the patched PKGBUILD for debugging\n          cat /tmp/build/PKGBUILD\n\n      - name: Build and install with makepkg\n        run: |\n          # makepkg refuses to run as root, create a builder user\n          useradd -m builder\n          chown -R builder:builder /tmp/build\n          # Allow builder to install packages via pacman\n          echo \"builder ALL=(ALL) NOPASSWD: ALL\" >> /etc/sudoers\n          # git needs to trust the workspace\n          git config --global --add safe.directory \"${GITHUB_WORKSPACE}\"\n          su builder -c \"git config --global --add safe.directory '${GITHUB_WORKSPACE}'\"\n          cd /tmp/build\n          su builder -c \"makepkg -si --noconfirm\"\n\n      - name: Smoke test\n        run: |\n          which pypr\n          which pypr-client\n          mkdir -p ~/.config/hypr\n          echo -e '[pyprland]\\nplugins = []' > ~/.config/hypr/pyprland.toml\n          pypr validate\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "---\nname: CI\n\non: [push]\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        python-version: [\"3.11\", \"3.12\", \"3.13\", \"3.14\"]\n    steps:\n      - uses: actions/checkout@v6\n      - name: Set up uv\n        uses: astral-sh/setup-uv@v7\n        with:\n          python-version: ${{ matrix.python-version }}\n      - name: Finish environment setup\n        run: |\n          mkdir ~/.config/hypr/\n          touch ~/.config/hypr/pyprland.toml\n      - name: Install dependencies\n        run: uv sync --all-groups\n      - name: Run tests\n        run: |\n          uv run ./scripts/generate_plugin_docs.py\n          uv run pytest -q tests\n      - name: Check wiki docs\n        if: matrix.python-version == '3.14'\n        run: |\n          uv run ./scripts/generate_plugin_docs.py\n          uv run ./scripts/check_plugin_docs.py\n"
  },
  {
    "path": ".github/workflows/nix-setup.yml",
    "content": "# This is a re-usable workflow that is used by .github/workflows/check.yml to handle necessary setup\n# before running Nix commands. E.g. this will install Nix and set up Magic Nix Cache\nname: \"Nix Setup\"\n\non:\n  workflow_call:\n    inputs:\n      command:\n        required: false\n        type: string\n      platform:\n        default: \"ubuntu\"\n        required: false\n        type: string\n    secrets:\n      GH_TOKEN:\n        required: true\n\njobs:\n  nix:\n    runs-on: \"${{ inputs.platform }}-latest\"\n    steps:\n      - name: \"Set default git branch (to reduce log spam)\"\n        run: git config --global init.defaultBranch main\n\n      - name: \"Checkout\"\n        uses: actions/checkout@v6\n        with:\n          token: \"${{ secrets.GH_TOKEN }}\"\n\n      - name: \"Set up QEMU support\"\n        uses: docker/setup-qemu-action@v4\n        with:\n          platforms: arm64\n\n      - name: \"Install nix\"\n        uses: cachix/install-nix-action@master\n        with:\n          install_url: https://nixos.org/nix/install\n          extra_nix_config: |\n            experimental-features = nix-command flakes fetch-tree\n            allow-import-from-derivation = false\n            extra-platforms = aarch64-linux\n\n      - name: \"Cachix Setup\"\n        uses: cachix/cachix-action@v17\n        with:\n          authToken: ${{ secrets.CACHIX_TOKEN }}\n          name: hyprland-community\n\n      - name: \"Nix Magic Cache\"\n        uses: DeterminateSystems/magic-nix-cache-action@main\n\n      - name: \"Run Input: ${{ inputs.command }}\"\n        run: \"${{ inputs.command }}\"\n"
  },
  {
    "path": ".github/workflows/nix.yml",
    "content": "name: Nix Flake Validation\n\non:\n  workflow_dispatch:\n  pull_request:\n    paths:\n      - \"pyprland/**\"\n      - \"**.nix\"\n      - \"**.lock\"\n      - \".github/workflows/check.yml\"\n  push:\n    paths:\n      - \"pyprland/**\"\n      - \"**.nix\"\n      - \"**.lock\"\n      - \".github/workflows/check.yml\"\n\njobs:\n  check:\n    uses: ./.github/workflows/nix-setup.yml\n    secrets:\n      GH_TOKEN: \"${{ secrets.GITHUB_TOKEN }}\"\n    with:\n      command: nix flake check --accept-flake-config --print-build-logs\n\n  build:\n    needs: [check]\n    uses: ./.github/workflows/nix-setup.yml\n    strategy:\n      matrix:\n        package:\n          - pyprland\n    secrets:\n      GH_TOKEN: \"${{ secrets.GITHUB_TOKEN }}\"\n    with:\n      command: nix build .#${{ matrix.package }} --print-build-logs\n"
  },
  {
    "path": ".github/workflows/site.yml",
    "content": "# Sample workflow for building and deploying a VitePress site to GitHub Pages\n#\nname: Website deployment\n\non:\n  # Runs on pushes targeting the `main` branch. Change this to `master` if you're\n  # using the `master` branch as the default branch.\n  push:\n    branches: [main]\n\n  # Allows you to run this workflow manually from the Actions tab\n  workflow_dispatch:\n\n# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages\npermissions:\n  contents: read\n  pages: write\n  id-token: write\n\n# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.\n# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.\nconcurrency:\n  group: pages\n  cancel-in-progress: false\n\njobs:\n  # Build job\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0 # Not needed if lastUpdated is not enabled\n      # - uses: pnpm/action-setup@v3 # Uncomment this if you're using pnpm\n      # - uses: oven-sh/setup-bun@v1 # Uncomment this if you're using Bun\n      - name: Setup Node\n        uses: actions/setup-node@v6\n        with:\n          node-version: 20\n          cache: npm # or pnpm / yarn\n      - name: Setup Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: '3.14'\n      - name: Install Python dependencies\n        run: pip install .\n      - name: Setup Pages\n        uses: actions/configure-pages@v6\n      - name: Install dependencies\n        run: npm ci # or pnpm install / yarn install / bun install\n      - name: Generate plugin documentation JSON\n        run: python scripts/generate_plugin_docs.py\n      - name: Build with VitePress\n        run: npm run docs:build # or pnpm docs:build / yarn docs:build / bun run docs:build\n      - name: Upload artifact\n        uses: actions/upload-pages-artifact@v4\n        with:\n          path: site/.vitepress/dist\n\n  # Deployment job\n  deploy:\n    environment:\n      name: github-pages\n      url: ${{ steps.deployment.outputs.page_url }}\n    needs: build\n    runs-on: ubuntu-latest\n    name: Deploy\n    steps:\n      - name: Deploy to GitHub Pages\n        id: deployment\n        uses: actions/deploy-pages@v5\n"
  },
  {
    "path": ".github/workflows/uv-install.yml",
    "content": "name: uv tool install Validation\n\non:\n  workflow_dispatch:\n  pull_request:\n    paths:\n      - \"pyprland/**\"\n      - \"pyproject.toml\"\n      - \"uv.lock\"\n      - \".github/workflows/uv-install.yml\"\n  push:\n    paths:\n      - \"pyprland/**\"\n      - \"pyproject.toml\"\n      - \"uv.lock\"\n      - \".github/workflows/uv-install.yml\"\n\njobs:\n  install:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        python-version: [\"3.11\", \"3.12\", \"3.13\", \"3.14\"]\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n\n      - name: Set up uv\n        uses: astral-sh/setup-uv@v7\n        with:\n          python-version: ${{ matrix.python-version }}\n\n      - name: Install with uv tool install\n        run: uv tool install .\n\n      - name: Smoke test\n        run: |\n          which pypr\n          mkdir -p ~/.config/hypr\n          echo -e '[pyprland]\\nplugins = []' > ~/.config/hypr/pyprland.toml\n          pypr validate\n"
  },
  {
    "path": ".gitignore",
    "content": "RELEASE_NOTES.md\n\n# Byte-compiled / optimized / DLL files\nsite/.vitepress/cache/\nsite/.vitepress/dist/\nnode_modules/\n__pycache__/\n*.py[cod]\n*$py.class\n\n# Generated documentation JSON (regenerated at build time)\nsite/generated/*.json\n\n# stale merges\n*.orig\n\n# random crap\n*.out\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.nox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n*.py,cover\n.hypothesis/\n.pytest_cache/\ncover/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\ndb.sqlite3\ndb.sqlite3-journal\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\n.pybuilder/\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# IPython\nprofile_default/\nipython_config.py\n\n# pyenv\n#   For a library or package, you might want to ignore these files since the code is\n#   intended to run in multiple environments; otherwise, check them in:\n# .python-version\n\n# pipenv\n#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.\n#   However, in case of collaboration, if having platform-specific dependencies or dependencies\n#   having no cross-platform support, pipenv may install dependencies that don't work, or not\n#   install all needed dependencies.\n#Pipfile.lock\n\n# poetry\n#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.\n#   This is especially recommended for binary packages to ensure reproducibility, and is more\n#   commonly ignored for libraries.\n#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control\n#poetry.lock\n\n# pdm\n#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.\n#pdm.lock\n#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it\n#   in version control.\n#   https://pdm.fming.dev/#use-with-ide\n.pdm.toml\n\n# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm\n__pypackages__/\n\n# Celery stuff\ncelerybeat-schedule\ncelerybeat.pid\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n.env\n.venv\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mypy\n.mypy_cache/\n.dmypy.json\ndmypy.json\n\n# Pyre type checker\n.pyre/\n\n# pytype static type analyzer\n.pytype/\n\n# Cython debug symbols\ncython_debug/\n\n# Nix\nresult\n\n# Local development files\nOLD/\n\n# AI assistant / editor tool files\n.opencode/\nCODEBASE_OVERVIEW.md\nDEVELOPMENT_GUIDELINES.md\n\n# One-time migration scripts\nsite/migration/\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "---\nrepos:\n  - repo: local\n    hooks:\n      - id: versionMgmt\n        name: Increment the version number\n        entry: ./scripts/update_version\n        language: system\n        pass_filenames: false\n      - id: wikiDocGen\n        name: Generate wiki docs\n        entry: ./scripts/generate_plugin_docs.py\n        language: system\n        pass_filenames: false\n      - id: wikiDocCheck\n        name: Check wiki docs coverage\n        entry: python scripts/check_plugin_docs.py\n        language: system\n        pass_filenames: false\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    # Ruff version.\n    rev: \"v0.14.14\"\n    hooks:\n      # Run the linter.\n      - id: ruff-check\n      - id: ruff-format\n  - repo: https://github.com/pre-commit/pre-commit-hooks\n    rev: \"v6.0.0\"\n    hooks:\n      - id: check-yaml\n      - id: check-json\n      - id: pretty-format-json\n        args: ['--autofix', '--no-sort-keys']\n  - repo: https://github.com/PyCQA/flake8\n    rev: 7.3.0\n    hooks:\n      - id: flake8\n        files: ^pyprland/\n        args: [--max-line-length=140]\n  - repo: https://github.com/lovesegfault/beautysh\n    rev: \"v6.4.2\"\n    hooks:\n      - id: beautysh\n  - repo: https://github.com/adrienverge/yamllint\n    rev: \"v1.38.0\"\n    hooks:\n      - id: yamllint\n\n  - repo: local\n    hooks:\n      - id: runtests\n        name: Run pytest\n        entry: uv run pytest tests\n        pass_filenames: false\n        language: system\n        stages: [pre-push]\n\n#        types: [python]\n#        pass_filenames: false\n"
  },
  {
    "path": ".pylintrc",
    "content": "[MASTER]\n\n# Specify a configuration file.\n#rcfile=\n\n# Python code to execute, usually for sys.path manipulation such as\n# pygtk.require().\n#init-hook=\n\n\n# Add <file or directory> to the black list. It should be a base name, not a\n# path. You may set this option multiple times.\nignore=.hg\n\n# Pickle collected data for later comparisons.\npersistent=yes\n\n# List of plugins (as comma separated values of python modules names) to load,\n# usually to register additional checkers.\nload-plugins=\n\n\n[MESSAGES CONTROL]\n\n# Enable the message, report, category or checker with the given id(s). You can\n# either give multiple identifier separated by comma (,) or put this option\n# multiple time.\n#enable=\n\n# Disable the message, report, category or checker with the given id(s). You\n# can either give multiple identifier separated by comma (,) or put this option\n# multiple time (only on the command line, not in the configuration file where\n# it should appear only once).\ndisable=R0903,W0603,yield-inside-async-function,unnecessary-ellipsis\n\n\n[REPORTS]\n\n# Set the output format. Available formats are text, parseable, colorized, msvs\n# (visual studio) and html\noutput-format=text\n# Tells whether to display a full report or only the messages\nreports=yes\n\n# Python expression which should return a note less than 10 (10 is the highest\n# note). You have access to the variables errors warning, statement which\n# respectively contain the number of errors / warnings messages and the total\n# number of statements analyzed. This is used by the global evaluation report\n# (R0004).\nevaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)\n\n\n\n[BASIC]\n\n\n# Regular expression which should only match correct module names\nmodule-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$\n\n# Regular expression which should only match correct module level names\nconst-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__)|log(_.*)?)$\n\n# Regular expression which should only match correct class names\nclass-rgx=[A-Z_][a-zA-Z0-9]+$\n\n# Regular expression which should only match correct function names\nfunction-rgx=[a-z_][a-zA-Z0-9_]{2,30}$\n\n# Regular expression which should only match correct method names\nmethod-rgx=[a-z_][a-zA-Z0-9_]{2,30}$\n\n# Regular expression which should only match correct instance attribute names\nattr-rgx=[a-z_][a-z0-9_]{2,30}$\n\n# Regular expression which should only match correct argument names\nargument-rgx=[a-z_][a-z0-9_]{2,30}$\n\n# Regular expression which should only match correct variable names\nvariable-rgx=[a-z_][a-z0-9_]{,30}$\n\n# Regular expression which should only match correct list comprehension /\n# generator expression variable names\ninlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$\n\n# Good variable names which should always be accepted, separated by a comma\ngood-names=i,j,k,ex,Run,_\n\n# Bad variable names which should always be refused, separated by a comma\nbad-names=foo,bar,baz,toto,tutu,tata,pdb\n\n# Regular expression which should only match functions or classes name which do\n# not require a docstring\nno-docstring-rgx=__.*__\n\n\n[FORMAT]\n\n# Maximum number of characters on a single line.\nmax-line-length=140\n\n# Maximum number of lines in a module\nmax-module-lines=1000\n\n# String used as indentation unit. This is usually \" \" (4 spaces) or \"\\t\" (1\n# tab).\nindent-string='    '\n\n\n[MISCELLANEOUS]\n\n# List of note tags to take in consideration, separated by a comma.\nnotes=FIXME,XXX,TODO\n\n\n[SIMILARITIES]\n\n# Minimum lines number of a similarity.\nmin-similarity-lines=4\n\n# Ignore comments when computing similarities.\nignore-comments=yes\n\n# Ignore docstrings when computing similarities.\nignore-docstrings=yes\n\n\n[TYPECHECK]\n\n# Tells whether missing members accessed in mixin class should be ignored. A\n# mixin class is detected if its name ends with \"mixin\" (case insensitive).\nignore-mixin-members=yes\n\n# List of classes names for which member attributes should not be checked\n# (useful for classes with attributes dynamically set).\nignored-classes=SQLObject\n\n\n# List of members which are set dynamically and missed by pylint inference\n# system, and so shouldn't trigger E0201 when accessed.\ngenerated-members=REQUEST,acl_users,aq_parent\n\n\n[VARIABLES]\n\n# Tells whether we should check for unused import in __init__ files.\ninit-import=no\n\n# A regular expression matching names used for dummy variables (i.e. not used).\ndummy-variables-rgx=_|dummy\n\n# List of additional names supposed to be defined in builtins. Remember that\n# you should avoid to define new builtins when possible.\n#additional-builtins=\nadditional-builtins = _,DBG\n\n\n[CLASSES]\n\n# List of method names used to declare (i.e. assign) instance attributes.\ndefining-attr-methods=__init__,__new__,setUp\n\n\n[DESIGN]\n\n# Maximum number of arguments for function / method\nmax-args=7\n\n# Argument names that match this expression will be ignored. Default to name\n# with leading underscore\nignored-argument-names=_.*\n\n# Maximum number of locals for function / method body\nmax-locals=15\n\n# Maximum number of return / yield for function / method body\nmax-returns=6\n\n# Maximum number of statements in function / method body\nmax-statements=50\n\n# Maximum number of parents for a class (see R0901).\nmax-parents=7\n\n# Maximum number of attributes for a class (see R0902).\nmax-attributes=10\n\n# Minimum number of public methods for a class (see R0903).\nmin-public-methods=2\n\n# Maximum number of public methods for a class (see R0904).\nmax-public-methods=24\n\n\n[IMPORTS]\n\n# Deprecated modules which should not be used, separated by a comma\ndeprecated-modules=regsub,string,TERMIOS,Bastion,rexec\n\n# Create a graph of every (i.e. internal and external) dependencies in the\n# given file (report RP0402 must not be disabled)\nimport-graph=\n\n# Create a graph of external dependencies in the given file (report RP0402 must\n# not be disabled)\next-import-graph=\n\n# Create a graph of internal dependencies in the given file (report RP0402 must\n# not be disabled)\nint-import-graph=\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participation in our\ncommunity a harassment-free experience for everyone, regardless of age, body\nsize, visible or invisible disability, ethnicity, sex characteristics, gender\nidentity and expression, level of experience, education, socio-economic status,\nnationality, personal appearance, race, religion, or sexual identity\nand orientation.\n\nWe pledge to act and interact in ways that contribute to an open, welcoming,\ndiverse, inclusive, and healthy community.\n\n## Our Standards\n\nExamples of behavior that contributes to a positive environment for our\ncommunity include:\n\n* Demonstrating empathy and kindness toward other people\n* Being respectful of differing opinions, viewpoints, and experiences\n* Giving and gracefully accepting constructive feedback\n* Accepting responsibility and apologizing to those affected by our mistakes,\n  and learning from the experience\n* Focusing on what is best not just for us as individuals, but for the\n  overall community\n\nExamples of unacceptable behavior include:\n\n* The use of sexualized language or imagery, and sexual attention or\n  advances of any kind\n* Trolling, insulting or derogatory comments, and personal or political attacks\n* Public or private harassment\n* Publishing others' private information, such as a physical or email\n  address, without their explicit permission\n* Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Enforcement Responsibilities\n\nCommunity leaders are responsible for clarifying and enforcing our standards of\nacceptable behavior and will take appropriate and fair corrective action in\nresponse to any behavior that they deem inappropriate, threatening, offensive,\nor harmful.\n\nCommunity leaders have the right and responsibility to remove, edit, or reject\ncomments, commits, code, wiki edits, issues, and other contributions that are\nnot aligned to this Code of Conduct, and will communicate reasons for moderation\ndecisions when appropriate.\n\n## Scope\n\nThis Code of Conduct applies within all community spaces, and also applies when\nan individual is officially representing the community in public spaces.\nExamples of representing our community include using an official e-mail address,\nposting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported to the community leaders responsible for enforcement at\nfdev31@gmail.com.\nAll complaints will be reviewed and investigated promptly and fairly.\n\nAll community leaders are obligated to respect the privacy and security of the\nreporter of any incident.\n\n## Enforcement Guidelines\n\nCommunity leaders will follow these Community Impact Guidelines in determining\nthe consequences for any action they deem in violation of this Code of Conduct:\n\n### 1. Correction\n\n**Community Impact**: Use of inappropriate language or other behavior deemed\nunprofessional or unwelcome in the community.\n\n**Consequence**: A private, written warning from community leaders, providing\nclarity around the nature of the violation and an explanation of why the\nbehavior was inappropriate. A public apology may be requested.\n\n### 2. Warning\n\n**Community Impact**: A violation through a single incident or series\nof actions.\n\n**Consequence**: A warning with consequences for continued behavior. No\ninteraction with the people involved, including unsolicited interaction with\nthose enforcing the Code of Conduct, for a specified period of time. This\nincludes avoiding interactions in community spaces as well as external channels\nlike social media. Violating these terms may lead to a temporary or\npermanent ban.\n\n### 3. Temporary Ban\n\n**Community Impact**: A serious violation of community standards, including\nsustained inappropriate behavior.\n\n**Consequence**: A temporary ban from any sort of interaction or public\ncommunication with the community for a specified period of time. No public or\nprivate interaction with the people involved, including unsolicited interaction\nwith those enforcing the Code of Conduct, is allowed during this period.\nViolating these terms may lead to a permanent ban.\n\n### 4. Permanent Ban\n\n**Community Impact**: Demonstrating a pattern of violation of community\nstandards, including sustained inappropriate behavior,  harassment of an\nindividual, or aggression toward or disparagement of classes of individuals.\n\n**Consequence**: A permanent ban from any sort of public interaction within\nthe community.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage],\nversion 2.0, available at\nhttps://www.contributor-covenant.org/version/2/0/code_of_conduct.html.\n\nCommunity Impact Guidelines were inspired by [Mozilla's code of conduct\nenforcement ladder](https://github.com/mozilla/diversity).\n\n[homepage]: https://www.contributor-covenant.org\n\nFor answers to common questions about this code of conduct, see the FAQ at\nhttps://www.contributor-covenant.org/faq. Translations are available at\nhttps://www.contributor-covenant.org/translations.\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "- ensure everything works when you run `tox`\n- use `ruff format` to format the code\n- provide documentation to be added to the wiki when relevant\n- provide some *tests* when possible\n- ensure you read https://github.com/hyprland-community/pyprland/wiki/Development\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2023 Fabien Devaux\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "![rect](https://github.com/hyprland-community/pyprland/assets/238622/3fab93b6-6445-4e7b-b757-035095b5c8e8)\n\n[![Hyprland](https://img.shields.io/badge/Made%20for-Hyprland-blue)](https://github.com/hyprwm/Hyprland)\n[![Discord](https://img.shields.io/discord/1055990214411169892?label=discord)](https://discord.com/channels/1458202721294356522/1458202722892386519)\n\n[![Documentation](https://img.shields.io/badge/Documentation-Read%20Now-brightgreen?style=for-the-badge)](https://hyprland-community.github.io/pyprland)\n\n[Discussions](https://github.com/hyprland-community/pyprland/discussions) • [Plugins](https://hyprland-community.github.io/pyprland/Plugins.html) • [Dotfiles](https://github.com/fdev31/dotfiles) • [Changes History](https://github.com/hyprland-community/pyprland/releases) • [Share](https://github.com/hyprland-community/pyprland/discussions/46)\n\n## Power up your desktop\n\nA plugin system that extends your graphical environment with features like scratchpads, dynamic popup nested menus, custom notifications, easy monitor settings and more.\n\nThink of it as a *Gnome tweak tool* for Hyprland, with options that can run on any desktop. With a fully plugin-based architecture, it's lightweight and easy to customize.\n\nContributions, suggestions, bug reports and comments are welcome.\n\n<details>\n<summary>\nAbout Pyprland (latest stable is: <b>3.3.1</b>)\n</summary>\n\n[![Packaging Status](https://repology.org/badge/vertical-allrepos/pyprland.svg)](https://repology.org/project/pyprland/versions)\n\n🎉 Hear what others are saying:\n\n- [Elsa in Mac](https://elsainmac.tistory.com/915) some tutorial article for fedora in Korean with a nice short demo video\n- [Archlinux Hyprland dotfiles](https://github.com/DinDotDout/.dotfiles/blob/main/conf-hyprland/.config/hypr/pyprland.toml) + [video](https://www.youtube.com/watch?v=jHuzcjf-FGM)\n- [\"It just works very very well\" - The Linux Cast (video)](https://youtu.be/Cjn0SFyyucY?si=hGb0TM9IDvlbcD6A&t=131) - February 2024\n- [You NEED This in your Hyprland Config - LibrePhoenix (video)](https://www.youtube.com/watch?v=CwGlm-rpok4) - October 2023 (*Now [TOML](https://toml.io/en/) format is preferred over [JSON](https://www.w3schools.com/js/js_json_intro.asp))\n\n</details>\n\n<details>\n\n<summary>\nContributing\n</summary>\n\nCheck out the [creating a pull request](https://docs.github.com/fr/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request) document for guidance.\n\n- Report bugs or propose features [here](https://github.com/hyprland-community/pyprland/issues)\n- Improve our [wiki](https://hyprland-community.github.io/pyprland/)\n- Read the [internal ticket list](https://github.com/hyprland-community/pyprland/blob/main/tickets.rst) for some PR ideas\n\nand if you have coding skills you can also\n\n- Enhance the coverage of our [tests](https://github.com/hyprland-community/pyprland/tree/main/tests)\n- Propose & write new plugins or enhancements\n\n</details>\n\n<details>\n<summary>\nDependencies\n</summary>\n\n- **Python** >= 3.11\n    - **aiofiles** (optional but recommended)\n    - **pillow** (optional, required for rounded borders in `wallpapers`)\n</details>\n\n<details>\n<summary>\nLatest major changes\n</summary>\n\nCheck the [Releases change log](https://github.com/hyprland-community/pyprland/releases) for more information\n\n### 3.0.0\n\n- Dynamic shell completions\n- Better error handling and configuration validation\n- Removed hard dependency on Hyprland\n- General polish including a couple ofbreaking changes\n  - remove old or broken options\n  - fixes\n\n### 2.5\n\n- wallpapers plugin refactored, supports rounded corners and pause\n- fcitx5 switcher plugin (appeared in late 2.4)\n\n### 2.4\n\n- Scratchpads are now pinned by default (set `pinned = false` for the old behavior)\n- Version >=2.4.4 is required for Hyprland 0.48.0\n- A snappier `pypr-client` command is available, meant to be used in the keyboard bindings (NOT to start pypr on startup!), eg:\n```sh\n$pypr = uwsm-app -- pypr-client\nbind = $mainMod SHIFT, Z, exec, $pypr zoom ++0.5\n ```\n\n### 2.3\n\n- Supports *Hyprland > 0.40.0*\n- Improved code kwaleetee\n- [monitors](https://hyprland-community.github.io/pyprland/monitors) allows general monitor settings\n- [scratchpads](https://hyprland-community.github.io/pyprland/scratchpads)\n  - better multi-window support\n  - better `preserve_aspect` implementation (i3 \"compatibility\")\n\n### 2.2\n\n- Added [wallpapers](https://hyprland-community.github.io/pyprland/wallpapers) and [system_notifier](https://hyprland-community.github.io/pyprland/system_notifier) plugins.\n- Deprecated [class_match](https://hyprland-community.github.io/pyprland/scratchpads_nonstandard) in [scratchpads](https://hyprland-community.github.io/pyprland/scratchpads)\n- Added [gbar](https://hyprland-community.github.io/pyprland/gbar) in 2.2.6\n- [scratchpads](https://hyprland-community.github.io/pyprland/scratchpads) supports multiple client windows (using 2.2.19 is recommended)\n- [monitors](https://hyprland-community.github.io/pyprland/monitors) and [scratchpads](https://hyprland-community.github.io/pyprland/scratchpads) supports rotation in 2.2.13\n- Improve [Nix support](https://hyprland-community.github.io/pyprland/Nix)\n\n### 2.1\n\n- Requires Hyprland >= 0.37\n- [Monitors](https://hyprland-community.github.io/pyprland/monitors) plugin improvements.\n\n### 2.0\n\n- New dependency: [aiofiles](https://pypi.org/project/aiofiles/)\n- Added [hysteresis](https://hyprland-community.github.io/pyprland/scratchpads#hysteresis-optional) support for [scratchpads](https://hyprland-community.github.io/pyprland/scratchpads).\n\n### 1.10\n\n- New [fetch_client_menu](https://hyprland-community.github.io/pyprland/fetch_client_menu) and [shortcuts_menu](https://hyprland-community.github.io/pyprland/shortcuts_menu) plugins.\n\n### 1.9\n\n- Introduced [shortcuts_menu](https://hyprland-community.github.io/pyprland/shortcuts_menu) plugin.\n\n### 1.8\n\n- Requires Hyprland >= 0.30\n- Added [layout_center](https://hyprland-community.github.io/pyprland/layout_center) plugin.\n\n</details>\n\n<a href=\"https://star-history.com/#fdev31/pyprland&Date\">\n  <picture>\n    <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/svg?repos=fdev31/pyprland&type=Timeline&theme=dark\" />\n    <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/svg?repos=fdev31/pyprland&type=Timeline\" />\n    <img alt=\"Star History Chart\" src=\"https://api.star-history.com/svg?repos=fdev31/pyprland&type=Timeline\" />\n  </picture>\n</a>\n"
  },
  {
    "path": "client/pypr-client.c",
    "content": "#include <stdio.h>\n#include <stdlib.h>\n#include <string.h>\n#include <unistd.h>\n#include <sys/socket.h>\n#include <sys/un.h>\n#include <libgen.h>\n\n// Exit codes matching pyprland/models.py ExitCode\n#define EXIT_SUCCESS_CODE 0\n#define EXIT_USAGE_ERROR 1\n#define EXIT_ENV_ERROR 2\n#define EXIT_CONNECTION_ERROR 3\n#define EXIT_COMMAND_ERROR 4\n\n// Response prefixes\n#define RESPONSE_OK \"OK\"\n#define RESPONSE_ERROR \"ERROR\"\n\nint main(int argc, char *argv[]) {\n    // If no argument passed, show usage\n    if (argc < 2) {\n        fprintf(stderr, \"No command provided.\\n\");\n        fprintf(stderr, \"Usage: pypr <command> [args...]\\n\");\n        fprintf(stderr, \"Try 'pypr help' for available commands.\\n\");\n        exit(EXIT_USAGE_ERROR);\n    }\n\n    // Get environment variables for socket path detection\n    const char *runtimeDir = getenv(\"XDG_RUNTIME_DIR\");\n    const char *signature = getenv(\"HYPRLAND_INSTANCE_SIGNATURE\");\n    const char *niriSocket = getenv(\"NIRI_SOCKET\");\n    const char *dataHome = getenv(\"XDG_DATA_HOME\");\n    const char *home = getenv(\"HOME\");\n\n    // Construct the socket path based on environment priority: Hyprland > Niri > Standalone\n    char socketPath[256];\n    int pathLen;\n\n    if (signature != NULL && runtimeDir != NULL) {\n        // Hyprland environment\n        pathLen = snprintf(socketPath, sizeof(socketPath), \"%s/hypr/%s/.pyprland.sock\", runtimeDir, signature);\n    } else if (niriSocket != NULL) {\n        // Niri environment - use dirname of NIRI_SOCKET\n        char *niriSocketCopy = strdup(niriSocket);\n        if (niriSocketCopy == NULL) {\n            fprintf(stderr, \"Error: Memory allocation failed.\\n\");\n            exit(EXIT_ENV_ERROR);\n        }\n        char *niriDir = dirname(niriSocketCopy);\n        pathLen = snprintf(socketPath, sizeof(socketPath), \"%s/.pyprland.sock\", niriDir);\n        free(niriSocketCopy);\n    } else {\n        // Standalone fallback - use XDG_DATA_HOME or ~/.local/share\n        if (dataHome != NULL) {\n            pathLen = snprintf(socketPath, sizeof(socketPath), \"%s/.pyprland.sock\", dataHome);\n        } else if (home != NULL) {\n            pathLen = snprintf(socketPath, sizeof(socketPath), \"%s/.local/share/.pyprland.sock\", home);\n        } else {\n            fprintf(stderr, \"Error: Cannot determine socket path. HOME not set.\\n\");\n            exit(EXIT_ENV_ERROR);\n        }\n    }\n\n    if (pathLen >= (int)sizeof(socketPath)) {\n        fprintf(stderr, \"Error: Socket path too long (max %zu characters).\\n\", sizeof(socketPath) - 1);\n        exit(EXIT_ENV_ERROR);\n    }\n\n    // Connect to the Unix socket\n    int sockfd = socket(AF_UNIX, SOCK_STREAM, 0);\n    if (sockfd < 0) {\n        fprintf(stderr, \"Error: Failed to create socket.\\n\");\n        exit(EXIT_CONNECTION_ERROR);\n    }\n\n    struct sockaddr_un addr;\n    memset(&addr, 0, sizeof(addr));\n    addr.sun_family = AF_UNIX;\n    strncpy(addr.sun_path, socketPath, sizeof(addr.sun_path) - 1);\n\n    if (connect(sockfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {\n        fprintf(stderr, \"Cannot connect to pyprland daemon at %s.\\n\", socketPath);\n        fprintf(stderr, \"Is the daemon running? Start it with: pypr (no arguments)\\n\");\n        close(sockfd);\n        exit(EXIT_CONNECTION_ERROR);\n    }\n\n    // Concatenate all command-line arguments with spaces, plus newline\n    char message[1024] = {0};\n    int offset = 0;\n    for (int i = 1; i < argc; i++) {\n        int remaining = sizeof(message) - offset - 2; // Reserve space for \\n and \\0\n        if (remaining <= 0) {\n            fprintf(stderr, \"Error: Command too long (max %zu characters).\\n\", sizeof(message) - 2);\n            close(sockfd);\n            exit(EXIT_USAGE_ERROR);\n        }\n        int written = snprintf(message + offset, remaining + 1, \"%s\", argv[i]);\n        if (written > remaining) {\n            fprintf(stderr, \"Error: Command too long (max %zu characters).\\n\", sizeof(message) - 2);\n            close(sockfd);\n            exit(EXIT_USAGE_ERROR);\n        }\n        offset += written;\n        if (i < argc - 1) {\n            if (offset < (int)sizeof(message) - 2) {\n                message[offset++] = ' ';\n            }\n        }\n    }\n    // Add newline for protocol\n    message[offset++] = '\\n';\n    message[offset] = '\\0';\n\n    // Send the message to the socket\n    if (write(sockfd, message, strlen(message)) < 0) {\n        fprintf(stderr, \"Error: Failed to send command to daemon.\\n\");\n        close(sockfd);\n        exit(EXIT_CONNECTION_ERROR);\n    }\n\n    // send EOF to indicate end of message\n    if (shutdown(sockfd, SHUT_WR) < 0) {\n        fprintf(stderr, \"Error: Failed to complete command transmission.\\n\");\n        close(sockfd);\n        exit(EXIT_CONNECTION_ERROR);\n    }\n\n    // Read the response from the socket until EOF\n    char buffer[4096];\n    char response[65536] = {0};\n    size_t totalRead = 0;\n    ssize_t bytesRead;\n\n    while ((bytesRead = read(sockfd, buffer, sizeof(buffer) - 1)) > 0) {\n        if (totalRead + bytesRead >= sizeof(response) - 1) {\n            // Response too large, just print what we have\n            buffer[bytesRead] = '\\0';\n            printf(\"%s\", buffer);\n        } else {\n            memcpy(response + totalRead, buffer, bytesRead);\n            totalRead += bytesRead;\n        }\n    }\n    response[totalRead] = '\\0';\n\n    close(sockfd);\n\n    // Parse response and determine exit code\n    int exitCode = EXIT_SUCCESS_CODE;\n\n    if (strncmp(response, RESPONSE_ERROR \":\", strlen(RESPONSE_ERROR \":\")) == 0) {\n        // Error response - extract message after \"ERROR: \"\n        const char *errorMsg = response + strlen(RESPONSE_ERROR \": \");\n        // Trim trailing whitespace\n        size_t len = strlen(errorMsg);\n        while (len > 0 && (errorMsg[len-1] == '\\n' || errorMsg[len-1] == ' ')) {\n            len--;\n        }\n        fprintf(stderr, \"Error: %.*s\\n\", (int)len, errorMsg);\n        exitCode = EXIT_COMMAND_ERROR;\n    } else if (strncmp(response, RESPONSE_OK, strlen(RESPONSE_OK)) == 0) {\n        // OK response - check for additional output after \"OK\"\n        const char *remaining = response + strlen(RESPONSE_OK);\n        // Skip whitespace/newlines\n        while (*remaining == ' ' || *remaining == '\\n') {\n            remaining++;\n        }\n        if (*remaining != '\\0') {\n            printf(\"%s\", remaining);\n        }\n        exitCode = EXIT_SUCCESS_CODE;\n    } else if (totalRead > 0) {\n        // Legacy response (version, help, dumpjson) - print as-is\n        // Trim trailing newlines for cleaner output\n        while (totalRead > 0 && response[totalRead-1] == '\\n') {\n            totalRead--;\n        }\n        response[totalRead] = '\\0';\n        if (totalRead > 0) {\n            printf(\"%s\\n\", response);\n        }\n        exitCode = EXIT_SUCCESS_CODE;\n    }\n\n    return exitCode;\n}\n"
  },
  {
    "path": "client/pypr-client.rs",
    "content": "use std::env;\nuse std::io::{Read, Write};\nuse std::os::unix::net::UnixStream;\nuse std::process::exit;\n\n// Exit codes matching pyprland/models.py ExitCode\nconst EXIT_SUCCESS: i32 = 0;\nconst EXIT_USAGE_ERROR: i32 = 1;\nconst EXIT_ENV_ERROR: i32 = 2;\nconst EXIT_CONNECTION_ERROR: i32 = 3;\nconst EXIT_COMMAND_ERROR: i32 = 4;\n\nfn run() -> Result<(), i32> {\n    // Collect arguments (skip program name)\n    let args: Vec<String> = env::args().skip(1).collect();\n\n    if args.is_empty() {\n        eprintln!(\"No command provided.\");\n        eprintln!(\"Usage: pypr <command> [args...]\");\n        eprintln!(\"Try 'pypr help' for available commands.\");\n        return Err(EXIT_USAGE_ERROR);\n    }\n\n    // Build command message\n    let message = format!(\"{}\\n\", args.join(\" \"));\n\n    if message.len() > 1024 {\n        eprintln!(\"Error: Command too long (max 1022 characters).\");\n        return Err(EXIT_USAGE_ERROR);\n    }\n\n    // Get socket path from environment\n    let runtime_dir = env::var(\"XDG_RUNTIME_DIR\").map_err(|_| {\n        eprintln!(\"Environment error: XDG_RUNTIME_DIR or HYPRLAND_INSTANCE_SIGNATURE not set.\");\n        eprintln!(\"Are you running under Hyprland?\");\n        EXIT_ENV_ERROR\n    })?;\n\n    let signature = env::var(\"HYPRLAND_INSTANCE_SIGNATURE\").map_err(|_| {\n        eprintln!(\"Environment error: XDG_RUNTIME_DIR or HYPRLAND_INSTANCE_SIGNATURE not set.\");\n        eprintln!(\"Are you running under Hyprland?\");\n        EXIT_ENV_ERROR\n    })?;\n\n    let socket_path = format!(\"{}/hypr/{}/.pyprland.sock\", runtime_dir, signature);\n\n    if socket_path.len() >= 256 {\n        eprintln!(\"Error: Socket path too long (max 255 characters).\");\n        return Err(EXIT_ENV_ERROR);\n    }\n\n    // Connect to Unix socket\n    let mut stream = UnixStream::connect(&socket_path).map_err(|_| {\n        eprintln!(\"Cannot connect to pyprland daemon at {}.\", socket_path);\n        eprintln!(\"Is the daemon running? Start it with: pypr (no arguments)\");\n        EXIT_CONNECTION_ERROR\n    })?;\n\n    // Send command\n    stream.write_all(message.as_bytes()).map_err(|_| {\n        eprintln!(\"Error: Failed to send command to daemon.\");\n        EXIT_CONNECTION_ERROR\n    })?;\n\n    // Signal end of message\n    stream.shutdown(std::net::Shutdown::Write).map_err(|_| {\n        eprintln!(\"Error: Failed to complete command transmission.\");\n        EXIT_CONNECTION_ERROR\n    })?;\n\n    // Read response\n    let mut response = String::new();\n    stream.read_to_string(&mut response).map_err(|_| {\n        eprintln!(\"Error: Failed to read response from daemon.\");\n        EXIT_CONNECTION_ERROR\n    })?;\n\n    // Parse response and determine exit code\n    if let Some(error_msg) = response.strip_prefix(\"ERROR: \") {\n        eprintln!(\"Error: {}\", error_msg.trim_end());\n        Err(EXIT_COMMAND_ERROR)\n    } else if let Some(rest) = response.strip_prefix(\"OK\") {\n        // Print any content after \"OK\" (skip leading whitespace/newlines)\n        let output = rest.trim_start();\n        if !output.is_empty() {\n            print!(\"{}\", output);\n        }\n        Ok(())\n    } else if !response.is_empty() {\n        // Legacy response (version, help, dumpjson) - print as-is\n        println!(\"{}\", response.trim_end_matches('\\n'));\n        Ok(())\n    } else {\n        Ok(())\n    }\n}\n\nfn main() {\n    exit(run().err().unwrap_or(EXIT_SUCCESS));\n}\n"
  },
  {
    "path": "client/pypr-rs/Cargo.toml",
    "content": "[package]\nname = \"pypr-client\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[profile.release]\nopt-level = \"z\"\nlto = true\ncodegen-units = 1\npanic = \"abort\"\nstrip = true\n"
  },
  {
    "path": "default.nix",
    "content": "(\n  import\n  (\n    let\n      lock = builtins.fromJSON (builtins.readFile ./flake.lock);\n      nodeName = lock.nodes.root.inputs.flake-compat;\n    in\n      fetchTarball {\n        url = lock.nodes.${nodeName}.locked.url or \"https://github.com/hyprland-community/pyprland/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz\";\n        sha256 = lock.nodes.${nodeName}.locked.narHash;\n      }\n  )\n  {src = ./.;}\n)\n"
  },
  {
    "path": "done.rst",
    "content": "scratchpads: attach / detach only attaches !\n============================================\n\n:bugid: 56\n:created: 2026-1-11T22:10:17\n:fixed: 2026-01-19T22:03:48\n:priority: 0\n\n--------------------------------------------------------------------------------\n\nHyprpaper integration\n=====================\n\n:bugid: 56\n:created: 2025-12-16T21:08:03\n:fixed: 2025-12-16T21:44:08\n:priority: 0\n\nUse the socket $XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.hyprpaper.sock directly if no command is provided in \"wallpapers\"\n\n--------------------------------------------------------------------------------\n\nWiki: remove optional and add mandatory (to the titles for the configuration options)\n=====================================================================================\n\n:bugid: 52\n:created: 2024-07-08T21:51:28\n:fixed: 2025-08-07T21:27:38\n:priority: 0\n\n--------------------------------------------------------------------------------\n\nimprove groups support\n======================\n\n:bugid: 37\n:created: 2024-04-15T00:27:52\n:fixed: 2024-07-08T21:54:09\n:priority: 0\n\nInstead of making it in \"layout_center\" by lack of choice, refactor:\n\n- make run_command return a code compatible with shell (0 = success, < 0 = error)\n- by default it returns 0\n\nelse: Add it to \"layout_center\" overriding prev & next\n\nif grouped, toggle over groups, when at the limit, really changes the focus\n\nOption: think about a \"chaining\" in handlers, (eg: \"pypr groups prev OR layout_center prev\") in case of a separate plugin called \"groups\"\n\n--------------------------------------------------------------------------------\n\nAdd a fallback to aiofiles (removes one dependency)\n===================================================\n\n:bugid: 49\n:created: 2024-06-02T01:41:33\n:fixed: 2024-07-08T21:51:40\n:priority: 0\n\n--------------------------------------------------------------------------------\n\nmonitors: allow \"screen descr\".transform = 3\n============================================\n\n:bugid: 48\n:created: 2024-05-07T00:50:34\n:fixed: 2024-05-23T20:56:53\n:priority: 0\n\nalso allow `.scale = <something>`\n\n--------------------------------------------------------------------------------\n\nreview CanceledError handling\n=============================\n\n:bugid: 38\n:created: 2024-04-17T23:24:13\n:fixed: 2024-05-16T20:38:37\n:priority: 0\n:timer: 20\n\n--------------------------------------------------------------------------------\n\nAdd \"satellite\" scratchpads\n===========================\n\n:bugid: 36\n:created: 2024-04-08T23:42:26\n:fixed: 2024-05-16T20:38:18\n:priority: 0\n\n- add a \"scratch\" command that sets the focused window into the currently focused scratchpad window\n\nEg: open a terminal, hover it + \"scratch\" it while a scratchpad is open.\nBehind the hood, it creates attached \"ghost scratchpads\" for each attached window. They use \"perserve_aspect\" by default.\n\n**Alternative**\n\nMove focused client into the named scratchpad's special workspace.\nRework pyprland's scratchpad to keep track of every window added to the special workspace and attach it to the last used scratch then hide it if the scratchpad is hidden.\nIf called on a scratchpad window, will \"de-attach\" this window.\n\nEvery attached window should be synchronized with the main one.\n\n\n**Option**\n\nPrepare / Simplify this dev by adding support for \"ScratchGroups\" (contains multiple Scratches which are synchronized).\nWould generalize the current feature: passing multiple scratches to the toggle command.\n\n--------------------------------------------------------------------------------\n\noffset & margin: support % and px units\n=======================================\n\n:bugid: 33\n:created: 2024-03-08T00:07:02\n:fixed: 2024-05-16T20:38:09\n:priority: 0\n\n--------------------------------------------------------------------------------\n\nscratchpads: experiment handling manual scratchpad workspace change\n===================================================================\n\n:bugid: 47\n:created: 2024-05-01T23:38:51\n:fixed: 2024-05-16T20:37:54\n:priority: 0\n\n--------------------------------------------------------------------------------\n\nCheck behavior of monitors when no match is found\n=================================================\n\n:bugid: 42\n:created: 2024-04-26T00:26:22\n:fixed: 2024-05-16T20:37:32\n:priority: 0\n\nShould ignore applying any rule\n\n--------------------------------------------------------------------------------\n\nCHECK / fix multi-monitor & attach command\n==========================================\n\n:bugid: 40\n:created: 2024-04-23T22:01:39\n:fixed: 2024-05-16T20:36:40\n:priority: 0\n\n--------------------------------------------------------------------------------\n\nReview smart_focus when toggling on a special workspace\n=======================================================\n\n:bugid: 43\n:created: 2024-04-27T18:25:47\n:fixed: 2024-05-16T20:36:26\n:priority: 0\n:timer: 20\n\n--------------------------------------------------------------------------------\n\nRe-introduce focus tracking with a twist\n========================================\n\n:bugid: 41\n:created: 2024-04-25T23:54:53\n:fixed: 2024-05-01T23:42:00\n:priority: 0\n\nOnly enable it if the focuse changed the active workspace\n\n--------------------------------------------------------------------------------\n\nTESTS: ensure commands are completed (push the proper events in the queue)\n==========================================================================\n\n:bugid: 27\n:created: 2024-02-29T23:30:02\n:fixed: 2024-05-01T23:40:05\n:priority: 0\n\n--------------------------------------------------------------------------------\n\nAdd a command to update config\n==============================\n\n:bugid: 22\n:created: 2024-02-18T17:53:17\n:fixed: 2024-05-01T23:39:56\n:priority: 0\n\ncfg_set and cfg_toggle commands\neg::\n\n  pypr cfg_toggle scratchpads.term.unfocus (toggles will toggle strings to \"\" and back - keeping a memory)\n\n--------------------------------------------------------------------------------\n\nRework focus\n============\n\n:bugid: 45\n:created: 2024-04-29T00:01:27\n:fixed: 2024-05-01T23:39:44\n:priority: 0\n\n\nSave workspace before hide,\nwhen hide is done, after processing some events (use a task), focus the workspace again\n\n--------------------------------------------------------------------------------\n\nAUR: add zsh completion file\n============================\n\n:bugid: 44\n:created: 2024-04-27T23:54:28\n:fixed: 2024-05-01T23:38:57\n:priority: 0\n\n--------------------------------------------------------------------------------\n\n2.1 ?\n=====\n\n:bugid: 35\n:created: 2024-03-08T00:22:35\n:fixed: 2024-04-09T21:28:26\n:priority: 0\n\n- lazy = true\n- positions in % and px (defaults to px if no unit is provided)\n- #34 done\n- #33 done\n- VISUAL REGRESSION TESTS\n\n--------------------------------------------------------------------------------\n\nMake an \"system_notifier\" plugin\n================================\n\n:bugid: 21\n:created: 2024-02-16T00:16:11\n:fixed: 2024-04-08T19:58:46\n:priority: 0\n\nReads journalctl -fxn and notifies some errors,\nuser can use some patterns to match additional errors\nand create their own notifications\n\n> Started, works but better examples are needed\n\n\n.. code:: toml\n\n    [system_notifier]\n    builtin_rules = true\n\n    [[system_notifier.source]]\n    name = \"kernel\"\n    source = \"sudo journalctl -fkn\"\n    duration = 10\n    rules = [\n        {match=\"xxx\", filter=[\"s/foobar//\", \"s/meow/plop/g\"], message=\"bad\"},\n        {contains=\"xxx\", filter=[\"s/foobar//\", \"s/meow/plop/g\"], message=\"xxx happened [orig] [filtered]\"},\n    ]\n\n    [[system_notifier.source]]\n    name = \"user journal\"\n    source = \"journalctl -fxn --user\"\n    rules = [\n        {match=\"Consumed \\d+.?\\d*s CPU time\", filter=\"s/.*: //\", message=\"[filtered]\"},\n        {match=\"xxx\", filter=[\"s/foobar//\", \"s/meow/plop/g\"], message=\"bad\"},\n        {contains=\"xxx\", filter=[\"s/foobar//\", \"s/meow/plop/g\"], message=\"xxx happened [orig] [filtered]\"},\n    ]\n\n    [[system_notifier.source]]\n    name = \"user journal\"\n    source = \"sudo journalctl -fxn\"\n    rules = [\n        {match=\"systemd-networkd\\[\\d+\\]: ([a-z0-9]+): Link (DOWN|UP)\", filter=\"s/.*: ([a-z0-9]+): Link (DOWN|UP)/\\1 \\2/\", message=\"[filtered]\"}\n        {match=\"wireplumber[1831]: Failure in Bluetooth audio transport \"}\n        {match=\"usb 7-1: Product: USB2.0 Hub\", message=\"detected\"}\n        {match=\"févr. 02 17:30:24 gamix systemd-coredump[11872]: [🡕] Process 11801 (tracker-extract) of user 1000 dumped core.\"}\n    ]\n\n    [[system_notifier.source]]\n    name = \"Hyprland\"\n    source = \"/tmp/pypr.log\"\n    duration = 10\n    rules = [\n        {message=\"[orig]\"},\n    ]\n\n    [[system_notifier.source]]\n    name = \"networkd\"\n\n--------------------------------------------------------------------------------\n\npreserve_aspect to manage multi-screen setups\n=============================================\n\n:bugid: 30\n:created: 2024-03-04T22:21:41\n:fixed: 2024-04-08T19:58:23\n:priority: 0\n\n--------------------------------------------------------------------------------\n\noffset computation (hide anim) rework\n=====================================\n\n:bugid: 34\n:created: 2024-03-08T00:11:31\n:fixed: 2024-03-08T21:35:57\n:priority: 0\n\nuse animation type + margin to do a reverse computation of the placement (out of screen)\n\n--------------------------------------------------------------------------------\n\nset preserve_aspect=true by default\n===================================\n\n:bugid: 32\n:created: 2024-03-06T23:28:41\n:fixed: 2024-03-06T23:29:33\n:priority: 0\n\nalso add a command \"scratch reset <uid>\" to set active scratch position and size according to the rules.\nCan support ommitting the <uid>, requires tracking of the currently active scratch (or just check focused window)\n\n--------------------------------------------------------------------------------\n\npreserve_aspect should adapt to screen changes\n==============================================\n\n:bugid: 29\n:created: 2024-03-03T01:56:28\n:fixed: 2024-03-06T23:29:32\n:priority: 0\n\n--------------------------------------------------------------------------------\n\nBUG: preserve_aspect + offset = KO\n==================================\n\n:bugid: 31\n:created: 2024-03-05T00:22:34\n:fixed: 2024-03-06T23:29:21\n:priority: 0\n:tags: #bug\n\ntested on \"term\"\n\n--------------------------------------------------------------------------------\n\nscratchpad: per monitor overrides\n=================================\n\n:bugid: 9\n:created: 2023-12-02T21:53:48\n:fixed: 2024-03-02T15:30:25\n:priority: 10\n\n--------------------------------------------------------------------------------\n\nCheck for types (schema?) in the config\n=======================================\n\n:bugid: 24\n:created: 2024-02-21T00:50:34\n:fixed: 2024-03-02T15:30:15\n:priority: 0\n\nnotify an error in case type isn't matching\n\n--------------------------------------------------------------------------------\n\nMake \"replace links\" script\n===========================\n\n:bugid: 23\n:created: 2024-02-20T00:15:31\n:fixed: 2024-03-02T15:29:57\n:priority: 0\n\nReads a file (like RELEASE NOTES) and replace `links` with something in the wiki\nuses difflib to make the job\n\n--------------------------------------------------------------------------------\n\nHide / Show ALL command\n=======================\n\n:bugid: 10\n:created: 2023-12-26T21:48:36\n:fixed: 2024-02-29T23:30:14\n:priority: 10\n\nhide all command for scratchpad\n\n--------------------------------------------------------------------------------\n\nMake a get_bool() util function\n===============================\n\n:bugid: 25\n:created: 2024-02-21T23:34:39\n:fixed: 2024-02-29T23:30:12\n:priority: 10\n\n\nShould detect \"no\", \"False\", etc.. (strings) as being false\n\nmakes a notification warning, that it has been automatically fixed to `False`\n\n--------------------------------------------------------------------------------\n\nAdd \"exit\" command that exits cleanly (& removing the socket)\n=============================================================\n\n:bugid: 20\n:created: 2024-02-15T19:29:48\n:fixed: 2024-02-29T22:38:15\n:priority: 0\n\n--------------------------------------------------------------------------------\n\nscratchpads: autofloat=True\n===========================\n\n:bugid: 26\n:created: 2024-02-28T19:40:02\n:fixed: 2024-02-29T22:37:52\n:priority: 0\n\n\nAllows to disable the automatic float toggle when the scratch is opened\n"
  },
  {
    "path": "examples/README.md",
    "content": "# Contribute your configuration\n\n[Dotfiles](https://github.com/fdev31/dotfiles)\n[![Discord](https://img.shields.io/discord/1055990214411169892?label=discord)](https://discord.com/channels/1458202721294356522/1458202722892386519)\n\nMake a [pull request](https://github.com/hyprland-community/pyprland/compare) with your files or a link to your dotfiles, you can use `copy_conf.sh` to get a starting point.\n\n"
  },
  {
    "path": "examples/copy_conf.sh",
    "content": "#!/bin/sh\nif [ -z \"$1\" ]; then\n    echo -n \"config name: \"\n    read name\nelse\n    name=$1\nfi\n[ -d $name/hypr/ ] || mkdir $name/hypr/\nFILENAMES=(\"hyprland.conf\" \"pyprland.toml\")\nfor fname in ${FILENAMES[@]}; do\n    install -T ~/.config/hypr/$fname $name/hypr/$fname\ndone\n\n# recursively install the ~/config/hypr/pyprland.d folder into $name/hypr/pyprland.d\n# cp -r ~/.config/hypr/pyprland.d $name/hypr/\n\n# for fname in \"config\" \"style.scss\" ; do\n#     install -T ~/.config/gBar/$fname $name/hypr/gBar/$fname\n# done\n"
  },
  {
    "path": "flake.nix",
    "content": "{\n  inputs = {\n    nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable-small\";\n\n    # <https://github.com/pyproject-nix/pyproject.nix>\n    pyproject-nix = {\n      url = \"github:nix-community/pyproject.nix\";\n      inputs.nixpkgs.follows = \"nixpkgs\";\n    };\n\n    # <https://github.com/nix-systems/nix-systems>\n    systems.url = \"github:nix-systems/default-linux\";\n\n    # <https://github.com/edolstra/flake-compat>\n    flake-compat = {\n      url = \"github:edolstra/flake-compat\";\n      flake = false;\n    };\n  };\n\n  outputs =\n    {\n      self,\n      nixpkgs,\n      pyproject-nix,\n      systems,\n      ...\n    }:\n    let\n      eachSystem = nixpkgs.lib.genAttrs (import systems);\n      pkgsFor = eachSystem (system: nixpkgs.legacyPackages.${system});\n      project = pyproject-nix.lib.project.loadPyproject {\n        projectRoot = ./.;\n      };\n    in\n    {\n      packages = eachSystem (\n        system:\n        let\n          pkgs = pkgsFor.${system};\n          python = pkgs.python3;\n\n          attrs = project.renderers.buildPythonPackage { inherit python; };\n        in\n        {\n          default = self.packages.${system}.pyprland;\n          pyprland = python.pkgs.buildPythonPackage (\n            attrs\n            // {\n              nativeBuildInputs = (attrs.nativeBuildInputs or [ ]) ++ [\n                python.pkgs.hatchling\n                pkgs.stdenv.cc\n              ];\n              postInstall = ''\n                $CC -O2 -o $out/bin/pypr-client $src/client/pypr-client.c\n              '';\n            }\n          );\n        }\n      );\n\n      devShells = eachSystem (\n        system:\n        let\n          pkgs = pkgsFor.${system};\n          python = pkgs.python3;\n\n          getDependencies = project.renderers.withPackages { inherit python; };\n          pythonWithPackages = python.withPackages getDependencies;\n        in\n        {\n          default = self.devShells.${system}.pyprland;\n          pyprland = pkgs.mkShell {\n            packages = [\n              pythonWithPackages\n              pkgs.uv\n            ];\n\n          };\n        }\n      );\n    };\n\n  nixConfig = {\n    extra-substituters = [ \"https://hyprland-community.cachix.org\" ];\n    extra-trusted-public-keys = [\n      \"hyprland-community.cachix.org-1:5dTHY+TjAJjnQs23X+vwMQG4va7j+zmvkTKoYuSXnmE=\"\n    ];\n  };\n}\n"
  },
  {
    "path": "hatch_build.py",
    "content": "\"\"\"Custom hatch build hook to compile the optional C client.\n\nWhen the PYPRLAND_BUILD_NATIVE environment variable is set to \"1\", compiles\nclient/pypr-client.c into a statically-linked native binary and includes it\nin a platform-specific wheel tagged for manylinux_2_17_x86_64.\n\nWithout the env var the hook does nothing and the wheel stays pure-python.\n\"\"\"\n\nimport os\nimport shutil\nimport subprocess\nimport tempfile\nfrom pathlib import Path\n\nfrom hatchling.builders.hooks.plugin.interface import BuildHookInterface\n\n# The manylinux tag to use for the platform-specific wheel.\n# 2.17 corresponds to glibc 2.17 (CentOS 7) — effectively all modern Linux.\n# With static linking the binary has *no* glibc dependency, so this is safe.\nMANYLINUX_TAG = \"cp3-none-manylinux_2_17_x86_64\"\n\n\ndef _find_compiler() -> str:\n    \"\"\"Find a C compiler from CC env var or common names.\n\n    Returns:\n        The compiler command string, or empty string if none found.\n    \"\"\"\n    cc = os.environ.get(\"CC\", \"\")\n    if cc:\n        return cc\n    for candidate in (\"cc\", \"gcc\", \"clang\"):\n        if shutil.which(candidate):\n            return candidate\n    return \"\"\n\n\ndef _try_compile(cc: str, source: Path, *, static: bool = False) -> tuple[Path | None, str]:\n    \"\"\"Attempt to compile the C client.\n\n    Args:\n        cc: C compiler command.\n        source: Path to the C source file.\n        static: Whether to produce a statically-linked binary.\n\n    Returns:\n        Tuple of (output_path or None, warning message if failed).\n    \"\"\"\n    tmpdir = Path(tempfile.mkdtemp(prefix=\"pypr-build-\"))\n    output = tmpdir / \"pypr-client\"\n    cmd = [cc, \"-O2\"]\n    if static:\n        cmd.append(\"-static\")\n    cmd.extend([\"-o\", str(output), str(source)])\n\n    try:\n        result = subprocess.run(cmd, capture_output=True, text=True, timeout=60, check=False)  # noqa: S603\n    except FileNotFoundError:\n        return None, f\"C compiler '{cc}' not found. Skipping native client build.\"\n    except subprocess.TimeoutExpired:\n        return None, \"C client compilation timed out. Skipping native client build.\"\n\n    if result.returncode != 0:\n        return None, (\n            f\"C client compilation failed (exit {result.returncode}). Skipping native client build.\\nstderr: {result.stderr.strip()}\"\n        )\n\n    if not output.exists():\n        return None, \"Compiled binary not found after build. Skipping native client.\"\n\n    output.chmod(0o755)  # noqa: S103\n    return output, \"\"\n\n\nclass NativeClientBuildHook(BuildHookInterface):\n    \"\"\"Build hook that compiles the native C client.\"\"\"\n\n    PLUGIN_NAME = \"native-client\"\n\n    def initialize(self, version: str, build_data: dict) -> None:  # noqa: ARG002\n        \"\"\"Compile the C client and include it in the wheel if successful.\n\n        Only runs when PYPRLAND_BUILD_NATIVE=1 is set and the build target\n        is a wheel.  The resulting wheel is tagged as manylinux so it can be\n        uploaded to PyPI.\n        \"\"\"\n        if self.target_name != \"wheel\":\n            return\n\n        if os.environ.get(\"PYPRLAND_BUILD_NATIVE\") != \"1\":\n            return\n\n        source = Path(self.root) / \"client\" / \"pypr-client.c\"\n        if not source.exists():\n            self.app.display_warning(\"C client source not found, skipping native client build\")\n            return\n\n        cc = _find_compiler()\n        if not cc:\n            self.app.display_warning(\"No C compiler found (set CC env var or install gcc/clang). Skipping native pypr-client build.\")\n            return\n\n        self.app.display_info(f\"Compiling native client with {cc} (static)\")\n        output, warning = _try_compile(cc, source, static=True)\n\n        if output is None:\n            self.app.display_warning(warning)\n            return\n\n        self.app.display_success(\"Native pypr-client compiled successfully\")\n\n        # Use shared_scripts so hatchling generates the correct\n        # {name}-{version}.data/scripts/ path in the wheel (PEP 427).\n        build_data[\"shared_scripts\"][str(output)] = \"pypr-client\"\n\n        # Mark the wheel as platform-specific with an explicit manylinux tag.\n        build_data[\"pure_python\"] = False\n        build_data[\"tag\"] = MANYLINUX_TAG\n"
  },
  {
    "path": "justfile",
    "content": "# Run tests quickly\nquicktest:\n    uv run pytest -q tests\n\n# Run pytest with optional parameters\ndebug *params='tests':\n    uv run pytest --pdb -s {{params}}\n\n# Start the documentation website in dev mode\nwebsite: gendoc\n    npm i\n    npm run docs:dev\n\n# Run linting and dead code detection\nlint:\n    uv run mypy --install-types --non-interactive --check-untyped-defs pyprland\n    uv run ruff format pyprland\n    uv run ruff check --fix pyprland\n    uv run pylint -E pyprland\n    uv run flake8 pyprland\n    uv run vulture --ignore-names 'event_*,run_*,fromtop,frombottom,fromleft,fromright,instance' pyprland scripts/v_whitelist.py\n\n# Run version registry checks\nvreg:\n    uv run --group vreg ./tests/vreg/run_tests.sh\n\n# Build documentation\ndoc:\n    uv run pdoc --docformat google ./pyprland\n\n# Generate wiki pages\nwiki:\n    ./scripts/generate_plugin_docs.py\n    ./scripts/check_plugin_docs.py\n\n# Generate plugin documentation from source\ngendoc:\n    python scripts/generate_plugin_docs.py\n\n# Generate codebase overview from module docstrings\noverview:\n    python scripts/generate_codebase_overview.py\n\n# Archive documentation for a specific version (creates static snapshot)\narchive-docs version:\n    cd site && ./make_version.sh {{version}}\n\n# Create a new release\nrelease:\n    uv lock --upgrade\n    git add uv.lock\n    ./scripts/make_release\n\n# Generate and open HTML coverage report\nhtmlcov:\n    uv run coverage run --source=pyprland -m pytest tests -q\n    uv run coverage html\n    uv run coverage report\n    xdg-open ./htmlcov/index.html\n\n# Run mypy type checks on pyprland\ntypes:\n    uv run mypy --check-untyped-defs pyprland\n\n# Build C client - release (~17K)\ncompile-c-client:\n    gcc -O2 -o client/pypr-client client/pypr-client.c\n\n# Build C client - debug with symbols\ncompile-c-client-debug:\n    gcc -g -O0 -o client/pypr-client client/pypr-client.c\n\n# Build Rust client via Cargo - release with LTO (~312K)\ncompile-rust-client:\n    cargo build --release --manifest-path client/pypr-rs/Cargo.toml\n    cp client/pypr-rs/target/release/pypr-client client/pypr-client\n\n# Build Rust client via Cargo - debug\ncompile-rust-client-debug:\n    cargo build --manifest-path client/pypr-rs/Cargo.toml\n    cp client/pypr-rs/target/debug/pypr-client client/pypr-client\n\n# Build Rust client via rustc - release (~375K)\ncompile-rust-client-simple:\n    rustc -C opt-level=3 -C strip=symbols client/pypr-client.rs -o client/pypr-client\n\n# Build Rust client via rustc - debug\ncompile-rust-client-simple-debug:\n    rustc client/pypr-client.rs -o client/pypr-client\n\n# Build GUI frontend static assets\ngui-build:\n    cd pyprland/gui/frontend && npm install && npm run build\n\n# Start GUI frontend dev server (hot-reload) + backend API server\ngui-dev:\n    cd pyprland/gui/frontend && npm install && npm run dev &\n    uv run pypr-gui --port 8099 --no-browser\n\n# Launch the GUI (production mode — serves pre-built frontend)\ngui *args='':\n    uv run pypr-gui {{args}}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"scripts\": {\n    \"docs:dev\": \"vitepress dev site\",\n    \"docs:build\": \"vitepress build site\",\n    \"docs:preview\": \"vitepress preview site\"\n  },\n  \"devDependencies\": {\n    \"mermaid\": \"^11.12.2\",\n    \"vitepress\": \"^1.6.4\",\n    \"vitepress-plugin-mermaid\": \"^2.0.17\"\n  },\n  \"dependencies\": {\n    \"markdown-it\": \"^14.1.0\"\n  }\n}\n"
  },
  {
    "path": "pyprland/__init__.py",
    "content": "\"\"\"Pyprland - a companion application for Hyprland and other Wayland compositors.\n\nProvides a plugin-based architecture for extending window manager functionality\nwith features like scratchpads, workspace management, monitor control, and more.\nThe daemon runs as an asyncio service, communicating via Unix sockets.\n\"\"\"\n"
  },
  {
    "path": "pyprland/adapters/__init__.py",
    "content": "\"\"\"Backend adapters for compositor abstraction.\n\nThis package provides the EnvironmentBackend abstraction layer that allows\nPyprland to work with multiple compositors (Hyprland, Niri) and fallback\nenvironments (generic Wayland via wlr-randr, X11 via xrandr).\n\"\"\"\n\nfrom .proxy import BackendProxy\n\n__all__ = [\"BackendProxy\"]\n"
  },
  {
    "path": "pyprland/adapters/backend.py",
    "content": "\"\"\"Abstract base class defining the compositor backend interface.\n\nEnvironmentBackend defines the contract for all compositor backends:\n- Window operations (get_clients, focus, move, resize, close)\n- Monitor queries (get_monitors, get_monitor_props)\n- Command execution (execute, execute_batch, execute_json)\n- Notifications (notify, notify_info, notify_error)\n- Event parsing (parse_event)\n\nAll methods accept a 'log' parameter for traceability via BackendProxy.\n\"\"\"\n\nfrom abc import ABC, abstractmethod\nfrom collections.abc import Callable\nfrom logging import Logger\nfrom typing import Any\n\nfrom ..common import MINIMUM_ADDR_LEN, SharedState\nfrom ..models import ClientInfo, MonitorInfo\n\n\nclass EnvironmentBackend(ABC):\n    \"\"\"Abstract base class for environment backends (Hyprland, Niri, etc).\n\n    All methods that perform logging require a `log` parameter to be passed.\n    This allows the calling code (via BackendProxy) to inject the appropriate\n    logger for traceability.\n    \"\"\"\n\n    def __init__(self, state: SharedState) -> None:\n        \"\"\"Initialize the backend.\n\n        Args:\n            state: Shared state object\n        \"\"\"\n        self.state = state\n\n    @abstractmethod\n    async def get_clients(\n        self,\n        mapped: bool = True,\n        workspace: str | None = None,\n        workspace_bl: str | None = None,\n        *,\n        log: Logger,\n    ) -> list[ClientInfo]:\n        \"\"\"Return the list of clients, optionally filtered.\n\n        Args:\n            mapped: If True, only return mapped clients\n            workspace: Filter to this workspace name\n            workspace_bl: Blacklist this workspace name\n            log: Logger to use for this operation\n        \"\"\"\n\n    @abstractmethod\n    async def get_monitors(self, *, log: Logger, include_disabled: bool = False) -> list[MonitorInfo]:\n        \"\"\"Return the list of monitors.\n\n        Args:\n            log: Logger to use for this operation\n            include_disabled: If True, include disabled monitors (Hyprland only)\n        \"\"\"\n\n    @abstractmethod\n    def parse_event(self, raw_data: str, *, log: Logger) -> tuple[str, Any] | None:\n        \"\"\"Parse a raw event string into (event_name, event_data).\n\n        Args:\n            raw_data: Raw event string from the compositor\n            log: Logger to use for this operation\n        \"\"\"\n\n    async def get_monitor_props(\n        self,\n        name: str | None = None,\n        include_disabled: bool = False,\n        *,\n        log: Logger,\n    ) -> MonitorInfo:\n        \"\"\"Return focused monitor data if `name` is not defined, else use monitor's name.\n\n        Args:\n            name: Monitor name to look for, or None for focused monitor\n            include_disabled: If True, include disabled monitors in search\n            log: Logger to use for this operation\n        \"\"\"\n        monitors = await self.get_monitors(log=log, include_disabled=include_disabled)\n        if name:\n            for mon in monitors:\n                if mon[\"name\"] == name:\n                    return mon\n        else:\n            for mon in monitors:\n                if mon.get(\"focused\"):\n                    return mon\n        msg = \"no focused monitor\"\n        raise RuntimeError(msg)\n\n    @abstractmethod\n    async def execute(self, command: str | list | dict, *, log: Logger, **kwargs: Any) -> bool:\n        \"\"\"Execute a command (or list of commands).\n\n        Args:\n            command: The command to execute\n            log: Logger to use for this operation\n            **kwargs: Additional arguments (base_command, weak, etc.)\n        \"\"\"\n\n    @abstractmethod\n    async def execute_json(self, command: str, *, log: Logger, **kwargs: Any) -> Any:\n        \"\"\"Execute a command and return the JSON result.\n\n        Args:\n            command: The command to execute\n            log: Logger to use for this operation\n            **kwargs: Additional arguments\n        \"\"\"\n\n    @abstractmethod\n    async def execute_batch(self, commands: list[str], *, log: Logger) -> None:\n        \"\"\"Execute a batch of commands.\n\n        Args:\n            commands: List of commands to execute\n            log: Logger to use for this operation\n        \"\"\"\n\n    @abstractmethod\n    async def notify(self, message: str, duration: int, color: str, *, log: Logger) -> None:\n        \"\"\"Send a notification.\n\n        Args:\n            message: The notification message\n            duration: Duration in milliseconds\n            color: Hex color code\n            log: Logger to use for this operation\n        \"\"\"\n\n    async def notify_info(self, message: str, duration: int = 5000, *, log: Logger) -> None:\n        \"\"\"Send an info notification (default: blue color).\n\n        Args:\n            message: The notification message\n            duration: Duration in milliseconds\n            log: Logger to use for this operation\n        \"\"\"\n        await self.notify(message, duration, \"0000ff\", log=log)\n\n    async def notify_error(self, message: str, duration: int = 5000, *, log: Logger) -> None:\n        \"\"\"Send an error notification (default: red color).\n\n        Args:\n            message: The notification message\n            duration: Duration in milliseconds\n            log: Logger to use for this operation\n        \"\"\"\n        await self.notify(message, duration, \"ff0000\", log=log)\n\n    async def get_client_props(\n        self,\n        match_fn: Callable[[Any, Any], bool] | None = None,\n        clients: list[ClientInfo] | None = None,\n        *,\n        log: Logger,\n        **kw: Any,\n    ) -> ClientInfo | None:\n        \"\"\"Return the properties of a client matching the given criteria.\n\n        Args:\n            match_fn: Custom match function (defaults to equality)\n            clients: Optional pre-fetched client list\n            log: Logger to use for this operation\n            **kw: Property to match (addr, cls, etc.)\n        \"\"\"\n        if match_fn is None:\n\n            def default_match_fn(value1: Any, value2: Any) -> bool:\n                return bool(value1 == value2)\n\n            match_fn = default_match_fn\n\n        assert kw\n\n        addr = kw.get(\"addr\")\n        klass = kw.get(\"cls\")\n\n        if addr:\n            assert len(addr) > MINIMUM_ADDR_LEN, \"Client address is invalid\"\n            prop_name = \"address\"\n            prop_value = addr\n        elif klass:\n            prop_name = \"class\"\n            prop_value = klass\n        else:\n            prop_name, prop_value = next(iter(kw.items()))\n\n        clients_list = clients or await self.get_clients(mapped=False, log=log)\n\n        for client in clients_list:\n            assert isinstance(client, dict)\n            val = client.get(prop_name)\n            if match_fn(val, prop_value):\n                return client\n        return None\n\n    # ─── Window Operation Helpers ─────────────────────────────────────────────\n\n    async def focus_window(self, address: str, *, log: Logger) -> bool:\n        \"\"\"Focus a window by address.\n\n        Args:\n            address: Window address (without 'address:' prefix)\n            log: Logger to use for this operation\n\n        Returns:\n            True if command succeeded\n        \"\"\"\n        return await self.execute(f\"focuswindow address:{address}\", log=log)\n\n    async def move_window_to_workspace(\n        self,\n        address: str,\n        workspace: str,\n        *,\n        silent: bool = True,\n        log: Logger,\n    ) -> bool:\n        \"\"\"Move a window to a workspace.\n\n        Args:\n            address: Window address (without 'address:' prefix)\n            workspace: Target workspace name or ID\n            silent: If True, don't follow the window (default: True)\n            log: Logger to use for this operation\n\n        Returns:\n            True if command succeeded\n        \"\"\"\n        cmd = \"movetoworkspacesilent\" if silent else \"movetoworkspace\"\n        return await self.execute(f\"{cmd} {workspace},address:{address}\", log=log)\n\n    async def pin_window(self, address: str, *, log: Logger) -> bool:\n        \"\"\"Toggle pin state of a window.\n\n        Args:\n            address: Window address (without 'address:' prefix)\n            log: Logger to use for this operation\n\n        Returns:\n            True if command succeeded\n        \"\"\"\n        return await self.execute(f\"pin address:{address}\", log=log)\n\n    async def close_window(self, address: str, *, silent: bool = True, log: Logger) -> bool:\n        \"\"\"Close a window.\n\n        Args:\n            address: Window address (without 'address:' prefix)\n            silent: Accepted for API consistency (currently unused for close)\n            log: Logger to use for this operation\n\n        Returns:\n            True if command succeeded\n        \"\"\"\n        return await self.execute(f\"closewindow address:{address}\", log=log)\n\n    async def resize_window(self, address: str, width: int, height: int, *, log: Logger) -> bool:\n        \"\"\"Resize a window to exact pixel dimensions.\n\n        Args:\n            address: Window address (without 'address:' prefix)\n            width: Target width in pixels\n            height: Target height in pixels\n            log: Logger to use for this operation\n\n        Returns:\n            True if command succeeded\n        \"\"\"\n        return await self.execute(f\"resizewindowpixel exact {width} {height},address:{address}\", log=log)\n\n    async def move_window(self, address: str, x: int, y: int, *, log: Logger) -> bool:  # pylint: disable=invalid-name\n        \"\"\"Move a window to exact pixel position.\n\n        Args:\n            address: Window address (without 'address:' prefix)\n            x: Target x position in pixels\n            y: Target y position in pixels\n            log: Logger to use for this operation\n\n        Returns:\n            True if command succeeded\n        \"\"\"\n        return await self.execute(f\"movewindowpixel exact {x} {y},address:{address}\", log=log)\n\n    async def toggle_floating(self, address: str, *, log: Logger) -> bool:\n        \"\"\"Toggle floating state of a window.\n\n        Args:\n            address: Window address (without 'address:' prefix)\n            log: Logger to use for this operation\n\n        Returns:\n            True if command succeeded\n        \"\"\"\n        return await self.execute(f\"togglefloating address:{address}\", log=log)\n\n    async def set_keyword(self, keyword_command: str, *, log: Logger) -> bool:\n        \"\"\"Execute a keyword/config command.\n\n        Args:\n            keyword_command: The keyword command string (e.g., \"general:gaps_out 10\")\n            log: Logger to use for this operation\n\n        Returns:\n            True if command succeeded\n        \"\"\"\n        return await self.execute(keyword_command, log=log, base_command=\"keyword\")\n"
  },
  {
    "path": "pyprland/adapters/colors.py",
    "content": "\"\"\"Color conversion & misc color related helpers.\"\"\"\n\n\ndef convert_color(description: str) -> str:\n    \"\"\"Get a color description and returns the 6 HEX digits as string.\n\n    Args:\n        description: Color description (e.g. \"#FF0000\" or \"rgb(255, 0, 0)\")\n    \"\"\"\n    if description[0] == \"#\":\n        return description[1:]\n    if description.startswith(\"rgb(\"):\n        return \"\".join([f\"{int(i):02x}\" for i in description[4:-1].split(\", \")])\n    return description\n"
  },
  {
    "path": "pyprland/adapters/fallback.py",
    "content": "\"\"\"Fallback backend base class for limited functionality environments.\"\"\"\n\nimport asyncio\nfrom abc import abstractmethod\nfrom collections.abc import Callable\nfrom logging import Logger\nfrom typing import Any\n\nfrom ..constants import DEFAULT_NOTIFICATION_DURATION_MS, DEFAULT_REFRESH_RATE_HZ\nfrom ..models import ClientInfo, MonitorInfo\nfrom .backend import EnvironmentBackend\n\n\ndef make_monitor_info(  # noqa: PLR0913  # pylint: disable=too-many-arguments,too-many-positional-arguments\n    index: int,\n    name: str,\n    width: int,\n    height: int,\n    pos_x: int = 0,\n    pos_y: int = 0,\n    scale: float = 1.0,\n    transform: int = 0,\n    refresh_rate: float = DEFAULT_REFRESH_RATE_HZ,\n    enabled: bool = True,\n    description: str = \"\",\n) -> MonitorInfo:\n    \"\"\"Create a MonitorInfo dict with default values for fallback backends.\n\n    Args:\n        index: Monitor index\n        name: Monitor name (e.g., \"DP-1\")\n        width: Monitor width in pixels\n        height: Monitor height in pixels\n        pos_x: X position\n        pos_y: Y position\n        scale: Scale factor\n        transform: Transform value (0-7)\n        refresh_rate: Refresh rate in Hz\n        enabled: Whether the monitor is enabled\n        description: Monitor description\n\n    Returns:\n        MonitorInfo dict with all required fields\n    \"\"\"\n    return MonitorInfo(\n        id=index,\n        name=name,\n        description=description or name,\n        make=\"\",\n        model=\"\",\n        serial=\"\",\n        width=width,\n        height=height,\n        refreshRate=refresh_rate,\n        x=pos_x,\n        y=pos_y,\n        activeWorkspace={\"id\": 0, \"name\": \"\"},\n        specialWorkspace={\"id\": 0, \"name\": \"\"},\n        reserved=[0, 0, 0, 0],\n        scale=scale,\n        transform=transform,\n        focused=index == 0,\n        dpmsStatus=enabled,\n        vrr=False,\n        activelyTearing=False,\n        disabled=not enabled,\n        currentFormat=\"\",\n        availableModes=[],\n        to_disable=False,\n    )\n\n\nclass FallbackBackend(EnvironmentBackend):\n    \"\"\"Base class for fallback backends (X11, generic Wayland).\n\n    Provides minimal functionality - only get_monitors() is implemented\n    by subclasses. Other methods are stubs that log warnings or no-op.\n\n    These backends provide monitor information for plugins like wallpapers\n    but do not support compositor-specific features like window management\n    or event handling.\n    \"\"\"\n\n    async def get_clients(\n        self,\n        mapped: bool = True,\n        workspace: str | None = None,\n        workspace_bl: str | None = None,\n        *,\n        log: Logger,\n    ) -> list[ClientInfo]:\n        \"\"\"Not supported in fallback mode.\n\n        Args:\n            mapped: Ignored\n            workspace: Ignored\n            workspace_bl: Ignored\n            log: Logger to use for this operation\n\n        Returns:\n            Empty list\n        \"\"\"\n        log.debug(\"get_clients() not supported in fallback backend\")\n        return []\n\n    def parse_event(self, raw_data: str, *, log: Logger) -> tuple[str, Any] | None:\n        \"\"\"No event support in fallback mode.\n\n        Args:\n            raw_data: Ignored\n            log: Logger to use for this operation\n\n        Returns:\n            None (no events)\n        \"\"\"\n        return None\n\n    async def execute(self, command: str | list | dict, *, log: Logger, **kwargs: Any) -> bool:\n        \"\"\"Not supported in fallback mode.\n\n        Args:\n            command: Ignored\n            log: Logger to use for this operation\n            **kwargs: Ignored\n\n        Returns:\n            False (command not executed)\n        \"\"\"\n        log.debug(\"execute() not supported in fallback backend\")\n        return False\n\n    async def execute_batch(self, commands: list[str], *, log: Logger) -> None:\n        \"\"\"Not supported in fallback mode.\n\n        Args:\n            commands: Ignored\n            log: Logger to use for this operation\n        \"\"\"\n        log.debug(\"execute_batch() not supported in fallback backend\")\n\n    async def execute_json(self, command: str, *, log: Logger, **kwargs: Any) -> Any:\n        \"\"\"Not supported in fallback mode.\n\n        Args:\n            command: Ignored\n            log: Logger to use for this operation\n            **kwargs: Ignored\n\n        Returns:\n            Empty dict\n        \"\"\"\n        log.debug(\"execute_json() not supported in fallback backend\")\n        return {}\n\n    async def notify(\n        self,\n        message: str,\n        duration: int = DEFAULT_NOTIFICATION_DURATION_MS,\n        color: str = \"ff0000\",\n        *,\n        log: Logger,\n    ) -> None:\n        \"\"\"Send notification via notify-send.\n\n        Args:\n            message: The notification message\n            duration: Duration in milliseconds\n            color: Ignored (notify-send doesn't support colors)\n            log: Logger to use for this operation\n        \"\"\"\n        log.info(\"Notification: %s\", message)\n        try:\n            # Convert duration from ms to ms (notify-send uses ms)\n            proc = await asyncio.create_subprocess_shell(\n                f'notify-send -t {duration} \"Pyprland\" \"{message}\"',\n                stdout=asyncio.subprocess.DEVNULL,\n                stderr=asyncio.subprocess.DEVNULL,\n            )\n            await proc.wait()\n        except OSError as e:\n            log.debug(\"notify-send failed: %s\", e)\n\n    @classmethod\n    @abstractmethod\n    async def is_available(cls) -> bool:\n        \"\"\"Check if this backend's required tool is available.\n\n        Subclasses must implement this to check for their required\n        tool (e.g., xrandr, wlr-randr).\n\n        Returns:\n            True if the backend can be used\n        \"\"\"\n\n    @classmethod\n    async def _check_command(cls, command: str) -> bool:\n        \"\"\"Check if a command is available and works.\n\n        Args:\n            command: The command to test\n\n        Returns:\n            True if command executed successfully\n        \"\"\"\n        try:\n            proc = await asyncio.create_subprocess_shell(\n                command,\n                stdout=asyncio.subprocess.DEVNULL,\n                stderr=asyncio.subprocess.DEVNULL,\n            )\n            return await proc.wait() == 0\n        except OSError:\n            return False\n\n    async def _run_monitor_command(\n        self,\n        command: str,\n        tool_name: str,\n        parser: Callable[[str, bool, Logger], list[MonitorInfo]],\n        *,\n        include_disabled: bool,\n        log: Logger,\n    ) -> list[MonitorInfo]:\n        \"\"\"Run a command and parse its output for monitor information.\n\n        This is a shared helper for wayland/xorg backends to reduce duplication.\n\n        Args:\n            command: Shell command to execute\n            tool_name: Name of the tool for error messages\n            parser: Function to parse the command output\n            include_disabled: Whether to include disabled monitors\n            log: Logger instance\n\n        Returns:\n            List of MonitorInfo dicts, empty on failure\n        \"\"\"\n        try:\n            proc = await asyncio.create_subprocess_shell(\n                command,\n                stdout=asyncio.subprocess.PIPE,\n                stderr=asyncio.subprocess.PIPE,\n            )\n            stdout, stderr = await proc.communicate()\n\n            if proc.returncode != 0:\n                log.error(\"%s failed: %s\", tool_name, stderr.decode())\n                return []\n\n            return parser(stdout.decode(), include_disabled, log)\n\n        except OSError as e:\n            log.warning(\"Failed to get monitors from %s: %s\", tool_name, e)\n            return []\n"
  },
  {
    "path": "pyprland/adapters/hyprland.py",
    "content": "\"\"\"Hyprland compositor backend implementation.\n\nPrimary backend for Hyprland, using its Unix socket IPC protocol.\nProvides full functionality including batched commands, JSON queries,\nnative notifications, and Hyprland-specific event parsing.\n\"\"\"\n\nfrom logging import Logger\nfrom typing import Any, cast\n\nfrom ..constants import DEFAULT_NOTIFICATION_DURATION_MS\nfrom ..ipc import get_response, hyprctl_connection, retry_on_reset\nfrom ..models import ClientInfo, MonitorInfo\nfrom .backend import EnvironmentBackend\n\n\nclass HyprlandBackend(EnvironmentBackend):\n    \"\"\"Hyprland backend implementation.\"\"\"\n\n    def _format_command(self, command_list: list[str] | list[list[str]], default_base_command: str) -> list[str]:\n        \"\"\"Format a list of commands to be sent to Hyprland.\"\"\"\n        result = []\n        for command in command_list:\n            if isinstance(command, str):\n                result.append(f\"{default_base_command} {command}\")\n            else:\n                result.append(f\"{command[1]} {command[0]}\")\n        return result\n\n    @retry_on_reset\n    async def execute(self, command: str | list | dict, *, log: Logger, **kwargs: Any) -> bool:\n        \"\"\"Execute a command (or list of commands).\n\n        Args:\n            command: The command to execute\n            log: Logger to use for this operation\n            **kwargs: Additional arguments (base_command, weak, etc.)\n        \"\"\"\n        base_command = kwargs.get(\"base_command\", \"dispatch\")\n        weak = kwargs.get(\"weak\", False)\n\n        if not command:\n            log.warning(\"%s triggered without a command!\", base_command)\n            return False\n        log.debug(\"%s %s\", base_command, command)\n\n        async with hyprctl_connection(log) as (ctl_reader, ctl_writer):\n            if isinstance(command, list):\n                nb_cmds = len(command)\n                ctl_writer.write(f\"[[BATCH]] {' ; '.join(self._format_command(command, base_command))}\".encode())\n            else:\n                nb_cmds = 1\n                ctl_writer.write(f\"/{base_command} {command}\".encode())\n            await ctl_writer.drain()\n            resp = await ctl_reader.read(100)\n\n        # remove \"\\n\" from the response\n        resp = b\"\".join(resp.split(b\"\\n\"))\n\n        r: bool = resp == b\"ok\" * nb_cmds\n        if not r:\n            if weak:\n                log.warning(\"FAILED %s\", resp)\n            else:\n                log.error(\"FAILED %s\", resp)\n        return r\n\n    @retry_on_reset\n    async def execute_json(self, command: str, *, log: Logger, **kwargs: Any) -> Any:\n        \"\"\"Execute a command and return the JSON result.\n\n        Args:\n            command: The command to execute\n            log: Logger to use for this operation\n            **kwargs: Additional arguments\n        \"\"\"\n        ret = await get_response(f\"-j/{command}\".encode(), log)\n        assert isinstance(ret, list | dict)\n        return ret\n\n    async def get_clients(\n        self,\n        mapped: bool = True,\n        workspace: str | None = None,\n        workspace_bl: str | None = None,\n        *,\n        log: Logger,\n    ) -> list[ClientInfo]:\n        \"\"\"Return the list of clients, optionally filtered.\n\n        Args:\n            mapped: If True, only return mapped clients\n            workspace: Filter to this workspace name\n            workspace_bl: Blacklist this workspace name\n            log: Logger to use for this operation\n        \"\"\"\n        return [\n            client\n            for client in cast(\"list[ClientInfo]\", await self.execute_json(\"clients\", log=log))\n            if (not mapped or client[\"mapped\"])\n            and (workspace is None or client[\"workspace\"][\"name\"] == workspace)\n            and (workspace_bl is None or client[\"workspace\"][\"name\"] != workspace_bl)\n        ]\n\n    async def get_monitors(self, *, log: Logger, include_disabled: bool = False) -> list[MonitorInfo]:\n        \"\"\"Return the list of monitors.\n\n        Args:\n            log: Logger to use for this operation\n            include_disabled: If True, include disabled monitors\n        \"\"\"\n        cmd = \"monitors all\" if include_disabled else \"monitors\"\n        return cast(\"list[MonitorInfo]\", await self.execute_json(cmd, log=log))\n\n    async def execute_batch(self, commands: list[str], *, log: Logger) -> None:\n        \"\"\"Execute a batch of commands.\n\n        Args:\n            commands: List of commands to execute\n            log: Logger to use for this operation\n        \"\"\"\n        if not commands:\n            return\n\n        log.debug(\"Batch %s\", commands)\n\n        # Format commands for batch execution\n        # Based on ipc.py _format_command implementation\n        formatted_cmds = [f\"dispatch {command}\" for command in commands]\n\n        async with hyprctl_connection(log) as (_, ctl_writer):\n            ctl_writer.write(f\"[[BATCH]] {' ; '.join(formatted_cmds)}\".encode())\n            await ctl_writer.drain()\n            # We assume it worked, similar to current implementation\n            # detailed error checking for batch is limited in current ipc.py implementation\n\n    def parse_event(self, raw_data: str, *, log: Logger) -> tuple[str, Any] | None:\n        \"\"\"Parse a raw event string into (event_name, event_data).\n\n        Args:\n            raw_data: Raw event string from the compositor\n            log: Logger to use for this operation (unused in Hyprland - simple parsing)\n        \"\"\"\n        if \">>\" not in raw_data:\n            return None\n        cmd, params = raw_data.split(\">>\", 1)\n        return f\"event_{cmd}\", params.rstrip(\"\\n\")\n\n    async def notify(self, message: str, duration: int = DEFAULT_NOTIFICATION_DURATION_MS, color: str = \"ff1010\", *, log: Logger) -> None:\n        \"\"\"Send a notification.\n\n        Args:\n            message: The notification message\n            duration: Duration in milliseconds\n            color: Hex color code\n            log: Logger to use for this operation\n        \"\"\"\n        # Using icon -1 for default/generic\n        await self._notify_impl(message, duration, color, -1, log=log)\n\n    async def notify_info(self, message: str, duration: int = DEFAULT_NOTIFICATION_DURATION_MS, *, log: Logger) -> None:\n        \"\"\"Send an info notification.\n\n        Args:\n            message: The notification message\n            duration: Duration in milliseconds\n            log: Logger to use for this operation\n        \"\"\"\n        # Using icon 1 for info\n        await self._notify_impl(message, duration, \"1010ff\", 1, log=log)\n\n    async def notify_error(self, message: str, duration: int = DEFAULT_NOTIFICATION_DURATION_MS, *, log: Logger) -> None:\n        \"\"\"Send an error notification.\n\n        Args:\n            message: The notification message\n            duration: Duration in milliseconds\n            log: Logger to use for this operation\n        \"\"\"\n        # Using icon 0 for error\n        await self._notify_impl(message, duration, \"ff1010\", 0, log=log)\n\n    async def _notify_impl(self, text: str, duration: int, color: str, icon: int, *, log: Logger) -> None:\n        \"\"\"Internal notify implementation.\n\n        Args:\n            text: The notification text\n            duration: Duration in milliseconds\n            color: Hex color code\n            icon: Icon code (-1 default, 0 error, 1 info)\n            log: Logger to use for this operation\n        \"\"\"\n        # This mirrors ipc.notify logic for Hyprland\n        await self.execute(f\"{icon} {duration} rgb({color})  {text}\", log=log, base_command=\"notify\")\n"
  },
  {
    "path": "pyprland/adapters/menus.py",
    "content": "\"\"\"Menu engine adapter.\"\"\"\n\nimport asyncio\nimport subprocess\nfrom collections.abc import Iterable\nfrom logging import Logger\nfrom typing import TYPE_CHECKING, ClassVar\n\nfrom ..common import apply_variables, get_logger\nfrom ..models import PyprError, ReloadReason\nfrom ..validation import ConfigField, ConfigItems\n\nif TYPE_CHECKING:\n    from ..config import Configuration\n\n__all__ = [\"MenuEngine\", \"MenuMixin\"]\n\nmenu_logger = get_logger(\"menus adapter\")\n\n\nclass MenuEngine:\n    \"\"\"Menu backend interface.\"\"\"\n\n    proc_name: str\n    \" process name for this engine \"\n    proc_extra_parameters: str = \"\"\n    \" process parameters to use for this engine \"\n    proc_detect_parameters: ClassVar[list[str]] = [\"--help\"]\n    \" process parameters used to check if the engine can run \"\n\n    def __init__(self, extra_parameters: str) -> None:\n        \"\"\"Initialize the engine with extra parameters.\n\n        Args:\n            extra_parameters: extra parameters to pass to the program\n        \"\"\"\n        if extra_parameters:\n            self.proc_extra_parameters = extra_parameters\n\n    @classmethod\n    def is_available(cls) -> bool:\n        \"\"\"Check engine availability.\"\"\"\n        try:\n            subprocess.call([cls.proc_name, *cls.proc_detect_parameters], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)\n        except FileNotFoundError:\n            return False\n        return True\n\n    async def run(self, choices: Iterable[str], prompt: str = \"\") -> str:\n        \"\"\"Run the engine and get the response for the proposed `choices`.\n\n        Args:\n            choices: options to chose from\n            prompt: prompt replacement variable (passed in `apply_variables`)\n\n        Returns:\n            The choice which have been selected by the user, or an empty string\n        \"\"\"\n        menu_text = \"\\n\".join(choices)\n        if not menu_text.strip():\n            return \"\"\n        command = apply_variables(\n            f\"{self.proc_name} {self.proc_extra_parameters}\",\n            {\"prompt\": f\"{prompt}:  \"} if prompt else {\"prompt\": \"\"},\n        )\n        menu_logger.debug(command)\n        proc = await asyncio.create_subprocess_shell(\n            command,\n            stdin=asyncio.subprocess.PIPE,\n            stdout=asyncio.subprocess.PIPE,\n        )\n        assert proc.stdin\n        assert proc.stdout\n\n        proc.stdin.write(menu_text.encode())\n        # flush program execution\n        await proc.stdin.drain()\n        proc.stdin.close()\n        await proc.wait()\n\n        return (await proc.stdout.read()).decode().strip()\n\n\ndef _menu(proc: str, params: str) -> type[MenuEngine]:\n    \"\"\"Create a menu engine class.\n\n    Args:\n        proc: process name for this engine\n        params: default parameters to pass to the process\n\n    Returns:\n        A MenuEngine subclass configured for the specified menu program\n    \"\"\"\n    return type(\n        f\"{proc.title()}Menu\",\n        (MenuEngine,),\n        {\"proc_name\": proc, \"proc_extra_parameters\": params, \"__doc__\": f\"A {proc} based menu.\"},\n    )\n\n\nTofiMenu = _menu(\"tofi\", \"--prompt-text '[prompt]'\")\nRofiMenu = _menu(\"rofi\", \"-dmenu -i -p '[prompt]'\")\nWofiMenu = _menu(\"wofi\", \"-dmenu -i -p '[prompt]'\")\nDmenuMenu = _menu(\"dmenu\", \"-i\")\nBemenuMenu = _menu(\"bemenu\", \"-c\")\nFuzzelMenu = _menu(\"fuzzel\", \"--match-mode=fuzzy -d -p '[prompt]'\")\nWalkerMenu = _menu(\"walker\", \"-d -k -p '[prompt]'\")\nAnyrunMenu = _menu(\"anyrun\", \"--plugins libstdin.so --show-results-immediately true\")\nVicinaeMenu = _menu(\"vicinae\", \"dmenu --no-quick-look\")\n\nevery_menu_engine = [FuzzelMenu, TofiMenu, RofiMenu, WofiMenu, BemenuMenu, DmenuMenu, AnyrunMenu, WalkerMenu, VicinaeMenu]\n\nMENU_ENGINE_CHOICES: list[str] = [engine.proc_name for engine in every_menu_engine]\n\"\"\"List of available menu engine names, derived from every_menu_engine.\"\"\"\n\n\nasync def init(force_engine: str | None = None, extra_parameters: str = \"\") -> MenuEngine:\n    \"\"\"Initialize the module.\n\n    Args:\n        force_engine: Name of the engine to force use of\n        extra_parameters: Extra parameters to pass to the engine\n    \"\"\"\n    try:\n        engines = [next(e for e in every_menu_engine if e.proc_name == force_engine)] if force_engine else every_menu_engine\n    except StopIteration:\n        engines = []\n\n    if force_engine and engines:\n        return engines[0](extra_parameters)\n\n    # detect engine\n    for engine in engines:\n        if engine.is_available():\n            return engine(extra_parameters)\n\n    # fallback if not found but forced\n    if force_engine:\n        # Attempt to use the user-supplied command\n        me = MenuEngine(extra_parameters)\n        me.proc_name = force_engine\n        return me\n\n    msg = \"No engine found\"\n    raise PyprError(msg)\n\n\nclass MenuMixin:\n    \"\"\"An extension mixin supporting 'engine' and 'parameters' config options to show a menu.\"\"\"\n\n    menu_config_schema = ConfigItems(\n        ConfigField(\n            \"engine\",\n            str,\n            description=\"Menu engine to use\",\n            choices=MENU_ENGINE_CHOICES,\n            category=\"menu\",\n        ),\n        ConfigField(\n            \"parameters\",\n            str,\n            description=\"Extra parameters for the menu engine command\",\n            category=\"menu\",\n        ),\n    )\n    \"\"\"Schema for menu configuration fields. Plugins using MenuMixin should include this in their config_schema.\"\"\"\n\n    _menu_configured = False\n    menu: MenuEngine\n    \"\"\" provided `MenuEngine` \"\"\"\n    config: \"Configuration\"\n    \" used by the mixin but provided by `pyprland.plugins.interface.Plugin` \"\n    log: Logger\n\n    \" used by the mixin but provided by `pyprland.plugins.interface.Plugin` \"\n\n    async def ensure_menu_configured(self) -> None:\n        \"\"\"If not configured, init the menu system.\"\"\"\n        if not self._menu_configured:\n            self.menu = await init(self.config.get_str(\"engine\") or None, self.config.get_str(\"parameters\"))\n            self.log.info(\"Using %s engine\", self.menu.proc_name)\n            self._menu_configured = True\n\n    async def on_reload(self, reason: ReloadReason = ReloadReason.RELOAD) -> None:\n        \"\"\"Reset the configuration status.\"\"\"\n        _ = reason  # unused\n        self._menu_configured = False\n"
  },
  {
    "path": "pyprland/adapters/niri.py",
    "content": "\"\"\"Niri compositor backend implementation.\n\nBackend for Niri compositor using its JSON-based IPC protocol.\nMaps Niri's window/output data structures to Hyprland-compatible formats.\nSome operations (pin, resize, move) are unavailable due to Niri's tiling nature.\n\"\"\"\n\nimport json\nfrom logging import Logger\nfrom typing import Any, cast\n\nfrom ..common import notify_send\nfrom ..constants import DEFAULT_NOTIFICATION_DURATION_MS, DEFAULT_REFRESH_RATE_HZ\nfrom ..ipc import niri_request\nfrom ..models import ClientInfo, MonitorInfo\nfrom .backend import EnvironmentBackend\n\n# Niri transform string to Hyprland-compatible integer mapping\n# Keys are lowercase for case-insensitive lookup\nNIRI_TRANSFORM_MAP: dict[str, int] = {\n    \"normal\": 0,\n    \"90\": 1,\n    \"180\": 2,\n    \"270\": 3,\n    \"flipped\": 4,\n    \"flipped90\": 5,\n    \"flipped-90\": 5,\n    \"flipped180\": 6,\n    \"flipped-180\": 6,\n    \"flipped270\": 7,\n    \"flipped-270\": 7,\n}\n\n\ndef get_niri_transform(value: str, default: int = 0) -> int:\n    \"\"\"Get transform integer from Niri transform string (case-insensitive).\n\n    Args:\n        value: Transform string like \"Normal\", \"90\", \"Flipped-90\", etc.\n        default: Value to return if not found\n\n    Returns:\n        Integer transform value (0-7)\n    \"\"\"\n    return NIRI_TRANSFORM_MAP.get(value.lower(), default)\n\n\ndef niri_output_to_monitor_info(name: str, data: dict[str, Any]) -> MonitorInfo:\n    \"\"\"Convert Niri output data to MonitorInfo.\n\n    Handles both Niri output formats:\n    - Format A: Uses \"logical\" object with nested x, y, scale, transform\n    - Format B: Uses \"logical_position\", \"logical_size\", \"scale\" at root level\n\n    Args:\n        name: Output name (e.g., \"DP-1\")\n        data: Niri output data dictionary\n\n    Returns:\n        MonitorInfo TypedDict with normalized fields\n    \"\"\"\n    # Try format A first (more detailed - has modes, logical object)\n    logical = data.get(\"logical\") or {}\n    mode: dict[str, Any] = next((m for m in data.get(\"modes\", []) if m.get(\"is_active\")), {})\n\n    # Fall back to format B for position/size if format A fields missing\n    x = logical.get(\"x\") if logical else data.get(\"logical_position\", {}).get(\"x\", 0)\n    y = logical.get(\"y\") if logical else data.get(\"logical_position\", {}).get(\"y\", 0)\n    scale = logical.get(\"scale\") if logical else data.get(\"scale\", 1.0)\n\n    # Width/height: prefer active mode, fall back to logical_size\n    width = mode.get(\"width\") if mode else data.get(\"logical_size\", {}).get(\"width\", 0)\n    height = mode.get(\"height\") if mode else data.get(\"logical_size\", {}).get(\"height\", 0)\n\n    # Refresh rate from mode (in mHz), default to 60Hz\n    refresh_rate = mode.get(\"refresh_rate\", DEFAULT_REFRESH_RATE_HZ * 1000) / 1000.0 if mode else DEFAULT_REFRESH_RATE_HZ\n\n    # Transform from logical object\n    transform_str = logical.get(\"transform\", \"Normal\") if logical else \"Normal\"\n\n    # Build description from make/model if available\n    make = data.get(\"make\", \"\")\n    model = data.get(\"model\", \"\")\n    description = f\"{make} {model}\".strip() if make or model else \"\"\n\n    return cast(\n        \"MonitorInfo\",\n        {\n            \"name\": name,\n            \"description\": description,\n            \"make\": make,\n            \"model\": model,\n            \"serial\": data.get(\"serial\", \"\"),\n            \"width\": width,\n            \"height\": height,\n            \"refreshRate\": refresh_rate,\n            \"x\": x if x is not None else 0,\n            \"y\": y if y is not None else 0,\n            \"scale\": scale if scale is not None else 1.0,\n            \"transform\": get_niri_transform(transform_str),\n            \"focused\": data.get(\"is_focused\", False),\n            # Fields not available in Niri - provide sensible defaults\n            \"id\": -1,\n            \"activeWorkspace\": {\"id\": -1, \"name\": \"\"},\n            \"specialWorkspace\": {\"id\": -1, \"name\": \"\"},\n            \"reserved\": [],\n            \"dpmsStatus\": True,\n            \"vrr\": False,\n            \"activelyTearing\": False,\n            \"disabled\": False,\n            \"currentFormat\": \"\",\n            \"availableModes\": [],\n            \"to_disable\": False,\n        },\n    )\n\n\nclass NiriBackend(EnvironmentBackend):\n    \"\"\"Niri backend implementation.\"\"\"\n\n    def parse_event(self, raw_data: str, *, log: Logger) -> tuple[str, Any] | None:\n        \"\"\"Parse a raw event string into (event_name, event_data).\n\n        Args:\n            raw_data: Raw event string from the compositor\n            log: Logger to use for this operation\n        \"\"\"\n        if not raw_data.strip().startswith(\"{\"):\n            return None\n        try:\n            event = json.loads(raw_data)\n        except json.JSONDecodeError:\n            log.exception(\"Invalid JSON event: %s\", raw_data)\n            return None\n\n        if \"Variant\" in event:\n            type_name = event[\"Variant\"][\"type\"]\n            data = event[\"Variant\"]\n            return f\"niri_{type_name.lower()}\", data\n        return None\n\n    async def execute(self, command: str | list | dict, *, log: Logger, **kwargs: Any) -> bool:\n        \"\"\"Execute a command (or list of commands).\n\n        Args:\n            command: The command to execute\n            log: Logger to use for this operation\n            **kwargs: Additional arguments (weak, etc.)\n        \"\"\"\n        weak = kwargs.get(\"weak\", False)\n        # Niri commands are typically lists of strings or objects, not a single string command line\n        # If we receive a string, we might need to wrap it.\n        # But looking at existing usage, nirictl expects list or dict.\n\n        # If we receive a list of strings from execute(), it might be multiple commands?\n        # Niri socket protocol is request-response JSON.\n\n        try:\n            ret = await niri_request(command, log)\n            if isinstance(ret, dict) and \"Ok\" in ret:\n                return True\n        except (OSError, ConnectionError, json.JSONDecodeError) as e:\n            log.warning(\"Niri command failed: %s\", e)\n            return False\n\n        if weak:\n            log.warning(\"Niri command failed: %s\", ret)\n        else:\n            log.error(\"Niri command failed: %s\", ret)\n        return False\n\n    async def execute_json(self, command: str, *, log: Logger, **kwargs: Any) -> Any:\n        \"\"\"Execute a command and return the JSON result.\n\n        Args:\n            command: The command to execute\n            log: Logger to use for this operation\n            **kwargs: Additional arguments\n        \"\"\"\n        ret = await niri_request(command, log)\n        if isinstance(ret, dict) and \"Ok\" in ret:\n            return ret[\"Ok\"]\n        msg = f\"Niri command failed: {ret}\"\n        raise RuntimeError(msg)\n\n    async def get_clients(\n        self,\n        mapped: bool = True,\n        workspace: str | None = None,\n        workspace_bl: str | None = None,\n        *,\n        log: Logger,\n    ) -> list[ClientInfo]:\n        \"\"\"Return the list of clients, optionally filtered.\n\n        Args:\n            mapped: If True, only return mapped clients\n            workspace: Filter to this workspace name\n            workspace_bl: Blacklist this workspace name\n            log: Logger to use for this operation\n        \"\"\"\n        return [\n            self._map_niri_client(client)\n            for client in cast(\"list[dict]\", await self.execute_json(\"windows\", log=log))\n            if (not mapped or client.get(\"is_mapped\", True))\n            and (workspace is None or str(client.get(\"workspace_id\")) == workspace)\n            and (workspace_bl is None or str(client.get(\"workspace_id\")) != workspace_bl)\n        ]\n\n    def _map_niri_client(self, niri_client: dict[str, Any]) -> ClientInfo:\n        \"\"\"Helper to map Niri window dict to ClientInfo TypedDict.\"\"\"\n        return cast(\n            \"ClientInfo\",\n            {\n                \"address\": str(niri_client.get(\"id\")),\n                \"class\": niri_client.get(\"app_id\"),\n                \"title\": niri_client.get(\"title\"),\n                \"workspace\": {\"name\": str(niri_client.get(\"workspace_id\"))},\n                \"pid\": -1,\n                \"mapped\": niri_client.get(\"is_mapped\", True),\n                \"hidden\": False,\n                \"at\": (0, 0),\n                \"size\": (0, 0),\n                \"floating\": False,\n                \"monitor\": -1,\n                \"initialClass\": niri_client.get(\"app_id\"),\n                \"initialTitle\": niri_client.get(\"title\"),\n                \"xwayland\": False,\n                \"pinned\": False,\n                \"fullscreen\": False,\n                \"fullscreenMode\": 0,\n                \"fakeFullscreen\": False,\n                \"grouped\": [],\n                \"swallowing\": \"\",\n                \"focusHistoryID\": 0,\n            },\n        )\n\n    async def get_monitors(self, *, log: Logger, include_disabled: bool = False) -> list[MonitorInfo]:\n        \"\"\"Return the list of monitors.\n\n        Args:\n            log: Logger to use for this operation\n            include_disabled: Ignored for Niri (no concept of disabled monitors)\n        \"\"\"\n        outputs = await self.execute_json(\"outputs\", log=log)\n        return [niri_output_to_monitor_info(name, output) for name, output in outputs.items()]\n\n    async def execute_batch(self, commands: list[str], *, log: Logger) -> None:\n        \"\"\"Execute a batch of commands.\n\n        Args:\n            commands: List of commands to execute\n            log: Logger to use for this operation\n        \"\"\"\n        # Niri doesn't support batching in the same way, so we iterate\n        for cmd in commands:\n            # We need to parse the command string into an action\n            # This is a bit tricky as niri commands are structured objects/lists\n            # For now, let's assume 'action' is a command to be sent via nirictl\n            # But wait, execute_batch typically receives \"dispatch <cmd>\" type strings for Hyprland.\n            # We need to adapt this.\n\n            # Simple adaptation: if it's a known string command, we try to map it or just send it if niri accepts string commands\n            # (it mostly uses 'action' msg)\n            # This part requires more knowledge of how commands are passed.\n            # In current Pyprland, nirictl takes a list or dict.\n\n            # Placeholder implementation:\n            await self.execute([\"action\", cmd], log=log)\n\n    async def notify(\n        self,\n        message: str,\n        duration: int = DEFAULT_NOTIFICATION_DURATION_MS,\n        color: str = \"ff0000\",\n        *,\n        log: Logger,\n    ) -> None:\n        \"\"\"Send a notification.\n\n        Args:\n            message: The notification message\n            duration: Duration in milliseconds\n            color: Hex color code\n            log: Logger to use for this operation (unused - notify_send doesn't log)\n        \"\"\"\n        # Niri doesn't have a built-in notification system exposed via IPC like Hyprland's `notify`\n        # We rely on `notify-send` via the common utility\n\n        await notify_send(message, duration, color)\n\n    # ─── Window Operation Helpers (Niri overrides) ────────────────────────────\n\n    async def focus_window(self, address: str, *, log: Logger) -> bool:\n        \"\"\"Focus a window by ID.\n\n        Args:\n            address: Window ID\n            log: Logger to use for this operation\n        \"\"\"\n        return await self.execute({\"Action\": {\"FocusWindow\": {\"id\": int(address)}}}, log=log)\n\n    async def move_window_to_workspace(\n        self,\n        address: str,\n        workspace: str,\n        *,\n        silent: bool = True,\n        log: Logger,\n    ) -> bool:\n        \"\"\"Move a window to a workspace (silent parameter ignored in Niri).\n\n        Args:\n            address: Window ID\n            workspace: Target workspace ID\n            silent: Ignored in Niri\n            log: Logger to use for this operation\n        \"\"\"\n        return await self.execute(\n            {\"Action\": {\"MoveWindowToWorkspace\": {\"window_id\": int(address), \"reference\": {\"Id\": int(workspace)}}}},\n            log=log,\n        )\n\n    async def pin_window(self, address: str, *, log: Logger) -> bool:\n        \"\"\"Toggle pin state - not available in Niri.\n\n        Args:\n            address: Window ID (unused)\n            log: Logger to use for this operation\n        \"\"\"\n        log.debug(\"pin_window: not available in Niri\")\n        return False\n\n    async def close_window(self, address: str, *, silent: bool = True, log: Logger) -> bool:\n        \"\"\"Close a window by ID.\n\n        Args:\n            address: Window ID\n            silent: Accepted for API consistency (currently unused for close)\n            log: Logger to use for this operation\n        \"\"\"\n        return await self.execute({\"Action\": {\"CloseWindow\": {\"id\": int(address)}}}, log=log)\n\n    async def resize_window(self, address: str, width: int, height: int, *, log: Logger) -> bool:\n        \"\"\"Resize a window - not available in Niri (tiling WM).\n\n        Args:\n            address: Window ID (unused)\n            width: Target width (unused)\n            height: Target height (unused)\n            log: Logger to use for this operation\n        \"\"\"\n        log.debug(\"resize_window: not available in Niri\")\n        return False\n\n    async def move_window(self, address: str, x: int, y: int, *, log: Logger) -> bool:\n        \"\"\"Move a window to exact position - not available in Niri (tiling WM).\n\n        Args:\n            address: Window ID (unused)\n            x: Target x position (unused)\n            y: Target y position (unused)\n            log: Logger to use for this operation\n        \"\"\"\n        log.debug(\"move_window: not available in Niri\")\n        return False\n\n    async def toggle_floating(self, address: str, *, log: Logger) -> bool:\n        \"\"\"Toggle floating state - not available in Niri.\n\n        Args:\n            address: Window ID (unused)\n            log: Logger to use for this operation\n        \"\"\"\n        log.debug(\"toggle_floating: not available in Niri\")\n        return False\n\n    async def set_keyword(self, keyword_command: str, *, log: Logger) -> bool:\n        \"\"\"Execute a keyword command - not available in Niri.\n\n        Args:\n            keyword_command: The keyword command (unused)\n            log: Logger to use for this operation\n        \"\"\"\n        log.debug(\"set_keyword: not available in Niri\")\n        return False\n"
  },
  {
    "path": "pyprland/adapters/proxy.py",
    "content": "\"\"\"Backend proxy that injects plugin logger into all calls.\n\nThis module provides a BackendProxy class that wraps an EnvironmentBackend\nand automatically passes the plugin's logger to all backend method calls.\nThis allows backend operations to be logged under the calling plugin's\nlogger for better traceability.\n\"\"\"\n\nfrom collections.abc import Callable\nfrom logging import Logger\nfrom typing import TYPE_CHECKING, Any\n\nfrom ..models import ClientInfo, MonitorInfo\n\nif TYPE_CHECKING:\n    from .backend import EnvironmentBackend\n\n\nclass BackendProxy:\n    \"\"\"Proxy that injects the plugin logger into all backend calls.\n\n    This allows backend operations to be logged under the calling plugin's\n    logger for better traceability. Each plugin gets its own BackendProxy\n    instance with its own logger, while sharing the underlying backend.\n\n    Attributes:\n        log: The logger to use for all backend operations\n        state: Reference to the shared state (from the underlying backend)\n    \"\"\"\n\n    def __init__(self, backend: \"EnvironmentBackend\", log: Logger) -> None:\n        \"\"\"Initialize the proxy.\n\n        Args:\n            backend: The underlying backend to delegate calls to\n            log: The logger to inject into all backend calls\n        \"\"\"\n        self._backend = backend\n        self.log = log\n        self.state = backend.state\n\n    # === Core execution methods ===\n\n    async def execute(self, command: str | list | dict, **kwargs: Any) -> bool:\n        \"\"\"Execute a command (or list of commands).\n\n        Args:\n            command: The command to execute\n            **kwargs: Additional arguments (base_command, weak, etc.)\n\n        Returns:\n            True if command succeeded\n        \"\"\"\n        return await self._backend.execute(command, log=self.log, **kwargs)\n\n    async def execute_json(self, command: str, **kwargs: Any) -> Any:\n        \"\"\"Execute a command and return the JSON result.\n\n        Args:\n            command: The command to execute\n            **kwargs: Additional arguments\n\n        Returns:\n            The JSON response\n        \"\"\"\n        return await self._backend.execute_json(command, log=self.log, **kwargs)\n\n    async def execute_batch(self, commands: list[str]) -> None:\n        \"\"\"Execute a batch of commands.\n\n        Args:\n            commands: List of commands to execute\n        \"\"\"\n        return await self._backend.execute_batch(commands, log=self.log)\n\n    # === Query methods ===\n\n    async def get_clients(\n        self,\n        mapped: bool = True,\n        workspace: str | None = None,\n        workspace_bl: str | None = None,\n    ) -> list[ClientInfo]:\n        \"\"\"Return the list of clients, optionally filtered.\n\n        Args:\n            mapped: If True, only return mapped clients\n            workspace: Filter to this workspace name\n            workspace_bl: Blacklist this workspace name\n\n        Returns:\n            List of matching clients\n        \"\"\"\n        return await self._backend.get_clients(mapped, workspace, workspace_bl, log=self.log)\n\n    async def get_monitors(self, include_disabled: bool = False) -> list[MonitorInfo]:\n        \"\"\"Return the list of monitors.\n\n        Args:\n            include_disabled: If True, include disabled monitors\n\n        Returns:\n            List of monitors\n        \"\"\"\n        return await self._backend.get_monitors(log=self.log, include_disabled=include_disabled)\n\n    async def get_monitor_props(\n        self,\n        name: str | None = None,\n        include_disabled: bool = False,\n    ) -> MonitorInfo:\n        \"\"\"Return focused monitor data if name is not defined, else use monitor's name.\n\n        Args:\n            name: Monitor name to look for, or None for focused monitor\n            include_disabled: If True, include disabled monitors in search\n\n        Returns:\n            Monitor info dict\n        \"\"\"\n        return await self._backend.get_monitor_props(name, include_disabled, log=self.log)\n\n    async def get_client_props(\n        self,\n        match_fn: Callable[[Any, Any], bool] | None = None,\n        clients: list[ClientInfo] | None = None,\n        **kw: Any,\n    ) -> ClientInfo | None:\n        \"\"\"Return the properties of a client matching the given criteria.\n\n        Args:\n            match_fn: Custom match function (defaults to equality)\n            clients: Optional pre-fetched client list\n            **kw: Property to match (addr, cls, etc.)\n\n        Returns:\n            Matching client info or None\n        \"\"\"\n        return await self._backend.get_client_props(match_fn, clients, log=self.log, **kw)\n\n    # === Notification methods ===\n\n    async def notify(self, message: str, duration: int = 5000, color: str = \"ff0000\") -> None:\n        \"\"\"Send a notification.\n\n        Args:\n            message: The notification message\n            duration: Duration in milliseconds\n            color: Hex color code\n        \"\"\"\n        return await self._backend.notify(message, duration, color, log=self.log)\n\n    async def notify_info(self, message: str, duration: int = 5000) -> None:\n        \"\"\"Send an info notification (blue color).\n\n        Args:\n            message: The notification message\n            duration: Duration in milliseconds\n        \"\"\"\n        return await self._backend.notify_info(message, duration, log=self.log)\n\n    async def notify_error(self, message: str, duration: int = 5000) -> None:\n        \"\"\"Send an error notification (red color).\n\n        Args:\n            message: The notification message\n            duration: Duration in milliseconds\n        \"\"\"\n        return await self._backend.notify_error(message, duration, log=self.log)\n\n    # === Window operation helpers ===\n\n    async def focus_window(self, address: str) -> bool:\n        \"\"\"Focus a window by address.\n\n        Args:\n            address: Window address (without 'address:' prefix)\n\n        Returns:\n            True if command succeeded\n        \"\"\"\n        return await self._backend.focus_window(address, log=self.log)\n\n    async def move_window_to_workspace(\n        self,\n        address: str,\n        workspace: str,\n        *,\n        silent: bool = True,\n    ) -> bool:\n        \"\"\"Move a window to a workspace.\n\n        Args:\n            address: Window address (without 'address:' prefix)\n            workspace: Target workspace name or ID\n            silent: If True, don't follow the window\n\n        Returns:\n            True if command succeeded\n        \"\"\"\n        return await self._backend.move_window_to_workspace(address, workspace, silent=silent, log=self.log)\n\n    async def pin_window(self, address: str) -> bool:\n        \"\"\"Toggle pin state of a window.\n\n        Args:\n            address: Window address (without 'address:' prefix)\n\n        Returns:\n            True if command succeeded\n        \"\"\"\n        return await self._backend.pin_window(address, log=self.log)\n\n    async def close_window(self, address: str, silent: bool = True) -> bool:\n        \"\"\"Close a window.\n\n        Args:\n            address: Window address (without 'address:' prefix)\n            silent: If True, don't shift focus (default: True)\n\n        Returns:\n            True if command succeeded\n        \"\"\"\n        return await self._backend.close_window(address, silent=silent, log=self.log)\n\n    async def resize_window(self, address: str, width: int, height: int) -> bool:\n        \"\"\"Resize a window to exact pixel dimensions.\n\n        Args:\n            address: Window address (without 'address:' prefix)\n            width: Target width in pixels\n            height: Target height in pixels\n\n        Returns:\n            True if command succeeded\n        \"\"\"\n        return await self._backend.resize_window(address, width, height, log=self.log)\n\n    async def move_window(self, address: str, x: int, y: int) -> bool:  # pylint: disable=invalid-name\n        \"\"\"Move a window to exact pixel position.\n\n        Args:\n            address: Window address (without 'address:' prefix)\n            x: Target x position in pixels\n            y: Target y position in pixels\n\n        Returns:\n            True if command succeeded\n        \"\"\"\n        return await self._backend.move_window(address, x, y, log=self.log)\n\n    async def toggle_floating(self, address: str) -> bool:\n        \"\"\"Toggle floating state of a window.\n\n        Args:\n            address: Window address (without 'address:' prefix)\n\n        Returns:\n            True if command succeeded\n        \"\"\"\n        return await self._backend.toggle_floating(address, log=self.log)\n\n    async def set_keyword(self, keyword_command: str) -> bool:\n        \"\"\"Execute a keyword/config command.\n\n        Args:\n            keyword_command: The keyword command string\n\n        Returns:\n            True if command succeeded\n        \"\"\"\n        return await self._backend.set_keyword(keyword_command, log=self.log)\n\n    # === Event parsing ===\n\n    def parse_event(self, raw_data: str) -> tuple[str, Any] | None:\n        \"\"\"Parse a raw event string into (event_name, event_data).\n\n        Args:\n            raw_data: Raw event string from the compositor\n\n        Returns:\n            Tuple of (event_name, event_data) or None if parsing failed\n        \"\"\"\n        return self._backend.parse_event(raw_data, log=self.log)\n"
  },
  {
    "path": "pyprland/adapters/units.py",
    "content": "\"\"\"Conversion functions for units used in Pyprland & plugins.\"\"\"\n\nfrom typing import Literal\n\nfrom ..common import is_rotated\nfrom ..models import MonitorInfo\n\nMonitorDimension = Literal[\"width\", \"height\"]\n\n\ndef convert_monitor_dimension(size: int | str, ref_value: int, monitor: MonitorInfo) -> int:\n    \"\"\"Convert `size` into pixels (given a reference value applied to a `monitor`).\n\n    if size is an integer, assumed pixels & return it\n    if size is a string, expects a \"%\" or \"px\" suffix\n    else throws an error\n\n    Args:\n        size: The size to convert (int or string with unit)\n        ref_value: Reference value for percentage calculations\n        monitor: Monitor information\n    \"\"\"\n    if isinstance(size, int):\n        return size\n\n    if isinstance(size, str):\n        if size.endswith(\"%\"):\n            p = int(size[:-1])\n            return int(ref_value / monitor[\"scale\"] * p / 100)\n        if size.endswith(\"px\"):\n            return int(size[:-2])\n\n    msg = f\"Unsupported format: {size} (applied to {ref_value})\"\n    raise ValueError(msg)\n\n\ndef convert_coords(coords: str, monitor: MonitorInfo) -> list[int]:\n    \"\"\"Convert a string like \"X Y\" to coordinates relative to monitor.\n\n    Supported formats for X, Y:\n    - Percentage: \"V%\". V in [0; 100]\n    - Pixels: \"Vpx\". V should fit in your screen and not be zero\n\n    Example:\n    \"10% 20%\", monitor 800x600 => 80, 120\n\n    Args:\n        coords: Coordinates string \"X Y\"\n        monitor: Monitor information\n    \"\"\"\n    coord_list = [coord.strip() for coord in coords.split()]\n    refs: tuple[MonitorDimension, MonitorDimension] = (\"height\", \"width\") if is_rotated(monitor) else (\"width\", \"height\")\n    return [convert_monitor_dimension(name, monitor[ref], monitor) for (name, ref) in zip(coord_list, refs, strict=False)]\n"
  },
  {
    "path": "pyprland/adapters/wayland.py",
    "content": "\"\"\"Generic Wayland backend using wlr-randr for monitor detection.\"\"\"\n\nimport re\nfrom logging import Logger\n\nfrom ..models import MonitorInfo\nfrom .fallback import FallbackBackend, make_monitor_info\nfrom .niri import NIRI_TRANSFORM_MAP\n\n\nclass WaylandBackend(FallbackBackend):\n    \"\"\"Generic Wayland backend using wlr-randr for monitor information.\n\n    Provides monitor detection for the wallpapers plugin on wlroots-based\n    compositors (Sway, etc.). Does not support window management, events,\n    or other compositor features.\n    \"\"\"\n\n    @classmethod\n    async def is_available(cls) -> bool:\n        \"\"\"Check if wlr-randr is available.\n\n        Returns:\n            True if wlr-randr command works\n        \"\"\"\n        return await cls._check_command(\"wlr-randr\")\n\n    async def get_monitors(self, *, log: Logger, include_disabled: bool = False) -> list[MonitorInfo]:\n        \"\"\"Get monitor information from wlr-randr.\n\n        Args:\n            log: Logger to use for this operation\n            include_disabled: If True, include disabled monitors\n\n        Returns:\n            List of MonitorInfo dicts\n        \"\"\"\n        return await self._run_monitor_command(\n            \"wlr-randr\",\n            \"wlr-randr\",\n            self._parse_wlr_randr_output,\n            include_disabled=include_disabled,\n            log=log,\n        )\n\n    def _parse_wlr_randr_output(self, output: str, include_disabled: bool, log: Logger) -> list[MonitorInfo]:\n        \"\"\"Parse wlr-randr output to extract monitor information.\n\n        Example wlr-randr output:\n            DP-1 \"Dell Inc. DELL U2415 ABC123\"\n              Enabled: yes\n              Modes:\n                1920x1200 px, 59.950 Hz (preferred, current)\n                1920x1080 px, 60.000 Hz\n              Position: 0,0\n              Transform: normal\n              Scale: 1.000000\n            HDMI-A-1 \"Some Monitor\"\n              Enabled: no\n              ...\n\n        Args:\n            output: Raw wlr-randr output\n            include_disabled: Whether to include disabled outputs\n            log: Logger for debug output\n\n        Returns:\n            List of MonitorInfo dicts\n        \"\"\"\n        monitors: list[MonitorInfo] = []\n\n        # Split into sections per output (each starts with output name at column 0)\n        sections = re.split(r\"^(?=\\S)\", output, flags=re.MULTILINE)\n\n        for raw_section in sections:\n            section = raw_section.strip()\n            if not section:\n                continue\n\n            monitor = self._parse_output_section(section, len(monitors), log)\n            if monitor is None:\n                continue\n\n            # Skip disabled unless requested\n            if monitor.get(\"disabled\") and not include_disabled:\n                continue\n\n            monitors.append(monitor)\n\n        return monitors\n\n    def _parse_output_section(  # noqa: C901  # pylint: disable=too-many-locals\n        self, section: str, index: int, log: Logger\n    ) -> MonitorInfo | None:\n        \"\"\"Parse a single output section from wlr-randr.\n\n        Args:\n            section: Section text for one output\n            index: Index for this monitor\n            log: Logger for debug output\n\n        Returns:\n            MonitorInfo dict or None if parsing failed\n        \"\"\"\n        lines = section.splitlines()\n        if not lines:\n            return None\n\n        # First line: output name and description\n        # Format: \"DP-1 \"Dell Inc. DELL U2415 ABC123\"\"\n        header_match = re.match(r'^(\\S+)\\s*(?:\"(.+)\")?', lines[0])\n        if not header_match:\n            return None\n\n        name = header_match.group(1)\n        description = header_match.group(2) or name\n\n        # Parse properties\n        enabled = True\n        width, height = 0, 0\n        x, y = 0, 0\n        scale = 1.0\n        transform = 0\n        refresh_rate = 60.0\n\n        for raw_line in lines[1:]:\n            line = raw_line.strip()\n\n            # Enabled: yes/no\n            if line.startswith(\"Enabled:\"):\n                enabled = \"yes\" in line.lower()\n\n            # Position: x,y\n            elif line.startswith(\"Position:\"):\n                pos_match = re.search(r\"(\\d+),\\s*(\\d+)\", line)\n                if pos_match:\n                    x, y = int(pos_match.group(1)), int(pos_match.group(2))\n\n            # Transform: normal/90/180/270/flipped/etc\n            elif line.startswith(\"Transform:\"):\n                transform_str = line.split(\":\", 1)[1].strip()\n                transform = NIRI_TRANSFORM_MAP.get(transform_str, 0)\n\n            # Scale: 1.000000\n            elif line.startswith(\"Scale:\"):\n                try:\n                    scale = float(line.split(\":\", 1)[1].strip())\n                except ValueError:\n                    scale = 1.0\n\n            # Mode line with \"current\": 1920x1200 px, 59.950 Hz (preferred, current)\n            elif \"current\" in line.lower() and \"x\" in line:\n                mode_match = re.match(r\"(\\d+)x(\\d+)\\s*px,\\s*([\\d.]+)\\s*Hz\", line)\n                if mode_match:\n                    width = int(mode_match.group(1))\n                    height = int(mode_match.group(2))\n                    refresh_rate = float(mode_match.group(3))\n\n        # Skip outputs without resolution\n        if width == 0 or height == 0:\n            log.debug(\"wlr-randr: skipping %s (no active mode)\", name)\n            return None\n\n        log.debug(\"wlr-randr monitor: %s %dx%d+%d+%d scale=%.2f transform=%d\", name, width, height, x, y, scale, transform)\n\n        return make_monitor_info(\n            index=index,\n            name=name,\n            width=width,\n            height=height,\n            pos_x=x,\n            pos_y=y,\n            scale=scale,\n            transform=transform,\n            refresh_rate=refresh_rate,\n            enabled=enabled,\n            description=description,\n        )\n"
  },
  {
    "path": "pyprland/adapters/xorg.py",
    "content": "\"\"\"X11/Xorg backend using xrandr for monitor detection.\"\"\"\n# pylint: disable=duplicate-code  # make_monitor_info calls share parameter patterns\n\nimport re\nfrom logging import Logger\n\nfrom ..models import MonitorInfo\nfrom .fallback import FallbackBackend, make_monitor_info\n\n# Map xrandr rotation names to transform integers\n# 0=normal, 1=90° (left), 2=180° (inverted), 3=270° (right)\nTRANSFORM_MAP = {\n    \"normal\": 0,\n    \"left\": 1,\n    \"inverted\": 2,\n    \"right\": 3,\n}\n\n\nclass XorgBackend(FallbackBackend):\n    \"\"\"X11/Xorg backend using xrandr for monitor information.\n\n    Provides monitor detection for the wallpapers plugin on X11 systems.\n    Does not support window management, events, or other compositor features.\n    \"\"\"\n\n    @classmethod\n    async def is_available(cls) -> bool:\n        \"\"\"Check if xrandr is available.\n\n        Returns:\n            True if xrandr command works\n        \"\"\"\n        return await cls._check_command(\"xrandr --version\")\n\n    async def get_monitors(self, *, log: Logger, include_disabled: bool = False) -> list[MonitorInfo]:\n        \"\"\"Get monitor information from xrandr.\n\n        Args:\n            log: Logger to use for this operation\n            include_disabled: If True, include disconnected monitors\n\n        Returns:\n            List of MonitorInfo dicts\n        \"\"\"\n        return await self._run_monitor_command(\n            \"xrandr --query\",\n            \"xrandr\",\n            self._parse_xrandr_output,\n            include_disabled=include_disabled,\n            log=log,\n        )\n\n    def _parse_xrandr_output(  # pylint: disable=too-many-locals\n        self, output: str, include_disabled: bool, log: Logger\n    ) -> list[MonitorInfo]:\n        \"\"\"Parse xrandr --query output to extract monitor information.\n\n        Example xrandr output:\n            DP-1 connected primary 1920x1080+0+0 left (normal left inverted right x axis y axis) 527mm x 296mm\n               1920x1080     60.00*+\n            HDMI-1 connected 2560x1440+1920+0 (normal left inverted right x axis y axis) 597mm x 336mm\n               2560x1440     59.95*+\n            VGA-1 disconnected (normal left inverted right x axis y axis)\n\n        Args:\n            output: Raw xrandr output\n            include_disabled: Whether to include disconnected outputs\n            log: Logger for debug output\n\n        Returns:\n            List of MonitorInfo dicts\n        \"\"\"\n        monitors: list[MonitorInfo] = []\n\n        # Pattern to match connected outputs with resolution\n        # Groups: name, primary?, resolution+position, transform?\n        # Example: \"DP-1 connected primary 1920x1080+0+0 left\"\n        pattern = re.compile(\n            r\"^(\\S+)\\s+(connected|disconnected)\"  # name, status\n            r\"(?:\\s+primary)?\"  # optional primary\n            r\"(?:\\s+(\\d+)x(\\d+)\\+(\\d+)\\+(\\d+))?\"  # optional WxH+X+Y\n            r\"(?:\\s+(normal|left|inverted|right))?\"  # optional transform\n        )\n\n        for line in output.splitlines():\n            match = pattern.match(line)\n            if not match:\n                continue\n\n            name = match.group(1)\n            connected = match.group(2) == \"connected\"\n            width = int(match.group(3)) if match.group(3) else 0\n            height = int(match.group(4)) if match.group(4) else 0\n            x = int(match.group(5)) if match.group(5) else 0\n            y = int(match.group(6)) if match.group(6) else 0\n            transform_str = match.group(7) or \"normal\"\n\n            # Skip disconnected unless requested\n            if not connected and not include_disabled:\n                continue\n\n            # Skip outputs without resolution (not active)\n            if (width == 0 or height == 0) and not include_disabled:\n                continue\n\n            transform = TRANSFORM_MAP.get(transform_str, 0)\n\n            log.debug(\"xrandr monitor: %s %dx%d+%d+%d transform=%d\", name, width, height, x, y, transform)\n\n            # Build MonitorInfo - X11 doesn't have fractional scaling via xrandr\n            monitor = make_monitor_info(\n                index=len(monitors),\n                name=name,\n                width=width,\n                height=height,\n                pos_x=x,\n                pos_y=y,\n                transform=transform,\n                enabled=connected,\n            )\n            monitors.append(monitor)\n\n        return monitors\n"
  },
  {
    "path": "pyprland/aioops.py",
    "content": "\"\"\"Async operation utilities.\n\nProvides fallback sync methods if aiofiles is not installed,\nplus async task management utilities.\n\"\"\"\n\n__all__ = [\n    \"DebouncedTask\",\n    \"TaskManager\",\n    \"aiexists\",\n    \"aiisdir\",\n    \"aiisfile\",\n    \"ailistdir\",\n    \"aiopen\",\n    \"airmdir\",\n    \"airmtree\",\n    \"aiunlink\",\n    \"graceful_cancel_tasks\",\n    \"is_process_running\",\n]\n\nimport asyncio\nimport contextlib\nimport io\nimport os\nimport shutil\nfrom collections.abc import AsyncIterator, Callable, Coroutine\nfrom types import TracebackType\nfrom typing import Any, Self\n\ntry:\n    import aiofiles.os\n    from aiofiles import open as aiopen\n    from aiofiles.os import listdir as ailistdir\n    from aiofiles.os import unlink as aiunlink\n\n    aiexists = aiofiles.os.path.exists\n    aiisdir = aiofiles.os.path.isdir\n    aiisfile = aiofiles.os.path.isfile\nexcept ImportError:\n\n    class AsyncFile:\n        \"\"\"Async file wrapper.\n\n        Args:\n            file: The file object to wrap\n        \"\"\"\n\n        def __init__(self, file: io.TextIOWrapper) -> None:\n            self.file = file\n\n        async def readlines(self) -> list[str]:\n            \"\"\"Read lines.\"\"\"\n            return self.file.readlines()\n\n        async def read(self) -> str:\n            \"\"\"Read lines.\"\"\"\n            return self.file.read()\n\n        async def __aenter__(self) -> Self:\n            return self\n\n        async def __aexit__(\n            self,\n            exc_type: type[BaseException] | None,\n            exc_val: BaseException | None,\n            exc_tb: TracebackType | None,\n        ) -> None:\n            self.file.close()\n\n    @contextlib.asynccontextmanager  # type: ignore[no-redef, unused-ignore]\n    async def aiopen(*args, **kwargs) -> AsyncIterator[AsyncFile]:\n        \"\"\"Async > sync wrapper.\"\"\"\n        with open(*args, **kwargs) as f:  # noqa: ASYNC230, PTH123  # pylint: disable=unspecified-encoding\n            yield AsyncFile(f)\n\n    async def aiexists(*args, **kwargs) -> bool:\n        \"\"\"Async > sync wrapper.\"\"\"\n        return os.path.exists(*args, **kwargs)  # noqa: ASYNC240\n\n    async def aiisdir(*args, **kwargs) -> bool:\n        \"\"\"Async > sync wrapper.\"\"\"\n        return os.path.isdir(*args, **kwargs)  # noqa: ASYNC240\n\n    async def aiisfile(*args, **kwargs) -> bool:\n        \"\"\"Async > sync wrapper.\"\"\"\n        return await asyncio.to_thread(os.path.isfile, *args, **kwargs)\n\n    async def ailistdir(*args, **kwargs) -> list[str]:  # type: ignore[no-redef, unused-ignore]\n        \"\"\"Async > sync wrapper.\"\"\"\n        return await asyncio.to_thread(os.listdir, *args, **kwargs)\n\n    async def aiunlink(*args, **kwargs) -> None:  # type: ignore[no-redef, misc, unused-ignore]\n        \"\"\"Async > sync wrapper.\"\"\"\n        await asyncio.to_thread(os.unlink, *args, **kwargs)\n\n\nasync def airmtree(path: str) -> None:\n    \"\"\"Async wrapper for shutil.rmtree.\n\n    Removes a directory tree recursively.\n\n    Args:\n        path: Directory to remove recursively.\n    \"\"\"\n    await asyncio.to_thread(shutil.rmtree, path)\n\n\nasync def airmdir(path: str) -> None:\n    \"\"\"Async wrapper for os.rmdir.\n\n    Removes an empty directory.\n\n    Args:\n        path: Empty directory to remove.\n    \"\"\"\n    await asyncio.to_thread(os.rmdir, path)\n\n\nasync def is_process_running(name: str) -> bool:\n    \"\"\"Check if a process with the given name is running.\n\n    Uses /proc filesystem to check process names (Linux only).\n\n    Args:\n        name: The process name to search for (matches /proc/<pid>/comm)\n\n    Returns:\n        True if a process with that name is running, False otherwise.\n    \"\"\"\n    for pid in await ailistdir(\"/proc\"):\n        if pid.isdigit():\n            try:\n                async with aiopen(f\"/proc/{pid}/comm\") as f:\n                    if (await f.read()).strip() == name:\n                        return True\n            except OSError:\n                pass  # Process may have exited\n    return False\n\n\nasync def graceful_cancel_tasks(\n    tasks: list[asyncio.Task],\n    timeout: float = 1.0,  # noqa: ASYNC109\n) -> None:\n    \"\"\"Cancel tasks with graceful timeout, then force cancel remaining.\n\n    This is the standard shutdown pattern for async tasks:\n    1. Wait up to `timeout` seconds for tasks to complete gracefully\n    2. Force cancel any tasks still running\n    3. Await all cancelled tasks to ensure cleanup\n\n    Args:\n        tasks: List of tasks to cancel (filters out already-done tasks)\n        timeout: Seconds to wait for graceful completion (default: 1.0)\n    \"\"\"\n    pending = [t for t in tasks if not t.done()]\n    if not pending:\n        return\n\n    # Wait for graceful completion\n    _, still_pending = await asyncio.wait(\n        pending,\n        timeout=timeout,\n        return_when=asyncio.ALL_COMPLETED,\n    )\n\n    # Force cancel remaining\n    for task in still_pending:\n        task.cancel()\n\n    # Await all cancelled tasks\n    for task in still_pending:\n        with contextlib.suppress(asyncio.CancelledError):\n            await task\n\n\nclass DebouncedTask:\n    \"\"\"A debounced async task with ignore window support.\n\n    Useful for plugins that react to events they can also trigger themselves.\n    The ignore window prevents reacting to self-triggered events.\n\n    Usage:\n        # Create instance (typically in on_reload)\n        self._relayout_debouncer = DebouncedTask(ignore_window=3.0)\n\n        # In event handler - schedule with delay\n        self._relayout_debouncer.schedule(self._delayed_relayout, delay=1.0)\n\n        # Before self-triggering actions - set ignore window\n        self._relayout_debouncer.set_ignore_window()\n        await self.backend.execute(cmd, base_command=\"keyword\")\n    \"\"\"\n\n    def __init__(self, ignore_window: float = 3.0) -> None:\n        \"\"\"Initialize the debounced task.\n\n        Args:\n            ignore_window: Duration in seconds to ignore schedule() calls\n                          after set_ignore_window() is called.\n        \"\"\"\n        self._task: asyncio.Task[None] | None = None\n        self._ignore_window = ignore_window\n        self._ignore_until: float = 0\n\n    def schedule(self, coro_func: Callable[[], Coroutine[Any, Any, Any]], delay: float = 0) -> bool:\n        \"\"\"Schedule or reschedule the task.\n\n        Cancels any pending task before scheduling. If within the ignore window,\n        the task is not scheduled.\n\n        Args:\n            coro_func: Async function to call (no arguments)\n            delay: Delay in seconds before executing\n\n        Returns:\n            True if scheduled, False if in ignore window\n        \"\"\"\n        if asyncio.get_event_loop().time() < self._ignore_until:\n            return False\n\n        self.cancel()\n\n        async def _run() -> None:\n            try:\n                if delay > 0:\n                    await asyncio.sleep(delay)\n                await coro_func()\n            except asyncio.CancelledError:\n                pass\n\n        self._task = asyncio.create_task(_run())\n        return True\n\n    def set_ignore_window(self) -> None:\n        \"\"\"Start the ignore window and cancel any pending task.\n\n        Calls to schedule() will be ignored until the window expires.\n        \"\"\"\n        self._ignore_until = asyncio.get_event_loop().time() + self._ignore_window\n        self.cancel()\n\n    def cancel(self) -> None:\n        \"\"\"Cancel any pending task.\"\"\"\n        if self._task and not self._task.done():\n            self._task.cancel()\n            self._task = None\n\n\nclass TaskManager:\n    \"\"\"Manages async tasks with proper lifecycle handling.\n\n    Provides consistent start/stop behavior with graceful shutdown:\n    1. Set running=False and signal stop event (graceful)\n    2. Wait with timeout for tasks to complete\n    3. Cancel remaining tasks if still alive\n    4. Always await to completion\n\n    Similar to ManagedProcess but for asyncio Tasks instead of subprocesses.\n\n    Usage:\n        # Single background loop\n        self._tasks = TaskManager()\n\n        async def on_reload(self):\n            self._tasks.start()\n            self._tasks.create(self._main_loop())\n\n        async def _main_loop(self):\n            while self._tasks.running:\n                await self.do_work()\n                if await self._tasks.sleep(60):\n                    break  # Stop requested\n\n        async def exit(self):\n            await self._tasks.stop()\n\n    Keyed tasks (for per-item tracking like scratchpad hysteresis):\n        self._tasks.create(self._delayed_hide(uid), key=uid)\n        self._tasks.cancel_keyed(uid)  # Cancel specific task\n    \"\"\"\n\n    def __init__(\n        self,\n        graceful_timeout: float = 1.0,\n        on_error: Callable[[asyncio.Task, BaseException], Coroutine[Any, Any, None]] | None = None,\n    ) -> None:\n        \"\"\"Initialize.\n\n        Args:\n            graceful_timeout: Seconds to wait for graceful stop before force cancel\n            on_error: Async callback when a task fails (receives task and exception)\n        \"\"\"\n        self._tasks: list[asyncio.Task] = []\n        self._keyed_tasks: dict[str, asyncio.Task] = {}\n        self._running: bool = False\n        self._stop_event: asyncio.Event | None = None\n        self._graceful_timeout = graceful_timeout\n        self._on_error = on_error\n\n    @property\n    def running(self) -> bool:\n        \"\"\"Check if manager is running (tasks should continue).\"\"\"\n        return self._running\n\n    def start(self) -> None:\n        \"\"\"Mark manager as running. Call before creating tasks.\"\"\"\n        self._running = True\n        self._stop_event = asyncio.Event()\n\n    def create(self, coro: Coroutine[Any, Any, Any], *, key: str | None = None) -> asyncio.Task:\n        \"\"\"Create and track a task.\n\n        Args:\n            coro: Coroutine to run\n            key: Optional key for keyed task (replaces existing task with same key)\n\n        Returns:\n            The created task\n        \"\"\"\n        if key is not None:\n            self.cancel_keyed(key)\n            task = asyncio.create_task(self._wrap_task(coro))\n            self._keyed_tasks[key] = task\n        else:\n            task = asyncio.create_task(self._wrap_task(coro))\n            self._tasks.append(task)\n        return task\n\n    async def _wrap_task(self, coro: Coroutine[Any, Any, Any]) -> None:\n        \"\"\"Wrap a coroutine to handle errors via callback.\"\"\"\n        try:\n            await coro\n        except asyncio.CancelledError:\n            raise\n        except BaseException as e:  # pylint: disable=broad-exception-caught\n            if self._on_error:\n                task = asyncio.current_task()\n                assert task is not None\n                await self._on_error(task, e)\n            else:\n                raise\n\n    def cancel_keyed(self, key: str) -> bool:\n        \"\"\"Cancel a keyed task immediately.\n\n        Args:\n            key: The task key\n\n        Returns:\n            True if task existed and was cancelled\n        \"\"\"\n        task = self._keyed_tasks.pop(key, None)\n        if task and not task.done():\n            task.cancel()\n            return True\n        return False\n\n    async def sleep(self, duration: float) -> bool:\n        \"\"\"Interruptible sleep that respects stop signal.\n\n        Use this instead of asyncio.sleep() in loops.\n\n        Args:\n            duration: Sleep duration in seconds\n\n        Returns:\n            True if interrupted (should exit loop), False if completed normally\n        \"\"\"\n        if self._stop_event is None:\n            await asyncio.sleep(duration)\n            return not self._running\n        try:\n            await asyncio.wait_for(self._stop_event.wait(), timeout=duration)\n        except TimeoutError:\n            return False  # Sleep completed normally\n        return True  # Stop event was set\n\n    async def stop(self) -> None:\n        \"\"\"Stop all tasks with graceful timeout.\n\n        Shutdown sequence (mirrors ManagedProcess):\n        1. Set running=False and signal stop event (graceful)\n        2. Wait up to graceful_timeout for tasks to complete\n        3. Cancel remaining tasks if still alive\n        4. Await all tasks to completion\n        \"\"\"\n        self._running = False\n        if self._stop_event:\n            self._stop_event.set()\n\n        all_tasks = self._tasks + list(self._keyed_tasks.values())\n        await graceful_cancel_tasks(all_tasks, timeout=self._graceful_timeout)\n\n        self._tasks.clear()\n        self._keyed_tasks.clear()\n        self._stop_event = None\n"
  },
  {
    "path": "pyprland/ansi.py",
    "content": "\"\"\"ANSI terminal color utilities.\n\nProvides constants and helpers for terminal coloring with proper\nNO_COLOR environment variable support and TTY detection.\n\"\"\"\n\nimport os\nimport sys\nfrom typing import TextIO\n\n__all__ = [\n    \"BLACK\",\n    \"BLUE\",\n    \"BOLD\",\n    \"CYAN\",\n    \"DIM\",\n    \"GREEN\",\n    \"RED\",\n    \"RESET\",\n    \"YELLOW\",\n    \"HandlerStyles\",\n    \"LogStyles\",\n    \"colorize\",\n    \"make_style\",\n    \"should_colorize\",\n]\n\n# ANSI escape sequence prefix\n_ESC = \"\\x1b[\"\n\n# Reset all attributes\nRESET = f\"{_ESC}0m\"\n\n# Style codes\nBOLD = \"1\"\nDIM = \"2\"\n\n# Foreground color codes\nBLACK = \"30\"\nRED = \"31\"\nGREEN = \"32\"\nYELLOW = \"33\"\nBLUE = \"34\"\nCYAN = \"36\"\n\n\ndef should_colorize(stream: TextIO | None = None) -> bool:\n    \"\"\"Determine if ANSI colors should be used for the given stream.\n\n    Respects:\n    - NO_COLOR environment variable (disables colors)\n    - FORCE_COLOR environment variable (forces colors)\n    - TTY detection (disables colors when piping)\n\n    Args:\n        stream: The output stream to check. Defaults to sys.stderr.\n\n    Returns:\n        True if colors should be used, False otherwise.\n    \"\"\"\n    if os.environ.get(\"NO_COLOR\"):\n        return False\n    if os.environ.get(\"FORCE_COLOR\"):\n        return True\n    if stream is None:\n        stream = sys.stderr\n    return hasattr(stream, \"isatty\") and stream.isatty()\n\n\ndef colorize(text: str, *codes: str) -> str:\n    \"\"\"Wrap text in ANSI color codes.\n\n    Args:\n        text: The text to colorize.\n        *codes: ANSI codes to apply (e.g., RED, BOLD).\n\n    Returns:\n        The text wrapped in ANSI escape sequences.\n    \"\"\"\n    if not codes:\n        return text\n    return f\"{_ESC}{';'.join(codes)}m{text}{RESET}\"\n\n\ndef make_style(*codes: str) -> tuple[str, str]:\n    \"\"\"Create a style prefix and suffix pair.\n\n    Args:\n        *codes: ANSI codes to apply.\n\n    Returns:\n        Tuple of (prefix, suffix) strings for use in formatters.\n    \"\"\"\n    if not codes:\n        return (\"\", RESET)\n    return (f\"{_ESC}{';'.join(codes)}m\", RESET)\n\n\nclass LogStyles:\n    \"\"\"Pre-built styles for log levels.\"\"\"\n\n    WARNING = (YELLOW, DIM)\n    ERROR = (RED, DIM)\n    CRITICAL = (RED, BOLD)\n\n\nclass HandlerStyles:\n    \"\"\"Pre-built styles for handler logging.\"\"\"\n\n    COMMAND = (YELLOW, BOLD)  # run_* methods\n    EVENT = (BLACK, BOLD)  # event_* methods\n"
  },
  {
    "path": "pyprland/client.py",
    "content": "\"\"\"Client-side functions for pyprland CLI.\"\"\"\n\nimport asyncio\nimport contextlib\nimport os\nimport sys\n\nfrom . import constants as pyprland_constants\nfrom .commands.parsing import normalize_command_name\nfrom .common import get_logger, notify_send, run_interactive_program\nfrom .models import ExitCode, ResponsePrefix\n\n__all__ = [\"run_client\"]\n\n\ndef _get_config_file_path() -> str:\n    \"\"\"Get the config file path, checking new location then legacy fallback.\n\n    Returns:\n        Path to the config file as a string.\n    \"\"\"\n    config_path = pyprland_constants.CONFIG_FILE\n    legacy_path = pyprland_constants.LEGACY_CONFIG_FILE\n    if config_path.exists():\n        return str(config_path)\n    if legacy_path.exists():\n        return str(legacy_path)\n    # Default to new path (will be created by user)\n    return str(config_path)\n\n\nasync def run_client() -> None:\n    \"\"\"Run the client (CLI).\"\"\"\n    log = get_logger(\"client\")\n\n    if sys.argv[1] == \"edit\":\n        editor = os.environ.get(\"EDITOR\", os.environ.get(\"VISUAL\", \"vi\"))\n        filename = _get_config_file_path()\n        run_interactive_program(f'{editor} \"{filename}\"')\n        sys.argv[1] = \"reload\"\n\n    elif sys.argv[1] == \"validate\":\n        # Validate doesn't require daemon - run locally and exit\n        from .validate_cli import run_validate  # noqa: PLC0415\n\n        run_validate()\n        return\n\n    elif sys.argv[1] in {\"--help\", \"-h\"}:\n        sys.argv[1] = \"help\"\n\n    try:\n        reader, writer = await asyncio.open_unix_connection(pyprland_constants.CONTROL)\n    except (ConnectionRefusedError, FileNotFoundError):\n        log.critical(\n            \"Cannot connect to pyprland daemon at %s.\\nIs the daemon running? Start it with: pypr (no arguments)\",\n            pyprland_constants.CONTROL,\n        )\n        with contextlib.suppress(Exception):\n            await notify_send(\"Pypr can't connect. Is daemon running?\", icon=\"dialog-error\")\n        sys.exit(ExitCode.CONNECTION_ERROR)\n\n    args = sys.argv[1:]\n    args[0] = normalize_command_name(args[0])\n    writer.write((\" \".join(args) + \"\\n\").encode())\n    writer.write_eof()\n    await writer.drain()\n    return_value = (await reader.read()).decode(\"utf-8\")\n    writer.close()\n    await writer.wait_closed()\n\n    # Parse response and set exit code\n    if return_value.startswith(f\"{ResponsePrefix.ERROR}:\"):\n        # Extract error message (skip \"ERROR: \" prefix)\n        error_msg = return_value[len(ResponsePrefix.ERROR) + 2 :].strip()\n        print(f\"Error: {error_msg}\", file=sys.stderr)\n        sys.exit(ExitCode.COMMAND_ERROR)\n    elif return_value.startswith(f\"{ResponsePrefix.OK}\"):\n        # Command succeeded, check for additional output after OK\n        remaining = return_value[len(ResponsePrefix.OK) :]\n        if remaining.startswith(\": \"):\n            # Success message format: \"OK: message\\n\"\n            print(remaining[2:].strip())\n        elif remaining.startswith(\"\\n\"):\n            # Content format: \"OK\\n<content>\"\n            content = remaining[1:].rstrip(\"\\n\")\n            if content:\n                print(content)\n        # \"OK\\n\" with no content: print nothing\n        sys.exit(ExitCode.SUCCESS)\n    else:\n        # Legacy response (version, help, dumpjson) - print as-is\n        print(return_value.rstrip())\n        sys.exit(ExitCode.SUCCESS)\n"
  },
  {
    "path": "pyprland/command.py",
    "content": "\"\"\"Pyprland - an Hyprland companion app (cli client & daemon).\"\"\"\n\nimport asyncio\nimport json\nimport sys\nfrom pathlib import Path\nfrom typing import Literal, overload\n\nfrom . import constants as pyprland_constants\nfrom .common import get_logger, init_logger\nfrom .constants import CONTROL\nfrom .ipc import init as ipc_init\nfrom .models import PyprError\n\n__all__: list[str] = [\"main\"]\n\n\n@overload\ndef use_param(txt: str, optional_value: Literal[False] = ...) -> str: ...\n\n\n@overload\ndef use_param(txt: str, optional_value: Literal[True]) -> str | bool: ...\n\n\ndef use_param(txt: str, optional_value: bool = False) -> str | bool:\n    \"\"\"Check if parameter `txt` is in sys.argv.\n\n    If found, removes it from sys.argv & returns the argument value.\n    If optional_value is True, the parameter value is optional.\n\n    Args:\n        txt: Parameter name to look for\n        optional_value: If True, value after parameter is optional\n\n    Returns:\n        - \"\" if parameter not present\n        - True if parameter present but no value (only when optional_value=True)\n        - The value string if parameter present with value\n    \"\"\"\n    if txt not in sys.argv:\n        return \"\"\n    i = sys.argv.index(txt)\n    # Check if there's a next arg and it's not a flag\n    if optional_value and (i + 1 >= len(sys.argv) or sys.argv[i + 1].startswith(\"-\")):\n        del sys.argv[i]\n        return True\n    v = sys.argv[i + 1]\n    del sys.argv[i : i + 2]\n    return v\n\n\ndef _run(invoke_daemon: bool) -> None:\n    \"\"\"Run the daemon or client, handling errors.\"\"\"\n    log = get_logger(\"startup\")\n    try:\n        if invoke_daemon:\n            from .pypr_daemon import run_daemon  # noqa: PLC0415\n\n            asyncio.run(run_daemon())\n        else:\n            from .client import run_client  # noqa: PLC0415\n\n            asyncio.run(run_client())\n    except KeyboardInterrupt:\n        pass\n    except PyprError:\n        log.critical(\"Command failed.\")\n    except json.decoder.JSONDecodeError as e:\n        log.critical(\"Invalid JSON syntax in the config file: %s\", e.args[0])\n    except Exception:  # pylint: disable=W0718\n        log.critical(\"Unhandled exception:\", exc_info=True)\n    finally:\n        if invoke_daemon and Path(CONTROL).exists():\n            Path(CONTROL).unlink()\n\n\ndef main() -> None:\n    \"\"\"Run the command.\"\"\"\n    debug_flag = use_param(\"--debug\", optional_value=True)\n    if debug_flag:\n        filename = debug_flag if isinstance(debug_flag, str) else None\n        init_logger(filename=filename, force_debug=True)\n    else:\n        init_logger()\n    ipc_init()\n\n    config_override = use_param(\"--config\")\n    if config_override:\n        pyprland_constants.CONFIG_FILE = Path(config_override)\n\n    invoke_daemon = len(sys.argv) <= 1\n    if invoke_daemon and Path(CONTROL).exists():\n        get_logger(\"startup\").critical(\n            \"\"\"%s exists,\nis pypr already running ?\nIf that's not the case, delete this file and run again.\"\"\",\n            CONTROL,\n        )\n    else:\n        _run(invoke_daemon)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "pyprland/commands/__init__.py",
    "content": "\"\"\"Command handling utilities for pyprland.\n\nThis package provides:\n- models: Data structures (CommandArg, CommandInfo, CommandNode, CLIENT_COMMANDS)\n- parsing: Docstring and command name parsing\n- discovery: Command extraction from plugins\n- tree: Hierarchical command tree building\n\"\"\"\n"
  },
  {
    "path": "pyprland/commands/discovery.py",
    "content": "\"\"\"Command extraction and discovery from plugins.\"\"\"\n\nfrom __future__ import annotations\n\nimport inspect\nfrom typing import TYPE_CHECKING\n\nfrom .models import CLIENT_COMMANDS, CommandInfo\nfrom .parsing import parse_docstring\n\nif TYPE_CHECKING:\n    from ..manager import Pyprland\n\n__all__ = [\"extract_commands_from_object\", \"get_all_commands\", \"get_client_commands\"]\n\n\ndef extract_commands_from_object(obj: object, source: str) -> list[CommandInfo]:\n    \"\"\"Extract commands from a plugin class or instance.\n\n    Works with both classes (for docs generation) and instances (runtime).\n    Looks for methods starting with \"run_\" and extracts their docstrings.\n\n    Args:\n        obj: A plugin class or instance\n        source: The source identifier (plugin name, \"built-in\", or \"client\")\n\n    Returns:\n        List of CommandInfo objects\n    \"\"\"\n    commands: list[CommandInfo] = []\n\n    for name in dir(obj):\n        if not name.startswith(\"run_\"):\n            continue\n\n        method = getattr(obj, name)\n        if not callable(method):\n            continue\n\n        command_name = name[4:]  # Remove 'run_' prefix\n        docstring = inspect.getdoc(method) or \"\"\n\n        args, short_desc, full_desc = parse_docstring(docstring)\n\n        commands.append(\n            CommandInfo(\n                name=command_name,\n                args=args,\n                short_description=short_desc,\n                full_description=full_desc,\n                source=source,\n            )\n        )\n\n    return commands\n\n\ndef get_client_commands() -> list[CommandInfo]:\n    \"\"\"Get client-only commands (edit, validate).\n\n    These commands run on the client side and don't go through the daemon.\n\n    Returns:\n        List of CommandInfo for client-only commands\n    \"\"\"\n    commands: list[CommandInfo] = []\n    for name, doc in CLIENT_COMMANDS.items():\n        args, short_desc, full_desc = parse_docstring(doc)\n        commands.append(\n            CommandInfo(\n                name=name,\n                args=args,\n                short_description=short_desc,\n                full_description=full_desc,\n                source=\"client\",\n            )\n        )\n    return commands\n\n\ndef get_all_commands(manager: Pyprland) -> dict[str, CommandInfo]:\n    \"\"\"Get all commands from plugins and client.\n\n    Args:\n        manager: The Pyprland manager instance with loaded plugins\n\n    Returns:\n        Dict mapping command name to CommandInfo\n    \"\"\"\n    commands: dict[str, CommandInfo] = {}\n\n    # Extract from all plugins\n    for plugin in manager.plugins.values():\n        source = \"built-in\" if plugin.name == \"pyprland\" else plugin.name\n        for cmd in extract_commands_from_object(plugin, source):\n            commands[cmd.name] = cmd\n\n    # Add client-only commands\n    for cmd in get_client_commands():\n        commands[cmd.name] = cmd\n\n    return commands\n"
  },
  {
    "path": "pyprland/commands/models.py",
    "content": "\"\"\"Data models for command handling.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\n\n__all__ = [\"CLIENT_COMMANDS\", \"CommandArg\", \"CommandInfo\", \"CommandNode\"]\n\n# Client-only commands with their docstrings (not sent to daemon)\nCLIENT_COMMANDS: dict[str, str] = {\n    \"edit\": \"\"\"Open the configuration file in $EDITOR, then reload.\n\nOpens pyprland.toml in your preferred editor (EDITOR or VISUAL env var,\ndefaults to vi). After the editor closes, the configuration is reloaded.\"\"\",\n    \"validate\": \"\"\"Validate the configuration file.\n\nChecks the configuration file for syntax errors and validates plugin\nconfigurations against their schemas. Does not require the daemon.\"\"\",\n}\n\n\n@dataclass\nclass CommandArg:\n    \"\"\"An argument parsed from a command's docstring.\"\"\"\n\n    value: str  # e.g., \"next|pause|clear\" or \"name\"\n    required: bool  # True for <arg>, False for [arg]\n\n\n@dataclass\nclass CommandInfo:\n    \"\"\"Complete information about a command.\"\"\"\n\n    name: str\n    args: list[CommandArg]\n    short_description: str\n    full_description: str\n    source: str  # \"built-in\", plugin name, or \"client\"\n\n\n@dataclass\nclass CommandNode:\n    \"\"\"A node in the command hierarchy.\n\n    Used to represent commands with subcommands (e.g., \"wall next\", \"wall pause\").\n    A node can have both its own handler (info) and children subcommands.\n    \"\"\"\n\n    name: str  # The segment name (e.g., \"wall\" or \"next\")\n    full_name: str  # Full command name (e.g., \"wall\" or \"wall_next\")\n    info: CommandInfo | None = None  # Command info if this node is callable\n    children: dict[str, CommandNode] = field(default_factory=dict)\n"
  },
  {
    "path": "pyprland/commands/parsing.py",
    "content": "\"\"\"Docstring and command name parsing utilities.\"\"\"\n\nfrom __future__ import annotations\n\nimport re\n\nfrom .models import CommandArg\n\n__all__ = [\"normalize_command_name\", \"parse_docstring\"]\n\n# Regex pattern to match args: <required> or [optional]\n_ARG_PATTERN = re.compile(r\"([<\\[])([^>\\]]+)([>\\]])\")\n\n\ndef normalize_command_name(cmd: str) -> str:\n    \"\"\"Normalize a user-typed command to internal format.\n\n    Converts spaces and hyphens to underscores.\n    E.g., \"wall rm\" -> \"wall_rm\", \"toggle-special\" -> \"toggle_special\"\n\n    Args:\n        cmd: User-typed command string\n\n    Returns:\n        Normalized command name with underscores\n    \"\"\"\n    return cmd.replace(\"-\", \"_\").replace(\" \", \"_\")\n\n\ndef parse_docstring(docstring: str) -> tuple[list[CommandArg], str, str]:\n    \"\"\"Parse a docstring to extract arguments and descriptions.\n\n    The first line may contain arguments like:\n    \"<arg> Short description\" or \"[optional_arg] Short description\"\n\n    Args:\n        docstring: The raw docstring to parse\n\n    Returns:\n        Tuple of (args, short_description, full_description)\n        - args: List of CommandArg objects\n        - short_description: Text after arguments on first line\n        - full_description: Complete docstring\n    \"\"\"\n    if not docstring:\n        return [], \"No description available.\", \"\"\n\n    full_description = docstring.strip()\n    lines = full_description.split(\"\\n\")\n    first_line = lines[0].strip()\n\n    args: list[CommandArg] = []\n    last_end = 0\n\n    # Find all args at the start of the line\n    for match in _ARG_PATTERN.finditer(first_line):\n        # Check if this match is at the expected position (start or after whitespace)\n        if match.start() != last_end and first_line[last_end : match.start()].strip():\n            # There's non-whitespace before this match, stop parsing args\n            break\n\n        bracket_open = match.group(1)\n        content = match.group(2)\n        required = bracket_open == \"<\"\n        args.append(CommandArg(value=content, required=required))\n        last_end = match.end()\n\n        # Skip any whitespace after the arg\n        while last_end < len(first_line) and first_line[last_end] == \" \":\n            last_end += 1\n\n    # The short description is what comes after the args\n    if args:\n        short_description = first_line[last_end:].strip()\n        if not short_description:\n            short_description = first_line\n    else:\n        short_description = first_line\n\n    return args, short_description, full_description\n"
  },
  {
    "path": "pyprland/commands/tree.py",
    "content": "\"\"\"Hierarchical command tree building and display name utilities.\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING\n\nfrom .models import CommandInfo, CommandNode\nfrom .parsing import normalize_command_name\n\nif TYPE_CHECKING:\n    from collections.abc import Iterable\n\n__all__ = [\"build_command_tree\", \"get_display_name\", \"get_parent_prefixes\"]\n\n\ndef get_parent_prefixes(commands: dict[str, str] | Iterable[str]) -> set[str]:\n    \"\"\"Identify prefixes that have multiple child commands from the same source.\n\n    A prefix becomes a parent node when more than one command from the\n    SAME source/plugin shares it. This prevents unrelated commands like\n    toggle_special and toggle_dpms (from different plugins) from being grouped.\n\n    Args:\n        commands: Either a dict mapping command name -> source/plugin name,\n                  or an iterable of command names (legacy, no source filtering)\n\n    Returns:\n        Set of prefixes that should become parent nodes\n    \"\"\"\n    # Handle legacy call with just command names (no source info)\n    if not isinstance(commands, dict):\n        commands = dict.fromkeys(commands, \"\")\n\n    # Group commands by (prefix, source) to find true hierarchies\n    prefix_source_counts: dict[tuple[str, str], int] = {}\n    for name, source in commands.items():\n        parts = name.split(\"_\")\n        for i in range(1, len(parts)):\n            prefix = \"_\".join(parts[:i])\n            key = (prefix, source)\n            prefix_source_counts[key] = prefix_source_counts.get(key, 0) + 1\n\n    # A prefix is a parent only if multiple commands from same source share it\n    return {prefix for (prefix, _source), count in prefix_source_counts.items() if count > 1}\n\n\ndef get_display_name(cmd_name: str, parent_prefixes: set[str]) -> str:\n    \"\"\"Get the user-facing display name for a command.\n\n    Converts underscore-separated hierarchical commands to space-separated.\n    E.g., \"wall_rm\" -> \"wall rm\" if \"wall\" is a parent prefix.\n    Non-hierarchical commands stay unchanged: \"shift_monitors\" -> \"shift_monitors\"\n\n    Args:\n        cmd_name: The internal command name (underscore-separated)\n        parent_prefixes: Set of prefixes that have multiple children\n\n    Returns:\n        The display name (space-separated for hierarchical commands)\n    \"\"\"\n    parts = cmd_name.split(\"_\")\n    for i in range(1, len(parts)):\n        prefix = \"_\".join(parts[:i])\n        if prefix in parent_prefixes:\n            subcommand = \"_\".join(parts[i:])\n            return f\"{prefix} {subcommand}\"\n    return cmd_name\n\n\ndef build_command_tree(commands: dict[str, CommandInfo]) -> dict[str, CommandNode]:\n    \"\"\"Build hierarchical command tree from flat command names.\n\n    Groups commands with shared prefixes into a tree structure.\n    For example, wall_next, wall_pause, wall_clear become children of \"wall\".\n\n    Only creates hierarchy when multiple commands share a prefix:\n    - wall_next + wall_pause -> wall: {next, pause}\n    - layout_center (alone) -> layout_center (no split)\n\n    Accepts command names in both formats:\n    - Internal format: wall_next (underscore-separated)\n    - Display format: wall next (space-separated)\n\n    Args:\n        commands: Dict mapping command name to CommandInfo\n\n    Returns:\n        Dict mapping root command names to CommandNode trees\n    \"\"\"\n    # Normalize names to internal format (underscore) for tree building\n    normalized_commands = {normalize_command_name(name): info for name, info in commands.items()}\n    parent_prefixes = get_parent_prefixes({name: info.source for name, info in normalized_commands.items()})\n\n    # Build the tree\n    roots: dict[str, CommandNode] = {}\n\n    for name, info in sorted(normalized_commands.items()):\n        parts = name.split(\"_\")\n\n        # Find the longest parent prefix for this command\n        parent_depth = 0\n        for i in range(1, len(parts)):\n            prefix = \"_\".join(parts[:i])\n            if prefix in parent_prefixes:\n                parent_depth = i\n\n        if parent_depth == 0:\n            # No parent prefix - this is a root command\n            if name not in roots:\n                roots[name] = CommandNode(name=name, full_name=name, info=info)\n            else:\n                roots[name].info = info\n        else:\n            # Has a parent prefix - add to tree\n            root_name = \"_\".join(parts[:parent_depth])\n\n            # Ensure root node exists\n            if root_name not in roots:\n                # Check if root itself is a command\n                root_info = normalized_commands.get(root_name)\n                roots[root_name] = CommandNode(name=root_name, full_name=root_name, info=root_info)\n\n            # Add this command as a child\n            if name != root_name:\n                child_name = \"_\".join(parts[parent_depth:])\n                roots[root_name].children[child_name] = CommandNode(\n                    name=child_name,\n                    full_name=name,\n                    info=info,\n                )\n\n    return roots\n"
  },
  {
    "path": "pyprland/common.py",
    "content": "\"\"\"Shared utilities - re-exports from focused modules for backward compatibility.\n\nThis module aggregates exports from specialized modules (debug, ipc_paths,\nlogging_setup, state, terminal, utils) providing a single import point\nfor commonly used functions and classes.\n\nNote: For new code, prefer importing directly from the specific modules.\n\"\"\"\n\n# Re-export from focused modules\nfrom .debug import DEBUG, is_debug, set_debug\nfrom .ipc_paths import (\n    HYPRLAND_INSTANCE_SIGNATURE,\n    IPC_FOLDER,\n    MINIMUM_ADDR_LEN,\n    MINIMUM_FULL_ADDR_LEN,\n    init_ipc_folder,\n)\nfrom .logging_setup import LogObjects, get_logger, init_logger\nfrom .state import SharedState\nfrom .terminal import run_interactive_program, set_raw_mode, set_terminal_size\nfrom .utils import apply_filter, apply_variables, is_rotated, merge, notify_send\n\n__all__ = [\n    \"DEBUG\",\n    \"HYPRLAND_INSTANCE_SIGNATURE\",\n    \"IPC_FOLDER\",\n    \"MINIMUM_ADDR_LEN\",\n    \"MINIMUM_FULL_ADDR_LEN\",\n    \"LogObjects\",\n    \"SharedState\",\n    \"apply_filter\",\n    \"apply_variables\",\n    \"get_logger\",\n    \"init_ipc_folder\",\n    \"init_logger\",\n    \"is_debug\",\n    \"is_rotated\",\n    \"merge\",\n    \"notify_send\",\n    \"run_interactive_program\",\n    \"set_debug\",\n    \"set_raw_mode\",\n    \"set_terminal_size\",\n]\n"
  },
  {
    "path": "pyprland/completions/__init__.py",
    "content": "\"\"\"Shell completion generators for pyprland.\n\nGenerates dynamic shell completions based on loaded plugins and configuration.\nSupports positional argument awareness with type-specific completions.\n\nThis package provides:\n- Command completion discovery from loaded plugins\n- Shell-specific completion script generators (bash, zsh, fish)\n- CLI handler for the `pypr compgen` command\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom .discovery import get_command_completions\nfrom .generators import GENERATORS\nfrom .handlers import get_default_path, handle_compgen\nfrom .models import CommandCompletion, CompletionArg\n\n__all__ = [\n    \"GENERATORS\",\n    \"CommandCompletion\",\n    \"CompletionArg\",\n    \"get_command_completions\",\n    \"get_default_path\",\n    \"handle_compgen\",\n]\n"
  },
  {
    "path": "pyprland/completions/discovery.py",
    "content": "\"\"\"Command completion discovery.\n\nExtracts structured completion data from loaded plugins and configuration.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING\n\nfrom ..commands.discovery import get_all_commands\nfrom ..commands.tree import build_command_tree\nfrom ..constants import SUPPORTED_SHELLS\nfrom .models import (\n    HINT_ARGS,\n    KNOWN_COMPLETIONS,\n    SCRATCHPAD_COMMANDS,\n    CommandCompletion,\n    CompletionArg,\n)\n\nif TYPE_CHECKING:\n    from ..commands.models import CommandInfo, CommandNode\n    from ..manager import Pyprland\n\n__all__ = [\"get_command_completions\"]\n\n\ndef _classify_arg(\n    arg_value: str,\n    cmd_name: str,\n    scratchpad_names: list[str],\n) -> tuple[str, list[str]]:\n    \"\"\"Classify an argument and determine its completion type and values.\n\n    Args:\n        arg_value: The argument value from docstring (e.g., \"next|pause|clear\")\n        cmd_name: The command name (for context-specific handling)\n        scratchpad_names: Available scratchpad names from config\n\n    Returns:\n        Tuple of (completion_type, values)\n    \"\"\"\n    # Check for pipe-separated choices\n    if \"|\" in arg_value:\n        return (\"choices\", arg_value.split(\"|\"))\n\n    # Check for scratchpad commands with \"name\" arg\n    if arg_value == \"name\" and cmd_name in SCRATCHPAD_COMMANDS:\n        return (\"dynamic\", scratchpad_names)\n\n    # Check for known completions\n    if arg_value in KNOWN_COMPLETIONS:\n        return (\"choices\", KNOWN_COMPLETIONS[arg_value])\n\n    # Check for literal values (like \"json\")\n    if arg_value == \"json\":\n        return (\"literal\", [arg_value])\n\n    # Check for hint args\n    if arg_value in HINT_ARGS:\n        return (\"hint\", [HINT_ARGS[arg_value]])\n\n    # Default: no completion, show arg name as hint\n    return (\"hint\", [arg_value])\n\n\ndef _build_completion_args(\n    cmd_name: str,\n    cmd_info: CommandInfo | None,\n    scratchpad_names: list[str],\n) -> list[CompletionArg]:\n    \"\"\"Build completion args from a CommandInfo.\"\"\"\n    if cmd_info is None:\n        return []\n    completion_args: list[CompletionArg] = []\n    for pos, arg in enumerate(cmd_info.args, start=1):\n        comp_type, values = _classify_arg(arg.value, cmd_name, scratchpad_names)\n        completion_args.append(\n            CompletionArg(\n                position=pos,\n                completion_type=comp_type,\n                values=values,\n                required=arg.required,\n                description=arg.value,\n            )\n        )\n    return completion_args\n\n\ndef _build_command_from_node(\n    root_name: str,\n    node: CommandNode,\n    scratchpad_names: list[str],\n) -> CommandCompletion:\n    \"\"\"Build a CommandCompletion from a CommandNode.\"\"\"\n    # Build subcommands dict\n    subcommands: dict[str, CommandCompletion] = {}\n    for child_name, child_node in node.children.items():\n        if child_node.info:\n            subcommands[child_name] = CommandCompletion(\n                name=child_name,\n                args=_build_completion_args(child_node.full_name, child_node.info, scratchpad_names),\n                description=child_node.info.short_description,\n            )\n\n    # Build root command completion\n    root_args: list[CompletionArg] = []\n    root_desc = \"\"\n    if node.info:\n        root_args = _build_completion_args(root_name, node.info, scratchpad_names)\n        root_desc = node.info.short_description\n\n    return CommandCompletion(\n        name=root_name,\n        args=root_args,\n        description=root_desc,\n        subcommands=subcommands,\n    )\n\n\ndef _apply_command_overrides(commands: dict[str, CommandCompletion], manager: Pyprland) -> None:\n    \"\"\"Apply special overrides for built-in commands (help, compgen, doc).\"\"\"\n    all_cmd_names = sorted(commands.keys())\n\n    # Build subcommand completions for help\n    help_subcommands: dict[str, CommandCompletion] = {}\n    for cmd_name, cmd in commands.items():\n        if cmd.subcommands:\n            help_subcommands[cmd_name] = CommandCompletion(\n                name=cmd_name,\n                args=[\n                    CompletionArg(\n                        position=1,\n                        completion_type=\"choices\",\n                        values=sorted(cmd.subcommands.keys()),\n                        required=False,\n                        description=\"subcommand\",\n                    )\n                ],\n                description=f\"Subcommands of {cmd_name}\",\n            )\n\n    if \"help\" in commands:\n        commands[\"help\"] = CommandCompletion(\n            name=\"help\",\n            args=[\n                CompletionArg(\n                    position=1,\n                    completion_type=\"choices\",\n                    values=all_cmd_names,\n                    required=False,\n                    description=\"command\",\n                )\n            ],\n            description=commands[\"help\"].description or \"Show available commands or detailed help\",\n            subcommands=help_subcommands,\n        )\n\n    if \"compgen\" in commands:\n        commands[\"compgen\"] = CommandCompletion(\n            name=\"compgen\",\n            args=[\n                CompletionArg(\n                    position=1,\n                    completion_type=\"choices\",\n                    values=list(SUPPORTED_SHELLS),\n                    required=True,\n                    description=\"shell\",\n                ),\n                CompletionArg(\n                    position=2,\n                    completion_type=\"choices\",\n                    values=[\"default\"],\n                    required=False,\n                    description=\"path\",\n                ),\n            ],\n            description=commands[\"compgen\"].description or \"Generate shell completions\",\n        )\n\n    if \"doc\" in commands:\n        plugin_names = [p for p in manager.plugins if p != \"pyprland\"]\n        commands[\"doc\"] = CommandCompletion(\n            name=\"doc\",\n            args=[\n                CompletionArg(\n                    position=1,\n                    completion_type=\"choices\",\n                    values=sorted(plugin_names),\n                    required=False,\n                    description=\"plugin\",\n                )\n            ],\n            description=commands[\"doc\"].description or \"Show plugin documentation\",\n        )\n\n\ndef get_command_completions(manager: Pyprland) -> dict[str, CommandCompletion]:\n    \"\"\"Extract structured completion data from loaded plugins.\n\n    Args:\n        manager: The Pyprland manager instance with loaded plugins\n\n    Returns:\n        Dict mapping command name -> CommandCompletion (with subcommands for hierarchical commands)\n    \"\"\"\n    scratchpad_names: list[str] = list(manager.config.get(\"scratchpads\", {}).keys())\n    command_tree = build_command_tree(get_all_commands(manager))\n\n    commands: dict[str, CommandCompletion] = {}\n    for root_name, node in command_tree.items():\n        commands[root_name] = _build_command_from_node(root_name, node, scratchpad_names)\n\n    _apply_command_overrides(commands, manager)\n\n    return commands\n"
  },
  {
    "path": "pyprland/completions/generators/__init__.py",
    "content": "\"\"\"Shell completion generators.\n\nProvides generator functions for each supported shell.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING\n\nfrom .bash import generate_bash\nfrom .fish import generate_fish\nfrom .zsh import generate_zsh\n\nif TYPE_CHECKING:\n    from collections.abc import Callable\n\n    from ..models import CommandCompletion\n\n__all__ = [\"GENERATORS\", \"generate_bash\", \"generate_fish\", \"generate_zsh\"]\n\nGENERATORS: dict[str, Callable[[dict[str, CommandCompletion]], str]] = {\n    \"bash\": generate_bash,\n    \"zsh\": generate_zsh,\n    \"fish\": generate_fish,\n}\n"
  },
  {
    "path": "pyprland/completions/generators/bash.py",
    "content": "\"\"\"Bash shell completion generator.\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING\n\nif TYPE_CHECKING:\n    from ..models import CommandCompletion\n\n__all__ = [\"generate_bash\"]\n\n\ndef _build_subcommand_case(cmd_name: str, cmd: CommandCompletion) -> str:\n    \"\"\"Build bash case statement for a command with subcommands.\"\"\"\n    subcmd_list = \" \".join(sorted(cmd.subcommands.keys()))\n    # Position 1 is subcommand selection\n    subcmd_cases: list[str] = [f'                1) COMPREPLY=($(compgen -W \"{subcmd_list}\" -- \"$cur\"));;']\n\n    # Build per-subcommand argument completions at position 2+\n    for subcmd_name, subcmd in sorted(cmd.subcommands.items()):\n        for arg in subcmd.args:\n            if arg.completion_type in (\"choices\", \"dynamic\", \"literal\"):\n                values_str = \" \".join(arg.values)\n                # Subcommand args start at position 2 (pos 1 is the subcommand itself)\n                subcmd_cases.append(\n                    f'                *) [[ \"${{COMP_WORDS[2]}}\" == \"{subcmd_name}\" ]] && '\n                    f\"[[ $pos -eq {arg.position + 1} ]] && \"\n                    f'COMPREPLY=($(compgen -W \"{values_str}\" -- \"$cur\"));;'\n                )\n\n    pos_block = \"\\n\".join(subcmd_cases)\n    return f\"\"\"            {cmd_name})\n                case $pos in\n{pos_block}\n                esac\n                ;;\"\"\"\n\n\ndef _build_help_case(cmd: CommandCompletion, all_commands: str) -> str:\n    \"\"\"Build bash case for help command with hierarchical completion.\n\n    Position 1: complete with all commands\n    Position 2+: complete subcommands based on the command at position 1\n    \"\"\"\n    # Build case statements for commands that have subcommands\n    subcmd_cases: list[str] = []\n    for parent_name, parent_cmd in sorted(cmd.subcommands.items()):\n        if parent_cmd.args:\n            subcmds_str = \" \".join(parent_cmd.args[0].values)\n            subcmd_cases.append(f'                    {parent_name}) COMPREPLY=($(compgen -W \"{subcmds_str}\" -- \"$cur\"));;')\n\n    subcmd_block = \"\\n\".join(subcmd_cases) if subcmd_cases else \"                    *) ;;\"\n\n    return f\"\"\"            help)\n                if [[ $pos -eq 1 ]]; then\n                    # Position 1: complete with all commands\n                    COMPREPLY=($(compgen -W \"{all_commands}\" -- \"$cur\"))\n                else\n                    # Position 2+: complete subcommands based on COMP_WORDS[2]\n                    case \"${{COMP_WORDS[2]}}\" in\n{subcmd_block}\n                    esac\n                fi\n                ;;\"\"\"\n\n\ndef _build_args_case(cmd_name: str, cmd: CommandCompletion) -> str | None:\n    \"\"\"Build bash case statement for a command with positional args.\"\"\"\n    pos_cases: list[str] = []\n    for arg in cmd.args:\n        if arg.completion_type in (\"choices\", \"dynamic\", \"literal\"):\n            values_str = \" \".join(arg.values)\n            pos_cases.append(f'                {arg.position}) COMPREPLY=($(compgen -W \"{values_str}\" -- \"$cur\"));;')\n        elif arg.completion_type == \"file\":\n            pos_cases.append(f'                {arg.position}) COMPREPLY=($(compgen -f -- \"$cur\"));;')\n        # hint and none types: no completion\n\n    if not pos_cases:\n        return None\n\n    pos_block = \"\\n\".join(pos_cases)\n    return f\"\"\"            {cmd_name})\n                case $pos in\n{pos_block}\n                esac\n                ;;\"\"\"\n\n\ndef generate_bash(commands: dict[str, CommandCompletion]) -> str:\n    \"\"\"Generate bash completion script content.\n\n    Args:\n        commands: Dict mapping command name -> CommandCompletion\n\n    Returns:\n        The bash completion script content\n    \"\"\"\n    cmd_list = \" \".join(sorted(commands.keys()))\n\n    # Build case statements for each command\n    case_statements: list[str] = []\n    for cmd_name, cmd in sorted(commands.items()):\n        if cmd_name == \"help\":\n            case_statements.append(_build_help_case(cmd, cmd_list))\n        elif cmd.subcommands:\n            case_statements.append(_build_subcommand_case(cmd_name, cmd))\n        elif cmd.args:\n            case_stmt = _build_args_case(cmd_name, cmd)\n            if case_stmt:\n                case_statements.append(case_stmt)\n\n    case_block = \"\\n\".join(case_statements) if case_statements else \"            *) ;;\"\n\n    return f\"\"\"# Bash completion for pypr\n# Generated by: pypr compgen bash\n\n_pypr() {{\n    local cur=\"${{COMP_WORDS[COMP_CWORD]}}\"\n    local cmd=\"${{COMP_WORDS[1]}}\"\n    local pos=$((COMP_CWORD - 1))\n\n    if [[ $COMP_CWORD -eq 1 ]]; then\n        COMPREPLY=($(compgen -W \"{cmd_list}\" -- \"$cur\"))\n        return\n    fi\n\n    case \"$cmd\" in\n{case_block}\n    esac\n}}\n\ncomplete -F _pypr pypr\n\"\"\"\n"
  },
  {
    "path": "pyprland/completions/generators/fish.py",
    "content": "\"\"\"Fish shell completion generator.\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING\n\nif TYPE_CHECKING:\n    from ..models import CommandCompletion\n\n__all__ = [\"generate_fish\"]\n\n_HEADER = \"\"\"# Fish completion for pypr\n# Generated by: pypr compgen fish\n\n# Disable default file completions for pypr\ncomplete -c pypr -f\n\n# Helper function to count args after command\nfunction __pypr_arg_count\n    set -l cmd (commandline -opc)\n    math (count $cmd) - 1\nend\n\n# Main commands\"\"\"\n\n\ndef _build_main_commands(commands: dict[str, CommandCompletion]) -> list[str]:\n    \"\"\"Build main command completion lines.\"\"\"\n    lines: list[str] = []\n    for cmd_name, cmd in sorted(commands.items()):\n        desc = cmd.description.replace('\"', '\\\\\"') if cmd.description else \"\"\n        # Add subcommand hint if applicable\n        if cmd.subcommands and not cmd.args and not desc:\n            subcmds = \"|\".join(sorted(cmd.subcommands.keys()))\n            desc = f\"<{subcmds}>\"\n        if desc:\n            lines.append(f'complete -c pypr -n \"__fish_use_subcommand\" -a \"{cmd_name}\" -d \"{desc}\"')\n        else:\n            lines.append(f'complete -c pypr -n \"__fish_use_subcommand\" -a \"{cmd_name}\"')\n    return lines\n\n\ndef _build_subcommand_completions(cmd_name: str, cmd: CommandCompletion) -> list[str]:\n    \"\"\"Build fish completions for a command's subcommands.\"\"\"\n    lines: list[str] = []\n    for subcmd_name, subcmd in sorted(cmd.subcommands.items()):\n        subdesc = subcmd.description.replace('\"', '\\\\\"') if subcmd.description else \"\"\n        if subdesc:\n            lines.append(\n                f'complete -c pypr -n \"__fish_seen_subcommand_from {cmd_name}; '\n                f'and test (__pypr_arg_count) -eq 1\" -a \"{subcmd_name}\" -d \"{subdesc}\"'\n            )\n        else:\n            lines.append(\n                f'complete -c pypr -n \"__fish_seen_subcommand_from {cmd_name}; and test (__pypr_arg_count) -eq 1\" -a \"{subcmd_name}\"'\n            )\n    return lines\n\n\ndef _build_help_completions(cmd: CommandCompletion, all_commands: list[str]) -> list[str]:\n    \"\"\"Build fish completions for help command with hierarchical completion.\n\n    Position 1: complete with all commands\n    Position 2+: complete subcommands based on the command at position 1\n    \"\"\"\n    lines: list[str] = []\n\n    # Position 1: complete with all command names\n    all_cmds_str = \" \".join(all_commands)\n    lines.append(f'complete -c pypr -n \"__fish_seen_subcommand_from help; and test (__pypr_arg_count) -eq 1\" -a \"{all_cmds_str}\"')\n\n    # Position 2+: complete subcommands for each parent command\n    for parent_name, parent_cmd in sorted(cmd.subcommands.items()):\n        if parent_cmd.args:\n            subcmds_str = \" \".join(parent_cmd.args[0].values)\n            lines.append(\n                f'complete -c pypr -n \"__fish_seen_subcommand_from help; '\n                f\"and contains {parent_name} (commandline -opc); \"\n                f'and test (__pypr_arg_count) -eq 2\" -a \"{subcmds_str}\"'\n            )\n\n    return lines\n\n\ndef _build_args_completions(cmd_name: str, cmd: CommandCompletion) -> list[str]:\n    \"\"\"Build fish completions for a command's positional args.\"\"\"\n    lines: list[str] = []\n    for arg in cmd.args:\n        if arg.completion_type in (\"choices\", \"dynamic\", \"literal\"):\n            values_str = \" \".join(arg.values)\n            lines.append(\n                f'complete -c pypr -n \"__fish_seen_subcommand_from {cmd_name}; '\n                f'and test (__pypr_arg_count) -eq {arg.position}\" -a \"{values_str}\"'\n            )\n        elif arg.completion_type == \"file\":\n            lines.append(f'complete -c pypr -n \"__fish_seen_subcommand_from {cmd_name}; and test (__pypr_arg_count) -eq {arg.position}\" -F')\n        # hint type: no completion added\n    return lines\n\n\ndef generate_fish(commands: dict[str, CommandCompletion]) -> str:\n    \"\"\"Generate fish completion script content.\n\n    Args:\n        commands: Dict mapping command name -> CommandCompletion\n\n    Returns:\n        The fish completion script content\n    \"\"\"\n    lines = [_HEADER]\n    lines.extend(_build_main_commands(commands))\n\n    lines.append(\"\")\n    lines.append(\"# Subcommand and positional argument completions\")\n\n    all_cmd_names = sorted(commands.keys())\n    for cmd_name, cmd in sorted(commands.items()):\n        if cmd_name == \"help\":\n            lines.extend(_build_help_completions(cmd, all_cmd_names))\n        elif cmd.subcommands:\n            lines.extend(_build_subcommand_completions(cmd_name, cmd))\n        elif cmd.args:\n            lines.extend(_build_args_completions(cmd_name, cmd))\n\n    return \"\\n\".join(lines) + \"\\n\"\n"
  },
  {
    "path": "pyprland/completions/generators/zsh.py",
    "content": "\"\"\"Zsh shell completion generator.\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING\n\nif TYPE_CHECKING:\n    from ..models import CommandCompletion\n\n__all__ = [\"generate_zsh\"]\n\n\ndef _build_command_descriptions(commands: dict[str, CommandCompletion]) -> str:\n    \"\"\"Build the command descriptions block for zsh.\"\"\"\n    cmd_descs: list[str] = []\n    for cmd_name, cmd in sorted(commands.items()):\n        desc = cmd.description.replace(\"'\", \"'\\\\''\") if cmd.description else cmd_name\n        # Add subcommand hint if applicable\n        if cmd.subcommands and not cmd.args:\n            subcmds = \"|\".join(sorted(cmd.subcommands.keys()))\n            desc = f\"<{subcmds}> {desc}\" if desc else f\"<{subcmds}>\"\n        cmd_descs.append(f\"        '{cmd_name}:{desc}'\")\n    return \"\\n\".join(cmd_descs)\n\n\ndef _build_subcommand_case(cmd_name: str, cmd: CommandCompletion) -> str:\n    \"\"\"Build zsh case statement for a command with subcommands.\"\"\"\n    subcmd_descs: list[str] = []\n    for subcmd_name, subcmd in sorted(cmd.subcommands.items()):\n        subdesc = subcmd.description.replace(\"'\", \"'\\\\''\") if subcmd.description else subcmd_name\n        subcmd_descs.append(f\"'{subcmd_name}:{subdesc}'\")\n    subcmd_desc_str = \" \".join(subcmd_descs)\n\n    return f\"\"\"                {cmd_name})\n                    local -a subcmds=({subcmd_desc_str})\n                    if [[ $CURRENT -eq 2 ]]; then\n                        _describe 'subcommand' subcmds\n                    fi\n                    ;;\"\"\"\n\n\ndef _build_help_case(cmd: CommandCompletion) -> str:\n    \"\"\"Build zsh case for help command with hierarchical completion.\n\n    Position 1: complete with all commands (reuses the commands array)\n    Position 2+: complete subcommands based on the command at position 1\n    \"\"\"\n    # Build case statements for commands that have subcommands\n    subcmd_cases: list[str] = []\n    for parent_name, parent_cmd in sorted(cmd.subcommands.items()):\n        if parent_cmd.args:\n            # Build subcommand descriptions for this parent\n            subcmds_str = \" \".join(f\"'{val}'\" for val in parent_cmd.args[0].values)\n            subcmd_cases.append(f\"                            {parent_name}) compadd {subcmds_str} ;;\")\n\n    subcmd_block = \"\\n\".join(subcmd_cases) if subcmd_cases else \"                            *) ;;\"\n\n    return f\"\"\"                help)\n                    if [[ $CURRENT -eq 2 ]]; then\n                        # Position 1: complete with all commands\n                        _describe 'command' commands\n                    else\n                        # Position 2+: complete subcommands based on previous word\n                        case $words[2] in\n{subcmd_block}\n                        esac\n                    fi\n                    ;;\"\"\"\n\n\ndef _build_args_case(cmd_name: str, cmd: CommandCompletion) -> str | None:\n    \"\"\"Build zsh case statement for a command with positional args.\"\"\"\n    arg_specs: list[str] = []\n    for arg in cmd.args:\n        pos = arg.position\n        desc = arg.description.replace(\"'\", \"'\\\\''\")\n\n        if arg.completion_type in (\"choices\", \"dynamic\", \"literal\"):\n            values_str = \" \".join(arg.values)\n            arg_specs.append(f\"'{pos}:{desc}:({values_str})'\")\n        elif arg.completion_type == \"file\":\n            arg_specs.append(f\"'{pos}:{desc}:_files'\")\n        elif arg.completion_type == \"hint\":\n            # Show description but no actual completions\n            hint = arg.values[0] if arg.values else desc\n            arg_specs.append(f\"'{pos}:{hint}:'\")\n\n    if not arg_specs:\n        return None\n\n    args_line = \" \\\\\\n                        \".join(arg_specs)\n    return f\"\"\"                {cmd_name})\n                    _arguments \\\\\n                        {args_line}\n                    ;;\"\"\"\n\n\ndef generate_zsh(commands: dict[str, CommandCompletion]) -> str:\n    \"\"\"Generate zsh completion script content.\n\n    Args:\n        commands: Dict mapping command name -> CommandCompletion\n\n    Returns:\n        The zsh completion script content\n    \"\"\"\n    cmd_desc_block = _build_command_descriptions(commands)\n\n    # Build case statements for each command\n    case_statements: list[str] = []\n    for cmd_name, cmd in sorted(commands.items()):\n        if cmd_name == \"help\":\n            case_statements.append(_build_help_case(cmd))\n        elif cmd.subcommands:\n            case_statements.append(_build_subcommand_case(cmd_name, cmd))\n        elif cmd.args:\n            case_stmt = _build_args_case(cmd_name, cmd)\n            if case_stmt:\n                case_statements.append(case_stmt)\n\n    case_block = \"\\n\".join(case_statements) if case_statements else \"                *) ;;\"\n\n    return f\"\"\"#compdef pypr\n# Zsh completion for pypr\n# Generated by: pypr compgen zsh\n\n_pypr() {{\n    local -a commands=(\n{cmd_desc_block}\n    )\n\n    _arguments -C \\\\\n        '1:command:->command' \\\\\n        '*::arg:->args'\n\n    case $state in\n        command)\n            _describe 'command' commands\n            ;;\n        args)\n            case $words[1] in\n{case_block}\n            esac\n            ;;\n    esac\n}}\n\n_pypr \"$@\"\n\"\"\"\n"
  },
  {
    "path": "pyprland/completions/handlers.py",
    "content": "\"\"\"CLI handlers for shell completion commands.\n\nProvides the handle_compgen function used by the pyprland plugin\nto generate and install shell completions.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING\n\nfrom ..constants import SUPPORTED_SHELLS\nfrom .discovery import get_command_completions\nfrom .generators import GENERATORS\nfrom .models import DEFAULT_PATHS\n\nif TYPE_CHECKING:\n    from ..manager import Pyprland\n\n__all__ = [\"get_default_path\", \"handle_compgen\"]\n\n\ndef get_default_path(shell: str) -> str:\n    \"\"\"Get the default user-level completion path for a shell.\n\n    Args:\n        shell: Shell type (\"bash\", \"zsh\", or \"fish\")\n\n    Returns:\n        Expanded absolute path to the default completion file\n    \"\"\"\n    return str(Path(DEFAULT_PATHS[shell]).expanduser())\n\n\ndef _get_success_message(shell: str, output_path: str, used_default: bool) -> str:\n    \"\"\"Generate a friendly success message after installing completions.\n\n    Args:\n        shell: Shell type\n        output_path: Path where completions were written\n        used_default: Whether the default path was used\n\n    Returns:\n        User-friendly success message\n    \"\"\"\n    # Use ~ in display path for readability\n    display_path = output_path.replace(str(Path.home()), \"~\")\n\n    if not used_default:\n        return f\"Completions written to {display_path}\"\n\n    if shell == \"bash\":\n        return f\"Completions installed to {display_path}\\nReload your shell or run: source ~/.bashrc\"\n\n    if shell == \"zsh\":\n        return (\n            f\"Completions installed to {display_path}\\n\"\n            \"Ensure ~/.zsh/completions is in your fpath. Add to ~/.zshrc:\\n\"\n            \"  fpath=(~/.zsh/completions $fpath)\\n\"\n            \"  autoload -Uz compinit && compinit\\n\"\n            \"Then reload your shell.\"\n        )\n\n    if shell == \"fish\":\n        return f\"Completions installed to {display_path}\\nReload your shell or run: source ~/.config/fish/config.fish\"\n\n    return f\"Completions written to {display_path}\"\n\n\ndef _parse_compgen_args(args: str) -> tuple[bool, str, str | None]:\n    \"\"\"Parse and validate compgen command arguments.\n\n    Args:\n        args: Arguments after \"compgen\" (e.g., \"zsh\" or \"zsh default\")\n\n    Returns:\n        Tuple of (success, shell_or_error, path_arg):\n        - On success: (True, shell, path_arg or None)\n        - On failure: (False, error_message, None)\n    \"\"\"\n    parts = args.split(None, 1)\n    if not parts:\n        shells = \"|\".join(SUPPORTED_SHELLS)\n        return (False, f\"Usage: compgen <{shells}> [default|path]\", None)\n\n    shell = parts[0]\n    if shell not in SUPPORTED_SHELLS:\n        return (False, f\"Unsupported shell: {shell}. Supported: {', '.join(SUPPORTED_SHELLS)}\", None)\n\n    path_arg = parts[1] if len(parts) > 1 else None\n    if path_arg is not None and path_arg != \"default\" and not path_arg.startswith((\"/\", \"~\")):\n        return (False, \"Relative paths not supported. Use absolute path, ~/path, or 'default'.\", None)\n\n    return (True, shell, path_arg)\n\n\ndef handle_compgen(manager: Pyprland, args: str) -> tuple[bool, str]:\n    \"\"\"Handle compgen command with path semantics.\n\n    Args:\n        manager: The Pyprland manager instance\n        args: Arguments after \"compgen\" (e.g., \"zsh\" or \"zsh default\")\n\n    Returns:\n        Tuple of (success, result):\n        - No path arg: result is the script content\n        - With path arg: result is success/error message\n    \"\"\"\n    success, shell_or_error, path_arg = _parse_compgen_args(args)\n    if not success:\n        return (False, shell_or_error)\n\n    shell = shell_or_error\n\n    try:\n        commands = get_command_completions(manager)\n        content = GENERATORS[shell](commands)\n    except (KeyError, ValueError, TypeError) as e:\n        return (False, f\"Failed to generate completions: {e}\")\n\n    if path_arg is None:\n        return (True, content)\n\n    # Determine output path\n    if path_arg == \"default\":\n        output_path = get_default_path(shell)\n        used_default = True\n    else:\n        output_path = str(Path(path_arg).expanduser())\n        used_default = False\n\n    manager.log.debug(\"Writing completions to: %s\", output_path)\n\n    # Write to file\n    try:\n        parent_dir = Path(output_path).parent\n        parent_dir.mkdir(parents=True, exist_ok=True)\n        Path(output_path).write_text(content, encoding=\"utf-8\")\n    except OSError as e:\n        return (False, f\"Failed to write completion file: {e}\")\n\n    return (True, _get_success_message(shell, output_path, used_default))\n"
  },
  {
    "path": "pyprland/completions/models.py",
    "content": "\"\"\"Data models and constants for shell completions.\n\nContains the data structures used to represent command completions\nand configuration constants for completion generation.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\n\nfrom ..plugins.wallpapers.models import ColorScheme\n\n__all__ = [\n    \"DEFAULT_PATHS\",\n    \"HINT_ARGS\",\n    \"KNOWN_COMPLETIONS\",\n    \"SCRATCHPAD_COMMANDS\",\n    \"CommandCompletion\",\n    \"CompletionArg\",\n]\n\n# Default user-level completion paths\nDEFAULT_PATHS = {\n    \"bash\": \"~/.local/share/bash-completion/completions/pypr\",\n    \"zsh\": \"~/.zsh/completions/_pypr\",\n    \"fish\": \"~/.config/fish/completions/pypr.fish\",\n}\n\n# Commands that use scratchpad names for completion\nSCRATCHPAD_COMMANDS = {\"toggle\", \"show\", \"hide\", \"attach\"}\n\n# Known static completions for specific arg names\nKNOWN_COMPLETIONS: dict[str, list[str]] = {\n    \"scheme\": [c.value for c in ColorScheme if c.value] + [\"fluorescent\"],  # Include alias\n    \"direction\": [\"1\", \"-1\"],\n}\n\n# Args that should show as hints (no actual completion values)\nHINT_ARGS: dict[str, str] = {\n    \"#RRGGBB\": \"#RRGGBB (hex color)\",\n    \"color\": \"#RRGGBB (hex color)\",\n    \"factor\": \"number (zoom level)\",\n}\n\n\n@dataclass\nclass CompletionArg:\n    \"\"\"Argument completion specification.\"\"\"\n\n    position: int  # 1-based position after command\n    completion_type: str  # \"choices\", \"literal\", \"hint\", \"file\", \"none\"\n    values: list[str] = field(default_factory=list)  # Values to complete or hint text\n    required: bool = True  # Whether the arg is required\n    description: str = \"\"  # Description for zsh\n\n\n@dataclass\nclass CommandCompletion:\n    \"\"\"Full completion spec for a command.\"\"\"\n\n    name: str\n    args: list[CompletionArg] = field(default_factory=list)\n    description: str = \"\"\n    subcommands: dict[str, CommandCompletion] = field(default_factory=dict)  # For hierarchical commands\n"
  },
  {
    "path": "pyprland/config.py",
    "content": "\"\"\"Configuration wrapper with typed accessors and schema-aware defaults.\n\nThe Configuration class extends dict with:\n- Typed getters (get_bool, get_int, get_float, get_str)\n- Schema-based default values via set_schema()\n- Boolean coercion handling loose string values (\"true\", \"yes\", \"1\", etc.)\n\nUsed by plugins to access their configuration sections with proper typing\nand automatic defaults from their config_schema definitions.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING, Any, cast, overload\n\nif TYPE_CHECKING:\n    import logging\n    from collections.abc import Iterator\n\n    from .validation import ConfigItems\n\n__all__ = [\"BOOL_FALSE_STRINGS\", \"BOOL_STRINGS\", \"BOOL_TRUE_STRINGS\", \"Configuration\", \"SchemaAwareMixin\", \"coerce_to_bool\"]\n\n# Type alias for config values\nConfigValueType = float | bool | str | list | dict\n\n# Boolean string constants (shared with validation module)\nBOOL_TRUE_STRINGS = frozenset({\"true\", \"yes\", \"on\", \"1\", \"enabled\"})\nBOOL_FALSE_STRINGS = frozenset({\"false\", \"no\", \"off\", \"0\", \"disabled\"})\nBOOL_STRINGS = BOOL_TRUE_STRINGS | BOOL_FALSE_STRINGS\n\n\ndef coerce_to_bool(value: ConfigValueType | None, default: bool = False) -> bool:\n    \"\"\"Coerce a value to boolean, handling loose typing.\n\n    Args:\n        value: The value to coerce\n        default: Default value if value is None\n\n    Returns:\n        The boolean value\n\n    Behavior:\n        - None → default\n        - Empty string → False\n        - Explicit falsy strings (\"false\", \"no\", \"off\", \"0\", \"disabled\") → False\n        - Any other non-empty string → True\n        - Non-string values → bool(value)\n    \"\"\"\n    if value is None:\n        return default\n    if isinstance(value, str):\n        if not value.strip():\n            return False\n        return value.lower().strip() not in BOOL_FALSE_STRINGS\n    return bool(value)\n\n\nclass SchemaAwareMixin:\n    \"\"\"Mixin providing schema-aware defaults and typed config value accessors.\n\n    Requires the implementing class to have:\n    - self._get_raw(name) method that returns the raw value or raises KeyError\n    - self.log (logging.Logger) attribute\n\n    Provides:\n    - Schema default value storage and lookup\n    - Typed accessors: get_bool, get_int, get_float, get_str\n    - Schema-aware get() method\n    \"\"\"\n\n    _schema_defaults: dict[str, ConfigValueType]\n    log: logging.Logger  # Required: implementing class must provide this\n\n    def __init_schema__(self) -> None:\n        \"\"\"Initialize schema defaults storage. Call from subclass __init__.\"\"\"\n        self._schema_defaults = {}\n\n    def set_schema(self, schema: ConfigItems) -> None:\n        \"\"\"Set or update the schema for default value lookups.\n\n        Args:\n            schema: List of ConfigField definitions\n        \"\"\"\n        self._schema_defaults = {field.name: field.default for field in schema if field.default is not None}\n\n    def _get_raw(self, name: str) -> ConfigValueType:\n        \"\"\"Get raw value without defaults. Raises KeyError if not found.\n\n        Override in subclasses to provide the actual lookup mechanism.\n        \"\"\"\n        raise NotImplementedError\n\n    @overload\n    def get(self, name: str) -> ConfigValueType | None: ...\n\n    @overload\n    def get(self, name: str, default: None) -> ConfigValueType | None: ...\n\n    @overload\n    def get(self, name: str, default: ConfigValueType) -> ConfigValueType: ...\n\n    def get(self, name: str, default: ConfigValueType | None = None) -> ConfigValueType | None:\n        \"\"\"Get a value with schema-aware defaults.\n\n        Args:\n            name: The configuration key\n            default: Fallback if key is missing and not in schema defaults\n\n        Returns:\n            The value, schema default, or provided default\n        \"\"\"\n        try:\n            return self._get_raw(name)\n        except KeyError:\n            if name in self._schema_defaults:\n                return self._schema_defaults[name]\n            return default\n\n    def get_bool(self, name: str, default: bool = False) -> bool:\n        \"\"\"Get a boolean value, handling loose typing.\n\n        Args:\n            name: The key name\n            default: Default value if key is missing\n\n        Returns:\n            The boolean value\n\n        Behavior:\n            - None (missing key) → default\n            - Empty string → False\n            - Explicit falsy strings (\"false\", \"no\", \"off\", \"0\", \"disabled\") → False\n            - Any other non-empty string → True\n            - Non-string values → bool(value)\n        \"\"\"\n        return coerce_to_bool(self.get(name), default)\n\n    def get_int(self, name: str, default: int = 0) -> int:\n        \"\"\"Get an integer value.\n\n        Args:\n            name: The key name\n            default: Default value if key is missing or invalid\n\n        Returns:\n            The integer value\n        \"\"\"\n        value = self.get(name)\n        if value is None:\n            return default\n        try:\n            return int(value)  # type: ignore[arg-type]\n        except (ValueError, TypeError):\n            self.log.warning(\"Invalid integer value for %s: %s\", name, value)\n            return default\n\n    def get_float(self, name: str, default: float = 0.0) -> float:\n        \"\"\"Get a float value.\n\n        Args:\n            name: The key name\n            default: Default value if key is missing or invalid\n\n        Returns:\n            The float value\n        \"\"\"\n        value = self.get(name)\n        if value is None:\n            return default\n        try:\n            return float(value)  # type: ignore[arg-type]\n        except (ValueError, TypeError):\n            self.log.warning(\"Invalid float value for %s: %s\", name, value)\n            return default\n\n    def get_str(self, name: str, default: str = \"\") -> str:\n        \"\"\"Get a string value.\n\n        Args:\n            name: The key name\n            default: Default value if key is missing\n\n        Returns:\n            The string value\n        \"\"\"\n        value = self.get(name)\n        if value is None:\n            return default\n        return str(value)\n\n    def has_explicit(self, name: str) -> bool:\n        \"\"\"Check if value was explicitly set (not from schema default).\n\n        Args:\n            name: The configuration key\n\n        Returns:\n            True if the value exists in the raw config (not from schema defaults)\n        \"\"\"\n        try:\n            self._get_raw(name)\n        except KeyError:\n            return False\n        return True\n\n\nclass Configuration(SchemaAwareMixin, dict):\n    \"\"\"Configuration wrapper providing typed access and section filtering.\n\n    Optionally accepts a schema to provide default values automatically.\n    \"\"\"\n\n    def __init__(\n        self,\n        *args: Any,\n        logger: logging.Logger,\n        schema: ConfigItems | None = None,\n        **kwargs: Any,\n    ):\n        \"\"\"Initialize the configuration object.\n\n        Args:\n            *args: Arguments for dict\n            logger: Logger instance to use for warnings\n            schema: Optional list of ConfigField definitions for automatic defaults\n            **kwargs: Keyword arguments for dict\n        \"\"\"\n        super().__init__(*args, **kwargs)\n        self.__init_schema__()\n        self.log = logger\n        if schema:\n            self.set_schema(schema)\n\n    def _get_raw(self, name: str) -> ConfigValueType:\n        \"\"\"Get raw value from dict. Raises KeyError if not found.\"\"\"\n        if name in self:\n            return cast(\"ConfigValueType\", self[name])\n        raise KeyError(name)\n\n    def get(self, name: str, default: ConfigValueType | None = None) -> ConfigValueType | None:  # type: ignore[override]\n        \"\"\"Get a value with schema-aware defaults.\n\n        Args:\n            name: The configuration key\n            default: Fallback if key is missing and not in schema defaults\n\n        Returns:\n            The value, schema default, or provided default\n        \"\"\"\n        return SchemaAwareMixin.get(self, name, default)\n\n    def iter_subsections(self) -> Iterator[tuple[str, dict[str, Any]]]:\n        \"\"\"Yield only keys that have dictionary values (e.g., defined scratchpads).\n\n        Returns:\n            Iterator of (key, value) pairs where value is a dictionary\n        \"\"\"\n        for k, v in self.items():\n            if isinstance(v, dict):\n                yield k, v\n"
  },
  {
    "path": "pyprland/config_loader.py",
    "content": "\"\"\"Configuration file loading utilities.\n\nThis module handles loading, parsing, and merging TOML/JSON configuration files.\n\nThe module-level functions :func:`resolve_config_path`, :func:`load_toml`,\n:func:`load_toml_directory`, and :func:`load_config` are the shared\nprimitives used by both the daemon (``ConfigLoader``) and the GUI\n(``pyprland.gui.api``).\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nimport os\nimport tomllib\nfrom pathlib import Path\nfrom typing import Any, cast\n\nfrom .aioops import aiexists, aiisdir\nfrom .constants import CONFIG_FILE, LEGACY_CONFIG_FILE, MIGRATION_NOTIFICATION_DURATION_MS, OLD_CONFIG_FILE\nfrom .models import PyprError\nfrom .utils import merge\n\n__all__ = [\n    \"ConfigLoader\",\n    \"load_config\",\n    \"load_toml\",\n    \"load_toml_directory\",\n    \"resolve_config_path\",\n]\n\n_log = logging.getLogger(__name__)\n\n\n# ---------------------------------------------------------------------------\n#  Shared primitives (used by both the daemon and the GUI)\n# ---------------------------------------------------------------------------\n\n\ndef resolve_config_path(config_filename: str) -> Path:\n    \"\"\"Resolve a config filename with variable and user expansion.\n\n    Args:\n        config_filename: Raw config file path (may contain ``$VARS`` or ``~``)\n\n    Returns:\n        Resolved Path object\n    \"\"\"\n    return Path(os.path.expandvars(config_filename)).expanduser()\n\n\ndef load_toml(path: Path) -> dict[str, Any]:\n    \"\"\"Load a single TOML file, returning ``{}`` on error.\n\n    Unlike :meth:`ConfigLoader._load_config_file` this never raises and\n    has no legacy JSON fallback — it is meant for best-effort loading.\n    \"\"\"\n    try:\n        with path.open(\"rb\") as fh:\n            return tomllib.load(fh)\n    except Exception:  # noqa: BLE001\n        _log.warning(\"Failed to load %s\", path, exc_info=True)\n        return {}\n\n\ndef load_toml_directory(directory: Path) -> dict[str, Any]:\n    \"\"\"Load and merge all ``.toml`` files in *directory* (sorted).\"\"\"\n    config: dict[str, Any] = {}\n    if not directory.is_dir():\n        return config\n    for name in sorted(f.name for f in directory.iterdir() if f.suffix == \".toml\"):\n        merge(config, load_toml(directory / name))\n    return config\n\n\ndef load_config(config_filename: str | None = None) -> dict[str, Any]:\n    \"\"\"Synchronously load config, recursively resolving includes.\n\n    Mirrors the daemon's :meth:`ConfigLoader._open_config` logic so that\n    any caller gets the same merged result as ``pypr dumpjson``.\n\n    When *config_filename* is given it is treated as an include path (may\n    be a file or a directory).  When ``None`` the default config path is\n    used and its ``pyprland.include`` entries are resolved recursively.\n    \"\"\"\n    if config_filename is not None:\n        resolved = resolve_config_path(config_filename)\n        if resolved.is_dir():\n            return load_toml_directory(resolved)\n        return load_toml(resolved)\n\n    # Default: load the main config and recursively resolve includes\n    from .quickstart.generator import get_config_path  # noqa: PLC0415\n\n    config_path = get_config_path()\n    config = load_toml(config_path) if config_path.exists() else {}\n\n    for extra in list(config.get(\"pyprland\", {}).get(\"include\", [])):\n        merge(config, load_config(extra))\n\n    return config\n\n\n# ---------------------------------------------------------------------------\n#  ConfigLoader — daemon-specific wrapper with async, logging, legacy fallback\n# ---------------------------------------------------------------------------\n\n\nclass ConfigLoader:\n    \"\"\"Handles loading and merging configuration files.\n\n    Supports:\n    - TOML configuration files (preferred)\n    - Legacy JSON configuration files\n    - Directory-based config (multiple .toml files merged)\n    - Include directives for modular configuration\n    \"\"\"\n\n    def __init__(self, log: logging.Logger) -> None:\n        \"\"\"Initialize the config loader.\n\n        Args:\n            log: Logger instance for status and error messages\n        \"\"\"\n        self.log = log\n        self._config: dict[str, Any] = {}\n        self.deferred_notifications: list[tuple[str, int]] = []\n\n    @property\n    def config(self) -> dict[str, Any]:\n        \"\"\"Return the loaded configuration.\"\"\"\n        return self._config\n\n    async def load(self, config_filename: str = \"\") -> dict[str, Any]:\n        \"\"\"Load configuration from file or directory.\n\n        Args:\n            config_filename: Optional path to config file or directory.\n                           If empty, uses default CONFIG_FILE location.\n\n        Returns:\n            The loaded and merged configuration dictionary.\n\n        Raises:\n            PyprError: If config file not found or has syntax errors.\n        \"\"\"\n        config = await self._open_config(config_filename)\n        merge(self._config, config, replace=True)\n        return self._config\n\n    async def _open_config(self, config_filename: str = \"\") -> dict[str, Any]:\n        \"\"\"Load config file(s) into a dictionary.\n\n        Args:\n            config_filename: Optional configuration file or directory path\n\n        Returns:\n            The loaded configuration dictionary\n        \"\"\"\n        if config_filename:\n            fname = resolve_config_path(config_filename)\n            if await aiisdir(str(fname)):\n                return self._load_config_directory(fname)\n            return self._load_config_file(fname)\n\n        # No filename specified - use defaults with legacy fallback\n        config_path = CONFIG_FILE\n        legacy_path = LEGACY_CONFIG_FILE\n        old_json_path = OLD_CONFIG_FILE\n\n        if await aiexists(config_path):\n            # New canonical location\n            fname = config_path\n        elif await aiexists(legacy_path):\n            # Legacy TOML location - use it but warn user\n            fname = legacy_path\n            self.log.warning(\"Using legacy config path: %s\", legacy_path)\n            self.log.warning(\"Please move your config to: %s\", config_path)\n            self.deferred_notifications.append(\n                (\n                    f\"Config at legacy location.\\nMove to: {config_path}\",\n                    MIGRATION_NOTIFICATION_DURATION_MS,\n                )\n            )\n        elif await aiexists(old_json_path):\n            # Very old JSON format - will be loaded via fallback in _load_config_file\n            self.log.warning(\"Using deprecated JSON config: %s\", old_json_path)\n            self.log.warning(\"Please migrate to TOML format at: %s\", config_path)\n            self.deferred_notifications.append(\n                (\n                    f\"JSON config is deprecated.\\nMigrate to: {config_path}\",\n                    MIGRATION_NOTIFICATION_DURATION_MS,\n                )\n            )\n            fname = config_path  # Will fall through to JSON loading in _load_config_file\n        else:\n            fname = config_path  # Will error in _load_config_file\n\n        config = self._load_config_file(fname)\n\n        # Process includes\n        for extra_config in list(config.get(\"pyprland\", {}).get(\"include\", [])):\n            merge(config, await self._open_config(extra_config))\n\n        return config\n\n    def _load_config_directory(self, directory: Path) -> dict[str, Any]:\n        \"\"\"Load and merge all .toml files from a directory.\n\n        Delegates to :func:`load_toml_directory` but uses\n        :meth:`_load_config_file` (which raises on errors) for each file.\n        \"\"\"\n        config: dict[str, Any] = {}\n        for toml_file in sorted(f.name for f in directory.iterdir()):\n            if not toml_file.endswith(\".toml\"):\n                continue\n            merge(config, self._load_config_file(directory / toml_file))\n        return config\n\n    def _load_config_file(self, fname: Path) -> dict[str, Any]:\n        \"\"\"Load a single configuration file.\n\n        Supports both TOML (preferred) and legacy JSON formats.\n\n        Args:\n            fname: Path to the configuration file\n\n        Returns:\n            Configuration dictionary\n\n        Raises:\n            PyprError: If file not found or has syntax errors\n        \"\"\"\n        if fname.exists():\n            self.log.info(\"Loading %s\", fname)\n            with fname.open(\"rb\") as f:\n                try:\n                    return tomllib.load(f)\n                except tomllib.TOMLDecodeError as e:\n                    self.log.critical(\"Problem reading %s: %s\", fname, e)\n                    raise PyprError from e\n\n        # Fallback to very old JSON config\n        if OLD_CONFIG_FILE.exists():\n            self.log.info(\"Loading %s\", OLD_CONFIG_FILE)\n            with OLD_CONFIG_FILE.open(encoding=\"utf-8\") as f:\n                return cast(\"dict[str, Any]\", json.loads(f.read()))\n\n        self.log.critical(\"Config file not found! Please create %s\", fname)\n        raise PyprError\n"
  },
  {
    "path": "pyprland/constants.py",
    "content": "\"\"\"Shared constants and configuration defaults for Pyprland.\n\nIncludes:\n- Config file paths (CONFIG_FILE, LEGACY_CONFIG_FILE)\n- Socket paths (CONTROL)\n- Timing constants (TASK_TIMEOUT, notification durations)\n- Display defaults (refresh rate, wallpaper dimensions)\n- IPC retry settings\n\"\"\"\n\nimport os\nfrom pathlib import Path\n\nfrom .common import IPC_FOLDER\n\n__all__ = [\n    \"CONFIG_FILE\",\n    \"CONTROL\",\n    \"DEFAULT_NOTIFICATION_DURATION_MS\",\n    \"DEFAULT_PALETTE_COLOR_RGB\",\n    \"DEFAULT_REFRESH_RATE_HZ\",\n    \"DEFAULT_WALLPAPER_HEIGHT\",\n    \"DEFAULT_WALLPAPER_WIDTH\",\n    \"DEMO_NOTIFICATION_DURATION_MS\",\n    \"ERROR_NOTIFICATION_DURATION_MS\",\n    \"IPC_MAX_RETRIES\",\n    \"IPC_RETRY_DELAY_MULTIPLIER\",\n    \"LEGACY_CONFIG_FILE\",\n    \"MIGRATION_NOTIFICATION_DURATION_MS\",\n    \"MIN_CLIENTS_FOR_LAYOUT\",\n    \"OLD_CONFIG_FILE\",\n    \"PREFETCH_MAX_RETRIES\",\n    \"PREFETCH_RETRY_BASE_SECONDS\",\n    \"PREFETCH_RETRY_MAX_SECONDS\",\n    \"PYPR_DEMO\",\n    \"SECONDS_PER_DAY\",\n    \"SUPPORTED_SHELLS\",\n    \"TASK_TIMEOUT\",\n]\n\nCONTROL = f\"{IPC_FOLDER}/.pyprland.sock\"\n\n# Config file paths - use XDG_CONFIG_HOME with fallback to ~/.config\n_xdg_config_home = Path(os.environ.get(\"XDG_CONFIG_HOME\") or Path.home() / \".config\")\nOLD_CONFIG_FILE = _xdg_config_home / \"hypr\" / \"pyprland.json\"  # Very old JSON format\nLEGACY_CONFIG_FILE = _xdg_config_home / \"hypr\" / \"pyprland.toml\"  # Old TOML location\nCONFIG_FILE = _xdg_config_home / \"pypr\" / \"config.toml\"  # New canonical location\n\nTASK_TIMEOUT = 35.0\n\nPYPR_DEMO = os.environ.get(\"PYPR_DEMO\")\n\n# Supported shells for completion generation\nSUPPORTED_SHELLS = (\"bash\", \"zsh\", \"fish\")\n\n# Notification durations (milliseconds)\nDEFAULT_NOTIFICATION_DURATION_MS = 5000\nERROR_NOTIFICATION_DURATION_MS = 8000\nDEMO_NOTIFICATION_DURATION_MS = 4000\nMIGRATION_NOTIFICATION_DURATION_MS = 15000\n\n# Display defaults\nDEFAULT_REFRESH_RATE_HZ = 60.0\n\n# IPC retry settings\nIPC_MAX_RETRIES = 3\nIPC_RETRY_DELAY_MULTIPLIER = 0.5\n\n# Layout thresholds\nMIN_CLIENTS_FOR_LAYOUT = 2\n\n# Time constants\nSECONDS_PER_DAY = 86400\n\n# Wallpapers defaults\nDEFAULT_WALLPAPER_WIDTH = 1920\nDEFAULT_WALLPAPER_HEIGHT = 1080\nDEFAULT_PALETTE_COLOR_RGB = (66, 133, 244)  # Google Blue #4285F4\n\n# Wallpapers prefetch retry settings\nPREFETCH_RETRY_BASE_SECONDS = 2\nPREFETCH_RETRY_MAX_SECONDS = 60\nPREFETCH_MAX_RETRIES = 10\n"
  },
  {
    "path": "pyprland/debug.py",
    "content": "\"\"\"Debug mode state management.\"\"\"\n\nimport os\n\n__all__ = [\n    \"DEBUG\",\n    \"is_debug\",\n    \"set_debug\",\n]\n\n\nclass _DebugState:\n    \"\"\"Container for mutable debug state to avoid global statement.\"\"\"\n\n    value: bool = bool(os.environ.get(\"DEBUG\"))\n\n\n_debug_state = _DebugState()\n\n\ndef is_debug() -> bool:\n    \"\"\"Return the current debug state.\"\"\"\n    return _debug_state.value\n\n\ndef set_debug(value: bool) -> None:\n    \"\"\"Set the debug state.\"\"\"\n    _debug_state.value = value\n\n\n# Backward compatible: DEBUG still works for reading initial state\n# New code should use is_debug() for dynamic checks\nDEBUG = bool(os.environ.get(\"DEBUG\"))\n"
  },
  {
    "path": "pyprland/doc.py",
    "content": "\"\"\"Documentation formatting for pypr doc command.\n\nFormats plugin and configuration documentation for terminal display\nwith ANSI colors and structured output. Uses runtime schema data to\nensure documentation is always accurate.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport sys\nfrom typing import TYPE_CHECKING, Any\n\nfrom .ansi import BOLD, CYAN, DIM, GREEN, colorize, should_colorize\n\nif TYPE_CHECKING:\n    from .commands.models import CommandInfo\n    from .plugins.interface import Plugin\n    from .validation import ConfigField, ConfigItems\n\n__all__ = [\"format_config_field_doc\", \"format_plugin_doc\", \"format_plugin_list\"]\n\n\ndef _c(text: str, *codes: str) -> str:\n    \"\"\"Colorize text if stdout is a TTY.\"\"\"\n    if should_colorize(sys.stdout):\n        return colorize(text, *codes)\n    return text\n\n\ndef _format_default(value: Any) -> str:\n    \"\"\"Format a default value for display.\"\"\"\n    if isinstance(value, str):\n        return f'\"{value}\"' if value else '\"\"'\n    if isinstance(value, bool):\n        return \"true\" if value else \"false\"\n    if isinstance(value, list):\n        return \"[]\" if not value else str(value)\n    if isinstance(value, dict):\n        return \"{}\" if not value else str(value)\n    return str(value)\n\n\ndef _get_plugin_description(plugin: Plugin) -> str:\n    \"\"\"Get the first line of plugin's docstring as description.\"\"\"\n    doc = getattr(plugin.__class__, \"__doc__\", \"\") or \"\"\n    if doc:\n        return doc.split(\"\\n\")[0].strip()\n    return \"\"\n\n\ndef format_plugin_list(plugins: dict[str, Plugin]) -> str:\n    \"\"\"Format list of all plugins with descriptions.\n\n    Args:\n        plugins: Dict mapping plugin name to Plugin instance\n\n    Returns:\n        Formatted string listing all plugins\n    \"\"\"\n    lines = [_c(\"AVAILABLE PLUGINS\", BOLD), \"\"]\n\n    # Sort by name, skip \"pyprland\" internal plugin\n    for name in sorted(plugins.keys()):\n        if name == \"pyprland\":\n            continue\n        plugin = plugins[name]\n        desc = _get_plugin_description(plugin)\n\n        # Get environments\n        envs = getattr(plugin, \"environments\", [])\n        env_str = f\" [{', '.join(envs)}]\" if envs else \"\"\n\n        lines.append(f\"  {_c(name, CYAN)}{_c(env_str, DIM)}\")\n        if desc:\n            lines.append(f\"      {desc}\")\n\n    lines.append(\"\")\n    lines.append(\"Use 'pypr doc <plugin>' for details.\")\n    return \"\\n\".join(lines)\n\n\ndef format_plugin_doc(\n    plugin: Plugin,\n    commands: list[CommandInfo],\n    schema_override: ConfigItems | None = None,\n    config_prefix: str = \"\",\n) -> str:\n    \"\"\"Format full plugin documentation.\n\n    Args:\n        plugin: The plugin instance\n        commands: List of CommandInfo for the plugin's commands\n        schema_override: Optional schema to use instead of plugin's config_schema\n        config_prefix: Prefix for config option names (e.g., \"[name].\" for scratchpads)\n\n    Returns:\n        Formatted string with full plugin documentation\n    \"\"\"\n    name = plugin.name.upper()\n    lines = [_c(name, BOLD)]\n\n    # Description from docstring\n    desc = _get_plugin_description(plugin)\n    if desc:\n        lines.append(desc)\n\n    # Environments\n    envs = getattr(plugin, \"environments\", [])\n    if envs:\n        lines.append(f\"\\nEnvironments: {', '.join(envs)}\")\n\n    # Commands\n    if commands:\n        lines.append(f\"\\n{_c('COMMANDS', BOLD)}\")\n        for cmd in commands:\n            args_str = \" \".join(f\"<{a.value}>\" if a.required else f\"[{a.value}]\" for a in cmd.args)\n            cmd_line = f\"  {_c(cmd.name, GREEN)}\"\n            if args_str:\n                cmd_line += f\" {args_str}\"\n            lines.append(cmd_line)\n            if cmd.short_description:\n                lines.append(f\"      {cmd.short_description}\")\n\n    # Configuration - use override if provided, otherwise get from plugin\n    schema: ConfigItems | None = schema_override or getattr(plugin, \"config_schema\", None)\n    if schema and len(schema) > 0:\n        lines.append(f\"\\n{_c('CONFIGURATION', BOLD)}\")\n        if config_prefix:\n            lines.append(f\"  (Options are per-item, prefix with {config_prefix})\")\n        lines.extend(_format_config_section(schema))\n\n    lines.append(\"\")\n    lines.append(\"Use 'pypr doc <plugin>.<option>' for option details.\")\n    return \"\\n\".join(lines)\n\n\ndef _format_config_section(schema: ConfigItems) -> list[str]:\n    \"\"\"Format configuration fields grouped by category.\n\n    Args:\n        schema: ConfigItems containing all fields\n\n    Returns:\n        List of formatted lines\n    \"\"\"\n    # Group by category\n    by_category: dict[str, list[ConfigField]] = {}\n    for field in schema:\n        cat = field.category or \"general\"\n        by_category.setdefault(cat, []).append(field)\n\n    lines: list[str] = []\n    for category, fields in by_category.items():\n        lines.append(f\"\\n  {_c(category.title(), DIM, BOLD)}\")\n        for field in fields:\n            lines.extend(_format_field_brief(field))\n\n    return lines\n\n\ndef _format_field_brief(field: ConfigField) -> list[str]:\n    \"\"\"Format a config field in brief form.\n\n    Args:\n        field: The ConfigField to format\n\n    Returns:\n        List of formatted lines\n    \"\"\"\n    # Name with type and flags\n    flags = []\n    if field.required:\n        flags.append(\"required\")\n    elif field.recommended:\n        flags.append(\"recommended\")\n\n    type_str = f\"({field.type_name})\"\n    flag_str = f\" [{', '.join(flags)}]\" if flags else \"\"\n\n    lines = [f\"    {_c(field.name, CYAN)} {_c(type_str, DIM)}{flag_str}\"]\n\n    # Description\n    if field.description:\n        lines.append(f\"        {field.description}\")\n\n    # Default (if not required and has one)\n    if not field.required and field.default is not None:\n        default_str = _format_default(field.default)\n        lines.append(f\"        Default: {default_str}\")\n\n    return lines\n\n\ndef _format_field_status(field: ConfigField) -> str:\n    \"\"\"Format the status line (required/recommended/optional).\"\"\"\n    if field.required:\n        return f\"Status: {_c('required', GREEN)}\"\n    if field.recommended:\n        return \"Status: recommended\"\n    return \"Status: optional\"\n\n\ndef _format_field_choices(field: ConfigField) -> list[str]:\n    \"\"\"Format choices section if present.\"\"\"\n    if not field.choices:\n        return []\n    lines = [f\"\\n{_c('Choices:', BOLD)}\"]\n    for choice in field.choices:\n        choice_str = f'\"{choice}\"' if isinstance(choice, str) else str(choice)\n        lines.append(f\"  - {choice_str}\")\n    return lines\n\n\ndef _format_field_children(field: ConfigField) -> list[str]:\n    \"\"\"Format nested options section if present.\"\"\"\n    if not field.children:\n        return []\n    lines = [f\"\\n{_c('Nested options:', BOLD)}\"]\n    for child in field.children:\n        lines.append(f\"  {child.name} ({child.type_name})\")\n        if child.description:\n            lines.append(f\"      {child.description}\")\n    return lines\n\n\ndef format_config_field_doc(plugin_name: str, field: ConfigField) -> str:\n    \"\"\"Format detailed documentation for a single config field.\n\n    Args:\n        plugin_name: Name of the plugin (for header)\n        field: The ConfigField to document\n\n    Returns:\n        Formatted string with full field documentation\n    \"\"\"\n    header = f\"{plugin_name.upper()}.{field.name.upper()}\"\n    lines = [_c(header, BOLD), \"\"]\n\n    # Type and status\n    lines.append(f\"Type: {_c(field.type_name, CYAN)}\")\n    lines.append(_format_field_status(field))\n\n    # Default\n    if field.default is not None or not field.required:\n        default_str = _format_default(field.default)\n        lines.append(f\"Default: {default_str}\")\n\n    # Category\n    if field.category:\n        lines.append(f\"Category: {field.category}\")\n\n    # Description\n    lines.append(\"\")\n    lines.append(field.description or \"(No description available)\")\n\n    # Choices and children\n    lines.extend(_format_field_choices(field))\n    lines.extend(_format_field_children(field))\n\n    return \"\\n\".join(lines)\n"
  },
  {
    "path": "pyprland/gui/__init__.py",
    "content": "\"\"\"Web-based configuration editor for pyprland.\n\nProvides a local web server with a Vue.js frontend for viewing and editing\nthe pyprland configuration file. Uses plugin schema metadata for type-aware\nform generation.\n\nUsage:\n    pypr-gui              # Start server and open browser\n    pypr-gui -s           # Print URL (start server in background if needed)\n    pypr-gui -w           # Open browser (explicit, same as default)\n    pypr-gui --no-browser # Start server without opening browser\n    pypr-gui --port N     # Use a specific port\n\"\"\"\n\nfrom __future__ import annotations\n\nimport atexit\nimport json\nimport logging\nimport os\nimport signal\nimport socket\nimport sys\nimport time\nimport webbrowser\nfrom pathlib import Path\n\nfrom .server import create_app\n\n__all__ = [\"main\"]\n\n# Default port (fixed so the URL is predictable across invocations)\nDEFAULT_PORT = 18099\n\n# Lock file location (next to the pyprland daemon socket)\n_xdg_runtime = Path(os.environ.get(\"XDG_RUNTIME_DIR\") or f\"/tmp/pypr-{os.getuid()}\")  # noqa: S108\nLOCK_FILE = _xdg_runtime / \"pypr\" / \"gui.lock\"\n\n# How long to wait when probing an existing instance\n_PROBE_TIMEOUT = 2.0\n\n# How long the parent waits for the daemonized server to become ready\n_DAEMON_STARTUP_TIMEOUT = 5.0\n_DAEMON_POLL_INTERVAL = 0.1\n\n\ndef _find_free_port() -> int:\n    \"\"\"Find an available TCP port.\"\"\"\n    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:\n        s.bind((\"127.0.0.1\", 0))\n        return s.getsockname()[1]\n\n\ndef _write_lock(port: int) -> None:\n    \"\"\"Write the lock file with our port and PID.\"\"\"\n    LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)\n    LOCK_FILE.write_text(json.dumps({\"port\": port, \"pid\": os.getpid()}), encoding=\"utf-8\")\n\n\ndef _remove_lock() -> None:\n    \"\"\"Remove the lock file if it belongs to us.\"\"\"\n    try:\n        if LOCK_FILE.exists():\n            data = json.loads(LOCK_FILE.read_text(encoding=\"utf-8\"))\n            if data.get(\"pid\") == os.getpid():\n                LOCK_FILE.unlink()\n    except Exception:  # noqa: BLE001  # pylint: disable=broad-exception-caught\n        logging.getLogger(__name__).debug(\"Failed to remove lock file\", exc_info=True)\n\n\ndef _check_existing_instance() -> int | None:\n    \"\"\"Check if another gui instance is already running.\n\n    Returns:\n        The port number if a live instance is found, None otherwise.\n    \"\"\"\n    if not LOCK_FILE.exists():\n        return None\n\n    try:\n        data = json.loads(LOCK_FILE.read_text(encoding=\"utf-8\"))\n        port = data[\"port\"]\n        pid = data.get(\"pid\")\n\n        # Check if the process is alive\n        if pid:\n            try:\n                os.kill(pid, 0)\n            except OSError:\n                # Process is dead - stale lock\n                LOCK_FILE.unlink(missing_ok=True)\n                return None\n\n        # Verify the server is actually responding\n        with socket.create_connection((\"127.0.0.1\", port), timeout=_PROBE_TIMEOUT):\n            return port\n\n    except (json.JSONDecodeError, KeyError, OSError):\n        # Corrupt lock file or server not responding\n        LOCK_FILE.unlink(missing_ok=True)\n        return None\n\n\ndef _wait_for_server(port: int) -> bool:\n    \"\"\"Poll until the server is accepting connections.\n\n    Returns:\n        True if the server became ready, False on timeout.\n    \"\"\"\n    deadline = time.monotonic() + _DAEMON_STARTUP_TIMEOUT\n    while time.monotonic() < deadline:\n        try:\n            with socket.create_connection((\"127.0.0.1\", port), timeout=_PROBE_TIMEOUT):\n                return True\n        except OSError:\n            time.sleep(_DAEMON_POLL_INTERVAL)\n    return False\n\n\ndef _start_server(port: int) -> None:\n    \"\"\"Start the aiohttp server (blocks until shutdown).\"\"\"\n    from aiohttp import web  # noqa: PLC0415\n\n    _write_lock(port)\n    atexit.register(_remove_lock)\n    signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))\n\n    app = create_app()\n    web.run_app(app, host=\"127.0.0.1\", port=port, print=lambda _: None)\n\n\ndef _daemonize_server(port: int) -> bool:\n    \"\"\"Fork and start the server in a background process.\n\n    The parent returns True once the server is ready, or False on timeout.\n    The child never returns (it runs the server until killed).\n    \"\"\"\n    pid = os.fork()\n    if pid > 0:\n        # Parent: wait for the child to be ready, then return\n        return _wait_for_server(port)\n\n    # Child: detach from the terminal and run the server\n    os.setsid()\n\n    # Close inherited stdio so the parent's terminal is released\n    devnull = os.open(os.devnull, os.O_RDWR)\n    os.dup2(devnull, 0)\n    os.dup2(devnull, 1)\n    os.dup2(devnull, 2)\n    os.close(devnull)\n\n    _start_server(port)\n    sys.exit(0)  # unreachable, but explicit\n\n\ndef main() -> None:\n    \"\"\"Entry point for pypr-gui.\"\"\"\n    import argparse  # noqa: PLC0415\n\n    parser = argparse.ArgumentParser(description=\"Pyprland configuration editor\")\n    parser.add_argument(\"--port\", type=int, default=DEFAULT_PORT, help=f\"Port to listen on (default: {DEFAULT_PORT})\")\n    parser.add_argument(\"-w\", action=\"store_true\", help=\"Open browser\")\n    parser.add_argument(\"-s\", action=\"store_true\", help=\"Print URL (start server in background if needed)\")\n    parser.add_argument(\"--no-browser\", action=\"store_true\", help=\"Don't open browser automatically\")\n    args = parser.parse_args()\n\n    port = args.port\n    open_browser = (not args.no_browser and not args.s) or args.w\n\n    # Check for existing instance\n    existing_port = _check_existing_instance()\n\n    if existing_port:\n        url = f\"http://127.0.0.1:{existing_port}\"\n        if args.s:\n            print(url)\n        else:\n            print(f\"pypr-gui is already running at {url}\")\n        if open_browser:\n            webbrowser.open(url)\n        sys.exit(0)\n\n    url = f\"http://127.0.0.1:{port}\"\n\n    if args.s:\n        # Start server in background, print URL, exit\n        if _daemonize_server(port):\n            print(url)\n        else:\n            print(f\"Failed to start pypr-gui on port {port}\", file=sys.stderr)\n            sys.exit(1)\n        if args.w:\n            webbrowser.open(url)\n        sys.exit(0)\n\n    # Foreground mode: start server and optionally open browser\n    print(f\"Starting pypr-gui at {url}\")\n    if open_browser:\n        from threading import Timer  # noqa: PLC0415\n\n        Timer(0.5, webbrowser.open, args=(url,)).start()\n\n    _start_server(port)\n"
  },
  {
    "path": "pyprland/gui/__main__.py",
    "content": "\"\"\"Allow running pyprland.gui as a module: python -m pyprland.gui.\"\"\"\n\nfrom . import main\n\nmain()\n"
  },
  {
    "path": "pyprland/gui/api.py",
    "content": "\"\"\"API logic for the pyprland GUI: schema serialization, config I/O, validation.\n\nBridges the existing pyprland infrastructure (discovery, validation, generator)\ninto JSON-friendly structures consumed by the Vue.js frontend.\n\nConfig layout convention managed by the GUI:\n\n* **Main file** (``config.toml``): ``[pyprland]`` section with ``plugins``\n  (only plugins that have **no config data**), ``include``, and other core\n  keys.  No plugin config sections live here.\n* **Per-plugin files** (``conf.d/<plugin>.toml``): Each file carries\n  ``[pyprland] plugins = [\"<name>\"]`` **plus** all config sections for that\n  plugin.  Created only when the plugin has actual config data.\n* **Variables** (``conf.d/variables.toml``): ``[pyprland.variables]`` section.\n* On save the GUI **normalises** ``conf.d/`` to one-plugin-per-file, backing\n  up any composite files it rewrites.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport logging\nimport re\nimport shutil\nfrom datetime import UTC, datetime\nfrom typing import TYPE_CHECKING, Any\n\nfrom ..config_loader import load_config, load_toml, resolve_config_path\nfrom ..constants import CONTROL\nfrom ..quickstart.discovery import PluginInfo, discover_plugins\nfrom ..quickstart.generator import (\n    backup_config,\n    format_toml_value,\n    generate_toml,\n    get_config_path,\n)\nfrom ..validation import ConfigField, ConfigItems, ConfigValidator\n\nif TYPE_CHECKING:\n    from pathlib import Path\n\n__all__ = [\n    \"apply_config\",\n    \"get_config\",\n    \"get_plugins_schema\",\n    \"save_config\",\n    \"validate_config\",\n]\n\n_log = logging.getLogger(\"pypr-gui.api\")\n\n\n# ---------------------------------------------------------------------------\n#  Schema serialization\n# ---------------------------------------------------------------------------\n\n\ndef _field_to_dict(field: ConfigField) -> dict[str, Any]:\n    \"\"\"Serialize a ConfigField to a JSON-friendly dict.\"\"\"\n    result: dict[str, Any] = {\n        \"name\": field.name,\n        \"type\": field.type_name,\n        \"description\": field.description,\n        \"category\": field.category or \"general\",\n        \"required\": field.required,\n        \"recommended\": field.recommended,\n    }\n\n    if field.default is not None:\n        result[\"default\"] = field.default\n    if field.choices is not None:\n        result[\"choices\"] = field.choices\n    if field.children is not None:\n        result[\"children\"] = [_field_to_dict(f) for f in field.children]\n        result[\"children_allow_extra\"] = field.children_allow_extra\n    if field.is_directory:\n        result[\"is_directory\"] = True\n\n    return result\n\n\ndef _schema_to_list(schema: ConfigItems | None) -> list[dict[str, Any]]:\n    \"\"\"Serialize a ConfigItems to a JSON-friendly list.\"\"\"\n    if schema is None:\n        return []\n    return [_field_to_dict(f) for f in schema]\n\n\ndef _plugin_to_dict(plugin: PluginInfo) -> dict[str, Any]:\n    \"\"\"Serialize a PluginInfo to a JSON-friendly dict.\"\"\"\n    result: dict[str, Any] = {\n        \"name\": plugin.name,\n        \"description\": plugin.description,\n        \"environments\": plugin.environments,\n        \"config_schema\": _schema_to_list(plugin.config_schema),\n    }\n\n    # Special-case scratchpads: include the per-scratchpad child schema\n    if plugin.name == \"scratchpads\":\n        try:\n            from ..plugins.scratchpads.schema import SCRATCHPAD_SCHEMA  # noqa: PLC0415\n\n            result[\"child_schema\"] = _schema_to_list(SCRATCHPAD_SCHEMA)\n        except ImportError:\n            pass\n\n    return result\n\n\ndef get_plugins_schema() -> list[dict[str, Any]]:\n    \"\"\"Return schema info for all available plugins.\"\"\"\n    plugins = discover_plugins()\n    return [_plugin_to_dict(p) for p in plugins]\n\n\n# ---------------------------------------------------------------------------\n#  Include-aware configuration loading\n# ---------------------------------------------------------------------------\n\n\ndef get_config() -> dict[str, Any]:\n    \"\"\"Load and return the fully-merged config (main + includes).\"\"\"\n    config_path = get_config_path()\n\n    # Determine conf.d path from the main file's include list\n    _, conf_d = _find_conf_d(config_path)\n\n    # Recursively load & merge (matches daemon's _open_config behaviour)\n    merged = load_config()\n\n    return {\n        \"path\": str(config_path),\n        \"conf_d\": str(conf_d) if conf_d else None,\n        \"exists\": config_path.exists(),\n        \"config\": merged,\n    }\n\n\n# ---------------------------------------------------------------------------\n#  Validation (unchanged — works on the merged dict)\n# ---------------------------------------------------------------------------\n\n\ndef validate_config(config: dict[str, Any]) -> list[str]:\n    \"\"\"Validate a config dict against all known plugin schemas.\n\n    Returns a list of error/warning strings (empty means valid).\n    \"\"\"\n    errors: list[str] = []\n    plugins = {p.name: p for p in discover_plugins()}\n\n    enabled = config.get(\"pyprland\", {}).get(\"plugins\", [])\n    for plugin_name in enabled:\n        plugin_config = config.get(plugin_name, {})\n        info = plugins.get(plugin_name)\n        if not info or not info.config_schema:\n            continue\n\n        validator = ConfigValidator(plugin_config, plugin_name, _log)\n        errors.extend(validator.validate(info.config_schema))\n        errors.extend(validator.warn_unknown_keys(info.config_schema))\n\n    if \"scratchpads\" in enabled:\n        try:\n            from ..plugins.scratchpads.schema import (  # noqa: PLC0415\n                get_template_names,\n                is_pure_template,\n                validate_scratchpad_config,\n            )\n\n            scratch_section = config.get(\"scratchpads\", {})\n            template_names = get_template_names(scratch_section)\n            for name, scratch_conf in scratch_section.items():\n                if isinstance(scratch_conf, dict):\n                    errors.extend(\n                        validate_scratchpad_config(\n                            name,\n                            scratch_conf,\n                            is_template=is_pure_template(name, scratch_section, template_names),\n                        )\n                    )\n        except ImportError:\n            pass\n\n    return errors\n\n\n# ---------------------------------------------------------------------------\n#  TOML generation helpers\n# ---------------------------------------------------------------------------\n\n_BARE_KEY_RE = re.compile(r\"^[A-Za-z0-9_-]+$\")\n\n\ndef _toml_key(key: str) -> str:\n    \"\"\"Quote a TOML key if it contains characters not allowed in bare keys.\"\"\"\n    if _BARE_KEY_RE.match(key):\n        return key\n    # Use double-quoted key with minimal escaping\n    escaped = key.replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n    return f'\"{escaped}\"'\n\n\ndef _generate_plugin_toml(plugin_name: str, plugin_data: dict[str, Any]) -> str:\n    \"\"\"Generate a ``conf.d/<plugin>.toml`` file.\n\n    Layout::\n\n        [pyprland]\n        plugins = [\"<plugin_name>\"]\n\n        [<plugin_name>]\n        key = value\n\n        [<plugin_name>.<sub>]\n        ...\n    \"\"\"\n    lines: list[str] = [\n        \"# Managed by pypr-gui\",\n        f\"# {datetime.now(tz=UTC).strftime('%Y-%m-%d %H:%M:%S')} UTC\",\n        \"\",\n        \"[pyprland]\",\n        f'plugins = [\"{plugin_name}\"]',\n        \"\",\n    ]\n\n    # Separate simple values from sub-tables\n    simple: dict[str, Any] = {}\n    subs: dict[str, dict[str, Any]] = {}\n    for key, value in plugin_data.items():\n        if isinstance(value, dict):\n            subs[key] = value\n        else:\n            simple[key] = value\n\n    # Top-level plugin section (only if there are simple values)\n    if simple:\n        lines.append(f\"[{plugin_name}]\")\n        for k, v in simple.items():\n            lines.append(f\"{_toml_key(k)} = {format_toml_value(v)}\")\n        lines.append(\"\")\n\n    # Sub-tables\n    for sub_name, sub_data in subs.items():\n        _write_subtable(lines, f\"{plugin_name}.{_toml_key(sub_name)}\", sub_data)\n\n    return \"\\n\".join(lines)\n\n\ndef _write_subtable(lines: list[str], prefix: str, data: dict[str, Any]) -> None:\n    \"\"\"Recursively write ``[prefix]`` and any nested sub-tables.\"\"\"\n    simple: dict[str, Any] = {}\n    subs: dict[str, dict[str, Any]] = {}\n    for k, v in data.items():\n        if isinstance(v, dict):\n            subs[k] = v\n        else:\n            simple[k] = v\n\n    lines.append(f\"[{prefix}]\")\n    for k, v in simple.items():\n        lines.append(f\"{_toml_key(k)} = {format_toml_value(v)}\")\n    lines.append(\"\")\n\n    for sub_name, sub_data in subs.items():\n        _write_subtable(lines, f\"{prefix}.{_toml_key(sub_name)}\", sub_data)\n\n\ndef _generate_variables_toml(variables: dict[str, Any]) -> str:\n    \"\"\"Generate ``conf.d/variables.toml``.\"\"\"\n    lines: list[str] = [\n        \"# Managed by pypr-gui\",\n        f\"# {datetime.now(tz=UTC).strftime('%Y-%m-%d %H:%M:%S')} UTC\",\n        \"\",\n        \"[pyprland.variables]\",\n    ]\n    for k, v in variables.items():\n        lines.append(f\"{_toml_key(k)} = {format_toml_value(v)}\")\n    lines.append(\"\")\n    return \"\\n\".join(lines)\n\n\ndef _generate_main_toml(\n    pyprland_section: dict[str, Any],\n    main_only_plugins: list[str],\n    include_paths: list[str],\n) -> str:\n    \"\"\"Generate the main ``config.toml``.\n\n    Contains only ``[pyprland]`` with the option-less plugins, the include\n    directive, and any other core pyprland keys (except ``variables`` which\n    live in ``conf.d/variables.toml``).\n    \"\"\"\n    lines: list[str] = [\n        \"# Pyprland configuration\",\n        \"# Managed by pypr-gui\",\n        f\"# {datetime.now(tz=UTC).strftime('%Y-%m-%d %H:%M:%S')} UTC\",\n        \"\",\n        \"[pyprland]\",\n    ]\n\n    # Include directive first\n    if include_paths:\n        lines.append(f\"include = {format_toml_value(include_paths)}\")\n\n    # Plugins list (only option-less plugins)\n    if main_only_plugins:\n        lines.append(f\"plugins = {format_toml_value(sorted(set(main_only_plugins)))}\")\n\n    # Any remaining pyprland keys (except plugins, include, variables)\n    skip = {\"plugins\", \"include\", \"variables\"}\n    for k, v in pyprland_section.items():\n        if k not in skip:\n            lines.append(f\"{_toml_key(k)} = {format_toml_value(v)}\")\n\n    lines.append(\"\")\n    return \"\\n\".join(lines)\n\n\n# ---------------------------------------------------------------------------\n#  Save with data-driven conf.d split\n# ---------------------------------------------------------------------------\n\n\ndef _backup_conf_d_file(path: Path) -> Path | None:\n    \"\"\"Back up a conf.d file by renaming to ``.bak``.\"\"\"\n    if not path.exists():\n        return None\n    bak = path.with_suffix(\".toml.bak\")\n    shutil.copy2(path, bak)\n    _log.info(\"Backed up %s -> %s\", path, bak)\n    return bak\n\n\ndef _find_conf_d(config_path: Path) -> tuple[list[str], Path | None]:\n    \"\"\"Read the original main file and resolve the conf.d directory.\n\n    Returns ``(include_paths, conf_d_path | None)``.\n    \"\"\"\n    original_base = load_toml(config_path) if config_path.exists() else {}\n    include_paths: list[str] = list(original_base.get(\"pyprland\", {}).get(\"include\", []))\n\n    for raw in include_paths:\n        resolved = resolve_config_path(raw)\n        if resolved.is_dir():\n            return include_paths, resolved\n\n    return include_paths, None\n\n\ndef _write_confd_plugins(\n    conf_d: Path,\n    confd_plugins: dict[str, dict[str, Any]],\n    existing_confd_files: dict[str, Path],\n) -> set[str]:\n    \"\"\"Write ``conf.d/<plugin>.toml`` for each plugin with data.\n\n    Also handles disabled plugins (clears their ``[pyprland] plugins`` list\n    but preserves config) and backs up composite files.\n\n    Returns the set of plugin names that were written.\n    \"\"\"\n    known_stems = set(confd_plugins.keys()) | {\"variables\"}\n\n    # Back up composite files whose stem doesn't match a known plugin\n    for stem, path in existing_confd_files.items():\n        if stem not in known_stems and path.exists():\n            _backup_conf_d_file(path)\n\n    # Write per-plugin files\n    written: set[str] = set()\n    for plugin_name, plugin_data in confd_plugins.items():\n        content = _generate_plugin_toml(plugin_name, plugin_data)\n        dest = conf_d / f\"{plugin_name}.toml\"\n        dest.write_text(content, encoding=\"utf-8\")\n        written.add(plugin_name)\n        _log.info(\"Wrote %s\", dest)\n\n    # Disabled plugins: clear their plugins list, keep config data\n    for stem, path in existing_confd_files.items():\n        if stem in written or stem == \"variables\" or not path.exists():\n            continue\n        existing = load_toml(path)\n        if existing.get(\"pyprland\", {}).get(\"plugins\"):\n            existing.setdefault(\"pyprland\", {})[\"plugins\"] = []\n            path.write_text(_generate_plugin_toml_raw(existing), encoding=\"utf-8\")\n            _log.info(\"Disabled plugin in %s (config preserved)\", path)\n\n    return written\n\n\ndef _cleanup_composite_files(\n    existing_confd_files: dict[str, Path],\n    known_stems: set[str],\n    written_files: set[str],\n    has_variables: bool,\n) -> None:\n    \"\"\"Remove fully-migrated composite conf.d files.\"\"\"\n    for stem, path in existing_confd_files.items():\n        if stem in known_stems or stem in written_files or not path.exists():\n            continue\n        old_data = load_toml(path)\n        old_plugin_names = old_data.get(\"pyprland\", {}).get(\"plugins\", [])\n        all_migrated = all(p in written_files for p in old_plugin_names)\n        old_has_vars = bool(old_data.get(\"pyprland\", {}).get(\"variables\"))\n        if all_migrated and (not old_has_vars or has_variables):\n            path.unlink()\n            _log.info(\"Removed fully-migrated composite file %s\", path)\n\n\ndef save_config(config: dict[str, Any]) -> dict[str, Any]:\n    \"\"\"Validate and save config, splitting across main file + conf.d/.\n\n    Rules:\n    * Plugins with config data → ``conf.d/<plugin>.toml``\n    * Plugins without config data → listed in main ``config.toml``\n    * ``pyprland.variables`` → ``conf.d/variables.toml``\n    * Disabled plugins keep their conf.d file (config preserved) but are\n      removed from the file's ``[pyprland] plugins`` list.\n    \"\"\"\n    errors = validate_config(config)\n    config_path = get_config_path()\n\n    include_paths, conf_d = _find_conf_d(config_path)\n    backup_path = backup_config(config_path)\n\n    pyprland_section: dict[str, Any] = dict(config.get(\"pyprland\", {}))\n    all_enabled: list[str] = list(pyprland_section.get(\"plugins\", []))\n    variables: dict[str, Any] = dict(pyprland_section.get(\"variables\", {}))\n\n    # No conf.d: single-file mode\n    if conf_d is None:\n        content = generate_toml(config)\n        config_path.parent.mkdir(parents=True, exist_ok=True)\n        config_path.write_text(content, encoding=\"utf-8\")\n        return _save_result(errors, config_path, backup_path)\n\n    # conf.d mode: split config across files\n    conf_d.mkdir(parents=True, exist_ok=True)\n\n    # Classify: plugins with data → conf.d, rest → main config.toml\n    main_only_plugins: list[str] = []\n    confd_plugins: dict[str, dict[str, Any]] = {}\n    for name in all_enabled:\n        data = config.get(name, {})\n        if isinstance(data, dict) and data:\n            confd_plugins[name] = data\n        else:\n            main_only_plugins.append(name)\n\n    existing_confd_files = {f.stem: f for f in conf_d.iterdir() if f.suffix == \".toml\"} if conf_d.is_dir() else {}\n\n    written = _write_confd_plugins(conf_d, confd_plugins, existing_confd_files)\n\n    if variables:\n        dest = conf_d / \"variables.toml\"\n        dest.write_text(_generate_variables_toml(variables), encoding=\"utf-8\")\n\n    known_stems = set(confd_plugins.keys()) | {\"variables\"}\n    _cleanup_composite_files(existing_confd_files, known_stems, written, bool(variables))\n\n    main_content = _generate_main_toml(pyprland_section, main_only_plugins, include_paths)\n    config_path.parent.mkdir(parents=True, exist_ok=True)\n    config_path.write_text(main_content, encoding=\"utf-8\")\n\n    return _save_result(errors, config_path, backup_path, conf_d)\n\n\ndef _save_result(\n    errors: list[str],\n    config_path: Path,\n    backup_path: Path | None,\n    conf_d: Path | None = None,\n) -> dict[str, Any]:\n    \"\"\"Build the JSON-serialisable save response.\"\"\"\n    result: dict[str, Any] = {\n        \"ok\": not errors,\n        \"errors\": errors,\n        \"path\": str(config_path),\n        \"backup\": str(backup_path) if backup_path else None,\n    }\n    if conf_d is not None:\n        result[\"conf_d\"] = str(conf_d)\n    return result\n\n\ndef _generate_plugin_toml_raw(existing_data: dict[str, Any]) -> str:\n    \"\"\"Re-serialize an existing conf.d file preserving its structure.\n\n    Used when we only need to tweak the ``[pyprland] plugins`` list of an\n    existing file (e.g. to disable a plugin while keeping its config).\n    \"\"\"\n    lines: list[str] = [\n        \"# Managed by pypr-gui\",\n        f\"# {datetime.now(tz=UTC).strftime('%Y-%m-%d %H:%M:%S')} UTC\",\n        \"\",\n    ]\n\n    # [pyprland] section\n    pypr = existing_data.get(\"pyprland\", {})\n    lines.append(\"[pyprland]\")\n    plugin_list = pypr.get(\"plugins\", [])\n    if plugin_list:\n        lines.append(f\"plugins = {format_toml_value(plugin_list)}\")\n    else:\n        lines.append(\"plugins = []\")\n    lines.append(\"\")\n\n    # Everything else\n    for section_name, section_data in existing_data.items():\n        if section_name == \"pyprland\" or not isinstance(section_data, dict):\n            continue\n        _write_subtable(lines, section_name, section_data)\n\n    return \"\\n\".join(lines)\n\n\n# ---------------------------------------------------------------------------\n#  IPC\n# ---------------------------------------------------------------------------\n\n\nasync def _send_ipc_command(command: str) -> str:\n    \"\"\"Send a command to the pyprland daemon via IPC.\n\n    Returns the daemon's response string.\n    \"\"\"\n    try:\n        reader, writer = await asyncio.open_unix_connection(CONTROL)\n        writer.write((command + \"\\n\").encode())\n        writer.write_eof()\n        await writer.drain()\n        response = (await reader.read()).decode(\"utf-8\")\n        writer.close()\n        await writer.wait_closed()\n    except (ConnectionRefusedError, FileNotFoundError):\n        return \"ERROR: pypr daemon is not running\"\n    else:\n        return response\n\n\nasync def apply_config(config: dict[str, Any]) -> dict[str, Any]:\n    \"\"\"Save config and reload the running daemon.\"\"\"\n    result = save_config(config)\n\n    # Attempt to reload the daemon\n    response = await _send_ipc_command(\"reload\")\n    result[\"reload_response\"] = response.strip()\n    result[\"daemon_reloaded\"] = not response.startswith(\"ERROR\")\n\n    return result\n"
  },
  {
    "path": "pyprland/gui/frontend/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "pyprland/gui/frontend/.vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\n    \"Vue.volar\"\n  ]\n}\n"
  },
  {
    "path": "pyprland/gui/frontend/README.md",
    "content": "# Vue 3 + Vite\n\nThis template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.\n\nLearn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).\n"
  },
  {
    "path": "pyprland/gui/frontend/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <link rel=\"icon\" type=\"image/png\" href=\"/icon.png\" />\n    <title>pypr-gui</title>\n  </head>\n  <body>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"/src/main.js\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "pyprland/gui/frontend/package.json",
    "content": "{\n  \"name\": \"frontend\",\n  \"private\": true,\n  \"version\": \"0.0.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"vite build\",\n    \"preview\": \"vite preview\"\n  },\n  \"dependencies\": {\n    \"vue\": \"^3.5.30\"\n  },\n  \"devDependencies\": {\n    \"@vitejs/plugin-vue\": \"^6.0.5\",\n    \"vite\": \"^8.0.1\"\n  }\n}\n"
  },
  {
    "path": "pyprland/gui/frontend/src/App.vue",
    "content": "<template>\n  <div v-if=\"loading\" class=\"loading\">Loading...</div>\n  <template v-else>\n    <header class=\"app-header\">\n      <h1><img src=\"/icon.png\" alt=\"\" class=\"header-icon\" />pypr-gui</h1>\n      <div class=\"action-bar\">\n        <span v-if=\"statusMsg\" :class=\"['status-message', statusType]\">{{ statusMsg }}</span>\n        <button class=\"btn btn-secondary\" @click=\"handleValidate\" :disabled=\"saving\">Validate</button>\n        <button class=\"btn btn-primary\" @click=\"handleSave\" :disabled=\"saving\">Save</button>\n        <button class=\"btn btn-success\" @click=\"handleApply\" :disabled=\"saving\">Apply</button>\n      </div>\n    </header>\n\n    <div class=\"app-body\">\n      <aside class=\"sidebar\">\n        <div class=\"sidebar-section\">\n          <h3>Config</h3>\n          <div style=\"padding: 0 8px; font-size: 0.75rem; color: var(--text-muted); font-family: monospace; word-break: break-all;\">\n            {{ configPath }}\n          </div>\n        </div>\n\n        <div class=\"sidebar-section\">\n          <h3>Plugins</h3>\n          <div\n            v-for=\"plugin in allPlugins\"\n            :key=\"plugin.name\"\n            :class=\"['plugin-item', { active: activeView === plugin.name, disabled: !isEnabled(plugin.name) }]\"\n            @click=\"selectPlugin(plugin.name)\"\n          >\n            <input\n              type=\"checkbox\"\n              :checked=\"isEnabled(plugin.name)\"\n              @click.stop\n              @change=\"togglePlugin(plugin.name)\"\n            />\n            <span class=\"plugin-name\">{{ plugin.name }}</span>\n          </div>\n        </div>\n\n        <div v-if=\"hasVariables\" class=\"sidebar-section\">\n          <h3>Misc</h3>\n          <div\n            :class=\"['plugin-item', { active: activeView === '_variables' }]\"\n            @click=\"activeView = '_variables'\"\n          >\n            <span class=\"plugin-name\">variables</span>\n          </div>\n        </div>\n      </aside>\n\n      <main class=\"main-content\">\n        <!-- Variables editor (flat DictEditor) -->\n        <template v-if=\"activeView === '_variables'\">\n          <div class=\"plugin-header\">\n            <h2>Variables</h2>\n            <p>Template variables available in plugin configs via <code>[var_name]</code> syntax.</p>\n          </div>\n          <DictEditor\n            :value=\"variables\"\n            :flat=\"true\"\n            @update:value=\"variables = $event\"\n          />\n        </template>\n\n        <!-- Plugin editor -->\n        <template v-else-if=\"activeView && currentPluginInfo\">\n          <PluginEditor\n            :plugin=\"currentPluginInfo\"\n            :config=\"getPluginConfig(activeView)\"\n            :docs-base=\"DOCS_BASE\"\n            @update:config=\"setPluginConfig(activeView, $event)\"\n          />\n        </template>\n\n        <div v-else class=\"empty-state\">\n          <h3>Select a plugin</h3>\n          <p>Choose a plugin from the sidebar to configure it. Check the checkbox to enable it.</p>\n        </div>\n      </main>\n    </div>\n\n    <ul v-if=\"errors.length\" class=\"error-list\" style=\"margin: 0 24px 12px;\">\n      <li v-for=\"(err, i) in errors\" :key=\"i\">{{ err }}</li>\n    </ul>\n  </template>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted } from 'vue'\nimport PluginEditor from './components/PluginEditor.vue'\nimport DictEditor from './components/DictEditor.vue'\n\nconst DOCS_BASE = 'https://hyprland-community.github.io/pyprland/'\n\nconst loading = ref(true)\nconst saving = ref(false)\nconst statusMsg = ref('')\nconst statusType = ref('success')\nconst errors = ref([])\n\nconst allPlugins = ref([])\nconst config = ref({})\nconst configPath = ref('')\nconst variables = ref({})\n\nonMounted(async () => {\n  try {\n    const [pluginsRes, configRes] = await Promise.all([\n      fetch('/api/plugins').then(r => r.json()),\n      fetch('/api/config').then(r => r.json()),\n    ])\n    allPlugins.value = pluginsRes\n    config.value = configRes.config || {}\n    configPath.value = configRes.path || ''\n\n    // Ensure pyprland section exists\n    if (!config.value.pyprland) {\n      config.value.pyprland = { plugins: [] }\n    }\n    if (!config.value.pyprland.plugins) {\n      config.value.pyprland.plugins = []\n    }\n    // Deduplicate plugins (merge() list concatenation can cause dupes)\n    config.value.pyprland.plugins = [...new Set(config.value.pyprland.plugins)]\n\n    // Extract variables into a separate ref (they live under pyprland.variables)\n    variables.value = config.value.pyprland.variables || {}\n  } catch (e) {\n    statusMsg.value = 'Failed to load: ' + e.message\n    statusType.value = 'error'\n  } finally {\n    loading.value = false\n  }\n})\n\n// activeView is either a plugin name or '_variables'\nconst activeView = ref(null)\n\nconst enabledPlugins = computed(() => [...new Set(config.value.pyprland?.plugins || [])])\n\nconst hasVariables = computed(() => Object.keys(variables.value).length > 0)\n\nconst currentPluginInfo = computed(() => {\n  if (!activeView.value || activeView.value === '_variables') return null\n  return allPlugins.value.find(p => p.name === activeView.value) || null\n})\n\nfunction isEnabled(name) {\n  return enabledPlugins.value.includes(name)\n}\n\nfunction selectPlugin(name) {\n  activeView.value = name\n}\n\nfunction togglePlugin(name) {\n  const plugins = config.value.pyprland.plugins\n  if (plugins.includes(name)) {\n    // Remove all occurrences (merge() can cause duplicates)\n    config.value.pyprland.plugins = plugins.filter(p => p !== name)\n    delete config.value[name]\n  } else {\n    plugins.push(name)\n    // Deduplicate and sort\n    config.value.pyprland.plugins = [...new Set(plugins)].sort()\n    if (!config.value[name]) {\n      config.value[name] = {}\n    }\n  }\n}\n\nfunction getPluginConfig(name) {\n  // Return the same reactive object each time so the PluginEditor prop\n  // reference stays stable and doesn't trigger unnecessary deep-watch cycles.\n  if (!config.value[name]) {\n    config.value[name] = {}\n  }\n  return config.value[name]\n}\n\nfunction setPluginConfig(name, newConf) {\n  config.value[name] = newConf\n}\n\n/** Merge variables back into the config before sending to the API. */\nfunction buildSavePayload() {\n  const payload = { ...config.value }\n  payload.pyprland = { ...payload.pyprland }\n  if (Object.keys(variables.value).length > 0) {\n    payload.pyprland.variables = { ...variables.value }\n  } else {\n    delete payload.pyprland.variables\n  }\n  return payload\n}\n\nfunction showStatus(msg, type, duration = 4000) {\n  statusMsg.value = msg\n  statusType.value = type\n  errors.value = []\n  if (duration) setTimeout(() => { statusMsg.value = '' }, duration)\n}\n\n/** Shared POST-to-API helper. */\nasync function apiAction(endpoint, onSuccess) {\n  saving.value = true\n  try {\n    const res = await fetch(endpoint, {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify({ config: buildSavePayload() }),\n    })\n    const data = await res.json()\n    errors.value = data.errors || []\n    onSuccess(data)\n  } catch (e) {\n    showStatus(`${endpoint.split('/').pop()} failed: ${e.message}`, 'error')\n  } finally {\n    saving.value = false\n  }\n}\n\nfunction handleValidate() {\n  apiAction('/api/validate', (data) => {\n    if (data.ok) showStatus('Valid', 'success')\n    else showStatus(`${data.errors.length} issue(s) found`, 'warning', 0)\n  })\n}\n\nfunction handleSave() {\n  apiAction('/api/save', (data) => {\n    showStatus(data.backup ? `Saved (backup: ${data.backup})` : 'Saved', 'success')\n  })\n}\n\nfunction handleApply() {\n  apiAction('/api/apply', (data) => {\n    if (data.daemon_reloaded) showStatus('Saved & reloaded', 'success')\n    else showStatus('Saved (daemon not running)', 'warning')\n  })\n}\n</script>\n"
  },
  {
    "path": "pyprland/gui/frontend/src/components/DictEditor.vue",
    "content": "<template>\n  <div class=\"dict-editor\" :class=\"{ nested: depth > 0 }\">\n    <div v-for=\"key in sortedKeys\" :key=\"key\" class=\"dict-entry\">\n      <div class=\"collapsible-header\" @click=\"toggle(key)\">\n        <span :class=\"['chevron', { open: opened[key] }]\">&#9654;</span>\n        <span class=\"dict-entry-name\">{{ key }}</span>\n        <template v-if=\"!flat\">\n          <span class=\"dict-entry-type\">{{ entryTypeLabel(local[key]) }}</span>\n          <span v-if=\"isString(local[key]) && !local[key].includes('\\n')\" class=\"dict-entry-preview\">{{ local[key] }}</span>\n        </template>\n        <div class=\"dict-entry-actions\">\n          <button class=\"btn btn-secondary btn-xs\" @click.stop=\"removeEntry(key)\">Remove</button>\n        </div>\n      </div>\n\n      <div v-if=\"opened[key]\" class=\"dict-entry-body\">\n        <!-- Flat mode: always a simple text input -->\n        <input\n          v-if=\"flat\"\n          type=\"text\"\n          :value=\"formatValue(local[key])\"\n          @input=\"updateValue(key, $event.target.value)\"\n        />\n\n        <!-- String value -->\n        <template v-else-if=\"isString(local[key])\">\n          <textarea\n            v-if=\"local[key].includes('\\n') || local[key].length > 100\"\n            class=\"dict-value-input\"\n            :value=\"local[key]\"\n            @change=\"updateValue(key, $event.target.value)\"\n            rows=\"4\"\n            spellcheck=\"false\"\n          ></textarea>\n          <input\n            v-else\n            type=\"text\"\n            :value=\"local[key]\"\n            @input=\"updateValue(key, $event.target.value)\"\n          />\n        </template>\n\n        <!-- Array value -->\n        <div v-else-if=\"isArray(local[key])\" class=\"dict-array-editor\">\n          <div v-for=\"(item, idx) in local[key]\" :key=\"idx\" class=\"dict-array-item\">\n            <!-- Array item is an object -->\n            <div v-if=\"isObject(item)\" class=\"dict-array-object\">\n              <div class=\"dict-array-object-header\">\n                <span class=\"dict-entry-type\">object</span>\n                <button class=\"btn btn-secondary btn-xs\" @click=\"removeArrayItem(key, idx)\">Remove</button>\n              </div>\n              <div v-for=\"(val, okey) in item\" :key=\"okey\" class=\"dict-array-object-field\">\n                <label class=\"dict-array-field-label\">{{ okey }}</label>\n                <input\n                  v-if=\"isString(val) || typeof val === 'number'\"\n                  type=\"text\"\n                  :value=\"String(val)\"\n                  @input=\"updateArrayObjectField(key, idx, okey, $event.target.value)\"\n                />\n                <textarea\n                  v-else-if=\"isArray(val)\"\n                  class=\"dict-value-input\"\n                  :value=\"JSON.stringify(val, null, 2)\"\n                  @change=\"updateArrayObjectFieldJson(key, idx, okey, $event.target.value)\"\n                  rows=\"2\"\n                  spellcheck=\"false\"\n                ></textarea>\n              </div>\n              <!-- Add field to array object -->\n              <div class=\"add-row\">\n                <input v-model=\"newArrayObjKey[key + '.' + idx]\" type=\"text\" placeholder=\"key\" class=\"add-row-key\" @keyup.enter=\"addArrayObjectField(key, idx)\" />\n                <input v-model=\"newArrayObjVal[key + '.' + idx]\" type=\"text\" placeholder=\"value\" class=\"add-row-val\" @keyup.enter=\"addArrayObjectField(key, idx)\" />\n                <button class=\"btn btn-secondary btn-xs\" @click=\"addArrayObjectField(key, idx)\" :disabled=\"!(newArrayObjKey[key + '.' + idx] || '').trim()\">+</button>\n              </div>\n            </div>\n            <!-- Array item is a string -->\n            <div v-else class=\"dict-array-string\">\n              <textarea\n                v-if=\"isString(item) && (item.includes('\\n') || item.length > 80)\"\n                class=\"dict-value-input\"\n                :value=\"item\"\n                @change=\"updateArrayItem(key, idx, $event.target.value)\"\n                rows=\"3\"\n                spellcheck=\"false\"\n              ></textarea>\n              <input\n                v-else\n                type=\"text\"\n                :value=\"String(item)\"\n                @input=\"updateArrayItem(key, idx, $event.target.value)\"\n              />\n              <button class=\"btn btn-secondary btn-xs\" @click=\"removeArrayItem(key, idx)\">x</button>\n            </div>\n          </div>\n          <!-- Add array item -->\n          <div class=\"add-row\">\n            <select v-model=\"newArrayItemType[key]\" class=\"add-row-type\">\n              <option value=\"string\">string</option>\n              <option value=\"object\">object</option>\n            </select>\n            <input v-if=\"newArrayItemType[key] !== 'object'\" v-model=\"newArrayItemVal[key]\" type=\"text\" placeholder=\"New item...\" class=\"add-row-val\" @keyup.enter=\"addArrayItem(key)\" />\n            <button class=\"btn btn-secondary btn-xs\" @click=\"addArrayItem(key)\">+ item</button>\n          </div>\n        </div>\n\n        <!-- Nested dict (submenu) — recurse -->\n        <DictEditor\n          v-else-if=\"isObject(local[key])\"\n          :value=\"local[key]\"\n          :depth=\"depth + 1\"\n          @update:value=\"updateValue(key, $event)\"\n        />\n\n        <!-- Fallback: show as JSON -->\n        <input v-else type=\"text\" :value=\"JSON.stringify(local[key])\" @change=\"updateValueJson(key, $event.target.value)\" />\n\n        <!-- Rename (hidden in flat mode) -->\n        <div v-if=\"!flat\" class=\"dict-entry-rename\">\n          <span class=\"dict-rename-label\">key:</span>\n          <input type=\"text\" :value=\"key\" @change=\"renameEntry(key, $event.target.value)\" class=\"dict-rename-input\" />\n        </div>\n      </div>\n    </div>\n\n    <!-- Add new entry -->\n    <div class=\"add-row\">\n      <input v-model=\"newKey\" type=\"text\" placeholder=\"New key...\" class=\"add-row-key\" @keyup.enter=\"addEntry\" />\n      <select v-if=\"!flat\" v-model=\"newType\" class=\"add-row-type\">\n        <option value=\"string\">string</option>\n        <option value=\"array\">array</option>\n        <option value=\"dict\">submenu</option>\n      </select>\n      <button class=\"btn btn-secondary btn-xs\" @click=\"addEntry\" :disabled=\"!newKey.trim()\">Add</button>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, reactive, computed } from 'vue'\nimport { useLocalCopy } from '../composables/useLocalCopy.js'\nimport { useToggleMap } from '../composables/useToggleMap.js'\nimport { isString, isArray, isObject, tryParseJson, formatValue } from '../utils.js'\n\nconst props = defineProps({\n  value: { type: Object, default: () => ({}) },\n  depth: { type: Number, default: 0 },\n  flat: { type: Boolean, default: false },\n})\n\nconst emit = defineEmits(['update:value'])\n\nconst local = useLocalCopy(() => props.value, emit, 'update:value')\nconst { state: opened, toggle } = useToggleMap()\n\nconst newKey = ref('')\nconst newType = ref('string')\nconst newArrayItemType = reactive({})\nconst newArrayItemVal = reactive({})\nconst newArrayObjKey = reactive({})\nconst newArrayObjVal = reactive({})\n\nconst sortedKeys = computed(() => Object.keys(local.value))\n\nfunction entryTypeLabel(v) {\n  if (isString(v)) return 'str'\n  if (isArray(v)) return `list(${v.length})`\n  if (isObject(v)) return `{${Object.keys(v).length}}`\n  return typeof v\n}\n\nfunction updateValue(key, val) {\n  local.value[key] = val\n}\n\nfunction updateValueJson(key, raw) {\n  const parsed = tryParseJson(raw)\n  if (parsed !== raw) local.value[key] = parsed\n}\n\nfunction removeEntry(key) {\n  delete local.value[key]\n  local.value = { ...local.value }\n}\n\nfunction renameEntry(oldKey, newName) {\n  const trimmed = newName.trim()\n  if (!trimmed || trimmed === oldKey) return\n  const rebuilt = {}\n  for (const [k, v] of Object.entries(local.value)) {\n    rebuilt[k === oldKey ? trimmed : k] = v\n  }\n  local.value = rebuilt\n  if (opened[oldKey]) {\n    delete opened[oldKey]\n    opened[trimmed] = true\n  }\n}\n\nfunction addEntry() {\n  const k = newKey.value.trim()\n  if (!k || local.value[k] !== undefined) return\n  if (props.flat || newType.value === 'string') {\n    local.value[k] = ''\n  } else if (newType.value === 'array') {\n    local.value[k] = []\n  } else {\n    local.value[k] = {}\n  }\n  opened[k] = true\n  newKey.value = ''\n}\n\n// --- Array item operations ---\n\nfunction updateArrayItem(key, idx, val) {\n  const arr = [...local.value[key]]\n  arr[idx] = val\n  local.value[key] = arr\n}\n\nfunction removeArrayItem(key, idx) {\n  const arr = [...local.value[key]]\n  arr.splice(idx, 1)\n  local.value[key] = arr\n}\n\nfunction addArrayItem(key) {\n  const arr = [...(local.value[key] || [])]\n  const type = newArrayItemType[key] || 'string'\n  if (type === 'object') {\n    arr.push({})\n  } else {\n    arr.push(newArrayItemVal[key] || '')\n    newArrayItemVal[key] = ''\n  }\n  local.value[key] = arr\n}\n\nfunction updateArrayObjectField(key, idx, okey, val) {\n  const arr = [...local.value[key]]\n  arr[idx] = { ...arr[idx], [okey]: val }\n  local.value[key] = arr\n}\n\nfunction updateArrayObjectFieldJson(key, idx, okey, raw) {\n  updateArrayObjectField(key, idx, okey, tryParseJson(raw))\n}\n\nfunction addArrayObjectField(key, idx) {\n  const compKey = key + '.' + idx\n  const k = (newArrayObjKey[compKey] || '').trim()\n  if (!k) return\n  updateArrayObjectField(key, idx, k, tryParseJson(newArrayObjVal[compKey] || ''))\n  newArrayObjKey[compKey] = ''\n  newArrayObjVal[compKey] = ''\n}\n</script>\n"
  },
  {
    "path": "pyprland/gui/frontend/src/components/FieldInput.vue",
    "content": "<template>\n  <div class=\"field-row\">\n    <div class=\"field-label\">\n      <div class=\"name\">\n        {{ field.name }}\n        <span v-if=\"field.required\" class=\"required\" title=\"Required\">*</span>\n        <span v-else-if=\"field.recommended\" class=\"recommended\" title=\"Recommended\">(rec)</span>\n      </div>\n      <div class=\"description\">{{ field.description }}</div>\n      <div class=\"type-badge\">{{ field.type }}</div>\n    </div>\n\n    <div class=\"field-input\">\n      <!-- Boolean: toggle switch -->\n      <div v-if=\"isBool\" class=\"toggle-wrap\">\n        <label class=\"toggle\">\n          <input type=\"checkbox\" :checked=\"boolValue\" @change=\"emit('update:value', $event.target.checked)\" />\n          <span class=\"slider\"></span>\n        </label>\n        <span class=\"toggle-label\">{{ boolValue ? 'true' : 'false' }}</span>\n      </div>\n\n      <!-- Choices: dropdown select -->\n      <select v-else-if=\"field.choices\" :value=\"displayValue\" @change=\"emitTyped($event.target.value)\">\n        <option v-if=\"!field.required\" value=\"\">-- default --</option>\n        <option v-for=\"c in field.choices\" :key=\"c\" :value=\"c\">{{ c || '(empty)' }}</option>\n      </select>\n\n      <!-- Number -->\n      <input\n        v-else-if=\"isNumeric\"\n        type=\"number\"\n        :value=\"displayValue\"\n        :placeholder=\"placeholderText\"\n        :step=\"isFloat ? '0.1' : '1'\"\n        @input=\"emitNumber($event.target.value)\"\n      />\n\n      <!-- Dict without children schema: structured recursive editor -->\n      <DictEditor\n        v-else-if=\"isDict\"\n        :value=\"props.value || {}\"\n        @update:value=\"emit('update:value', $event)\"\n      />\n\n      <!-- List of objects: JSON textarea -->\n      <div v-else-if=\"isComplexList\">\n        <textarea\n          class=\"json-textarea\"\n          :value=\"jsonDisplayValue\"\n          :placeholder=\"placeholderText || '[]'\"\n          @change=\"emitJson($event.target.value)\"\n          rows=\"6\"\n          spellcheck=\"false\"\n        ></textarea>\n        <div class=\"field-hint\">\n          JSON format (list)\n          <span v-if=\"jsonError\" class=\"json-error\">{{ jsonError }}</span>\n        </div>\n      </div>\n\n      <!-- List of primitives (as comma-separated text) -->\n      <div v-else-if=\"isList\">\n        <input\n          type=\"text\"\n          :value=\"listDisplayValue\"\n          :placeholder=\"placeholderText\"\n          @input=\"emitList($event.target.value)\"\n        />\n        <div class=\"field-hint\">Comma-separated values</div>\n      </div>\n\n      <!-- String (default) -->\n      <input\n        v-else\n        type=\"text\"\n        :value=\"displayValue\"\n        :placeholder=\"placeholderText\"\n        @input=\"emitTyped($event.target.value)\"\n      />\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { computed, ref } from 'vue'\nimport DictEditor from './DictEditor.vue'\n\nconst props = defineProps({\n  field: { type: Object, required: true },\n  value: { default: undefined },\n})\n\nconst emit = defineEmits(['update:value'])\n\nconst fieldType = computed(() => (props.field.type || 'str').toLowerCase())\n\nconst isBool = computed(() => fieldType.value === 'bool')\nconst isNumeric = computed(() => fieldType.value === 'int' || fieldType.value === 'float')\nconst isFloat = computed(() => fieldType.value === 'float')\nconst isList = computed(() => fieldType.value.startsWith('list'))\n\n// Dict without a children schema — rendered with DictEditor\nconst isDict = computed(() => fieldType.value === 'dict' && !props.field.children?.length)\n\n// List whose items are objects (not flat strings) — rendered as JSON textarea\nconst isComplexList = computed(() => {\n  if (!isList.value) return false\n  const check = (arr) => Array.isArray(arr) && arr.length > 0 && typeof arr[0] === 'object' && arr[0] !== null\n  return check(props.value) || check(props.field.default)\n})\n\nconst boolValue = computed(() => {\n  if (props.value === undefined || props.value === null) return props.field.default ?? false\n  return !!props.value\n})\n\nconst displayValue = computed(() => {\n  if (props.value === undefined || props.value === null) return ''\n  return String(props.value)\n})\n\nconst listDisplayValue = computed(() => {\n  if (props.value === undefined || props.value === null) return ''\n  if (Array.isArray(props.value)) return props.value.join(', ')\n  return String(props.value)\n})\n\nconst jsonDisplayValue = computed(() => {\n  if (props.value === undefined || props.value === null) return ''\n  return JSON.stringify(props.value, null, 2)\n})\n\nconst jsonError = ref('')\n\nconst placeholderText = computed(() => {\n  if (props.field.default !== undefined && props.field.default !== null) {\n    const d = props.field.default\n    if (typeof d === 'object') return JSON.stringify(d, null, 2)\n    if (Array.isArray(d)) return d.length ? d.join(', ') : '(empty list)'\n    return String(d)\n  }\n  return ''\n})\n\nfunction emitTyped(str) {\n  emit('update:value', str === '' ? undefined : str)\n}\n\nfunction emitNumber(str) {\n  if (str === '') { emit('update:value', undefined); return }\n  const num = isFloat.value ? parseFloat(str) : parseInt(str, 10)\n  if (!isNaN(num)) emit('update:value', num)\n}\n\nfunction emitList(str) {\n  if (str === '') { emit('update:value', undefined); return }\n  emit('update:value', str.split(',').map(s => s.trim()).filter(s => s !== ''))\n}\n\nfunction emitJson(str) {\n  jsonError.value = ''\n  if (str.trim() === '') { emit('update:value', undefined); return }\n  try {\n    emit('update:value', JSON.parse(str))\n  } catch (e) {\n    jsonError.value = e.message\n  }\n}\n</script>\n"
  },
  {
    "path": "pyprland/gui/frontend/src/components/PluginEditor.vue",
    "content": "<template>\n  <div class=\"plugin-header\">\n    <h2>{{ plugin.name }} <a :href=\"docsBase + plugin.name + '.html'\" target=\"_blank\" rel=\"noopener\" class=\"docs-link\">docs &#8599;</a></h2>\n    <p>{{ plugin.description }}</p>\n    <div v-if=\"plugin.environments.length\" class=\"plugin-envs\">\n      <span v-for=\"env in plugin.environments\" :key=\"env\" class=\"env-badge\">{{ env }}</span>\n    </div>\n  </div>\n\n  <!-- Top-level config fields (grouped by category) — excludes dict fields with children -->\n  <div v-if=\"topLevelFields.length\">\n    <div v-for=\"(fields, cat) in groupedFields\" :key=\"cat\" class=\"category-group\">\n      <h3>{{ cat }}</h3>\n      <FieldInput\n        v-for=\"field in fields\"\n        :key=\"field.name\"\n        :field=\"field\"\n        :value=\"local[field.name]\"\n        @update:value=\"local[field.name] = $event\"\n      />\n    </div>\n  </div>\n\n  <!-- Dict fields with children schema (e.g., monitors.placement) -->\n  <div v-for=\"dictField in dictFieldsWithChildren\" :key=\"dictField.name\" class=\"child-entries\">\n    <h3 class=\"child-entries-title\">{{ dictField.name }}</h3>\n    <p v-if=\"dictField.description\" class=\"child-entries-desc\">{{ dictField.description }}</p>\n\n    <div v-for=\"entryName in getDictEntryNames(dictField.name)\" :key=\"dictField.name + '.' + entryName\" class=\"dict-entry\">\n      <div class=\"collapsible-header\" @click=\"toggle(dictField.name + '.' + entryName)\">\n        <span :class=\"['chevron', { open: opened[dictField.name + '.' + entryName] }]\">&#9654;</span>\n        <h4 style=\"font-size: 0.9rem; font-family: monospace;\">{{ entryName }}</h4>\n        <div class=\"dict-entry-actions\">\n          <button class=\"btn btn-secondary btn-xs\" @click.stop=\"removeDictEntry(dictField.name, entryName)\">Remove</button>\n        </div>\n      </div>\n      <div v-if=\"opened[dictField.name + '.' + entryName]\" class=\"dict-entry-body\">\n        <!-- Schema-defined fields -->\n        <div v-for=\"(fields, cat) in groupFields(dictField.children)\" :key=\"cat\" class=\"category-group\">\n          <h3>{{ cat }}</h3>\n          <FieldInput\n            v-for=\"childField in fields\"\n            :key=\"childField.name\"\n            :field=\"childField\"\n            :value=\"getDictEntryValue(dictField.name, entryName, childField.name)\"\n            @update:value=\"setDictEntryValue(dictField.name, entryName, childField.name, $event)\"\n          />\n        </div>\n        <!-- Extra keys not in children schema — delegate to flat DictEditor -->\n        <div v-if=\"dictField.children_allow_extra\">\n          <h3 class=\"category-group\" style=\"font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border);\">extra</h3>\n          <DictEditor\n            :value=\"getExtraEntries(dictField, entryName)\"\n            :flat=\"true\"\n            @update:value=\"setExtraEntries(dictField.name, entryName, $event)\"\n          />\n        </div>\n      </div>\n    </div>\n\n    <div class=\"add-row\">\n      <input v-model=\"newEntryNames[dictField.name]\" type=\"text\" :placeholder=\"'New ' + dictField.name + ' entry...'\" @keyup.enter=\"addDictEntry(dictField.name)\" />\n      <button class=\"btn btn-secondary btn-xs\" @click=\"addDictEntry(dictField.name)\" :disabled=\"!(newEntryNames[dictField.name] || '').trim()\">Add</button>\n    </div>\n  </div>\n\n  <!-- Plugin-level child entries (e.g., scratchpads named entries) -->\n  <div v-if=\"plugin.child_schema\" class=\"child-entries\">\n    <h3 class=\"child-entries-title\">Entries</h3>\n\n    <div v-for=\"name in childNames\" :key=\"name\" class=\"dict-entry\">\n      <div class=\"collapsible-header\" @click=\"toggle(name)\">\n        <span :class=\"['chevron', { open: opened[name] }]\">&#9654;</span>\n        <h4 style=\"font-size: 0.9rem; font-family: monospace;\">{{ name }}</h4>\n        <div class=\"dict-entry-actions\">\n          <button class=\"btn btn-secondary btn-xs\" @click.stop=\"removeChild(name)\">Remove</button>\n        </div>\n      </div>\n      <div v-if=\"opened[name]\" class=\"dict-entry-body\">\n        <div v-for=\"(fields, cat) in groupFields(plugin.child_schema)\" :key=\"cat\" class=\"category-group\">\n          <h3>{{ cat }}</h3>\n          <FieldInput\n            v-for=\"field in fields\"\n            :key=\"field.name\"\n            :field=\"field\"\n            :value=\"local[name]?.[field.name]\"\n            @update:value=\"setChildValue(name, field.name, $event)\"\n          />\n        </div>\n      </div>\n    </div>\n\n    <div class=\"add-row\">\n      <input v-model=\"newChildName\" type=\"text\" placeholder=\"New entry name...\" @keyup.enter=\"addChild\" />\n      <button class=\"btn btn-secondary btn-xs\" @click=\"addChild\" :disabled=\"!newChildName.trim()\">Add</button>\n    </div>\n  </div>\n\n  <!-- Empty config state -->\n  <div v-if=\"!topLevelFields.length && !plugin.child_schema && !dictFieldsWithChildren.length\" class=\"empty-state\">\n    <p>This plugin has no configurable options.</p>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, reactive } from 'vue'\nimport FieldInput from './FieldInput.vue'\nimport DictEditor from './DictEditor.vue'\nimport { useLocalCopy } from '../composables/useLocalCopy.js'\nimport { useToggleMap } from '../composables/useToggleMap.js'\nimport { isObject, groupFields } from '../utils.js'\n\nconst props = defineProps({\n  plugin: { type: Object, required: true },\n  config: { type: Object, required: true },\n  docsBase: { type: String, default: '' },\n})\n\nconst emit = defineEmits(['update:config'])\n\nconst local = useLocalCopy(() => props.config, emit, 'update:config')\nconst { state: opened, toggle } = useToggleMap()\n\n// ---------------------------------------------------------------------------\n//  Top-level fields (excludes dict-with-children — they get their own section)\n// ---------------------------------------------------------------------------\n\nconst dictFieldsWithChildren = computed(() =>\n  (props.plugin.config_schema || []).filter(f => f.type === 'dict' && f.children?.length)\n)\n\nconst dictWithChildrenNames = computed(() =>\n  new Set(dictFieldsWithChildren.value.map(f => f.name))\n)\n\nconst topLevelFields = computed(() =>\n  (props.plugin.config_schema || []).filter(f => !dictWithChildrenNames.value.has(f.name))\n)\n\nconst groupedFields = computed(() => groupFields(topLevelFields.value))\n\n// ---------------------------------------------------------------------------\n//  Dict fields with children schema (e.g., monitors.placement)\n// ---------------------------------------------------------------------------\n\nconst newEntryNames = reactive({})\n\nfunction getDictEntryNames(fieldName) {\n  const val = local.value[fieldName]\n  if (!val || !isObject(val)) return []\n  return Object.keys(val).filter(k => isObject(val[k]))\n}\n\nfunction getDictEntryValue(fieldName, entryName, childFieldName) {\n  return local.value[fieldName]?.[entryName]?.[childFieldName]\n}\n\nfunction setDictEntryValue(fieldName, entryName, childFieldName, value) {\n  if (!local.value[fieldName]) local.value[fieldName] = {}\n  if (!local.value[fieldName][entryName]) local.value[fieldName][entryName] = {}\n  local.value[fieldName][entryName][childFieldName] = value\n}\n\nfunction removeDictEntry(fieldName, entryName) {\n  if (local.value[fieldName]) {\n    delete local.value[fieldName][entryName]\n    local.value[fieldName] = { ...local.value[fieldName] }\n  }\n  delete opened[fieldName + '.' + entryName]\n}\n\nfunction addDictEntry(fieldName) {\n  const name = (newEntryNames[fieldName] || '').trim()\n  if (!name) return\n  if (!local.value[fieldName]) local.value[fieldName] = {}\n  if (!local.value[fieldName][name]) {\n    local.value[fieldName][name] = {}\n  }\n  opened[fieldName + '.' + name] = true\n  newEntryNames[fieldName] = ''\n}\n\n/** Return only keys in this entry that are NOT in the children schema. */\nfunction getExtraEntries(dictField, entryName) {\n  const entry = local.value[dictField.name]?.[entryName]\n  if (!entry || !isObject(entry)) return {}\n  const schemaNames = new Set((dictField.children || []).map(f => f.name))\n  const result = {}\n  for (const [k, v] of Object.entries(entry)) {\n    if (!schemaNames.has(k)) result[k] = v\n  }\n  return result\n}\n\n/** Replace extra keys in an entry (merge with schema-defined keys). */\nfunction setExtraEntries(fieldName, entryName, extraData) {\n  const entry = local.value[fieldName]?.[entryName]\n  if (!entry) return\n  const dictField = dictFieldsWithChildren.value.find(f => f.name === fieldName)\n  const schemaNames = new Set((dictField?.children || []).map(f => f.name))\n  // Keep only schema-defined keys, then merge in the new extra data\n  const kept = {}\n  for (const [k, v] of Object.entries(entry)) {\n    if (schemaNames.has(k)) kept[k] = v\n  }\n  local.value[fieldName][entryName] = { ...kept, ...extraData }\n}\n\n// ---------------------------------------------------------------------------\n//  Plugin-level child entries (scratchpads etc.)\n// ---------------------------------------------------------------------------\n\nconst newChildName = ref('')\n\nconst childNames = computed(() => {\n  if (!props.plugin.child_schema) return []\n  const topNames = new Set((props.plugin.config_schema || []).map(f => f.name))\n  return Object.keys(local.value).filter(k =>\n    isObject(local.value[k]) && !topNames.has(k)\n  )\n})\n\nfunction setChildValue(childName, fieldName, value) {\n  if (!local.value[childName]) {\n    local.value[childName] = {}\n  }\n  local.value[childName][fieldName] = value\n}\n\nfunction addChild() {\n  const name = newChildName.value.trim()\n  if (!name) return\n  if (!local.value[name]) {\n    local.value[name] = {}\n  }\n  opened[name] = true\n  newChildName.value = ''\n}\n\nfunction removeChild(name) {\n  delete local.value[name]\n  delete opened[name]\n}\n</script>\n"
  },
  {
    "path": "pyprland/gui/frontend/src/composables/useLocalCopy.js",
    "content": "import { ref, watch, nextTick } from 'vue'\n\n/**\n * Two-way sync a local copy of an object prop with a guard to prevent\n * infinite watch cycles.\n *\n * @param {() => object} propGetter - getter that returns the prop value\n * @param {Function} emit - the component's emit function\n * @param {string} eventName - event to emit on local changes (e.g. 'update:value')\n * @returns {import('vue').Ref<object>} reactive local copy\n */\nexport function useLocalCopy(propGetter, emit, eventName) {\n  const local = ref({ ...propGetter() })\n  let updating = false\n\n  watch(propGetter, (v) => {\n    updating = true\n    local.value = { ...v }\n    nextTick(() => { updating = false })\n  }, { deep: true })\n\n  watch(local, (v) => {\n    if (updating) return\n    emit(eventName, { ...v })\n  }, { deep: true })\n\n  return local\n}\n"
  },
  {
    "path": "pyprland/gui/frontend/src/composables/useToggleMap.js",
    "content": "import { reactive } from 'vue'\n\n/**\n * A reactive map of boolean toggle states, keyed by string.\n *\n * @returns {{ state: Record<string, boolean>, toggle: (key: string) => void }}\n */\nexport function useToggleMap() {\n  const state = reactive({})\n  const toggle = (key) => { state[key] = !state[key] }\n  return { state, toggle }\n}\n"
  },
  {
    "path": "pyprland/gui/frontend/src/main.js",
    "content": "import { createApp } from 'vue'\nimport './style.css'\nimport App from './App.vue'\n\ncreateApp(App).mount('#app')\n"
  },
  {
    "path": "pyprland/gui/frontend/src/style.css",
    "content": "/* pypr-gui global styles — dark theme inspired by Hyprland aesthetics */\n\n:root {\n  --bg-primary: #1a1b26;\n  --bg-secondary: #24283b;\n  --bg-tertiary: #2f3348;\n  --bg-hover: #3b3f57;\n  --text-primary: #c0caf5;\n  --text-secondary: #a9b1d6;\n  --text-muted: #565f89;\n  --accent: #7aa2f7;\n  --accent-hover: #89b4fa;\n  --success: #9ece6a;\n  --warning: #e0af68;\n  --error: #f7768e;\n  --border: #3b3f57;\n  --radius: 6px;\n  --radius-lg: 10px;\n}\n\n* {\n  margin: 0;\n  padding: 0;\n  box-sizing: border-box;\n}\n\nbody {\n  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;\n  background: var(--bg-primary);\n  color: var(--text-primary);\n  line-height: 1.6;\n  min-height: 100vh;\n}\n\n#app {\n  display: flex;\n  flex-direction: column;\n  min-height: 100vh;\n}\n\n/* Layout */\n.app-header {\n  background: var(--bg-secondary);\n  border-bottom: 1px solid var(--border);\n  padding: 12px 24px;\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  position: sticky;\n  top: 0;\n  z-index: 100;\n}\n\n.app-header h1 {\n  font-size: 1.2rem;\n  font-weight: 600;\n  color: var(--accent);\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.app-header .header-icon {\n  width: 28px;\n  height: 28px;\n}\n\n.app-header .config-path {\n  font-size: 0.8rem;\n  color: var(--text-muted);\n  font-family: monospace;\n}\n\n.app-body {\n  display: flex;\n  flex: 1;\n  overflow: hidden;\n}\n\n/* Sidebar */\n.sidebar {\n  width: 260px;\n  min-width: 260px;\n  background: var(--bg-secondary);\n  border-right: 1px solid var(--border);\n  overflow-y: auto;\n  padding: 12px 0;\n}\n\n.sidebar-section {\n  padding: 0 12px;\n  margin-bottom: 16px;\n}\n\n.sidebar-section h3 {\n  font-size: 0.7rem;\n  text-transform: uppercase;\n  letter-spacing: 0.08em;\n  color: var(--text-muted);\n  margin-bottom: 6px;\n  padding: 0 8px;\n}\n\n.plugin-item {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding: 6px 8px;\n  border-radius: var(--radius);\n  cursor: pointer;\n  font-size: 0.85rem;\n  transition: background 0.15s;\n}\n\n.plugin-item:hover {\n  background: var(--bg-hover);\n}\n\n.plugin-item.active {\n  background: var(--bg-tertiary);\n  color: var(--accent);\n}\n\n.plugin-item.disabled {\n  opacity: 0.5;\n}\n\n.plugin-item input[type=\"checkbox\"] {\n  accent-color: var(--accent);\n  cursor: pointer;\n}\n\n.plugin-item .plugin-name {\n  flex: 1;\n}\n\n/* Main content */\n.main-content {\n  flex: 1;\n  overflow-y: auto;\n  padding: 24px 32px;\n}\n\n.plugin-header {\n  margin-bottom: 24px;\n}\n\n.plugin-header h2 {\n  font-size: 1.3rem;\n  font-weight: 600;\n  margin-bottom: 4px;\n}\n\n.plugin-header p {\n  color: var(--text-secondary);\n  font-size: 0.9rem;\n}\n\n.docs-link {\n  font-size: 0.75rem;\n  font-family: monospace;\n  color: var(--text-muted);\n  text-decoration: none;\n  margin-left: 6px;\n  vertical-align: middle;\n}\n\n.docs-link:hover {\n  color: var(--accent);\n}\n\n.plugin-envs {\n  display: flex;\n  gap: 6px;\n  margin-top: 8px;\n}\n\n.env-badge {\n  font-size: 0.7rem;\n  padding: 2px 8px;\n  border-radius: 99px;\n  background: var(--bg-tertiary);\n  color: var(--text-muted);\n  border: 1px solid var(--border);\n}\n\n/* Category groups */\n.category-group {\n  margin-bottom: 24px;\n}\n\n.category-group h3 {\n  font-size: 0.8rem;\n  text-transform: uppercase;\n  letter-spacing: 0.06em;\n  color: var(--text-muted);\n  margin-bottom: 12px;\n  padding-bottom: 6px;\n  border-bottom: 1px solid var(--border);\n}\n\n/* Form fields */\n.field-row {\n  display: flex;\n  align-items: flex-start;\n  gap: 12px;\n  padding: 8px 0;\n}\n\n.field-row + .field-row {\n  border-top: 1px solid var(--bg-tertiary);\n}\n\n.field-label {\n  width: 200px;\n  min-width: 200px;\n  padding-top: 6px;\n}\n\n.field-label .name {\n  font-size: 0.85rem;\n  font-weight: 500;\n  font-family: monospace;\n  color: var(--text-primary);\n}\n\n.field-label .name .required {\n  color: var(--error);\n  margin-left: 2px;\n}\n\n.field-label .name .recommended {\n  color: var(--warning);\n  margin-left: 2px;\n  font-size: 0.7rem;\n}\n\n.field-label .description {\n  font-size: 0.75rem;\n  color: var(--text-muted);\n  margin-top: 2px;\n}\n\n.field-label .type-badge {\n  font-size: 0.65rem;\n  color: var(--text-muted);\n  font-family: monospace;\n  opacity: 0.7;\n}\n\n.field-input {\n  flex: 1;\n  min-width: 0;\n}\n\n/* Input styles */\ninput[type=\"text\"],\ninput[type=\"number\"],\ntextarea,\nselect {\n  width: 100%;\n  padding: 6px 10px;\n  background: var(--bg-primary);\n  border: 1px solid var(--border);\n  border-radius: var(--radius);\n  color: var(--text-primary);\n  font-size: 0.85rem;\n  font-family: monospace;\n  outline: none;\n  transition: border-color 0.15s;\n}\n\ninput:focus,\ntextarea:focus,\nselect:focus {\n  border-color: var(--accent);\n}\n\ninput::placeholder,\ntextarea::placeholder {\n  color: var(--text-muted);\n  opacity: 0.6;\n}\n\ntextarea {\n  min-height: 60px;\n  resize: vertical;\n}\n\ntextarea.json-textarea {\n  min-height: 120px;\n  font-family: monospace;\n  font-size: 0.8rem;\n  line-height: 1.5;\n  tab-size: 2;\n  white-space: pre;\n}\n\n/* Field hints (below inputs) */\n.field-hint {\n  font-size: 0.7rem;\n  color: var(--text-muted);\n  margin-top: 2px;\n}\n\n.field-hint .json-error {\n  color: var(--error);\n  margin-left: 8px;\n}\n\n/* Child entries section titles */\n.child-entries-title {\n  color: var(--text-muted);\n  font-size: 0.8rem;\n  text-transform: uppercase;\n  letter-spacing: 0.06em;\n  margin-bottom: 4px;\n}\n\n.child-entries-desc {\n  color: var(--text-secondary);\n  font-size: 0.8rem;\n  margin-bottom: 12px;\n}\n\nselect {\n  cursor: pointer;\n  appearance: none;\n  background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23565f89' d='M6 8L1 3h10z'/%3E%3C/svg%3E\");\n  background-repeat: no-repeat;\n  background-position: right 8px center;\n  padding-right: 28px;\n}\n\n/* Toggle switch */\n.toggle-wrap {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding-top: 6px;\n}\n\n.toggle {\n  position: relative;\n  width: 36px;\n  height: 20px;\n  cursor: pointer;\n}\n\n.toggle input {\n  display: none;\n}\n\n.toggle .slider {\n  position: absolute;\n  inset: 0;\n  background: var(--bg-primary);\n  border: 1px solid var(--border);\n  border-radius: 20px;\n  transition: all 0.2s;\n}\n\n.toggle .slider::before {\n  content: '';\n  position: absolute;\n  width: 14px;\n  height: 14px;\n  left: 2px;\n  top: 2px;\n  background: var(--text-muted);\n  border-radius: 50%;\n  transition: all 0.2s;\n}\n\n.toggle input:checked + .slider {\n  background: var(--accent);\n  border-color: var(--accent);\n}\n\n.toggle input:checked + .slider::before {\n  transform: translateX(16px);\n  background: white;\n}\n\n.toggle-label {\n  font-size: 0.8rem;\n  color: var(--text-muted);\n}\n\n/* Buttons */\n.btn {\n  padding: 8px 16px;\n  border: none;\n  border-radius: var(--radius);\n  font-size: 0.85rem;\n  font-weight: 500;\n  cursor: pointer;\n  transition: all 0.15s;\n  display: inline-flex;\n  align-items: center;\n  gap: 6px;\n}\n\n.btn-primary {\n  background: var(--accent);\n  color: var(--bg-primary);\n}\n\n.btn-primary:hover {\n  background: var(--accent-hover);\n}\n\n.btn-secondary {\n  background: var(--bg-tertiary);\n  color: var(--text-primary);\n}\n\n.btn-secondary:hover {\n  background: var(--bg-hover);\n}\n\n.btn-success {\n  background: var(--success);\n  color: var(--bg-primary);\n}\n\n.btn-success:hover {\n  opacity: 0.9;\n}\n\n.btn:disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n}\n\n/* Action bar */\n.action-bar {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n}\n\n/* Status messages */\n.status-message {\n  font-size: 0.8rem;\n  padding: 6px 12px;\n  border-radius: var(--radius);\n}\n\n.status-message.success {\n  background: rgba(158, 206, 106, 0.15);\n  color: var(--success);\n}\n\n.status-message.error {\n  background: rgba(247, 118, 142, 0.15);\n  color: var(--error);\n}\n\n.status-message.warning {\n  background: rgba(224, 175, 104, 0.15);\n  color: var(--warning);\n}\n\n/* Error list */\n.error-list {\n  margin-top: 8px;\n  padding: 8px 12px;\n  background: rgba(247, 118, 142, 0.1);\n  border-radius: var(--radius);\n  border: 1px solid rgba(247, 118, 142, 0.2);\n  list-style: none;\n}\n\n.error-list li {\n  font-size: 0.8rem;\n  color: var(--error);\n  margin: 4px 0;\n  font-family: monospace;\n}\n\n/* Scratchpad / child entries */\n.child-entries {\n  margin-top: 12px;\n}\n\n/* Collapsible header — shared by child entries and dict entries */\n.collapsible-header {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding: 6px 12px;\n  background: var(--bg-tertiary);\n  cursor: pointer;\n  user-select: none;\n  font-size: 0.85rem;\n}\n\n.collapsible-header .chevron {\n  color: var(--text-muted);\n  transition: transform 0.2s;\n  font-size: 0.7rem;\n  flex-shrink: 0;\n}\n\n.collapsible-header .chevron.open {\n  transform: rotate(90deg);\n}\n\n/* Loading */\n.loading {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  height: 200px;\n  color: var(--text-muted);\n}\n\n/* Scrollbar styling */\n::-webkit-scrollbar {\n  width: 8px;\n}\n\n::-webkit-scrollbar-track {\n  background: var(--bg-primary);\n}\n\n::-webkit-scrollbar-thumb {\n  background: var(--border);\n  border-radius: 4px;\n}\n\n::-webkit-scrollbar-thumb:hover {\n  background: var(--text-muted);\n}\n\n/* Empty state */\n.empty-state {\n  text-align: center;\n  padding: 48px 24px;\n  color: var(--text-muted);\n}\n\n.empty-state h3 {\n  font-size: 1.1rem;\n  margin-bottom: 8px;\n  color: var(--text-secondary);\n}\n\n.empty-state p {\n  font-size: 0.85rem;\n}\n\n/* Dict editor (generic recursive) */\n.dict-editor.nested {\n  border-left: 2px solid var(--border);\n  padding-left: 12px;\n  margin-left: 4px;\n}\n\n.dict-entry {\n  background: var(--bg-secondary);\n  border: 1px solid var(--border);\n  border-radius: var(--radius);\n  margin-bottom: 6px;\n  overflow: hidden;\n}\n\n.dict-entry-name {\n  font-family: monospace;\n  font-weight: 500;\n  color: var(--text-primary);\n}\n\n.dict-entry-type {\n  font-size: 0.7rem;\n  color: var(--text-muted);\n  font-family: monospace;\n}\n\n.dict-entry-preview {\n  flex: 1;\n  min-width: 0;\n  font-size: 0.75rem;\n  color: var(--text-muted);\n  font-family: monospace;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n.dict-entry-actions {\n  margin-left: auto;\n  flex-shrink: 0;\n}\n\n.dict-entry-body {\n  padding: 10px 12px;\n}\n\n.dict-entry-rename {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  margin-top: 8px;\n  padding-top: 8px;\n  border-top: 1px solid var(--bg-tertiary);\n}\n\n.dict-rename-label {\n  font-size: 0.7rem;\n  color: var(--text-muted);\n}\n\n.dict-rename-input {\n  width: 200px;\n  font-size: 0.8rem;\n  padding: 3px 8px;\n}\n\n.dict-value-input {\n  font-family: monospace;\n  font-size: 0.8rem;\n}\n\n/* Array items inside dict entries */\n.dict-array-editor {\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n\n.dict-array-item {\n  display: flex;\n  flex-direction: column;\n}\n\n.dict-array-string {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n}\n\n.dict-array-string input,\n.dict-array-string textarea {\n  flex: 1;\n}\n\n.dict-array-object {\n  background: var(--bg-primary);\n  border: 1px solid var(--border);\n  border-radius: var(--radius);\n  padding: 8px 10px;\n}\n\n.dict-array-object-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  margin-bottom: 6px;\n}\n\n.dict-array-object-field {\n  display: flex;\n  align-items: flex-start;\n  gap: 8px;\n  margin-bottom: 4px;\n}\n\n.dict-array-field-label {\n  width: 100px;\n  min-width: 100px;\n  font-family: monospace;\n  font-size: 0.8rem;\n  color: var(--text-secondary);\n  padding-top: 5px;\n}\n\n.dict-array-object-field input,\n.dict-array-object-field textarea {\n  flex: 1;\n  font-size: 0.8rem;\n}\n\n/* Add row — shared by dict entries, array items, and child entries */\n.add-row {\n  display: flex;\n  gap: 6px;\n  align-items: center;\n  margin-top: 6px;\n}\n\n.add-row-key {\n  width: 160px;\n}\n\n.add-row-val {\n  flex: 1;\n}\n\n.add-row-type {\n  width: 90px;\n  font-size: 0.8rem;\n  padding: 4px 6px;\n}\n\n/* Tiny button variant */\n.btn-xs {\n  padding: 2px 8px;\n  font-size: 0.75rem;\n}\n"
  },
  {
    "path": "pyprland/gui/frontend/src/utils.js",
    "content": "/** Type-checking helpers */\nexport const isString = (v) => typeof v === 'string'\nexport const isArray = (v) => Array.isArray(v)\nexport const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)\n\n/**\n * Try to JSON.parse a string; return *fallback* on failure.\n * @param {string} raw\n * @param {*} [fallback] defaults to raw itself\n */\nexport function tryParseJson(raw, fallback) {\n  try { return JSON.parse(raw) } catch { return fallback !== undefined ? fallback : raw }\n}\n\n/**\n * Format any value for display in a text input.\n * Objects are JSON-stringified, primitives become strings.\n */\nexport function formatValue(val) {\n  if (val === undefined || val === null) return ''\n  if (typeof val === 'object') return JSON.stringify(val)\n  return String(val)\n}\n\n/**\n * Group an array of field descriptor objects by their `category` property.\n * @param {Array<{category?: string}>} fields\n * @returns {Record<string, Array>}\n */\nexport function groupFields(fields) {\n  const groups = {}\n  for (const f of fields) {\n    const cat = f.category || 'general'\n    if (!groups[cat]) groups[cat] = []\n    groups[cat].push(f)\n  }\n  return groups\n}\n"
  },
  {
    "path": "pyprland/gui/frontend/vite.config.js",
    "content": "import { defineConfig } from 'vite'\nimport vue from '@vitejs/plugin-vue'\n\nexport default defineConfig({\n  plugins: [vue()],\n  build: {\n    outDir: '../static',\n    emptyOutDir: true,\n  },\n  server: {\n    proxy: {\n      '/api': 'http://127.0.0.1:8099',\n    },\n  },\n})\n"
  },
  {
    "path": "pyprland/gui/server.py",
    "content": "\"\"\"aiohttp.web server for the pyprland GUI.\n\nDefines HTTP routes that serve the Vue.js frontend and expose the\nJSON API consumed by it.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\nfrom typing import Any\n\nfrom aiohttp import web\n\nfrom . import api\n\n__all__ = [\"create_app\"]\n\n# Pre-built Vue app lives here (committed to repo)\nSTATIC_DIR = Path(__file__).parent / \"static\"\n\n\n# ---------------------------------------------------------------------------\n#  API routes\n# ---------------------------------------------------------------------------\n\n\nasync def handle_get_plugins(_request: web.Request) -> web.Response:\n    \"\"\"GET /api/plugins — list all plugins with schema metadata.\"\"\"\n    data = api.get_plugins_schema()\n    return web.json_response(data)\n\n\nasync def handle_get_config(_request: web.Request) -> web.Response:\n    \"\"\"GET /api/config — return current configuration.\"\"\"\n    data = api.get_config()\n    return web.json_response(data)\n\n\nasync def handle_validate(request: web.Request) -> web.Response:\n    \"\"\"POST /api/validate — validate config without saving.\"\"\"\n    body = await request.json()\n    config: dict[str, Any] = body.get(\"config\", {})\n    errors = api.validate_config(config)\n    return web.json_response({\"ok\": not errors, \"errors\": errors})\n\n\nasync def handle_save(request: web.Request) -> web.Response:\n    \"\"\"POST /api/save — validate and save config to disk.\"\"\"\n    body = await request.json()\n    config: dict[str, Any] = body.get(\"config\", {})\n    result = api.save_config(config)\n    return web.json_response(result)\n\n\nasync def handle_apply(request: web.Request) -> web.Response:\n    \"\"\"POST /api/apply — save config and reload the daemon.\"\"\"\n    body = await request.json()\n    config: dict[str, Any] = body.get(\"config\", {})\n    result = await api.apply_config(config)\n    return web.json_response(result)\n\n\n# ---------------------------------------------------------------------------\n#  SPA fallback: serve static files or index.html\n# ---------------------------------------------------------------------------\n\n# Resolved once so path traversal checks are reliable\n_STATIC_DIR_RESOLVED = STATIC_DIR.resolve()\n\n\nasync def handle_spa_fallback(request: web.Request) -> web.FileResponse:\n    \"\"\"Serve a static file if it exists, otherwise fall back to index.html.\n\n    This is the standard pattern for single-page applications: real files\n    (icon.png, robots.txt, etc.) are served directly, while unknown paths\n    return the SPA entry point so client-side routing can take over.\n    \"\"\"\n    tail = request.match_info.get(\"tail\", \"\")\n    if tail:\n        candidate = (STATIC_DIR / tail).resolve()\n        # Only serve if the file exists and stays inside STATIC_DIR (prevent traversal)\n        if candidate.is_file() and _STATIC_DIR_RESOLVED in candidate.parents:\n            return web.FileResponse(candidate)\n\n    index = STATIC_DIR / \"index.html\"\n    if not index.exists():\n        raise web.HTTPNotFound(text=\"Frontend not built. Run the Vue build first.\")\n    return web.FileResponse(index)\n\n\n# ---------------------------------------------------------------------------\n#  App factory\n# ---------------------------------------------------------------------------\n\n\ndef create_app() -> web.Application:\n    \"\"\"Build and return the aiohttp Application.\"\"\"\n    app = web.Application()\n\n    # API routes\n    app.router.add_get(\"/api/plugins\", handle_get_plugins)\n    app.router.add_get(\"/api/config\", handle_get_config)\n    app.router.add_post(\"/api/validate\", handle_validate)\n    app.router.add_post(\"/api/save\", handle_save)\n    app.router.add_post(\"/api/apply\", handle_apply)\n\n    # Static assets (JS, CSS, etc.)\n    if STATIC_DIR.exists() and (STATIC_DIR / \"assets\").exists():\n        app.router.add_static(\"/assets\", STATIC_DIR / \"assets\")\n\n    # SPA fallback - serve static files or index.html for everything else\n    app.router.add_get(\"/{tail:.*}\", handle_spa_fallback)\n\n    return app\n"
  },
  {
    "path": "pyprland/gui/static/assets/index-CX03GsX-.js",
    "content": "(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel=\"modulepreload\"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();function e(e){let t=Object.create(null);for(let n of e.split(`,`))t[n]=1;return e=>e in t}var t={},n=[],r=()=>{},i=()=>!1,a=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),o=e=>e.startsWith(`onUpdate:`),s=Object.assign,c=(e,t)=>{let n=e.indexOf(t);n>-1&&e.splice(n,1)},l=Object.prototype.hasOwnProperty,u=(e,t)=>l.call(e,t),d=Array.isArray,f=e=>x(e)===`[object Map]`,p=e=>x(e)===`[object Set]`,m=e=>x(e)===`[object Date]`,h=e=>typeof e==`function`,g=e=>typeof e==`string`,_=e=>typeof e==`symbol`,v=e=>typeof e==`object`&&!!e,y=e=>(v(e)||h(e))&&h(e.then)&&h(e.catch),b=Object.prototype.toString,x=e=>b.call(e),S=e=>x(e).slice(8,-1),C=e=>x(e)===`[object Object]`,w=e=>g(e)&&e!==`NaN`&&e[0]!==`-`&&``+parseInt(e,10)===e,ee=e(`,key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted`),te=e=>{let t=Object.create(null);return(n=>t[n]||(t[n]=e(n)))},ne=/-\\w/g,T=te(e=>e.replace(ne,e=>e.slice(1).toUpperCase())),re=/\\B([A-Z])/g,E=te(e=>e.replace(re,`-$1`).toLowerCase()),ie=te(e=>e.charAt(0).toUpperCase()+e.slice(1)),ae=te(e=>e?`on${ie(e)}`:``),D=(e,t)=>!Object.is(e,t),oe=(e,...t)=>{for(let n=0;n<e.length;n++)e[n](...t)},O=(e,t,n,r=!1)=>{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:r,value:n})},se=e=>{let t=parseFloat(e);return isNaN(t)?e:t},ce,le=()=>ce||=typeof globalThis<`u`?globalThis:typeof self<`u`?self:typeof window<`u`?window:typeof global<`u`?global:{};function ue(e){if(d(e)){let t={};for(let n=0;n<e.length;n++){let r=e[n],i=g(r)?me(r):ue(r);if(i)for(let e in i)t[e]=i[e]}return t}else if(g(e)||v(e))return e}var de=/;(?![^(]*\\))/g,fe=/:([^]+)/,pe=/\\/\\*[^]*?\\*\\//g;function me(e){let t={};return e.replace(pe,``).split(de).forEach(e=>{if(e){let n=e.split(fe);n.length>1&&(t[n[0].trim()]=n[1].trim())}}),t}function k(e){let t=``;if(g(e))t=e;else if(d(e))for(let n=0;n<e.length;n++){let r=k(e[n]);r&&(t+=r+` `)}else if(v(e))for(let n in e)e[n]&&(t+=n+` `);return t.trim()}var he=`itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly`,ge=e(he);he+``;function _e(e){return!!e||e===``}function ve(e,t){if(e.length!==t.length)return!1;let n=!0;for(let r=0;n&&r<e.length;r++)n=ye(e[r],t[r]);return n}function ye(e,t){if(e===t)return!0;let n=m(e),r=m(t);if(n||r)return n&&r?e.getTime()===t.getTime():!1;if(n=_(e),r=_(t),n||r)return e===t;if(n=d(e),r=d(t),n||r)return n&&r?ve(e,t):!1;if(n=v(e),r=v(t),n||r){if(!n||!r||Object.keys(e).length!==Object.keys(t).length)return!1;for(let n in e){let r=e.hasOwnProperty(n),i=t.hasOwnProperty(n);if(r&&!i||!r&&i||!ye(e[n],t[n]))return!1}}return String(e)===String(t)}function be(e,t){return e.findIndex(e=>ye(e,t))}var xe=e=>!!(e&&e.__v_isRef===!0),A=e=>g(e)?e:e==null?``:d(e)||v(e)&&(e.toString===b||!h(e.toString))?xe(e)?A(e.value):JSON.stringify(e,Se,2):String(e),Se=(e,t)=>xe(t)?Se(e,t.value):f(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((e,[t,n],r)=>(e[Ce(t,r)+` =>`]=n,e),{})}:p(t)?{[`Set(${t.size})`]:[...t.values()].map(e=>Ce(e))}:_(t)?Ce(t):v(t)&&!d(t)&&!C(t)?String(t):t,Ce=(e,t=``)=>_(e)?`Symbol(${e.description??t})`:e,j,we=class{constructor(e=!1){this.detached=e,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this.__v_skip=!0,this.parent=j,!e&&j&&(this.index=(j.scopes||=[]).push(this)-1)}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let e,t;if(this.scopes)for(e=0,t=this.scopes.length;e<t;e++)this.scopes[e].pause();for(e=0,t=this.effects.length;e<t;e++)this.effects[e].pause()}}resume(){if(this._active&&this._isPaused){this._isPaused=!1;let e,t;if(this.scopes)for(e=0,t=this.scopes.length;e<t;e++)this.scopes[e].resume();for(e=0,t=this.effects.length;e<t;e++)this.effects[e].resume()}}run(e){if(this._active){let t=j;try{return j=this,e()}finally{j=t}}}on(){++this._on===1&&(this.prevScope=j,j=this)}off(){this._on>0&&--this._on===0&&(j=this.prevScope,this.prevScope=void 0)}stop(e){if(this._active){this._active=!1;let t,n;for(t=0,n=this.effects.length;t<n;t++)this.effects[t].stop();for(this.effects.length=0,t=0,n=this.cleanups.length;t<n;t++)this.cleanups[t]();if(this.cleanups.length=0,this.scopes){for(t=0,n=this.scopes.length;t<n;t++)this.scopes[t].stop(!0);this.scopes.length=0}if(!this.detached&&this.parent&&!e){let e=this.parent.scopes.pop();e&&e!==this&&(this.parent.scopes[this.index]=e,e.index=this.index)}this.parent=void 0}}};function Te(){return j}var M,Ee=new WeakSet,De=class{constructor(e){this.fn=e,this.deps=void 0,this.depsTail=void 0,this.flags=5,this.next=void 0,this.cleanup=void 0,this.scheduler=void 0,j&&j.active&&j.effects.push(this)}pause(){this.flags|=64}resume(){this.flags&64&&(this.flags&=-65,Ee.has(this)&&(Ee.delete(this),this.trigger()))}notify(){this.flags&2&&!(this.flags&32)||this.flags&8||je(this)}run(){if(!(this.flags&1))return this.fn();this.flags|=2,We(this),Pe(this);let e=M,t=Be;M=this,Be=!0;try{return this.fn()}finally{Fe(this),M=e,Be=t,this.flags&=-3}}stop(){if(this.flags&1){for(let e=this.deps;e;e=e.nextDep)Re(e);this.deps=this.depsTail=void 0,We(this),this.onStop&&this.onStop(),this.flags&=-2}}trigger(){this.flags&64?Ee.add(this):this.scheduler?this.scheduler():this.runIfDirty()}runIfDirty(){Ie(this)&&this.run()}get dirty(){return Ie(this)}},Oe=0,ke,Ae;function je(e,t=!1){if(e.flags|=8,t){e.next=Ae,Ae=e;return}e.next=ke,ke=e}function Me(){Oe++}function Ne(){if(--Oe>0)return;if(Ae){let e=Ae;for(Ae=void 0;e;){let t=e.next;e.next=void 0,e.flags&=-9,e=t}}let e;for(;ke;){let t=ke;for(ke=void 0;t;){let n=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(t){e||=t}t=n}}if(e)throw e}function Pe(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function Fe(e){let t,n=e.depsTail,r=n;for(;r;){let e=r.prevDep;r.version===-1?(r===n&&(n=e),Re(r),ze(r)):t=r,r.dep.activeLink=r.prevActiveLink,r.prevActiveLink=void 0,r=e}e.deps=t,e.depsTail=n}function Ie(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(Le(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function Le(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===Ge)||(e.globalVersion=Ge,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Ie(e))))return;e.flags|=2;let t=e.dep,n=M,r=Be;M=e,Be=!0;try{Pe(e);let n=e.fn(e._value);(t.version===0||D(n,e._value))&&(e.flags|=128,e._value=n,t.version++)}catch(e){throw t.version++,e}finally{M=n,Be=r,Fe(e),e.flags&=-3}}function Re(e,t=!1){let{dep:n,prevSub:r,nextSub:i}=e;if(r&&(r.nextSub=i,e.prevSub=void 0),i&&(i.prevSub=r,e.nextSub=void 0),n.subs===e&&(n.subs=r,!r&&n.computed)){n.computed.flags&=-5;for(let e=n.computed.deps;e;e=e.nextDep)Re(e,!0)}!t&&!--n.sc&&n.map&&n.map.delete(n.key)}function ze(e){let{prevDep:t,nextDep:n}=e;t&&(t.nextDep=n,e.prevDep=void 0),n&&(n.prevDep=t,e.nextDep=void 0)}var Be=!0,Ve=[];function He(){Ve.push(Be),Be=!1}function Ue(){let e=Ve.pop();Be=e===void 0?!0:e}function We(e){let{cleanup:t}=e;if(e.cleanup=void 0,t){let e=M;M=void 0;try{t()}finally{M=e}}}var Ge=0,Ke=class{constructor(e,t){this.sub=e,this.dep=t,this.version=t.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}},qe=class{constructor(e){this.computed=e,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(e){if(!M||!Be||M===this.computed)return;let t=this.activeLink;if(t===void 0||t.sub!==M)t=this.activeLink=new Ke(M,this),M.deps?(t.prevDep=M.depsTail,M.depsTail.nextDep=t,M.depsTail=t):M.deps=M.depsTail=t,Je(t);else if(t.version===-1&&(t.version=this.version,t.nextDep)){let e=t.nextDep;e.prevDep=t.prevDep,t.prevDep&&(t.prevDep.nextDep=e),t.prevDep=M.depsTail,t.nextDep=void 0,M.depsTail.nextDep=t,M.depsTail=t,M.deps===t&&(M.deps=e)}return t}trigger(e){this.version++,Ge++,this.notify(e)}notify(e){Me();try{for(let e=this.subs;e;e=e.prevSub)e.sub.notify()&&e.sub.dep.notify()}finally{Ne()}}};function Je(e){if(e.dep.sc++,e.sub.flags&4){let t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let e=t.deps;e;e=e.nextDep)Je(e)}let n=e.dep.subs;n!==e&&(e.prevSub=n,n&&(n.nextSub=e)),e.dep.subs=e}}var Ye=new WeakMap,Xe=Symbol(``),Ze=Symbol(``),Qe=Symbol(``);function N(e,t,n){if(Be&&M){let t=Ye.get(e);t||Ye.set(e,t=new Map);let r=t.get(n);r||(t.set(n,r=new qe),r.map=t,r.key=n),r.track()}}function $e(e,t,n,r,i,a){let o=Ye.get(e);if(!o){Ge++;return}let s=e=>{e&&e.trigger()};if(Me(),t===`clear`)o.forEach(s);else{let i=d(e),a=i&&w(n);if(i&&n===`length`){let e=Number(r);o.forEach((t,n)=>{(n===`length`||n===Qe||!_(n)&&n>=e)&&s(t)})}else switch((n!==void 0||o.has(void 0))&&s(o.get(n)),a&&s(o.get(Qe)),t){case`add`:i?a&&s(o.get(`length`)):(s(o.get(Xe)),f(e)&&s(o.get(Ze)));break;case`delete`:i||(s(o.get(Xe)),f(e)&&s(o.get(Ze)));break;case`set`:f(e)&&s(o.get(Xe));break}}Ne()}function et(e){let t=F(e);return t===e?t:(N(t,`iterate`,Qe),P(e)?t:t.map(I))}function tt(e){return N(e=F(e),`iterate`,Qe),e}function nt(e,t){return zt(e)?Ht(Rt(e)?I(t):t):I(t)}var rt={__proto__:null,[Symbol.iterator](){return it(this,Symbol.iterator,e=>nt(this,e))},concat(...e){return et(this).concat(...e.map(e=>d(e)?et(e):e))},entries(){return it(this,`entries`,e=>(e[1]=nt(this,e[1]),e))},every(e,t){return ot(this,`every`,e,t,void 0,arguments)},filter(e,t){return ot(this,`filter`,e,t,e=>e.map(e=>nt(this,e)),arguments)},find(e,t){return ot(this,`find`,e,t,e=>nt(this,e),arguments)},findIndex(e,t){return ot(this,`findIndex`,e,t,void 0,arguments)},findLast(e,t){return ot(this,`findLast`,e,t,e=>nt(this,e),arguments)},findLastIndex(e,t){return ot(this,`findLastIndex`,e,t,void 0,arguments)},forEach(e,t){return ot(this,`forEach`,e,t,void 0,arguments)},includes(...e){return ct(this,`includes`,e)},indexOf(...e){return ct(this,`indexOf`,e)},join(e){return et(this).join(e)},lastIndexOf(...e){return ct(this,`lastIndexOf`,e)},map(e,t){return ot(this,`map`,e,t,void 0,arguments)},pop(){return lt(this,`pop`)},push(...e){return lt(this,`push`,e)},reduce(e,...t){return st(this,`reduce`,e,t)},reduceRight(e,...t){return st(this,`reduceRight`,e,t)},shift(){return lt(this,`shift`)},some(e,t){return ot(this,`some`,e,t,void 0,arguments)},splice(...e){return lt(this,`splice`,e)},toReversed(){return et(this).toReversed()},toSorted(e){return et(this).toSorted(e)},toSpliced(...e){return et(this).toSpliced(...e)},unshift(...e){return lt(this,`unshift`,e)},values(){return it(this,`values`,e=>nt(this,e))}};function it(e,t,n){let r=tt(e),i=r[t]();return r!==e&&!P(e)&&(i._next=i.next,i.next=()=>{let e=i._next();return e.done||(e.value=n(e.value)),e}),i}var at=Array.prototype;function ot(e,t,n,r,i,a){let o=tt(e),s=o!==e&&!P(e),c=o[t];if(c!==at[t]){let t=c.apply(e,a);return s?I(t):t}let l=n;o!==e&&(s?l=function(t,r){return n.call(this,nt(e,t),r,e)}:n.length>2&&(l=function(t,r){return n.call(this,t,r,e)}));let u=c.call(o,l,r);return s&&i?i(u):u}function st(e,t,n,r){let i=tt(e),a=i!==e&&!P(e),o=n,s=!1;i!==e&&(a?(s=r.length===0,o=function(t,r,i){return s&&(s=!1,t=nt(e,t)),n.call(this,t,nt(e,r),i,e)}):n.length>3&&(o=function(t,r,i){return n.call(this,t,r,i,e)}));let c=i[t](o,...r);return s?nt(e,c):c}function ct(e,t,n){let r=F(e);N(r,`iterate`,Qe);let i=r[t](...n);return(i===-1||i===!1)&&Bt(n[0])?(n[0]=F(n[0]),r[t](...n)):i}function lt(e,t,n=[]){He(),Me();let r=F(e)[t].apply(e,n);return Ne(),Ue(),r}var ut=e(`__proto__,__v_isRef,__isVue`),dt=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!==`arguments`&&e!==`caller`).map(e=>Symbol[e]).filter(_));function ft(e){_(e)||(e=String(e));let t=F(this);return N(t,`has`,e),t.hasOwnProperty(e)}var pt=class{constructor(e=!1,t=!1){this._isReadonly=e,this._isShallow=t}get(e,t,n){if(t===`__v_skip`)return e.__v_skip;let r=this._isReadonly,i=this._isShallow;if(t===`__v_isReactive`)return!r;if(t===`__v_isReadonly`)return r;if(t===`__v_isShallow`)return i;if(t===`__v_raw`)return n===(r?i?jt:At:i?kt:Ot).get(e)||Object.getPrototypeOf(e)===Object.getPrototypeOf(n)?e:void 0;let a=d(e);if(!r){let e;if(a&&(e=rt[t]))return e;if(t===`hasOwnProperty`)return ft}let o=Reflect.get(e,t,L(e)?e:n);if((_(t)?dt.has(t):ut(t))||(r||N(e,`get`,t),i))return o;if(L(o)){let e=a&&w(t)?o:o.value;return r&&v(e)?It(e):e}return v(o)?r?It(o):Pt(o):o}},mt=class extends pt{constructor(e=!1){super(!1,e)}set(e,t,n,r){let i=e[t],a=d(e)&&w(t);if(!this._isShallow){let e=zt(i);if(!P(n)&&!zt(n)&&(i=F(i),n=F(n)),!a&&L(i)&&!L(n))return e||(i.value=n),!0}let o=a?Number(t)<e.length:u(e,t),s=Reflect.set(e,t,n,L(e)?e:r);return e===F(r)&&(o?D(n,i)&&$e(e,`set`,t,n,i):$e(e,`add`,t,n)),s}deleteProperty(e,t){let n=u(e,t),r=e[t],i=Reflect.deleteProperty(e,t);return i&&n&&$e(e,`delete`,t,void 0,r),i}has(e,t){let n=Reflect.has(e,t);return(!_(t)||!dt.has(t))&&N(e,`has`,t),n}ownKeys(e){return N(e,`iterate`,d(e)?`length`:Xe),Reflect.ownKeys(e)}},ht=class extends pt{constructor(e=!1){super(!0,e)}set(e,t){return!0}deleteProperty(e,t){return!0}},gt=new mt,_t=new ht,vt=new mt(!0),yt=e=>e,bt=e=>Reflect.getPrototypeOf(e);function xt(e,t,n){return function(...r){let i=this.__v_raw,a=F(i),o=f(a),c=e===`entries`||e===Symbol.iterator&&o,l=e===`keys`&&o,u=i[e](...r),d=n?yt:t?Ht:I;return!t&&N(a,`iterate`,l?Ze:Xe),s(Object.create(u),{next(){let{value:e,done:t}=u.next();return t?{value:e,done:t}:{value:c?[d(e[0]),d(e[1])]:d(e),done:t}}})}}function St(e){return function(...t){return e===`delete`?!1:e===`clear`?void 0:this}}function Ct(e,t){let n={get(n){let r=this.__v_raw,i=F(r),a=F(n);e||(D(n,a)&&N(i,`get`,n),N(i,`get`,a));let{has:o}=bt(i),s=t?yt:e?Ht:I;if(o.call(i,n))return s(r.get(n));if(o.call(i,a))return s(r.get(a));r!==i&&r.get(n)},get size(){let t=this.__v_raw;return!e&&N(F(t),`iterate`,Xe),t.size},has(t){let n=this.__v_raw,r=F(n),i=F(t);return e||(D(t,i)&&N(r,`has`,t),N(r,`has`,i)),t===i?n.has(t):n.has(t)||n.has(i)},forEach(n,r){let i=this,a=i.__v_raw,o=F(a),s=t?yt:e?Ht:I;return!e&&N(o,`iterate`,Xe),a.forEach((e,t)=>n.call(r,s(e),s(t),i))}};return s(n,e?{add:St(`add`),set:St(`set`),delete:St(`delete`),clear:St(`clear`)}:{add(e){let n=F(this),r=bt(n),i=F(e),a=!t&&!P(e)&&!zt(e)?i:e;return r.has.call(n,a)||D(e,a)&&r.has.call(n,e)||D(i,a)&&r.has.call(n,i)||(n.add(a),$e(n,`add`,a,a)),this},set(e,n){!t&&!P(n)&&!zt(n)&&(n=F(n));let r=F(this),{has:i,get:a}=bt(r),o=i.call(r,e);o||=(e=F(e),i.call(r,e));let s=a.call(r,e);return r.set(e,n),o?D(n,s)&&$e(r,`set`,e,n,s):$e(r,`add`,e,n),this},delete(e){let t=F(this),{has:n,get:r}=bt(t),i=n.call(t,e);i||=(e=F(e),n.call(t,e));let a=r?r.call(t,e):void 0,o=t.delete(e);return i&&$e(t,`delete`,e,void 0,a),o},clear(){let e=F(this),t=e.size!==0,n=e.clear();return t&&$e(e,`clear`,void 0,void 0,void 0),n}}),[`keys`,`values`,`entries`,Symbol.iterator].forEach(r=>{n[r]=xt(r,e,t)}),n}function wt(e,t){let n=Ct(e,t);return(t,r,i)=>r===`__v_isReactive`?!e:r===`__v_isReadonly`?e:r===`__v_raw`?t:Reflect.get(u(n,r)&&r in t?n:t,r,i)}var Tt={get:wt(!1,!1)},Et={get:wt(!1,!0)},Dt={get:wt(!0,!1)},Ot=new WeakMap,kt=new WeakMap,At=new WeakMap,jt=new WeakMap;function Mt(e){switch(e){case`Object`:case`Array`:return 1;case`Map`:case`Set`:case`WeakMap`:case`WeakSet`:return 2;default:return 0}}function Nt(e){return e.__v_skip||!Object.isExtensible(e)?0:Mt(S(e))}function Pt(e){return zt(e)?e:Lt(e,!1,gt,Tt,Ot)}function Ft(e){return Lt(e,!1,vt,Et,kt)}function It(e){return Lt(e,!0,_t,Dt,At)}function Lt(e,t,n,r,i){if(!v(e)||e.__v_raw&&!(t&&e.__v_isReactive))return e;let a=Nt(e);if(a===0)return e;let o=i.get(e);if(o)return o;let s=new Proxy(e,a===2?r:n);return i.set(e,s),s}function Rt(e){return zt(e)?Rt(e.__v_raw):!!(e&&e.__v_isReactive)}function zt(e){return!!(e&&e.__v_isReadonly)}function P(e){return!!(e&&e.__v_isShallow)}function Bt(e){return e?!!e.__v_raw:!1}function F(e){let t=e&&e.__v_raw;return t?F(t):e}function Vt(e){return!u(e,`__v_skip`)&&Object.isExtensible(e)&&O(e,`__v_skip`,!0),e}var I=e=>v(e)?Pt(e):e,Ht=e=>v(e)?It(e):e;function L(e){return e?e.__v_isRef===!0:!1}function R(e){return Ut(e,!1)}function Ut(e,t){return L(e)?e:new Wt(e,t)}var Wt=class{constructor(e,t){this.dep=new qe,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=t?e:F(e),this._value=t?e:I(e),this.__v_isShallow=t}get value(){return this.dep.track(),this._value}set value(e){let t=this._rawValue,n=this.__v_isShallow||P(e)||zt(e);e=n?e:F(e),D(e,t)&&(this._rawValue=e,this._value=n?e:I(e),this.dep.trigger())}};function z(e){return L(e)?e.value:e}var Gt={get:(e,t,n)=>t===`__v_raw`?e:z(Reflect.get(e,t,n)),set:(e,t,n,r)=>{let i=e[t];return L(i)&&!L(n)?(i.value=n,!0):Reflect.set(e,t,n,r)}};function Kt(e){return Rt(e)?e:new Proxy(e,Gt)}var qt=class{constructor(e,t,n){this.fn=e,this.setter=t,this._value=void 0,this.dep=new qe(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=Ge-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!t,this.isSSR=n}notify(){if(this.flags|=16,!(this.flags&8)&&M!==this)return je(this,!0),!0}get value(){let e=this.dep.track();return Le(this),e&&(e.version=this.dep.version),this._value}set value(e){this.setter&&this.setter(e)}};function Jt(e,t,n=!1){let r,i;return h(e)?r=e:(r=e.get,i=e.set),new qt(r,i,n)}var Yt={},Xt=new WeakMap,Zt=void 0;function Qt(e,t=!1,n=Zt){if(n){let t=Xt.get(n);t||Xt.set(n,t=[]),t.push(e)}}function $t(e,n,i=t){let{immediate:a,deep:o,once:s,scheduler:l,augmentJob:u,call:f}=i,p=e=>o?e:P(e)||o===!1||o===0?en(e,1):en(e),m,g,_,v,y=!1,b=!1;if(L(e)?(g=()=>e.value,y=P(e)):Rt(e)?(g=()=>p(e),y=!0):d(e)?(b=!0,y=e.some(e=>Rt(e)||P(e)),g=()=>e.map(e=>{if(L(e))return e.value;if(Rt(e))return p(e);if(h(e))return f?f(e,2):e()})):g=h(e)?n?f?()=>f(e,2):e:()=>{if(_){He();try{_()}finally{Ue()}}let t=Zt;Zt=m;try{return f?f(e,3,[v]):e(v)}finally{Zt=t}}:r,n&&o){let e=g,t=o===!0?1/0:o;g=()=>en(e(),t)}let x=Te(),S=()=>{m.stop(),x&&x.active&&c(x.effects,m)};if(s&&n){let e=n;n=(...t)=>{e(...t),S()}}let C=b?Array(e.length).fill(Yt):Yt,w=e=>{if(!(!(m.flags&1)||!m.dirty&&!e))if(n){let e=m.run();if(o||y||(b?e.some((e,t)=>D(e,C[t])):D(e,C))){_&&_();let t=Zt;Zt=m;try{let t=[e,C===Yt?void 0:b&&C[0]===Yt?[]:C,v];C=e,f?f(n,3,t):n(...t)}finally{Zt=t}}}else m.run()};return u&&u(w),m=new De(g),m.scheduler=l?()=>l(w,!1):w,v=e=>Qt(e,!1,m),_=m.onStop=()=>{let e=Xt.get(m);if(e){if(f)f(e,4);else for(let t of e)t();Xt.delete(m)}},n?a?w(!0):C=m.run():l?l(w.bind(null,!0),!0):m.run(),S.pause=m.pause.bind(m),S.resume=m.resume.bind(m),S.stop=S,S}function en(e,t=1/0,n){if(t<=0||!v(e)||e.__v_skip||(n||=new Map,(n.get(e)||0)>=t))return e;if(n.set(e,t),t--,L(e))en(e.value,t,n);else if(d(e))for(let r=0;r<e.length;r++)en(e[r],t,n);else if(p(e)||f(e))e.forEach(e=>{en(e,t,n)});else if(C(e)){for(let r in e)en(e[r],t,n);for(let r of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,r)&&en(e[r],t,n)}return e}function tn(e,t,n,r){try{return r?e(...r):e()}catch(e){rn(e,t,n)}}function nn(e,t,n,r){if(h(e)){let i=tn(e,t,n,r);return i&&y(i)&&i.catch(e=>{rn(e,t,n)}),i}if(d(e)){let i=[];for(let a=0;a<e.length;a++)i.push(nn(e[a],t,n,r));return i}}function rn(e,n,r,i=!0){let a=n?n.vnode:null,{errorHandler:o,throwUnhandledErrorInProduction:s}=n&&n.appContext.config||t;if(n){let t=n.parent,i=n.proxy,a=`https://vuejs.org/error-reference/#runtime-${r}`;for(;t;){let n=t.ec;if(n){for(let t=0;t<n.length;t++)if(n[t](e,i,a)===!1)return}t=t.parent}if(o){He(),tn(o,null,10,[e,i,a]),Ue();return}}an(e,r,a,i,s)}function an(e,t,n,r=!0,i=!1){if(i)throw e;console.error(e)}var B=[],on=-1,sn=[],cn=null,ln=0,un=Promise.resolve(),dn=null;function fn(e){let t=dn||un;return e?t.then(this?e.bind(this):e):t}function pn(e){let t=on+1,n=B.length;for(;t<n;){let r=t+n>>>1,i=B[r],a=yn(i);a<e||a===e&&i.flags&2?t=r+1:n=r}return t}function mn(e){if(!(e.flags&1)){let t=yn(e),n=B[B.length-1];!n||!(e.flags&2)&&t>=yn(n)?B.push(e):B.splice(pn(t),0,e),e.flags|=1,hn()}}function hn(){dn||=un.then(bn)}function gn(e){d(e)?sn.push(...e):cn&&e.id===-1?cn.splice(ln+1,0,e):e.flags&1||(sn.push(e),e.flags|=1),hn()}function _n(e,t,n=on+1){for(;n<B.length;n++){let t=B[n];if(t&&t.flags&2){if(e&&t.id!==e.uid)continue;B.splice(n,1),n--,t.flags&4&&(t.flags&=-2),t(),t.flags&4||(t.flags&=-2)}}}function vn(e){if(sn.length){let e=[...new Set(sn)].sort((e,t)=>yn(e)-yn(t));if(sn.length=0,cn){cn.push(...e);return}for(cn=e,ln=0;ln<cn.length;ln++){let e=cn[ln];e.flags&4&&(e.flags&=-2),e.flags&8||e(),e.flags&=-2}cn=null,ln=0}}var yn=e=>e.id==null?e.flags&2?-1:1/0:e.id;function bn(e){try{for(on=0;on<B.length;on++){let e=B[on];e&&!(e.flags&8)&&(e.flags&4&&(e.flags&=-2),tn(e,e.i,e.i?15:14),e.flags&4||(e.flags&=-2))}}finally{for(;on<B.length;on++){let e=B[on];e&&(e.flags&=-2)}on=-1,B.length=0,vn(e),dn=null,(B.length||sn.length)&&bn(e)}}var V=null,xn=null;function Sn(e){let t=V;return V=e,xn=e&&e.type.__scopeId||null,t}function Cn(e,t=V,n){if(!t||e._n)return e;let r=(...n)=>{r._d&&ki(-1);let i=Sn(t),a;try{a=e(...n)}finally{Sn(i),r._d&&ki(1)}return a};return r._n=!0,r._c=!0,r._d=!0,r}function wn(e,n){if(V===null)return e;let r=la(V),i=e.dirs||=[];for(let e=0;e<n.length;e++){let[a,o,s,c=t]=n[e];a&&(h(a)&&(a={mounted:a,updated:a}),a.deep&&en(o),i.push({dir:a,instance:r,value:o,oldValue:void 0,arg:s,modifiers:c}))}return e}function Tn(e,t,n,r){let i=e.dirs,a=t&&t.dirs;for(let o=0;o<i.length;o++){let s=i[o];a&&(s.oldValue=a[o].value);let c=s.dir[r];c&&(He(),nn(c,n,8,[e.el,s,e,t]),Ue())}}function En(e,t){if(Q){let n=Q.provides,r=Q.parent&&Q.parent.provides;r===n&&(n=Q.provides=Object.create(r)),n[e]=t}}function Dn(e,t,n=!1){let r=Ji();if(r||Pr){let i=Pr?Pr._context.provides:r?r.parent==null||r.ce?r.vnode.appContext&&r.vnode.appContext.provides:r.parent.provides:void 0;if(i&&e in i)return i[e];if(arguments.length>1)return n&&h(t)?t.call(r&&r.proxy):t}}var On=Symbol.for(`v-scx`),kn=()=>Dn(On);function An(e,t,n){return jn(e,t,n)}function jn(e,n,i=t){let{immediate:a,deep:o,flush:c,once:l}=i,u=s({},i),d=n&&a||!n&&c!==`post`,f;if(ea){if(c===`sync`){let e=kn();f=e.__watcherHandles||=[]}else if(!d){let e=()=>{};return e.stop=r,e.resume=r,e.pause=r,e}}let p=Q;u.call=(e,t,n)=>nn(e,p,t,n);let m=!1;c===`post`?u.scheduler=e=>{W(e,p&&p.suspense)}:c!==`sync`&&(m=!0,u.scheduler=(e,t)=>{t?e():mn(e)}),u.augmentJob=e=>{n&&(e.flags|=4),m&&(e.flags|=2,p&&(e.id=p.uid,e.i=p))};let h=$t(e,n,u);return ea&&(f?f.push(h):d&&h()),h}function Mn(e,t,n){let r=this.proxy,i=g(e)?e.includes(`.`)?Nn(r,e):()=>r[e]:e.bind(r,r),a;h(t)?a=t:(a=t.handler,n=t);let o=Zi(this),s=jn(i,a.bind(r),n);return o(),s}function Nn(e,t){let n=t.split(`.`);return()=>{let t=e;for(let e=0;e<n.length&&t;e++)t=t[n[e]];return t}}var Pn=Symbol(`_vte`),Fn=e=>e.__isTeleport,In=Symbol(`_leaveCb`);function Ln(e,t){e.shapeFlag&6&&e.component?(e.transition=t,Ln(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function Rn(e){e.ids=[e.ids[0]+ e.ids[2]+++`-`,0,0]}function zn(e,t){let n;return!!((n=Object.getOwnPropertyDescriptor(e,t))&&!n.configurable)}var Bn=new WeakMap;function Vn(e,n,r,a,o=!1){if(d(e)){e.forEach((e,t)=>Vn(e,n&&(d(n)?n[t]:n),r,a,o));return}if(Un(a)&&!o){a.shapeFlag&512&&a.type.__asyncResolved&&a.component.subTree.component&&Vn(e,n,r,a.component.subTree);return}let s=a.shapeFlag&4?la(a.component):a.el,l=o?null:s,{i:f,r:p}=e,m=n&&n.r,_=f.refs===t?f.refs={}:f.refs,v=f.setupState,y=F(v),b=v===t?i:e=>zn(_,e)?!1:u(y,e),x=(e,t)=>!(t&&zn(_,t));if(m!=null&&m!==p){if(Hn(n),g(m))_[m]=null,b(m)&&(v[m]=null);else if(L(m)){let e=n;x(m,e.k)&&(m.value=null),e.k&&(_[e.k]=null)}}if(h(p))tn(p,f,12,[l,_]);else{let t=g(p),n=L(p);if(t||n){let i=()=>{if(e.f){let n=t?b(p)?v[p]:_[p]:x(p)||!e.k?p.value:_[e.k];if(o)d(n)&&c(n,s);else if(d(n))n.includes(s)||n.push(s);else if(t)_[p]=[s],b(p)&&(v[p]=_[p]);else{let t=[s];x(p,e.k)&&(p.value=t),e.k&&(_[e.k]=t)}}else t?(_[p]=l,b(p)&&(v[p]=l)):n&&(x(p,e.k)&&(p.value=l),e.k&&(_[e.k]=l))};if(l){let t=()=>{i(),Bn.delete(e)};t.id=-1,Bn.set(e,t),W(t,r)}else Hn(e),i()}}}function Hn(e){let t=Bn.get(e);t&&(t.flags|=8,Bn.delete(e))}le().requestIdleCallback,le().cancelIdleCallback;var Un=e=>!!e.type.__asyncLoader,Wn=e=>e.type.__isKeepAlive;function Gn(e,t){qn(e,`a`,t)}function Kn(e,t){qn(e,`da`,t)}function qn(e,t,n=Q){let r=e.__wdc||=()=>{let t=n;for(;t;){if(t.isDeactivated)return;t=t.parent}return e()};if(Yn(t,r,n),n){let e=n.parent;for(;e&&e.parent;)Wn(e.parent.vnode)&&Jn(r,t,n,e),e=e.parent}}function Jn(e,t,n,r){let i=Yn(t,e,r,!0);nr(()=>{c(r[t],i)},n)}function Yn(e,t,n=Q,r=!1){if(n){let i=n[e]||(n[e]=[]),a=t.__weh||=(...r)=>{He();let i=Zi(n),a=nn(t,n,e,r);return i(),Ue(),a};return r?i.unshift(a):i.push(a),a}}var Xn=e=>(t,n=Q)=>{(!ea||e===`sp`)&&Yn(e,(...e)=>t(...e),n)},Zn=Xn(`bm`),Qn=Xn(`m`),$n=Xn(`bu`),er=Xn(`u`),tr=Xn(`bum`),nr=Xn(`um`),rr=Xn(`sp`),ir=Xn(`rtg`),ar=Xn(`rtc`);function or(e,t=Q){Yn(`ec`,e,t)}var sr=`components`;function cr(e,t){return ur(sr,e,!0,t)||e}var lr=Symbol.for(`v-ndc`);function ur(e,t,n=!0,r=!1){let i=V||Q;if(i){let n=i.type;if(e===sr){let e=ua(n,!1);if(e&&(e===t||e===T(t)||e===ie(T(t))))return n}let a=dr(i[e]||n[e],t)||dr(i.appContext[e],t);return!a&&r?n:a}}function dr(e,t){return e&&(e[t]||e[T(t)]||e[ie(T(t))])}function H(e,t,n,r){let i,a=n&&n[r],o=d(e);if(o||g(e)){let n=o&&Rt(e),r=!1,s=!1;n&&(r=!P(e),s=zt(e),e=tt(e)),i=Array(e.length);for(let n=0,o=e.length;n<o;n++)i[n]=t(r?s?Ht(I(e[n])):I(e[n]):e[n],n,void 0,a&&a[n])}else if(typeof e==`number`){i=Array(e);for(let n=0;n<e;n++)i[n]=t(n+1,n,void 0,a&&a[n])}else if(v(e))if(e[Symbol.iterator])i=Array.from(e,(e,n)=>t(e,n,void 0,a&&a[n]));else{let n=Object.keys(e);i=Array(n.length);for(let r=0,o=n.length;r<o;r++){let o=n[r];i[r]=t(e[o],o,r,a&&a[r])}}else i=[];return n&&(n[r]=i),i}var fr=e=>e?$i(e)?la(e):fr(e.parent):null,pr=s(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>fr(e.parent),$root:e=>fr(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>Sr(e),$forceUpdate:e=>e.f||=()=>{mn(e.update)},$nextTick:e=>e.n||=fn.bind(e.proxy),$watch:e=>Mn.bind(e)}),mr=(e,n)=>e!==t&&!e.__isScriptSetup&&u(e,n),hr={get({_:e},n){if(n===`__v_skip`)return!0;let{ctx:r,setupState:i,data:a,props:o,accessCache:s,type:c,appContext:l}=e;if(n[0]!==`$`){let e=s[n];if(e!==void 0)switch(e){case 1:return i[n];case 2:return a[n];case 4:return r[n];case 3:return o[n]}else if(mr(i,n))return s[n]=1,i[n];else if(a!==t&&u(a,n))return s[n]=2,a[n];else if(u(o,n))return s[n]=3,o[n];else if(r!==t&&u(r,n))return s[n]=4,r[n];else _r&&(s[n]=0)}let d=pr[n],f,p;if(d)return n===`$attrs`&&N(e.attrs,`get`,``),d(e);if((f=c.__cssModules)&&(f=f[n]))return f;if(r!==t&&u(r,n))return s[n]=4,r[n];if(p=l.config.globalProperties,u(p,n))return p[n]},set({_:e},n,r){let{data:i,setupState:a,ctx:o}=e;return mr(a,n)?(a[n]=r,!0):i!==t&&u(i,n)?(i[n]=r,!0):u(e.props,n)||n[0]===`$`&&n.slice(1)in e?!1:(o[n]=r,!0)},has({_:{data:e,setupState:n,accessCache:r,ctx:i,appContext:a,props:o,type:s}},c){let l;return!!(r[c]||e!==t&&c[0]!==`$`&&u(e,c)||mr(n,c)||u(o,c)||u(i,c)||u(pr,c)||u(a.config.globalProperties,c)||(l=s.__cssModules)&&l[c])},defineProperty(e,t,n){return n.get==null?u(n,`value`)&&this.set(e,t,n.value,null):e._.accessCache[t]=0,Reflect.defineProperty(e,t,n)}};function gr(e){return d(e)?e.reduce((e,t)=>(e[t]=null,e),{}):e}var _r=!0;function vr(e){let t=Sr(e),n=e.proxy,i=e.ctx;_r=!1,t.beforeCreate&&br(t.beforeCreate,e,`bc`);let{data:a,computed:o,methods:s,watch:c,provide:l,inject:u,created:f,beforeMount:p,mounted:m,beforeUpdate:g,updated:_,activated:y,deactivated:b,beforeDestroy:x,beforeUnmount:S,destroyed:C,unmounted:w,render:ee,renderTracked:te,renderTriggered:ne,errorCaptured:T,serverPrefetch:re,expose:E,inheritAttrs:ie,components:ae,directives:D,filters:oe}=t;if(u&&yr(u,i,null),s)for(let e in s){let t=s[e];h(t)&&(i[e]=t.bind(n))}if(a){let t=a.call(n,n);v(t)&&(e.data=Pt(t))}if(_r=!0,o)for(let e in o){let t=o[e],a=$({get:h(t)?t.bind(n,n):h(t.get)?t.get.bind(n,n):r,set:!h(t)&&h(t.set)?t.set.bind(n):r});Object.defineProperty(i,e,{enumerable:!0,configurable:!0,get:()=>a.value,set:e=>a.value=e})}if(c)for(let e in c)xr(c[e],i,n,e);if(l){let e=h(l)?l.call(n):l;Reflect.ownKeys(e).forEach(t=>{En(t,e[t])})}f&&br(f,e,`c`);function O(e,t){d(t)?t.forEach(t=>e(t.bind(n))):t&&e(t.bind(n))}if(O(Zn,p),O(Qn,m),O($n,g),O(er,_),O(Gn,y),O(Kn,b),O(or,T),O(ar,te),O(ir,ne),O(tr,S),O(nr,w),O(rr,re),d(E))if(E.length){let t=e.exposed||={};E.forEach(e=>{Object.defineProperty(t,e,{get:()=>n[e],set:t=>n[e]=t,enumerable:!0})})}else e.exposed||={};ee&&e.render===r&&(e.render=ee),ie!=null&&(e.inheritAttrs=ie),ae&&(e.components=ae),D&&(e.directives=D),re&&Rn(e)}function yr(e,t,n=r){d(e)&&(e=Dr(e));for(let n in e){let r=e[n],i;i=v(r)?`default`in r?Dn(r.from||n,r.default,!0):Dn(r.from||n):Dn(r),L(i)?Object.defineProperty(t,n,{enumerable:!0,configurable:!0,get:()=>i.value,set:e=>i.value=e}):t[n]=i}}function br(e,t,n){nn(d(e)?e.map(e=>e.bind(t.proxy)):e.bind(t.proxy),t,n)}function xr(e,t,n,r){let i=r.includes(`.`)?Nn(n,r):()=>n[r];if(g(e)){let n=t[e];h(n)&&An(i,n)}else if(h(e))An(i,e.bind(n));else if(v(e))if(d(e))e.forEach(e=>xr(e,t,n,r));else{let r=h(e.handler)?e.handler.bind(n):t[e.handler];h(r)&&An(i,r,e)}}function Sr(e){let t=e.type,{mixins:n,extends:r}=t,{mixins:i,optionsCache:a,config:{optionMergeStrategies:o}}=e.appContext,s=a.get(t),c;return s?c=s:!i.length&&!n&&!r?c=t:(c={},i.length&&i.forEach(e=>Cr(c,e,o,!0)),Cr(c,t,o)),v(t)&&a.set(t,c),c}function Cr(e,t,n,r=!1){let{mixins:i,extends:a}=t;a&&Cr(e,a,n,!0),i&&i.forEach(t=>Cr(e,t,n,!0));for(let i in t)if(!(r&&i===`expose`)){let r=wr[i]||n&&n[i];e[i]=r?r(e[i],t[i]):t[i]}return e}var wr={data:Tr,props:kr,emits:kr,methods:Or,computed:Or,beforeCreate:U,created:U,beforeMount:U,mounted:U,beforeUpdate:U,updated:U,beforeDestroy:U,beforeUnmount:U,destroyed:U,unmounted:U,activated:U,deactivated:U,errorCaptured:U,serverPrefetch:U,components:Or,directives:Or,watch:Ar,provide:Tr,inject:Er};function Tr(e,t){return t?e?function(){return s(h(e)?e.call(this,this):e,h(t)?t.call(this,this):t)}:t:e}function Er(e,t){return Or(Dr(e),Dr(t))}function Dr(e){if(d(e)){let t={};for(let n=0;n<e.length;n++)t[e[n]]=e[n];return t}return e}function U(e,t){return e?[...new Set([].concat(e,t))]:t}function Or(e,t){return e?s(Object.create(null),e,t):t}function kr(e,t){return e?d(e)&&d(t)?[...new Set([...e,...t])]:s(Object.create(null),gr(e),gr(t??{})):t}function Ar(e,t){if(!e)return t;if(!t)return e;let n=s(Object.create(null),e);for(let r in t)n[r]=U(e[r],t[r]);return n}function jr(){return{app:null,config:{isNativeTag:i,performance:!1,globalProperties:{},optionMergeStrategies:{},errorHandler:void 0,warnHandler:void 0,compilerOptions:{}},mixins:[],components:{},directives:{},provides:Object.create(null),optionsCache:new WeakMap,propsCache:new WeakMap,emitsCache:new WeakMap}}var Mr=0;function Nr(e,t){return function(n,r=null){h(n)||(n=s({},n)),r!=null&&!v(r)&&(r=null);let i=jr(),a=new WeakSet,o=[],c=!1,l=i.app={_uid:Mr++,_component:n,_props:r,_container:null,_context:i,_instance:null,version:fa,get config(){return i.config},set config(e){},use(e,...t){return a.has(e)||(e&&h(e.install)?(a.add(e),e.install(l,...t)):h(e)&&(a.add(e),e(l,...t))),l},mixin(e){return i.mixins.includes(e)||i.mixins.push(e),l},component(e,t){return t?(i.components[e]=t,l):i.components[e]},directive(e,t){return t?(i.directives[e]=t,l):i.directives[e]},mount(a,o,s){if(!c){let u=l._ceVNode||X(n,r);return u.appContext=i,s===!0?s=`svg`:s===!1&&(s=void 0),o&&t?t(u,a):e(u,a,s),c=!0,l._container=a,a.__vue_app__=l,la(u.component)}},onUnmount(e){o.push(e)},unmount(){c&&(nn(o,l._instance,16),e(null,l._container),delete l._container.__vue_app__)},provide(e,t){return i.provides[e]=t,l},runWithContext(e){let t=Pr;Pr=l;try{return e()}finally{Pr=t}}};return l}}var Pr=null,Fr=(e,t)=>t===`modelValue`||t===`model-value`?e.modelModifiers:e[`${t}Modifiers`]||e[`${T(t)}Modifiers`]||e[`${E(t)}Modifiers`];function Ir(e,n,...r){if(e.isUnmounted)return;let i=e.vnode.props||t,a=r,o=n.startsWith(`update:`),s=o&&Fr(i,n.slice(7));s&&(s.trim&&(a=r.map(e=>g(e)?e.trim():e)),s.number&&(a=r.map(se)));let c,l=i[c=ae(n)]||i[c=ae(T(n))];!l&&o&&(l=i[c=ae(E(n))]),l&&nn(l,e,6,a);let u=i[c+`Once`];if(u){if(!e.emitted)e.emitted={};else if(e.emitted[c])return;e.emitted[c]=!0,nn(u,e,6,a)}}var Lr=new WeakMap;function Rr(e,t,n=!1){let r=n?Lr:t.emitsCache,i=r.get(e);if(i!==void 0)return i;let a=e.emits,o={},c=!1;if(!h(e)){let r=e=>{let n=Rr(e,t,!0);n&&(c=!0,s(o,n))};!n&&t.mixins.length&&t.mixins.forEach(r),e.extends&&r(e.extends),e.mixins&&e.mixins.forEach(r)}return!a&&!c?(v(e)&&r.set(e,null),null):(d(a)?a.forEach(e=>o[e]=null):s(o,a),v(e)&&r.set(e,o),o)}function zr(e,t){return!e||!a(t)?!1:(t=t.slice(2).replace(/Once$/,``),u(e,t[0].toLowerCase()+t.slice(1))||u(e,E(t))||u(e,t))}function Br(e){let{type:t,vnode:n,proxy:r,withProxy:i,propsOptions:[a],slots:s,attrs:c,emit:l,render:u,renderCache:d,props:f,data:p,setupState:m,ctx:h,inheritAttrs:g}=e,_=Sn(e),v,y;try{if(n.shapeFlag&4){let e=i||r,t=e;v=Bi(u.call(t,e,d,f,m,p,h)),y=c}else{let e=t;v=Bi(e.length>1?e(f,{attrs:c,slots:s,emit:l}):e(f,null)),y=t.props?c:Vr(c)}}catch(t){Ei.length=0,rn(t,e,1),v=X(wi)}let b=v;if(y&&g!==!1){let e=Object.keys(y),{shapeFlag:t}=b;e.length&&t&7&&(a&&e.some(o)&&(y=Hr(y,a)),b=Ri(b,y,!1,!0))}return n.dirs&&(b=Ri(b,null,!1,!0),b.dirs=b.dirs?b.dirs.concat(n.dirs):n.dirs),n.transition&&Ln(b,n.transition),v=b,Sn(_),v}var Vr=e=>{let t;for(let n in e)(n===`class`||n===`style`||a(n))&&((t||={})[n]=e[n]);return t},Hr=(e,t)=>{let n={};for(let r in e)(!o(r)||!(r.slice(9)in t))&&(n[r]=e[r]);return n};function Ur(e,t,n){let{props:r,children:i,component:a}=e,{props:o,children:s,patchFlag:c}=t,l=a.emitsOptions;if(t.dirs||t.transition)return!0;if(n&&c>=0){if(c&1024)return!0;if(c&16)return r?Wr(r,o,l):!!o;if(c&8){let e=t.dynamicProps;for(let t=0;t<e.length;t++){let n=e[t];if(Gr(o,r,n)&&!zr(l,n))return!0}}}else return(i||s)&&(!s||!s.$stable)?!0:r===o?!1:r?o?Wr(r,o,l):!0:!!o;return!1}function Wr(e,t,n){let r=Object.keys(t);if(r.length!==Object.keys(e).length)return!0;for(let i=0;i<r.length;i++){let a=r[i];if(Gr(t,e,a)&&!zr(n,a))return!0}return!1}function Gr(e,t,n){let r=e[n],i=t[n];return n===`style`&&v(r)&&v(i)?!ye(r,i):r!==i}function Kr({vnode:e,parent:t,suspense:n},r){for(;t;){let n=t.subTree;if(n.suspense&&n.suspense.activeBranch===e&&(n.suspense.vnode.el=n.el=r,e=n),n===e)(e=t.vnode).el=r,t=t.parent;else break}n&&n.activeBranch===e&&(n.vnode.el=r)}var qr={},Jr=()=>Object.create(qr),Yr=e=>Object.getPrototypeOf(e)===qr;function Xr(e,t,n,r=!1){let i={},a=Jr();e.propsDefaults=Object.create(null),Qr(e,t,i,a);for(let t in e.propsOptions[0])t in i||(i[t]=void 0);n?e.props=r?i:Ft(i):e.type.props?e.props=i:e.props=a,e.attrs=a}function Zr(e,t,n,r){let{props:i,attrs:a,vnode:{patchFlag:o}}=e,s=F(i),[c]=e.propsOptions,l=!1;if((r||o>0)&&!(o&16)){if(o&8){let n=e.vnode.dynamicProps;for(let r=0;r<n.length;r++){let o=n[r];if(zr(e.emitsOptions,o))continue;let d=t[o];if(c)if(u(a,o))d!==a[o]&&(a[o]=d,l=!0);else{let t=T(o);i[t]=$r(c,s,t,d,e,!1)}else d!==a[o]&&(a[o]=d,l=!0)}}}else{Qr(e,t,i,a)&&(l=!0);let r;for(let a in s)(!t||!u(t,a)&&((r=E(a))===a||!u(t,r)))&&(c?n&&(n[a]!==void 0||n[r]!==void 0)&&(i[a]=$r(c,s,a,void 0,e,!0)):delete i[a]);if(a!==s)for(let e in a)(!t||!u(t,e))&&(delete a[e],l=!0)}l&&$e(e.attrs,`set`,``)}function Qr(e,n,r,i){let[a,o]=e.propsOptions,s=!1,c;if(n)for(let t in n){if(ee(t))continue;let l=n[t],d;a&&u(a,d=T(t))?!o||!o.includes(d)?r[d]=l:(c||={})[d]=l:zr(e.emitsOptions,t)||(!(t in i)||l!==i[t])&&(i[t]=l,s=!0)}if(o){let n=F(r),i=c||t;for(let t=0;t<o.length;t++){let s=o[t];r[s]=$r(a,n,s,i[s],e,!u(i,s))}}return s}function $r(e,t,n,r,i,a){let o=e[n];if(o!=null){let e=u(o,`default`);if(e&&r===void 0){let e=o.default;if(o.type!==Function&&!o.skipFactory&&h(e)){let{propsDefaults:a}=i;if(n in a)r=a[n];else{let o=Zi(i);r=a[n]=e.call(null,t),o()}}else r=e;i.ce&&i.ce._setProp(n,r)}o[0]&&(a&&!e?r=!1:o[1]&&(r===``||r===E(n))&&(r=!0))}return r}var ei=new WeakMap;function ti(e,r,i=!1){let a=i?ei:r.propsCache,o=a.get(e);if(o)return o;let c=e.props,l={},f=[],p=!1;if(!h(e)){let t=e=>{p=!0;let[t,n]=ti(e,r,!0);s(l,t),n&&f.push(...n)};!i&&r.mixins.length&&r.mixins.forEach(t),e.extends&&t(e.extends),e.mixins&&e.mixins.forEach(t)}if(!c&&!p)return v(e)&&a.set(e,n),n;if(d(c))for(let e=0;e<c.length;e++){let n=T(c[e]);ni(n)&&(l[n]=t)}else if(c)for(let e in c){let t=T(e);if(ni(t)){let n=c[e],r=l[t]=d(n)||h(n)?{type:n}:s({},n),i=r.type,a=!1,o=!0;if(d(i))for(let e=0;e<i.length;++e){let t=i[e],n=h(t)&&t.name;if(n===`Boolean`){a=!0;break}else n===`String`&&(o=!1)}else a=h(i)&&i.name===`Boolean`;r[0]=a,r[1]=o,(a||u(r,`default`))&&f.push(t)}}let m=[l,f];return v(e)&&a.set(e,m),m}function ni(e){return e[0]!==`$`&&!ee(e)}var ri=e=>e===`_`||e===`_ctx`||e===`$stable`,ii=e=>d(e)?e.map(Bi):[Bi(e)],ai=(e,t,n)=>{if(t._n)return t;let r=Cn((...e)=>ii(t(...e)),n);return r._c=!1,r},oi=(e,t,n)=>{let r=e._ctx;for(let n in e){if(ri(n))continue;let i=e[n];if(h(i))t[n]=ai(n,i,r);else if(i!=null){let e=ii(i);t[n]=()=>e}}},si=(e,t)=>{let n=ii(t);e.slots.default=()=>n},ci=(e,t,n)=>{for(let r in t)(n||!ri(r))&&(e[r]=t[r])},li=(e,t,n)=>{let r=e.slots=Jr();if(e.vnode.shapeFlag&32){let e=t._;e?(ci(r,t,n),n&&O(r,`_`,e,!0)):oi(t,r)}else t&&si(e,t)},ui=(e,n,r)=>{let{vnode:i,slots:a}=e,o=!0,s=t;if(i.shapeFlag&32){let e=n._;e?r&&e===1?o=!1:ci(a,n,r):(o=!n.$stable,oi(n,a)),s=n}else n&&(si(e,n),s={default:1});if(o)for(let e in a)!ri(e)&&s[e]==null&&delete a[e]},W=Si;function di(e){return fi(e)}function fi(e,i){let a=le();a.__VUE__=!0;let{insert:o,remove:s,patchProp:c,createElement:l,createText:u,createComment:d,setText:f,setElementText:p,parentNode:m,nextSibling:h,setScopeId:g=r,insertStaticContent:_}=e,v=(e,t,n,r=null,i=null,a=null,o=void 0,s=null,c=!!t.dynamicChildren)=>{if(e===t)return;e&&!Ni(e,t)&&(r=ye(e),k(e,i,a,!0),e=null),t.patchFlag===-2&&(c=!1,t.dynamicChildren=null);let{type:l,ref:u,shapeFlag:d}=t;switch(l){case Ci:y(e,t,n,r);break;case wi:b(e,t,n,r);break;case Ti:e??x(t,n,r,o);break;case G:ae(e,t,n,r,i,a,o,s,c);break;default:d&1?w(e,t,n,r,i,a,o,s,c):d&6?D(e,t,n,r,i,a,o,s,c):(d&64||d&128)&&l.process(e,t,n,r,i,a,o,s,c,A)}u!=null&&i?Vn(u,e&&e.ref,a,t||e,!t):u==null&&e&&e.ref!=null&&Vn(e.ref,null,a,e,!0)},y=(e,t,n,r)=>{if(e==null)o(t.el=u(t.children),n,r);else{let n=t.el=e.el;t.children!==e.children&&f(n,t.children)}},b=(e,t,n,r)=>{e==null?o(t.el=d(t.children||``),n,r):t.el=e.el},x=(e,t,n,r)=>{[e.el,e.anchor]=_(e.children,t,n,r,e.el,e.anchor)},S=({el:e,anchor:t},n,r)=>{let i;for(;e&&e!==t;)i=h(e),o(e,n,r),e=i;o(t,n,r)},C=({el:e,anchor:t})=>{let n;for(;e&&e!==t;)n=h(e),s(e),e=n;s(t)},w=(e,t,n,r,i,a,o,s,c)=>{if(t.type===`svg`?o=`svg`:t.type===`math`&&(o=`mathml`),e==null)te(t,n,r,i,a,o,s,c);else{let n=e.el&&e.el._isVueCE?e.el:null;try{n&&n._beginPatch(),re(e,t,i,a,o,s,c)}finally{n&&n._endPatch()}}},te=(e,t,n,r,i,a,s,u)=>{let d,f,{props:m,shapeFlag:h,transition:g,dirs:_}=e;if(d=e.el=l(e.type,a,m&&m.is,m),h&8?p(d,e.children):h&16&&T(e.children,d,null,r,i,pi(e,a),s,u),_&&Tn(e,null,r,`created`),ne(d,e,e.scopeId,s,r),m){for(let e in m)e!==`value`&&!ee(e)&&c(d,e,null,m[e],a,r);`value`in m&&c(d,`value`,null,m.value,a),(f=m.onVnodeBeforeMount)&&Wi(f,r,e)}_&&Tn(e,null,r,`beforeMount`);let v=hi(i,g);v&&g.beforeEnter(d),o(d,t,n),((f=m&&m.onVnodeMounted)||v||_)&&W(()=>{try{f&&Wi(f,r,e),v&&g.enter(d),_&&Tn(e,null,r,`mounted`)}finally{}},i)},ne=(e,t,n,r,i)=>{if(n&&g(e,n),r)for(let t=0;t<r.length;t++)g(e,r[t]);if(i){let n=i.subTree;if(t===n||xi(n.type)&&(n.ssContent===t||n.ssFallback===t)){let t=i.vnode;ne(e,t,t.scopeId,t.slotScopeIds,i.parent)}}},T=(e,t,n,r,i,a,o,s,c=0)=>{for(let l=c;l<e.length;l++)v(null,e[l]=s?Vi(e[l]):Bi(e[l]),t,n,r,i,a,o,s)},re=(e,n,r,i,a,o,s)=>{let l=n.el=e.el,{patchFlag:u,dynamicChildren:d,dirs:f}=n;u|=e.patchFlag&16;let m=e.props||t,h=n.props||t,g;if(r&&mi(r,!1),(g=h.onVnodeBeforeUpdate)&&Wi(g,r,n,e),f&&Tn(n,e,r,`beforeUpdate`),r&&mi(r,!0),(m.innerHTML&&h.innerHTML==null||m.textContent&&h.textContent==null)&&p(l,``),d?E(e.dynamicChildren,d,l,r,i,pi(n,a),o):s||de(e,n,l,null,r,i,pi(n,a),o,!1),u>0){if(u&16)ie(l,m,h,r,a);else if(u&2&&m.class!==h.class&&c(l,`class`,null,h.class,a),u&4&&c(l,`style`,m.style,h.style,a),u&8){let e=n.dynamicProps;for(let t=0;t<e.length;t++){let n=e[t],i=m[n],o=h[n];(o!==i||n===`value`)&&c(l,n,i,o,a,r)}}u&1&&e.children!==n.children&&p(l,n.children)}else !s&&d==null&&ie(l,m,h,r,a);((g=h.onVnodeUpdated)||f)&&W(()=>{g&&Wi(g,r,n,e),f&&Tn(n,e,r,`updated`)},i)},E=(e,t,n,r,i,a,o)=>{for(let s=0;s<t.length;s++){let c=e[s],l=t[s];v(c,l,c.el&&(c.type===G||!Ni(c,l)||c.shapeFlag&198)?m(c.el):n,null,r,i,a,o,!0)}},ie=(e,n,r,i,a)=>{if(n!==r){if(n!==t)for(let t in n)!ee(t)&&!(t in r)&&c(e,t,n[t],null,a,i);for(let t in r){if(ee(t))continue;let o=r[t],s=n[t];o!==s&&t!==`value`&&c(e,t,s,o,a,i)}`value`in r&&c(e,`value`,n.value,r.value,a)}},ae=(e,t,n,r,i,a,s,c,l)=>{let d=t.el=e?e.el:u(``),f=t.anchor=e?e.anchor:u(``),{patchFlag:p,dynamicChildren:m,slotScopeIds:h}=t;h&&(c=c?c.concat(h):h),e==null?(o(d,n,r),o(f,n,r),T(t.children||[],n,f,i,a,s,c,l)):p>0&&p&64&&m&&e.dynamicChildren&&e.dynamicChildren.length===m.length?(E(e.dynamicChildren,m,n,i,a,s,c),(t.key!=null||i&&t===i.subTree)&&gi(e,t,!0)):de(e,t,n,f,i,a,s,c,l)},D=(e,t,n,r,i,a,o,s,c)=>{t.slotScopeIds=s,e==null?t.shapeFlag&512?i.ctx.activate(t,n,r,o,c):O(t,n,r,i,a,o,c):se(e,t,c)},O=(e,t,n,r,i,a,o)=>{let s=e.component=qi(e,r,i);if(Wn(e)&&(s.ctx.renderer=A),ta(s,!1,o),s.asyncDep){if(i&&i.registerDep(s,ce,o),!e.el){let r=s.subTree=X(wi);b(null,r,t,n),e.placeholder=r.el}}else ce(s,e,t,n,i,a,o)},se=(e,t,n)=>{let r=t.component=e.component;if(Ur(e,t,n))if(r.asyncDep&&!r.asyncResolved){ue(r,t,n);return}else r.next=t,r.update();else t.el=e.el,r.vnode=t},ce=(e,t,n,r,i,a,o)=>{let s=()=>{if(e.isMounted){let{next:t,bu:n,u:r,parent:s,vnode:c}=e;{let n=vi(e);if(n){t&&(t.el=c.el,ue(e,t,o)),n.asyncDep.then(()=>{W(()=>{e.isUnmounted||l()},i)});return}}let u=t,d;mi(e,!1),t?(t.el=c.el,ue(e,t,o)):t=c,n&&oe(n),(d=t.props&&t.props.onVnodeBeforeUpdate)&&Wi(d,s,t,c),mi(e,!0);let f=Br(e),p=e.subTree;e.subTree=f,v(p,f,m(p.el),ye(p),e,i,a),t.el=f.el,u===null&&Kr(e,f.el),r&&W(r,i),(d=t.props&&t.props.onVnodeUpdated)&&W(()=>Wi(d,s,t,c),i)}else{let o,{el:s,props:c}=t,{bm:l,m:u,parent:d,root:f,type:p}=e,m=Un(t);if(mi(e,!1),l&&oe(l),!m&&(o=c&&c.onVnodeBeforeMount)&&Wi(o,d,t),mi(e,!0),s&&Ce){let t=()=>{e.subTree=Br(e),Ce(s,e.subTree,e,i,null)};m&&p.__asyncHydrate?p.__asyncHydrate(s,e,t):t()}else{f.ce&&f.ce._hasShadowRoot()&&f.ce._injectChildStyle(p,e.parent?e.parent.type:void 0);let o=e.subTree=Br(e);v(null,o,n,r,e,i,a),t.el=o.el}if(u&&W(u,i),!m&&(o=c&&c.onVnodeMounted)){let e=t;W(()=>Wi(o,d,e),i)}(t.shapeFlag&256||d&&Un(d.vnode)&&d.vnode.shapeFlag&256)&&e.a&&W(e.a,i),e.isMounted=!0,t=n=r=null}};e.scope.on();let c=e.effect=new De(s);e.scope.off();let l=e.update=c.run.bind(c),u=e.job=c.runIfDirty.bind(c);u.i=e,u.id=e.uid,c.scheduler=()=>mn(u),mi(e,!0),l()},ue=(e,t,n)=>{t.component=e;let r=e.vnode.props;e.vnode=t,e.next=null,Zr(e,t.props,r,n),ui(e,t.children,n),He(),_n(e),Ue()},de=(e,t,n,r,i,a,o,s,c=!1)=>{let l=e&&e.children,u=e?e.shapeFlag:0,d=t.children,{patchFlag:f,shapeFlag:m}=t;if(f>0){if(f&128){pe(l,d,n,r,i,a,o,s,c);return}else if(f&256){fe(l,d,n,r,i,a,o,s,c);return}}m&8?(u&16&&ve(l,i,a),d!==l&&p(n,d)):u&16?m&16?pe(l,d,n,r,i,a,o,s,c):ve(l,i,a,!0):(u&8&&p(n,``),m&16&&T(d,n,r,i,a,o,s,c))},fe=(e,t,r,i,a,o,s,c,l)=>{e||=n,t||=n;let u=e.length,d=t.length,f=Math.min(u,d),p;for(p=0;p<f;p++){let n=t[p]=l?Vi(t[p]):Bi(t[p]);v(e[p],n,r,null,a,o,s,c,l)}u>d?ve(e,a,o,!0,!1,f):T(t,r,i,a,o,s,c,l,f)},pe=(e,t,r,i,a,o,s,c,l)=>{let u=0,d=t.length,f=e.length-1,p=d-1;for(;u<=f&&u<=p;){let n=e[u],i=t[u]=l?Vi(t[u]):Bi(t[u]);if(Ni(n,i))v(n,i,r,null,a,o,s,c,l);else break;u++}for(;u<=f&&u<=p;){let n=e[f],i=t[p]=l?Vi(t[p]):Bi(t[p]);if(Ni(n,i))v(n,i,r,null,a,o,s,c,l);else break;f--,p--}if(u>f){if(u<=p){let e=p+1,n=e<d?t[e].el:i;for(;u<=p;)v(null,t[u]=l?Vi(t[u]):Bi(t[u]),r,n,a,o,s,c,l),u++}}else if(u>p)for(;u<=f;)k(e[u],a,o,!0),u++;else{let m=u,h=u,g=new Map;for(u=h;u<=p;u++){let e=t[u]=l?Vi(t[u]):Bi(t[u]);e.key!=null&&g.set(e.key,u)}let _,y=0,b=p-h+1,x=!1,S=0,C=Array(b);for(u=0;u<b;u++)C[u]=0;for(u=m;u<=f;u++){let n=e[u];if(y>=b){k(n,a,o,!0);continue}let i;if(n.key!=null)i=g.get(n.key);else for(_=h;_<=p;_++)if(C[_-h]===0&&Ni(n,t[_])){i=_;break}i===void 0?k(n,a,o,!0):(C[i-h]=u+1,i>=S?S=i:x=!0,v(n,t[i],r,null,a,o,s,c,l),y++)}let w=x?_i(C):n;for(_=w.length-1,u=b-1;u>=0;u--){let e=h+u,n=t[e],f=t[e+1],p=e+1<d?f.el||bi(f):i;C[u]===0?v(null,n,r,p,a,o,s,c,l):x&&(_<0||u!==w[_]?me(n,r,p,2):_--)}}},me=(e,t,n,r,i=null)=>{let{el:a,type:c,transition:l,children:u,shapeFlag:d}=e;if(d&6){me(e.component.subTree,t,n,r);return}if(d&128){e.suspense.move(t,n,r);return}if(d&64){c.move(e,t,n,A);return}if(c===G){o(a,t,n);for(let e=0;e<u.length;e++)me(u[e],t,n,r);o(e.anchor,t,n);return}if(c===Ti){S(e,t,n);return}if(r!==2&&d&1&&l)if(r===0)l.beforeEnter(a),o(a,t,n),W(()=>l.enter(a),i);else{let{leave:r,delayLeave:i,afterLeave:c}=l,u=()=>{e.ctx.isUnmounted?s(a):o(a,t,n)},d=()=>{a._isLeaving&&a[In](!0),r(a,()=>{u(),c&&c()})};i?i(a,u,d):d()}else o(a,t,n)},k=(e,t,n,r=!1,i=!1)=>{let{type:a,props:o,ref:s,children:c,dynamicChildren:l,shapeFlag:u,patchFlag:d,dirs:f,cacheIndex:p,memo:m}=e;if(d===-2&&(i=!1),s!=null&&(He(),Vn(s,null,n,e,!0),Ue()),p!=null&&(t.renderCache[p]=void 0),u&256){t.ctx.deactivate(e);return}let h=u&1&&f,g=!Un(e),_;if(g&&(_=o&&o.onVnodeBeforeUnmount)&&Wi(_,t,e),u&6)_e(e.component,n,r);else{if(u&128){e.suspense.unmount(n,r);return}h&&Tn(e,null,t,`beforeUnmount`),u&64?e.type.remove(e,t,n,A,r):l&&!l.hasOnce&&(a!==G||d>0&&d&64)?ve(l,t,n,!1,!0):(a===G&&d&384||!i&&u&16)&&ve(c,t,n),r&&he(e)}let v=m!=null&&p==null;(g&&(_=o&&o.onVnodeUnmounted)||h||v)&&W(()=>{_&&Wi(_,t,e),h&&Tn(e,null,t,`unmounted`),v&&(e.el=null)},n)},he=e=>{let{type:t,el:n,anchor:r,transition:i}=e;if(t===G){ge(n,r);return}if(t===Ti){C(e);return}let a=()=>{s(n),i&&!i.persisted&&i.afterLeave&&i.afterLeave()};if(e.shapeFlag&1&&i&&!i.persisted){let{leave:t,delayLeave:r}=i,o=()=>t(n,a);r?r(e.el,a,o):o()}else a()},ge=(e,t)=>{let n;for(;e!==t;)n=h(e),s(e),e=n;s(t)},_e=(e,t,n)=>{let{bum:r,scope:i,job:a,subTree:o,um:s,m:c,a:l}=e;yi(c),yi(l),r&&oe(r),i.stop(),a&&(a.flags|=8,k(o,e,t,n)),s&&W(s,t),W(()=>{e.isUnmounted=!0},t)},ve=(e,t,n,r=!1,i=!1,a=0)=>{for(let o=a;o<e.length;o++)k(e[o],t,n,r,i)},ye=e=>{if(e.shapeFlag&6)return ye(e.component.subTree);if(e.shapeFlag&128)return e.suspense.next();let t=h(e.anchor||e.el),n=t&&t[Pn];return n?h(n):t},be=!1,xe=(e,t,n)=>{let r;e==null?t._vnode&&(k(t._vnode,null,null,!0),r=t._vnode.component):v(t._vnode||null,e,t,null,null,null,n),t._vnode=e,be||=(be=!0,_n(r),vn(),!1)},A={p:v,um:k,m:me,r:he,mt:O,mc:T,pc:de,pbc:E,n:ye,o:e},Se,Ce;return i&&([Se,Ce]=i(A)),{render:xe,hydrate:Se,createApp:Nr(xe,Se)}}function pi({type:e,props:t},n){return n===`svg`&&e===`foreignObject`||n===`mathml`&&e===`annotation-xml`&&t&&t.encoding&&t.encoding.includes(`html`)?void 0:n}function mi({effect:e,job:t},n){n?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function hi(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function gi(e,t,n=!1){let r=e.children,i=t.children;if(d(r)&&d(i))for(let e=0;e<r.length;e++){let t=r[e],a=i[e];a.shapeFlag&1&&!a.dynamicChildren&&((a.patchFlag<=0||a.patchFlag===32)&&(a=i[e]=Vi(i[e]),a.el=t.el),!n&&a.patchFlag!==-2&&gi(t,a)),a.type===Ci&&(a.patchFlag===-1&&(a=i[e]=Vi(a)),a.el=t.el),a.type===wi&&!a.el&&(a.el=t.el)}}function _i(e){let t=e.slice(),n=[0],r,i,a,o,s,c=e.length;for(r=0;r<c;r++){let c=e[r];if(c!==0){if(i=n[n.length-1],e[i]<c){t[r]=i,n.push(r);continue}for(a=0,o=n.length-1;a<o;)s=a+o>>1,e[n[s]]<c?a=s+1:o=s;c<e[n[a]]&&(a>0&&(t[r]=n[a-1]),n[a]=r)}}for(a=n.length,o=n[a-1];a-- >0;)n[a]=o,o=t[o];return n}function vi(e){let t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:vi(t)}function yi(e){if(e)for(let t=0;t<e.length;t++)e[t].flags|=8}function bi(e){if(e.placeholder)return e.placeholder;let t=e.component;return t?bi(t.subTree):null}var xi=e=>e.__isSuspense;function Si(e,t){t&&t.pendingBranch?d(e)?t.effects.push(...e):t.effects.push(e):gn(e)}var G=Symbol.for(`v-fgt`),Ci=Symbol.for(`v-txt`),wi=Symbol.for(`v-cmt`),Ti=Symbol.for(`v-stc`),Ei=[],K=null;function q(e=!1){Ei.push(K=e?null:[])}function Di(){Ei.pop(),K=Ei[Ei.length-1]||null}var Oi=1;function ki(e,t=!1){Oi+=e,e<0&&K&&t&&(K.hasOnce=!0)}function Ai(e){return e.dynamicChildren=Oi>0?K||n:null,Di(),Oi>0&&K&&K.push(e),e}function J(e,t,n,r,i,a){return Ai(Y(e,t,n,r,i,a,!0))}function ji(e,t,n,r,i){return Ai(X(e,t,n,r,i,!0))}function Mi(e){return e?e.__v_isVNode===!0:!1}function Ni(e,t){return e.type===t.type&&e.key===t.key}var Pi=({key:e})=>e??null,Fi=({ref:e,ref_key:t,ref_for:n})=>(typeof e==`number`&&(e=``+e),e==null?null:g(e)||L(e)||h(e)?{i:V,r:e,k:t,f:!!n}:e);function Y(e,t=null,n=null,r=0,i=null,a=e===G?0:1,o=!1,s=!1){let c={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&Pi(t),ref:t&&Fi(t),scopeId:xn,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:a,patchFlag:r,dynamicProps:i,dynamicChildren:null,appContext:null,ctx:V};return s?(Hi(c,n),a&128&&e.normalize(c)):n&&(c.shapeFlag|=g(n)?8:16),Oi>0&&!o&&K&&(c.patchFlag>0||a&6)&&c.patchFlag!==32&&K.push(c),c}var X=Ii;function Ii(e,t=null,n=null,r=0,i=null,a=!1){if((!e||e===lr)&&(e=wi),Mi(e)){let r=Ri(e,t,!0);return n&&Hi(r,n),Oi>0&&!a&&K&&(r.shapeFlag&6?K[K.indexOf(e)]=r:K.push(r)),r.patchFlag=-2,r}if(da(e)&&(e=e.__vccOpts),t){t=Li(t);let{class:e,style:n}=t;e&&!g(e)&&(t.class=k(e)),v(n)&&(Bt(n)&&!d(n)&&(n=s({},n)),t.style=ue(n))}let o=g(e)?1:xi(e)?128:Fn(e)?64:v(e)?4:h(e)?2:0;return Y(e,t,n,r,i,o,a,!0)}function Li(e){return e?Bt(e)||Yr(e)?s({},e):e:null}function Ri(e,t,n=!1,r=!1){let{props:i,ref:a,patchFlag:o,children:s,transition:c}=e,l=t?Ui(i||{},t):i,u={__v_isVNode:!0,__v_skip:!0,type:e.type,props:l,key:l&&Pi(l),ref:t&&t.ref?n&&a?d(a)?a.concat(Fi(t)):[a,Fi(t)]:Fi(t):a,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:s,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==G?o===-1?16:o|16:o,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:c,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&Ri(e.ssContent),ssFallback:e.ssFallback&&Ri(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return c&&r&&Ln(u,c.clone(u)),u}function zi(e=` `,t=0){return X(Ci,null,e,t)}function Z(e=``,t=!1){return t?(q(),ji(wi,null,e)):X(wi,null,e)}function Bi(e){return e==null||typeof e==`boolean`?X(wi):d(e)?X(G,null,e.slice()):Mi(e)?Vi(e):X(Ci,null,String(e))}function Vi(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:Ri(e)}function Hi(e,t){let n=0,{shapeFlag:r}=e;if(t==null)t=null;else if(d(t))n=16;else if(typeof t==`object`)if(r&65){let n=t.default;n&&(n._c&&(n._d=!1),Hi(e,n()),n._c&&(n._d=!0));return}else{n=32;let r=t._;!r&&!Yr(t)?t._ctx=V:r===3&&V&&(V.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else h(t)?(t={default:t,_ctx:V},n=32):(t=String(t),r&64?(n=16,t=[zi(t)]):n=8);e.children=t,e.shapeFlag|=n}function Ui(...e){let t={};for(let n=0;n<e.length;n++){let r=e[n];for(let e in r)if(e===`class`)t.class!==r.class&&(t.class=k([t.class,r.class]));else if(e===`style`)t.style=ue([t.style,r.style]);else if(a(e)){let n=t[e],i=r[e];i&&n!==i&&!(d(n)&&n.includes(i))?t[e]=n?[].concat(n,i):i:i==null&&n==null&&!o(e)&&(t[e]=i)}else e!==``&&(t[e]=r[e])}return t}function Wi(e,t,n,r=null){nn(e,t,7,[n,r])}var Gi=jr(),Ki=0;function qi(e,n,r){let i=e.type,a=(n?n.appContext:e.appContext)||Gi,o={uid:Ki++,vnode:e,type:i,parent:n,appContext:a,root:null,next:null,subTree:null,effect:null,update:null,job:null,scope:new we(!0),render:null,proxy:null,exposed:null,exposeProxy:null,withProxy:null,provides:n?n.provides:Object.create(a.provides),ids:n?n.ids:[``,0,0],accessCache:null,renderCache:[],components:null,directives:null,propsOptions:ti(i,a),emitsOptions:Rr(i,a),emit:null,emitted:null,propsDefaults:t,inheritAttrs:i.inheritAttrs,ctx:t,data:t,props:t,attrs:t,slots:t,refs:t,setupState:t,setupContext:null,suspense:r,suspenseId:r?r.pendingId:0,asyncDep:null,asyncResolved:!1,isMounted:!1,isUnmounted:!1,isDeactivated:!1,bc:null,c:null,bm:null,m:null,bu:null,u:null,um:null,bum:null,da:null,a:null,rtg:null,rtc:null,ec:null,sp:null};return o.ctx={_:o},o.root=n?n.root:o,o.emit=Ir.bind(null,o),e.ce&&e.ce(o),o}var Q=null,Ji=()=>Q||V,Yi,Xi;{let e=le(),t=(t,n)=>{let r;return(r=e[t])||(r=e[t]=[]),r.push(n),e=>{r.length>1?r.forEach(t=>t(e)):r[0](e)}};Yi=t(`__VUE_INSTANCE_SETTERS__`,e=>Q=e),Xi=t(`__VUE_SSR_SETTERS__`,e=>ea=e)}var Zi=e=>{let t=Q;return Yi(e),e.scope.on(),()=>{e.scope.off(),Yi(t)}},Qi=()=>{Q&&Q.scope.off(),Yi(null)};function $i(e){return e.vnode.shapeFlag&4}var ea=!1;function ta(e,t=!1,n=!1){t&&Xi(t);let{props:r,children:i}=e.vnode,a=$i(e);Xr(e,r,a,t),li(e,i,n||t);let o=a?na(e,t):void 0;return t&&Xi(!1),o}function na(e,t){let n=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,hr);let{setup:r}=n;if(r){He();let n=e.setupContext=r.length>1?ca(e):null,i=Zi(e),a=tn(r,e,0,[e.props,n]),o=y(a);if(Ue(),i(),(o||e.sp)&&!Un(e)&&Rn(e),o){if(a.then(Qi,Qi),t)return a.then(n=>{ra(e,n,t)}).catch(t=>{rn(t,e,0)});e.asyncDep=a}else ra(e,a,t)}else oa(e,t)}function ra(e,t,n){h(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:v(t)&&(e.setupState=Kt(t)),oa(e,n)}var ia,aa;function oa(e,t,n){let i=e.type;if(!e.render){if(!t&&ia&&!i.render){let t=i.template||Sr(e).template;if(t){let{isCustomElement:n,compilerOptions:r}=e.appContext.config,{delimiters:a,compilerOptions:o}=i;i.render=ia(t,s(s({isCustomElement:n,delimiters:a},r),o))}}e.render=i.render||r,aa&&aa(e)}{let t=Zi(e);He();try{vr(e)}finally{Ue(),t()}}}var sa={get(e,t){return N(e,`get`,``),e[t]}};function ca(e){return{attrs:new Proxy(e.attrs,sa),slots:e.slots,emit:e.emit,expose:t=>{e.exposed=t||{}}}}function la(e){return e.exposed?e.exposeProxy||=new Proxy(Kt(Vt(e.exposed)),{get(t,n){if(n in t)return t[n];if(n in pr)return pr[n](e)},has(e,t){return t in e||t in pr}}):e.proxy}function ua(e,t=!0){return h(e)?e.displayName||e.name:e.name||t&&e.__name}function da(e){return h(e)&&`__vccOpts`in e}var $=(e,t)=>Jt(e,t,ea),fa=`3.5.31`,pa=void 0,ma=typeof window<`u`&&window.trustedTypes;if(ma)try{pa=ma.createPolicy(`vue`,{createHTML:e=>e})}catch{}var ha=pa?e=>pa.createHTML(e):e=>e,ga=`http://www.w3.org/2000/svg`,_a=`http://www.w3.org/1998/Math/MathML`,va=typeof document<`u`?document:null,ya=va&&va.createElement(`template`),ba={insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{let t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,r)=>{let i=t===`svg`?va.createElementNS(ga,e):t===`mathml`?va.createElementNS(_a,e):n?va.createElement(e,{is:n}):va.createElement(e);return e===`select`&&r&&r.multiple!=null&&i.setAttribute(`multiple`,r.multiple),i},createText:e=>va.createTextNode(e),createComment:e=>va.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>va.querySelector(e),setScopeId(e,t){e.setAttribute(t,``)},insertStaticContent(e,t,n,r,i,a){let o=n?n.previousSibling:t.lastChild;if(i&&(i===a||i.nextSibling))for(;t.insertBefore(i.cloneNode(!0),n),!(i===a||!(i=i.nextSibling)););else{ya.innerHTML=ha(r===`svg`?`<svg>${e}</svg>`:r===`mathml`?`<math>${e}</math>`:e);let i=ya.content;if(r===`svg`||r===`mathml`){let e=i.firstChild;for(;e.firstChild;)i.appendChild(e.firstChild);i.removeChild(e)}t.insertBefore(i,n)}return[o?o.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}},xa=Symbol(`_vtc`);function Sa(e,t,n){let r=e[xa];r&&(t=(t?[t,...r]:[...r]).join(` `)),t==null?e.removeAttribute(`class`):n?e.setAttribute(`class`,t):e.className=t}var Ca=Symbol(`_vod`),wa=Symbol(`_vsh`),Ta=Symbol(``),Ea=/(?:^|;)\\s*display\\s*:/;function Da(e,t,n){let r=e.style,i=g(n),a=!1;if(n&&!i){if(t)if(g(t))for(let e of t.split(`;`)){let t=e.slice(0,e.indexOf(`:`)).trim();n[t]??ka(r,t,``)}else for(let e in t)n[e]??ka(r,e,``);for(let e in n)e===`display`&&(a=!0),ka(r,e,n[e])}else if(i){if(t!==n){let e=r[Ta];e&&(n+=`;`+e),r.cssText=n,a=Ea.test(n)}}else t&&e.removeAttribute(`style`);Ca in e&&(e[Ca]=a?r.display:``,e[wa]&&(r.display=`none`))}var Oa=/\\s*!important$/;function ka(e,t,n){if(d(n))n.forEach(n=>ka(e,t,n));else if(n??=``,t.startsWith(`--`))e.setProperty(t,n);else{let r=Ma(e,t);Oa.test(n)?e.setProperty(E(r),n.replace(Oa,``),`important`):e[r]=n}}var Aa=[`Webkit`,`Moz`,`ms`],ja={};function Ma(e,t){let n=ja[t];if(n)return n;let r=T(t);if(r!==`filter`&&r in e)return ja[t]=r;r=ie(r);for(let n=0;n<Aa.length;n++){let i=Aa[n]+r;if(i in e)return ja[t]=i}return t}var Na=`http://www.w3.org/1999/xlink`;function Pa(e,t,n,r,i,a=ge(t)){r&&t.startsWith(`xlink:`)?n==null?e.removeAttributeNS(Na,t.slice(6,t.length)):e.setAttributeNS(Na,t,n):n==null||a&&!_e(n)?e.removeAttribute(t):e.setAttribute(t,a?``:_(n)?String(n):n)}function Fa(e,t,n,r,i){if(t===`innerHTML`||t===`textContent`){n!=null&&(e[t]=t===`innerHTML`?ha(n):n);return}let a=e.tagName;if(t===`value`&&a!==`PROGRESS`&&!a.includes(`-`)){let r=a===`OPTION`?e.getAttribute(`value`)||``:e.value,i=n==null?e.type===`checkbox`?`on`:``:String(n);(r!==i||!(`_value`in e))&&(e.value=i),n??e.removeAttribute(t),e._value=n;return}let o=!1;if(n===``||n==null){let r=typeof e[t];r===`boolean`?n=_e(n):n==null&&r===`string`?(n=``,o=!0):r===`number`&&(n=0,o=!0)}try{e[t]=n}catch{}o&&e.removeAttribute(i||t)}function Ia(e,t,n,r){e.addEventListener(t,n,r)}function La(e,t,n,r){e.removeEventListener(t,n,r)}var Ra=Symbol(`_vei`);function za(e,t,n,r,i=null){let a=e[Ra]||(e[Ra]={}),o=a[t];if(r&&o)o.value=r;else{let[n,s]=Va(t);r?Ia(e,n,a[t]=Ga(r,i),s):o&&(La(e,n,o,s),a[t]=void 0)}}var Ba=/(?:Once|Passive|Capture)$/;function Va(e){let t;if(Ba.test(e)){t={};let n;for(;n=e.match(Ba);)e=e.slice(0,e.length-n[0].length),t[n[0].toLowerCase()]=!0}return[e[2]===`:`?e.slice(3):E(e.slice(2)),t]}var Ha=0,Ua=Promise.resolve(),Wa=()=>Ha||=(Ua.then(()=>Ha=0),Date.now());function Ga(e,t){let n=e=>{if(!e._vts)e._vts=Date.now();else if(e._vts<=n.attached)return;nn(Ka(e,n.value),t,5,[e])};return n.value=e,n.attached=Wa(),n}function Ka(e,t){if(d(t)){let n=e.stopImmediatePropagation;return e.stopImmediatePropagation=()=>{n.call(e),e._stopped=!0},t.map(e=>t=>!t._stopped&&e&&e(t))}else return t}var qa=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,Ja=(e,t,n,r,i,s)=>{let c=i===`svg`;t===`class`?Sa(e,r,c):t===`style`?Da(e,n,r):a(t)?o(t)||za(e,t,n,r,s):(t[0]===`.`?(t=t.slice(1),!0):t[0]===`^`?(t=t.slice(1),!1):Ya(e,t,r,c))?(Fa(e,t,r),!e.tagName.includes(`-`)&&(t===`value`||t===`checked`||t===`selected`)&&Pa(e,t,r,c,s,t!==`value`)):e._isVueCE&&(Xa(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!g(r)))?Fa(e,T(t),r,s,t):(t===`true-value`?e._trueValue=r:t===`false-value`&&(e._falseValue=r),Pa(e,t,r,c))};function Ya(e,t,n,r){if(r)return!!(t===`innerHTML`||t===`textContent`||t in e&&qa(t)&&h(n));if(t===`spellcheck`||t===`draggable`||t===`translate`||t===`autocorrect`||t===`sandbox`&&e.tagName===`IFRAME`||t===`form`||t===`list`&&e.tagName===`INPUT`||t===`type`&&e.tagName===`TEXTAREA`)return!1;if(t===`width`||t===`height`){let t=e.tagName;if(t===`IMG`||t===`VIDEO`||t===`CANVAS`||t===`SOURCE`)return!1}return qa(t)&&g(n)?!1:t in e}function Xa(e,t){let n=e._def.props;if(!n)return!1;let r=T(t);return Array.isArray(n)?n.some(e=>T(e)===r):Object.keys(n).some(e=>T(e)===r)}var Za=e=>{let t=e.props[`onUpdate:modelValue`]||!1;return d(t)?e=>oe(t,e):t};function Qa(e){e.target.composing=!0}function $a(e){let t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event(`input`)))}var eo=Symbol(`_assign`);function to(e,t,n){return t&&(e=e.trim()),n&&(e=se(e)),e}var no={created(e,{modifiers:{lazy:t,trim:n,number:r}},i){e[eo]=Za(i);let a=r||i.props&&i.props.type===`number`;Ia(e,t?`change`:`input`,t=>{t.target.composing||e[eo](to(e.value,n,a))}),(n||a)&&Ia(e,`change`,()=>{e.value=to(e.value,n,a)}),t||(Ia(e,`compositionstart`,Qa),Ia(e,`compositionend`,$a),Ia(e,`change`,$a))},mounted(e,{value:t}){e.value=t??``},beforeUpdate(e,{value:t,oldValue:n,modifiers:{lazy:r,trim:i,number:a}},o){if(e[eo]=Za(o),e.composing)return;let s=(a||e.type===`number`)&&!/^0\\d/.test(e.value)?se(e.value):e.value,c=t??``;if(s===c)return;let l=e.getRootNode();(l instanceof Document||l instanceof ShadowRoot)&&l.activeElement===e&&e.type!==`range`&&(r&&t===n||i&&e.value.trim()===c)||(e.value=c)}},ro={deep:!0,created(e,{value:t,modifiers:{number:n}},r){let i=p(t);Ia(e,`change`,()=>{let t=Array.prototype.filter.call(e.options,e=>e.selected).map(e=>n?se(ao(e)):ao(e));e[eo](e.multiple?i?new Set(t):t:t[0]),e._assigning=!0,fn(()=>{e._assigning=!1})}),e[eo]=Za(r)},mounted(e,{value:t}){io(e,t)},beforeUpdate(e,t,n){e[eo]=Za(n)},updated(e,{value:t}){e._assigning||io(e,t)}};function io(e,t){let n=e.multiple,r=d(t);if(!(n&&!r&&!p(t))){for(let i=0,a=e.options.length;i<a;i++){let a=e.options[i],o=ao(a);if(n)if(r){let e=typeof o;e===`string`||e===`number`?a.selected=t.some(e=>String(e)===String(o)):a.selected=be(t,o)>-1}else a.selected=t.has(o);else if(ye(ao(a),t)){e.selectedIndex!==i&&(e.selectedIndex=i);return}}!n&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function ao(e){return`_value`in e?e._value:e.value}var oo=[`ctrl`,`shift`,`alt`,`meta`],so={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>`button`in e&&e.button!==0,middle:e=>`button`in e&&e.button!==1,right:e=>`button`in e&&e.button!==2,exact:(e,t)=>oo.some(n=>e[`${n}Key`]&&!t.includes(n))},co=(e,t)=>{if(!e)return e;let n=e._withMods||={},r=t.join(`.`);return n[r]||(n[r]=((n,...r)=>{for(let e=0;e<t.length;e++){let r=so[t[e]];if(r&&r(n,t))return}return e(n,...r)}))},lo={esc:`escape`,space:` `,up:`arrow-up`,left:`arrow-left`,right:`arrow-right`,down:`arrow-down`,delete:`backspace`},uo=(e,t)=>{let n=e._withKeys||={},r=t.join(`.`);return n[r]||(n[r]=(n=>{if(!(`key`in n))return;let r=E(n.key);if(t.some(e=>e===r||lo[e]===r))return e(n)}))},fo=s({patchProp:Ja},ba),po;function mo(){return po||=di(fo)}var ho=((...e)=>{let t=mo().createApp(...e),{mount:n}=t;return t.mount=e=>{let r=_o(e);if(!r)return;let i=t._component;!h(i)&&!i.render&&!i.template&&(i.template=r.innerHTML),r.nodeType===1&&(r.textContent=``);let a=n(r,!1,go(r));return r instanceof Element&&(r.removeAttribute(`v-cloak`),r.setAttribute(`data-v-app`,``)),a},t});function go(e){if(e instanceof SVGElement)return`svg`;if(typeof MathMLElement==`function`&&e instanceof MathMLElement)return`mathml`}function _o(e){return g(e)?document.querySelector(e):e}function vo(e,t,n){let r=R({...e()}),i=!1;return An(e,e=>{i=!0,r.value={...e},fn(()=>{i=!1})},{deep:!0}),An(r,e=>{i||t(n,{...e})},{deep:!0}),r}function yo(){let e=Pt({});return{state:e,toggle:t=>{e[t]=!e[t]}}}var bo=e=>typeof e==`string`,xo=e=>Array.isArray(e),So=e=>typeof e==`object`&&!!e&&!Array.isArray(e);function Co(e,t){try{return JSON.parse(e)}catch{return t===void 0?e:t}}function wo(e){return e==null?``:typeof e==`object`?JSON.stringify(e):String(e)}function To(e){let t={};for(let n of e){let e=n.category||`general`;t[e]||(t[e]=[]),t[e].push(n)}return t}var Eo=[`onClick`],Do={class:`dict-entry-name`},Oo={class:`dict-entry-type`},ko={key:0,class:`dict-entry-preview`},Ao={class:`dict-entry-actions`},jo=[`onClick`],Mo={key:0,class:`dict-entry-body`},No=[`value`,`onInput`],Po=[`value`,`onChange`],Fo=[`value`,`onInput`],Io={key:2,class:`dict-array-editor`},Lo={key:0,class:`dict-array-object`},Ro={class:`dict-array-object-header`},zo=[`onClick`],Bo={class:`dict-array-field-label`},Vo=[`value`,`onInput`],Ho=[`value`,`onChange`],Uo={class:`add-row`},Wo=[`onUpdate:modelValue`,`onKeyup`],Go=[`onUpdate:modelValue`,`onKeyup`],Ko=[`onClick`,`disabled`],qo={key:1,class:`dict-array-string`},Jo=[`value`,`onChange`],Yo=[`value`,`onInput`],Xo=[`onClick`],Zo={class:`add-row`},Qo=[`onUpdate:modelValue`],$o=[`onUpdate:modelValue`,`onKeyup`],es=[`onClick`],ts=[`value`,`onChange`],ns={key:5,class:`dict-entry-rename`},rs=[`value`,`onChange`],is={class:`add-row`},as=[`disabled`],os={__name:`DictEditor`,props:{value:{type:Object,default:()=>({})},depth:{type:Number,default:0},flat:{type:Boolean,default:!1}},emits:[`update:value`],setup(e,{emit:t}){let n=e,r=vo(()=>n.value,t,`update:value`),{state:i,toggle:a}=yo(),o=R(``),s=R(`string`),c=Pt({}),l=Pt({}),u=Pt({}),d=Pt({}),f=$(()=>Object.keys(r.value));function p(e){return bo(e)?`str`:xo(e)?`list(${e.length})`:So(e)?`{${Object.keys(e).length}}`:typeof e}function m(e,t){r.value[e]=t}function h(e,t){let n=Co(t);n!==t&&(r.value[e]=n)}function g(e){delete r.value[e],r.value={...r.value}}function _(e,t){let n=t.trim();if(!n||n===e)return;let a={};for(let[t,i]of Object.entries(r.value))a[t===e?n:t]=i;r.value=a,i[e]&&(delete i[e],i[n]=!0)}function v(){let e=o.value.trim();!e||r.value[e]!==void 0||(n.flat||s.value===`string`?r.value[e]=``:s.value===`array`?r.value[e]=[]:r.value[e]={},i[e]=!0,o.value=``)}function y(e,t,n){let i=[...r.value[e]];i[t]=n,r.value[e]=i}function b(e,t){let n=[...r.value[e]];n.splice(t,1),r.value[e]=n}function x(e){let t=[...r.value[e]||[]];(c[e]||`string`)===`object`?t.push({}):(t.push(l[e]||``),l[e]=``),r.value[e]=t}function S(e,t,n,i){let a=[...r.value[e]];a[t]={...a[t],[n]:i},r.value[e]=a}function C(e,t,n,r){S(e,t,n,Co(r))}function w(e,t){let n=e+`.`+t,r=(u[n]||``).trim();r&&(S(e,t,r,Co(d[n]||``)),u[n]=``,d[n]=``)}return(t,n)=>{let ee=cr(`DictEditor`,!0);return q(),J(`div`,{class:k([`dict-editor`,{nested:e.depth>0}])},[(q(!0),J(G,null,H(f.value,t=>(q(),J(`div`,{key:t,class:`dict-entry`},[Y(`div`,{class:`collapsible-header`,onClick:e=>z(a)(t)},[Y(`span`,{class:k([`chevron`,{open:z(i)[t]}])},`▶`,2),Y(`span`,Do,A(t),1),e.flat?Z(``,!0):(q(),J(G,{key:0},[Y(`span`,Oo,A(p(z(r)[t])),1),z(bo)(z(r)[t])&&!z(r)[t].includes(`\n`)?(q(),J(`span`,ko,A(z(r)[t]),1)):Z(``,!0)],64)),Y(`div`,Ao,[Y(`button`,{class:`btn btn-secondary btn-xs`,onClick:co(e=>g(t),[`stop`])},`Remove`,8,jo)])],8,Eo),z(i)[t]?(q(),J(`div`,Mo,[e.flat?(q(),J(`input`,{key:0,type:`text`,value:z(wo)(z(r)[t]),onInput:e=>m(t,e.target.value)},null,40,No)):z(bo)(z(r)[t])?(q(),J(G,{key:1},[z(r)[t].includes(`\n`)||z(r)[t].length>100?(q(),J(`textarea`,{key:0,class:`dict-value-input`,value:z(r)[t],onChange:e=>m(t,e.target.value),rows:`4`,spellcheck:`false`},null,40,Po)):(q(),J(`input`,{key:1,type:`text`,value:z(r)[t],onInput:e=>m(t,e.target.value)},null,40,Fo))],64)):z(xo)(z(r)[t])?(q(),J(`div`,Io,[(q(!0),J(G,null,H(z(r)[t],(e,r)=>(q(),J(`div`,{key:r,class:`dict-array-item`},[z(So)(e)?(q(),J(`div`,Lo,[Y(`div`,Ro,[n[2]||=Y(`span`,{class:`dict-entry-type`},`object`,-1),Y(`button`,{class:`btn btn-secondary btn-xs`,onClick:e=>b(t,r)},`Remove`,8,zo)]),(q(!0),J(G,null,H(e,(e,n)=>(q(),J(`div`,{key:n,class:`dict-array-object-field`},[Y(`label`,Bo,A(n),1),z(bo)(e)||typeof e==`number`?(q(),J(`input`,{key:0,type:`text`,value:String(e),onInput:e=>S(t,r,n,e.target.value)},null,40,Vo)):z(xo)(e)?(q(),J(`textarea`,{key:1,class:`dict-value-input`,value:JSON.stringify(e,null,2),onChange:e=>C(t,r,n,e.target.value),rows:`2`,spellcheck:`false`},null,40,Ho)):Z(``,!0)]))),128)),Y(`div`,Uo,[wn(Y(`input`,{\"onUpdate:modelValue\":e=>u[t+`.`+r]=e,type:`text`,placeholder:`key`,class:`add-row-key`,onKeyup:uo(e=>w(t,r),[`enter`])},null,40,Wo),[[no,u[t+`.`+r]]]),wn(Y(`input`,{\"onUpdate:modelValue\":e=>d[t+`.`+r]=e,type:`text`,placeholder:`value`,class:`add-row-val`,onKeyup:uo(e=>w(t,r),[`enter`])},null,40,Go),[[no,d[t+`.`+r]]]),Y(`button`,{class:`btn btn-secondary btn-xs`,onClick:e=>w(t,r),disabled:!(u[t+`.`+r]||``).trim()},`+`,8,Ko)])])):(q(),J(`div`,qo,[z(bo)(e)&&(e.includes(`\n`)||e.length>80)?(q(),J(`textarea`,{key:0,class:`dict-value-input`,value:e,onChange:e=>y(t,r,e.target.value),rows:`3`,spellcheck:`false`},null,40,Jo)):(q(),J(`input`,{key:1,type:`text`,value:String(e),onInput:e=>y(t,r,e.target.value)},null,40,Yo)),Y(`button`,{class:`btn btn-secondary btn-xs`,onClick:e=>b(t,r)},`x`,8,Xo)]))]))),128)),Y(`div`,Zo,[wn(Y(`select`,{\"onUpdate:modelValue\":e=>c[t]=e,class:`add-row-type`},[...n[3]||=[Y(`option`,{value:`string`},`string`,-1),Y(`option`,{value:`object`},`object`,-1)]],8,Qo),[[ro,c[t]]]),c[t]===`object`?Z(``,!0):wn((q(),J(`input`,{key:0,\"onUpdate:modelValue\":e=>l[t]=e,type:`text`,placeholder:`New item...`,class:`add-row-val`,onKeyup:uo(e=>x(t),[`enter`])},null,40,$o)),[[no,l[t]]]),Y(`button`,{class:`btn btn-secondary btn-xs`,onClick:e=>x(t)},`+ item`,8,es)])])):z(So)(z(r)[t])?(q(),ji(ee,{key:3,value:z(r)[t],depth:e.depth+1,\"onUpdate:value\":e=>m(t,e)},null,8,[`value`,`depth`,`onUpdate:value`])):(q(),J(`input`,{key:4,type:`text`,value:JSON.stringify(z(r)[t]),onChange:e=>h(t,e.target.value)},null,40,ts)),e.flat?Z(``,!0):(q(),J(`div`,ns,[n[4]||=Y(`span`,{class:`dict-rename-label`},`key:`,-1),Y(`input`,{type:`text`,value:t,onChange:e=>_(t,e.target.value),class:`dict-rename-input`},null,40,rs)]))])):Z(``,!0)]))),128)),Y(`div`,is,[wn(Y(`input`,{\"onUpdate:modelValue\":n[0]||=e=>o.value=e,type:`text`,placeholder:`New key...`,class:`add-row-key`,onKeyup:uo(v,[`enter`])},null,544),[[no,o.value]]),e.flat?Z(``,!0):wn((q(),J(`select`,{key:0,\"onUpdate:modelValue\":n[1]||=e=>s.value=e,class:`add-row-type`},[...n[5]||=[Y(`option`,{value:`string`},`string`,-1),Y(`option`,{value:`array`},`array`,-1),Y(`option`,{value:`dict`},`submenu`,-1)]],512)),[[ro,s.value]]),Y(`button`,{class:`btn btn-secondary btn-xs`,onClick:v,disabled:!o.value.trim()},`Add`,8,as)])],2)}}},ss={class:`field-row`},cs={class:`field-label`},ls={class:`name`},us={key:0,class:`required`,title:`Required`},ds={key:1,class:`recommended`,title:`Recommended`},fs={class:`description`},ps={class:`type-badge`},ms={class:`field-input`},hs={key:0,class:`toggle-wrap`},gs={class:`toggle`},_s=[`checked`],vs={class:`toggle-label`},ys=[`value`],bs={key:0,value:``},xs=[`value`],Ss=[`value`,`placeholder`,`step`],Cs={key:4},ws=[`value`,`placeholder`],Ts={class:`field-hint`},Es={key:0,class:`json-error`},Ds={key:5},Os=[`value`,`placeholder`],ks=[`value`,`placeholder`],As={__name:`FieldInput`,props:{field:{type:Object,required:!0},value:{default:void 0}},emits:[`update:value`],setup(e,{emit:t}){let n=e,r=t,i=$(()=>(n.field.type||`str`).toLowerCase()),a=$(()=>i.value===`bool`),o=$(()=>i.value===`int`||i.value===`float`),s=$(()=>i.value===`float`),c=$(()=>i.value.startsWith(`list`)),l=$(()=>i.value===`dict`&&!n.field.children?.length),u=$(()=>{if(!c.value)return!1;let e=e=>Array.isArray(e)&&e.length>0&&typeof e[0]==`object`&&e[0]!==null;return e(n.value)||e(n.field.default)}),d=$(()=>n.value===void 0||n.value===null?n.field.default??!1:!!n.value),f=$(()=>n.value===void 0||n.value===null?``:String(n.value)),p=$(()=>n.value===void 0||n.value===null?``:Array.isArray(n.value)?n.value.join(`, `):String(n.value)),m=$(()=>n.value===void 0||n.value===null?``:JSON.stringify(n.value,null,2)),h=R(``),g=$(()=>{if(n.field.default!==void 0&&n.field.default!==null){let e=n.field.default;return typeof e==`object`?JSON.stringify(e,null,2):Array.isArray(e)?e.length?e.join(`, `):`(empty list)`:String(e)}return``});function _(e){r(`update:value`,e===``?void 0:e)}function v(e){if(e===``){r(`update:value`,void 0);return}let t=s.value?parseFloat(e):parseInt(e,10);isNaN(t)||r(`update:value`,t)}function y(e){if(e===``){r(`update:value`,void 0);return}r(`update:value`,e.split(`,`).map(e=>e.trim()).filter(e=>e!==``))}function b(e){if(h.value=``,e.trim()===``){r(`update:value`,void 0);return}try{r(`update:value`,JSON.parse(e))}catch(e){h.value=e.message}}return(t,i)=>(q(),J(`div`,ss,[Y(`div`,cs,[Y(`div`,ls,[zi(A(e.field.name)+` `,1),e.field.required?(q(),J(`span`,us,`*`)):e.field.recommended?(q(),J(`span`,ds,`(rec)`)):Z(``,!0)]),Y(`div`,fs,A(e.field.description),1),Y(`div`,ps,A(e.field.type),1)]),Y(`div`,ms,[a.value?(q(),J(`div`,hs,[Y(`label`,gs,[Y(`input`,{type:`checkbox`,checked:d.value,onChange:i[0]||=e=>r(`update:value`,e.target.checked)},null,40,_s),i[7]||=Y(`span`,{class:`slider`},null,-1)]),Y(`span`,vs,A(d.value?`true`:`false`),1)])):e.field.choices?(q(),J(`select`,{key:1,value:f.value,onChange:i[1]||=e=>_(e.target.value)},[e.field.required?Z(``,!0):(q(),J(`option`,bs,`-- default --`)),(q(!0),J(G,null,H(e.field.choices,e=>(q(),J(`option`,{key:e,value:e},A(e||`(empty)`),9,xs))),128))],40,ys)):o.value?(q(),J(`input`,{key:2,type:`number`,value:f.value,placeholder:g.value,step:s.value?`0.1`:`1`,onInput:i[2]||=e=>v(e.target.value)},null,40,Ss)):l.value?(q(),ji(os,{key:3,value:n.value||{},\"onUpdate:value\":i[3]||=e=>r(`update:value`,e)},null,8,[`value`])):u.value?(q(),J(`div`,Cs,[Y(`textarea`,{class:`json-textarea`,value:m.value,placeholder:g.value||`[]`,onChange:i[4]||=e=>b(e.target.value),rows:`6`,spellcheck:`false`},null,40,ws),Y(`div`,Ts,[i[8]||=zi(` JSON format (list) `,-1),h.value?(q(),J(`span`,Es,A(h.value),1)):Z(``,!0)])])):c.value?(q(),J(`div`,Ds,[Y(`input`,{type:`text`,value:p.value,placeholder:g.value,onInput:i[5]||=e=>y(e.target.value)},null,40,Os),i[9]||=Y(`div`,{class:`field-hint`},`Comma-separated values`,-1)])):(q(),J(`input`,{key:6,type:`text`,value:f.value,placeholder:g.value,onInput:i[6]||=e=>_(e.target.value)},null,40,ks))])]))}},js={class:`plugin-header`},Ms=[`href`],Ns={key:0,class:`plugin-envs`},Ps={key:0},Fs={class:`child-entries-title`},Is={key:0,class:`child-entries-desc`},Ls=[`onClick`],Rs={style:{\"font-size\":`0.9rem`,\"font-family\":`monospace`}},zs={class:`dict-entry-actions`},Bs=[`onClick`],Vs={key:0,class:`dict-entry-body`},Hs={key:0},Us={class:`add-row`},Ws=[`onUpdate:modelValue`,`placeholder`,`onKeyup`],Gs=[`onClick`,`disabled`],Ks={key:1,class:`child-entries`},qs=[`onClick`],Js={style:{\"font-size\":`0.9rem`,\"font-family\":`monospace`}},Ys={class:`dict-entry-actions`},Xs=[`onClick`],Zs={key:0,class:`dict-entry-body`},Qs={class:`add-row`},$s=[`disabled`],ec={key:2,class:`empty-state`},tc={__name:`PluginEditor`,props:{plugin:{type:Object,required:!0},config:{type:Object,required:!0},docsBase:{type:String,default:``}},emits:[`update:config`],setup(e,{emit:t}){let n=e,r=vo(()=>n.config,t,`update:config`),{state:i,toggle:a}=yo(),o=$(()=>(n.plugin.config_schema||[]).filter(e=>e.type===`dict`&&e.children?.length)),s=$(()=>new Set(o.value.map(e=>e.name))),c=$(()=>(n.plugin.config_schema||[]).filter(e=>!s.value.has(e.name))),l=$(()=>To(c.value)),u=Pt({});function d(e){let t=r.value[e];return!t||!So(t)?[]:Object.keys(t).filter(e=>So(t[e]))}function f(e,t,n){return r.value[e]?.[t]?.[n]}function p(e,t,n,i){r.value[e]||(r.value[e]={}),r.value[e][t]||(r.value[e][t]={}),r.value[e][t][n]=i}function m(e,t){r.value[e]&&(delete r.value[e][t],r.value[e]={...r.value[e]}),delete i[e+`.`+t]}function h(e){let t=(u[e]||``).trim();t&&(r.value[e]||(r.value[e]={}),r.value[e][t]||(r.value[e][t]={}),i[e+`.`+t]=!0,u[e]=``)}function g(e,t){let n=r.value[e.name]?.[t];if(!n||!So(n))return{};let i=new Set((e.children||[]).map(e=>e.name)),a={};for(let[e,t]of Object.entries(n))i.has(e)||(a[e]=t);return a}function _(e,t,n){let i=r.value[e]?.[t];if(!i)return;let a=o.value.find(t=>t.name===e),s=new Set((a?.children||[]).map(e=>e.name)),c={};for(let[e,t]of Object.entries(i))s.has(e)&&(c[e]=t);r.value[e][t]={...c,...n}}let v=R(``),y=$(()=>{if(!n.plugin.child_schema)return[];let e=new Set((n.plugin.config_schema||[]).map(e=>e.name));return Object.keys(r.value).filter(t=>So(r.value[t])&&!e.has(t))});function b(e,t,n){r.value[e]||(r.value[e]={}),r.value[e][t]=n}function x(){let e=v.value.trim();e&&(r.value[e]||(r.value[e]={}),i[e]=!0,v.value=``)}function S(e){delete r.value[e],delete i[e]}return(t,n)=>(q(),J(G,null,[Y(`div`,js,[Y(`h2`,null,[zi(A(e.plugin.name)+` `,1),Y(`a`,{href:e.docsBase+e.plugin.name+`.html`,target:`_blank`,rel:`noopener`,class:`docs-link`},`docs ↗`,8,Ms)]),Y(`p`,null,A(e.plugin.description),1),e.plugin.environments.length?(q(),J(`div`,Ns,[(q(!0),J(G,null,H(e.plugin.environments,e=>(q(),J(`span`,{key:e,class:`env-badge`},A(e),1))),128))])):Z(``,!0)]),c.value.length?(q(),J(`div`,Ps,[(q(!0),J(G,null,H(l.value,(e,t)=>(q(),J(`div`,{key:t,class:`category-group`},[Y(`h3`,null,A(t),1),(q(!0),J(G,null,H(e,e=>(q(),ji(As,{key:e.name,field:e,value:z(r)[e.name],\"onUpdate:value\":t=>z(r)[e.name]=t},null,8,[`field`,`value`,`onUpdate:value`]))),128))]))),128))])):Z(``,!0),(q(!0),J(G,null,H(o.value,e=>(q(),J(`div`,{key:e.name,class:`child-entries`},[Y(`h3`,Fs,A(e.name),1),e.description?(q(),J(`p`,Is,A(e.description),1)):Z(``,!0),(q(!0),J(G,null,H(d(e.name),t=>(q(),J(`div`,{key:e.name+`.`+t,class:`dict-entry`},[Y(`div`,{class:`collapsible-header`,onClick:n=>z(a)(e.name+`.`+t)},[Y(`span`,{class:k([`chevron`,{open:z(i)[e.name+`.`+t]}])},`▶`,2),Y(`h4`,Rs,A(t),1),Y(`div`,zs,[Y(`button`,{class:`btn btn-secondary btn-xs`,onClick:co(n=>m(e.name,t),[`stop`])},`Remove`,8,Bs)])],8,Ls),z(i)[e.name+`.`+t]?(q(),J(`div`,Vs,[(q(!0),J(G,null,H(z(To)(e.children),(n,r)=>(q(),J(`div`,{key:r,class:`category-group`},[Y(`h3`,null,A(r),1),(q(!0),J(G,null,H(n,n=>(q(),ji(As,{key:n.name,field:n,value:f(e.name,t,n.name),\"onUpdate:value\":r=>p(e.name,t,n.name,r)},null,8,[`field`,`value`,`onUpdate:value`]))),128))]))),128)),e.children_allow_extra?(q(),J(`div`,Hs,[n[1]||=Y(`h3`,{class:`category-group`,style:{\"font-size\":`0.8rem`,\"text-transform\":`uppercase`,\"letter-spacing\":`0.06em`,color:`var(--text-muted)`,\"margin-bottom\":`12px`,\"padding-bottom\":`6px`,\"border-bottom\":`1px solid var(--border)`}},`extra`,-1),X(os,{value:g(e,t),flat:!0,\"onUpdate:value\":n=>_(e.name,t,n)},null,8,[`value`,`onUpdate:value`])])):Z(``,!0)])):Z(``,!0)]))),128)),Y(`div`,Us,[wn(Y(`input`,{\"onUpdate:modelValue\":t=>u[e.name]=t,type:`text`,placeholder:`New `+e.name+` entry...`,onKeyup:uo(t=>h(e.name),[`enter`])},null,40,Ws),[[no,u[e.name]]]),Y(`button`,{class:`btn btn-secondary btn-xs`,onClick:t=>h(e.name),disabled:!(u[e.name]||``).trim()},`Add`,8,Gs)])]))),128)),e.plugin.child_schema?(q(),J(`div`,Ks,[n[2]||=Y(`h3`,{class:`child-entries-title`},`Entries`,-1),(q(!0),J(G,null,H(y.value,t=>(q(),J(`div`,{key:t,class:`dict-entry`},[Y(`div`,{class:`collapsible-header`,onClick:e=>z(a)(t)},[Y(`span`,{class:k([`chevron`,{open:z(i)[t]}])},`▶`,2),Y(`h4`,Js,A(t),1),Y(`div`,Ys,[Y(`button`,{class:`btn btn-secondary btn-xs`,onClick:co(e=>S(t),[`stop`])},`Remove`,8,Xs)])],8,qs),z(i)[t]?(q(),J(`div`,Zs,[(q(!0),J(G,null,H(z(To)(e.plugin.child_schema),(e,n)=>(q(),J(`div`,{key:n,class:`category-group`},[Y(`h3`,null,A(n),1),(q(!0),J(G,null,H(e,e=>(q(),ji(As,{key:e.name,field:e,value:z(r)[t]?.[e.name],\"onUpdate:value\":n=>b(t,e.name,n)},null,8,[`field`,`value`,`onUpdate:value`]))),128))]))),128))])):Z(``,!0)]))),128)),Y(`div`,Qs,[wn(Y(`input`,{\"onUpdate:modelValue\":n[0]||=e=>v.value=e,type:`text`,placeholder:`New entry name...`,onKeyup:uo(x,[`enter`])},null,544),[[no,v.value]]),Y(`button`,{class:`btn btn-secondary btn-xs`,onClick:x,disabled:!v.value.trim()},`Add`,8,$s)])])):Z(``,!0),!c.value.length&&!e.plugin.child_schema&&!o.value.length?(q(),J(`div`,ec,[...n[3]||=[Y(`p`,null,`This plugin has no configurable options.`,-1)]])):Z(``,!0)],64))}},nc={key:0,class:`loading`},rc={class:`app-header`},ic={class:`action-bar`},ac=[`disabled`],oc=[`disabled`],sc=[`disabled`],cc={class:`app-body`},lc={class:`sidebar`},uc={class:`sidebar-section`},dc={style:{padding:`0 8px`,\"font-size\":`0.75rem`,color:`var(--text-muted)`,\"font-family\":`monospace`,\"word-break\":`break-all`}},fc={class:`sidebar-section`},pc=[`onClick`],mc=[`checked`,`onChange`],hc={class:`plugin-name`},gc={key:0,class:`sidebar-section`},_c={class:`main-content`},vc={key:2,class:`empty-state`},yc={key:0,class:`error-list`,style:{margin:`0 24px 12px`}},bc=`https://hyprland-community.github.io/pyprland/`;ho({__name:`App`,setup(e){let t=R(!0),n=R(!1),r=R(``),i=R(`success`),a=R([]),o=R([]),s=R({}),c=R(``),l=R({});Qn(async()=>{try{let[e,t]=await Promise.all([fetch(`/api/plugins`).then(e=>e.json()),fetch(`/api/config`).then(e=>e.json())]);o.value=e,s.value=t.config||{},c.value=t.path||``,s.value.pyprland||(s.value.pyprland={plugins:[]}),s.value.pyprland.plugins||(s.value.pyprland.plugins=[]),s.value.pyprland.plugins=[...new Set(s.value.pyprland.plugins)],l.value=s.value.pyprland.variables||{}}catch(e){r.value=`Failed to load: `+e.message,i.value=`error`}finally{t.value=!1}});let u=R(null),d=$(()=>[...new Set(s.value.pyprland?.plugins||[])]),f=$(()=>Object.keys(l.value).length>0),p=$(()=>!u.value||u.value===`_variables`?null:o.value.find(e=>e.name===u.value)||null);function m(e){return d.value.includes(e)}function h(e){u.value=e}function g(e){let t=s.value.pyprland.plugins;t.includes(e)?(s.value.pyprland.plugins=t.filter(t=>t!==e),delete s.value[e]):(t.push(e),s.value.pyprland.plugins=[...new Set(t)].sort(),s.value[e]||(s.value[e]={}))}function _(e){return s.value[e]||(s.value[e]={}),s.value[e]}function v(e,t){s.value[e]=t}function y(){let e={...s.value};return e.pyprland={...e.pyprland},Object.keys(l.value).length>0?e.pyprland.variables={...l.value}:delete e.pyprland.variables,e}function b(e,t,n=4e3){r.value=e,i.value=t,a.value=[],n&&setTimeout(()=>{r.value=``},n)}async function x(e,t){n.value=!0;try{let n=await(await fetch(e,{method:`POST`,headers:{\"Content-Type\":`application/json`},body:JSON.stringify({config:y()})})).json();a.value=n.errors||[],t(n)}catch(t){b(`${e.split(`/`).pop()} failed: ${t.message}`,`error`)}finally{n.value=!1}}function S(){x(`/api/validate`,e=>{e.ok?b(`Valid`,`success`):b(`${e.errors.length} issue(s) found`,`warning`,0)})}function C(){x(`/api/save`,e=>{b(e.backup?`Saved (backup: ${e.backup})`:`Saved`,`success`)})}function w(){x(`/api/apply`,e=>{e.daemon_reloaded?b(`Saved & reloaded`,`success`):b(`Saved (daemon not running)`,`warning`)})}return(e,s)=>t.value?(q(),J(`div`,nc,`Loading...`)):(q(),J(G,{key:1},[Y(`header`,rc,[s[4]||=Y(`h1`,null,[Y(`img`,{src:`/icon.png`,alt:``,class:`header-icon`}),zi(`pypr-gui`)],-1),Y(`div`,ic,[r.value?(q(),J(`span`,{key:0,class:k([`status-message`,i.value])},A(r.value),3)):Z(``,!0),Y(`button`,{class:`btn btn-secondary`,onClick:S,disabled:n.value},`Validate`,8,ac),Y(`button`,{class:`btn btn-primary`,onClick:C,disabled:n.value},`Save`,8,oc),Y(`button`,{class:`btn btn-success`,onClick:w,disabled:n.value},`Apply`,8,sc)])]),Y(`div`,cc,[Y(`aside`,lc,[Y(`div`,uc,[s[5]||=Y(`h3`,null,`Config`,-1),Y(`div`,dc,A(c.value),1)]),Y(`div`,fc,[s[6]||=Y(`h3`,null,`Plugins`,-1),(q(!0),J(G,null,H(o.value,e=>(q(),J(`div`,{key:e.name,class:k([`plugin-item`,{active:u.value===e.name,disabled:!m(e.name)}]),onClick:t=>h(e.name)},[Y(`input`,{type:`checkbox`,checked:m(e.name),onClick:s[0]||=co(()=>{},[`stop`]),onChange:t=>g(e.name)},null,40,mc),Y(`span`,hc,A(e.name),1)],10,pc))),128))]),f.value?(q(),J(`div`,gc,[s[8]||=Y(`h3`,null,`Misc`,-1),Y(`div`,{class:k([`plugin-item`,{active:u.value===`_variables`}]),onClick:s[1]||=e=>u.value=`_variables`},[...s[7]||=[Y(`span`,{class:`plugin-name`},`variables`,-1)]],2)])):Z(``,!0)]),Y(`main`,_c,[u.value===`_variables`?(q(),J(G,{key:0},[s[9]||=Y(`div`,{class:`plugin-header`},[Y(`h2`,null,`Variables`),Y(`p`,null,[zi(`Template variables available in plugin configs via `),Y(`code`,null,`[var_name]`),zi(` syntax.`)])],-1),X(os,{value:l.value,flat:!0,\"onUpdate:value\":s[2]||=e=>l.value=e},null,8,[`value`])],64)):u.value&&p.value?(q(),ji(tc,{key:1,plugin:p.value,config:_(u.value),\"docs-base\":bc,\"onUpdate:config\":s[3]||=e=>v(u.value,e)},null,8,[`plugin`,`config`])):(q(),J(`div`,vc,[...s[10]||=[Y(`h3`,null,`Select a plugin`,-1),Y(`p`,null,`Choose a plugin from the sidebar to configure it. Check the checkbox to enable it.`,-1)]]))])]),a.value.length?(q(),J(`ul`,yc,[(q(!0),J(G,null,H(a.value,(e,t)=>(q(),J(`li`,{key:t},A(e),1))),128))])):Z(``,!0)],64))}}).mount(`#app`);"
  },
  {
    "path": "pyprland/gui/static/assets/index-Dpu0NgRN.css",
    "content": ":root{--bg-primary:#1a1b26;--bg-secondary:#24283b;--bg-tertiary:#2f3348;--bg-hover:#3b3f57;--text-primary:#c0caf5;--text-secondary:#a9b1d6;--text-muted:#565f89;--accent:#7aa2f7;--accent-hover:#89b4fa;--success:#9ece6a;--warning:#e0af68;--error:#f7768e;--border:#3b3f57;--radius:6px;--radius-lg:10px}*{box-sizing:border-box;margin:0;padding:0}body{background:var(--bg-primary);color:var(--text-primary);min-height:100vh;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,sans-serif;line-height:1.6}#app{flex-direction:column;min-height:100vh;display:flex}.app-header{background:var(--bg-secondary);border-bottom:1px solid var(--border);z-index:100;justify-content:space-between;align-items:center;padding:12px 24px;display:flex;position:sticky;top:0}.app-header h1{color:var(--accent);align-items:center;gap:8px;font-size:1.2rem;font-weight:600;display:flex}.app-header .header-icon{width:28px;height:28px}.app-header .config-path{color:var(--text-muted);font-family:monospace;font-size:.8rem}.app-body{flex:1;display:flex;overflow:hidden}.sidebar{background:var(--bg-secondary);border-right:1px solid var(--border);width:260px;min-width:260px;padding:12px 0;overflow-y:auto}.sidebar-section{margin-bottom:16px;padding:0 12px}.sidebar-section h3{text-transform:uppercase;letter-spacing:.08em;color:var(--text-muted);margin-bottom:6px;padding:0 8px;font-size:.7rem}.plugin-item{border-radius:var(--radius);cursor:pointer;align-items:center;gap:8px;padding:6px 8px;font-size:.85rem;transition:background .15s;display:flex}.plugin-item:hover{background:var(--bg-hover)}.plugin-item.active{background:var(--bg-tertiary);color:var(--accent)}.plugin-item.disabled{opacity:.5}.plugin-item input[type=checkbox]{accent-color:var(--accent);cursor:pointer}.plugin-item .plugin-name{flex:1}.main-content{flex:1;padding:24px 32px;overflow-y:auto}.plugin-header{margin-bottom:24px}.plugin-header h2{margin-bottom:4px;font-size:1.3rem;font-weight:600}.plugin-header p{color:var(--text-secondary);font-size:.9rem}.docs-link{color:var(--text-muted);vertical-align:middle;margin-left:6px;font-family:monospace;font-size:.75rem;text-decoration:none}.docs-link:hover{color:var(--accent)}.plugin-envs{gap:6px;margin-top:8px;display:flex}.env-badge{background:var(--bg-tertiary);color:var(--text-muted);border:1px solid var(--border);border-radius:99px;padding:2px 8px;font-size:.7rem}.category-group{margin-bottom:24px}.category-group h3{text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);border-bottom:1px solid var(--border);margin-bottom:12px;padding-bottom:6px;font-size:.8rem}.field-row{align-items:flex-start;gap:12px;padding:8px 0;display:flex}.field-row+.field-row{border-top:1px solid var(--bg-tertiary)}.field-label{width:200px;min-width:200px;padding-top:6px}.field-label .name{color:var(--text-primary);font-family:monospace;font-size:.85rem;font-weight:500}.field-label .name .required{color:var(--error);margin-left:2px}.field-label .name .recommended{color:var(--warning);margin-left:2px;font-size:.7rem}.field-label .description{color:var(--text-muted);margin-top:2px;font-size:.75rem}.field-label .type-badge{color:var(--text-muted);opacity:.7;font-family:monospace;font-size:.65rem}.field-input{flex:1;min-width:0}input[type=text],input[type=number],textarea,select{background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius);width:100%;color:var(--text-primary);outline:none;padding:6px 10px;font-family:monospace;font-size:.85rem;transition:border-color .15s}input:focus,textarea:focus,select:focus{border-color:var(--accent)}input::placeholder,textarea::placeholder{color:var(--text-muted);opacity:.6}textarea{resize:vertical;min-height:60px}textarea.json-textarea{tab-size:2;white-space:pre;min-height:120px;font-family:monospace;font-size:.8rem;line-height:1.5}.field-hint{color:var(--text-muted);margin-top:2px;font-size:.7rem}.field-hint .json-error{color:var(--error);margin-left:8px}.child-entries-title{color:var(--text-muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px;font-size:.8rem}.child-entries-desc{color:var(--text-secondary);margin-bottom:12px;font-size:.8rem}select{cursor:pointer;appearance:none;background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23565f89' d='M6 8L1 3h10z'/%3E%3C/svg%3E\");background-position:right 8px center;background-repeat:no-repeat;padding-right:28px}.toggle-wrap{align-items:center;gap:8px;padding-top:6px;display:flex}.toggle{cursor:pointer;width:36px;height:20px;position:relative}.toggle input{display:none}.toggle .slider{background:var(--bg-primary);border:1px solid var(--border);border-radius:20px;transition:all .2s;position:absolute;inset:0}.toggle .slider:before{content:\"\";background:var(--text-muted);border-radius:50%;width:14px;height:14px;transition:all .2s;position:absolute;top:2px;left:2px}.toggle input:checked+.slider{background:var(--accent);border-color:var(--accent)}.toggle input:checked+.slider:before{background:#fff;transform:translate(16px)}.toggle-label{color:var(--text-muted);font-size:.8rem}.btn{border-radius:var(--radius);cursor:pointer;border:none;align-items:center;gap:6px;padding:8px 16px;font-size:.85rem;font-weight:500;transition:all .15s;display:inline-flex}.btn-primary{background:var(--accent);color:var(--bg-primary)}.btn-primary:hover{background:var(--accent-hover)}.btn-secondary{background:var(--bg-tertiary);color:var(--text-primary)}.btn-secondary:hover{background:var(--bg-hover)}.btn-success{background:var(--success);color:var(--bg-primary)}.btn-success:hover{opacity:.9}.btn:disabled{opacity:.5;cursor:not-allowed}.action-bar{align-items:center;gap:12px;display:flex}.status-message{border-radius:var(--radius);padding:6px 12px;font-size:.8rem}.status-message.success{color:var(--success);background:#9ece6a26}.status-message.error{color:var(--error);background:#f7768e26}.status-message.warning{color:var(--warning);background:#e0af6826}.error-list{border-radius:var(--radius);background:#f7768e1a;border:1px solid #f7768e33;margin-top:8px;padding:8px 12px;list-style:none}.error-list li{color:var(--error);margin:4px 0;font-family:monospace;font-size:.8rem}.child-entries{margin-top:12px}.collapsible-header{background:var(--bg-tertiary);cursor:pointer;-webkit-user-select:none;user-select:none;align-items:center;gap:8px;padding:6px 12px;font-size:.85rem;display:flex}.collapsible-header .chevron{color:var(--text-muted);flex-shrink:0;font-size:.7rem;transition:transform .2s}.collapsible-header .chevron.open{transform:rotate(90deg)}.loading{height:200px;color:var(--text-muted);justify-content:center;align-items:center;display:flex}::-webkit-scrollbar{width:8px}::-webkit-scrollbar-track{background:var(--bg-primary)}::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:var(--text-muted)}.empty-state{text-align:center;color:var(--text-muted);padding:48px 24px}.empty-state h3{color:var(--text-secondary);margin-bottom:8px;font-size:1.1rem}.empty-state p{font-size:.85rem}.dict-editor.nested{border-left:2px solid var(--border);margin-left:4px;padding-left:12px}.dict-entry{background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:6px;overflow:hidden}.dict-entry-name{color:var(--text-primary);font-family:monospace;font-weight:500}.dict-entry-type{color:var(--text-muted);font-family:monospace;font-size:.7rem}.dict-entry-preview{min-width:0;color:var(--text-muted);white-space:nowrap;text-overflow:ellipsis;flex:1;font-family:monospace;font-size:.75rem;overflow:hidden}.dict-entry-actions{flex-shrink:0;margin-left:auto}.dict-entry-body{padding:10px 12px}.dict-entry-rename{border-top:1px solid var(--bg-tertiary);align-items:center;gap:6px;margin-top:8px;padding-top:8px;display:flex}.dict-rename-label{color:var(--text-muted);font-size:.7rem}.dict-rename-input{width:200px;padding:3px 8px;font-size:.8rem}.dict-value-input{font-family:monospace;font-size:.8rem}.dict-array-editor{flex-direction:column;gap:6px;display:flex}.dict-array-item{flex-direction:column;display:flex}.dict-array-string{align-items:center;gap:6px;display:flex}.dict-array-string input,.dict-array-string textarea{flex:1}.dict-array-object{background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius);padding:8px 10px}.dict-array-object-header{justify-content:space-between;align-items:center;margin-bottom:6px;display:flex}.dict-array-object-field{align-items:flex-start;gap:8px;margin-bottom:4px;display:flex}.dict-array-field-label{width:100px;min-width:100px;color:var(--text-secondary);padding-top:5px;font-family:monospace;font-size:.8rem}.dict-array-object-field input,.dict-array-object-field textarea{flex:1;font-size:.8rem}.add-row{align-items:center;gap:6px;margin-top:6px;display:flex}.add-row-key{width:160px}.add-row-val{flex:1}.add-row-type{width:90px;padding:4px 6px;font-size:.8rem}.btn-xs{padding:2px 8px;font-size:.75rem}\n"
  },
  {
    "path": "pyprland/gui/static/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <link rel=\"icon\" type=\"image/png\" href=\"/icon.png\" />\n    <title>pypr-gui</title>\n    <script type=\"module\" crossorigin src=\"/assets/index-CX03GsX-.js\"></script>\n    <link rel=\"stylesheet\" crossorigin href=\"/assets/index-Dpu0NgRN.css\">\n  </head>\n  <body>\n    <div id=\"app\"></div>\n  </body>\n</html>\n"
  },
  {
    "path": "pyprland/help.py",
    "content": "\"\"\"Help and documentation functions for pyprland commands.\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING\n\nfrom .commands.discovery import get_all_commands\nfrom .commands.parsing import normalize_command_name\nfrom .commands.tree import build_command_tree, get_display_name, get_parent_prefixes\n\nif TYPE_CHECKING:\n    from .manager import Pyprland\n\n__all__ = [\"get_command_help\", \"get_commands_help\", \"get_help\"]\n\n\ndef get_commands_help(manager: Pyprland) -> dict[str, tuple[str, str]]:\n    \"\"\"Get the available commands and their short documentation.\n\n    Uses command tree to show parent commands with their subcommands.\n    Example: \"wall\" -> (\"<next|pause|clear|rm|cleanup>\", \"wallpapers\")\n\n    Args:\n        manager: The Pyprland manager instance\n\n    Returns:\n        Dict mapping command name to (short_description, source) tuple\n    \"\"\"\n    all_commands = get_all_commands(manager)\n    command_tree = build_command_tree(all_commands)\n\n    result: dict[str, tuple[str, str]] = {}\n\n    for root_name, node in sorted(command_tree.items()):\n        if node.children:\n            # Command with subcommands - show inline\n            subcmds = \"|\".join(sorted(node.children.keys()))\n            # Get source from first child with info, or from parent\n            source = \"\"\n            for child in node.children.values():\n                if child.info:\n                    source = child.info.source\n                    break\n            if not source and node.info:\n                source = node.info.source\n            desc = node.info.short_description if node.info else \"\"\n            result[root_name] = (f\"<{subcmds}> {desc}\".strip(), source)\n        elif node.info:\n            # Regular command\n            result[root_name] = (node.info.short_description, node.info.source)\n\n    return result\n\n\ndef get_help(manager: Pyprland) -> str:\n    \"\"\"Get the help documentation for all commands.\n\n    Commands are grouped by plugin, with built-in commands listed first.\n    Client-only commands (pypr only, not pypr-client) are marked with *.\n\n    Args:\n        manager: The Pyprland manager instance\n    \"\"\"\n    intro = \"\"\"Syntax: pypr [command]\n\nIf the command is omitted, runs the daemon which will start every configured plugin.\n\nAvailable commands:\n\"\"\"\n    commands_help = get_commands_help(manager)\n\n    # Group by source (plugin), merging \"client\" into \"built-in\"\n    by_source: dict[str, list[tuple[str, str, bool]]] = {}\n    for name, (desc, source) in commands_help.items():\n        # Mark client commands and merge into built-in\n        is_pypr_only = source == \"client\"\n        group = \"built-in\" if source in (\"built-in\", \"client\") else source\n        by_source.setdefault(group, []).append((name, desc, is_pypr_only))\n\n    # Build output grouped by source, built-in first\n    lines: list[str] = []\n    sources = sorted(by_source.keys(), key=lambda s: (s != \"built-in\", s))\n\n    for source in sources:\n        # Header with pypr-only note for built-in\n        if source == \"built-in\":\n            lines.append(f\"\\n{source} (* = pypr only):\")\n        else:\n            lines.append(f\"\\n{source}:\")\n\n        # Sort commands: regular first, then pypr-only\n        cmds = sorted(by_source[source], key=lambda x: (x[2], x[0]))\n        for name, desc, is_pypr_only in cmds:\n            prefix = \"* \" if is_pypr_only else \"  \"\n            lines.append(f\"{prefix}{name:20s} {desc}\")\n\n    return intro + \"\\n\".join(lines)\n\n\ndef get_command_help(manager: Pyprland, command: str) -> str:\n    \"\"\"Get detailed help for a specific command.\n\n    Supports space-separated subcommands: \"wall next\" -> looks up \"wall_next\"\n    For parent commands with subcommands, shows list of subcommands.\n\n    Args:\n        manager: The Pyprland manager instance\n        command: Command name to get help for (may contain spaces for subcommands)\n\n    Returns:\n        Full docstring with source indicator, or error message if not found\n    \"\"\"\n    # Handle space-separated subcommands: \"wall next\" -> \"wall_next\"\n    command = normalize_command_name(command)\n    all_commands = get_all_commands(manager)\n    command_tree = build_command_tree(all_commands)\n    parent_prefixes = get_parent_prefixes({name: info.source for name, info in all_commands.items()})\n\n    # Try direct lookup first (e.g., \"wall_next\" or \"toggle\")\n    if command in all_commands:\n        cmd = all_commands[command]\n        doc = cmd.full_description\n        doc_formatted = doc if doc.endswith(\"\\n\") else f\"{doc}\\n\"\n        display_name = get_display_name(command, parent_prefixes)\n        return f\"{display_name} ({cmd.source})\\n\\n{doc_formatted}\"\n\n    # Check if this is a parent command with subcommands\n    if command in command_tree:\n        node = command_tree[command]\n        if node.children:\n            # Show subcommands\n            # Get source from parent info, or first child with info\n            source = node.info.source if node.info else \"\"\n            if not source:\n                for child in node.children.values():\n                    if child.info:\n                        source = child.info.source\n                        break\n            lines = [f\"{command} ({source or 'unknown'})\", \"\"]\n\n            if node.info and node.info.full_description:\n                lines.append(node.info.full_description)\n                lines.append(\"\")\n\n            lines.append(\"Subcommands:\")\n            for subcmd_name, subcmd_node in sorted(node.children.items()):\n                if subcmd_node.info:\n                    lines.append(f\"  {subcmd_name:15s} {subcmd_node.info.short_description}\")\n                else:\n                    lines.append(f\"  {subcmd_name}\")\n            lines.append(\"\")\n            return \"\\n\".join(lines)\n\n    return f\"Unknown command: {command}\\nRun 'pypr help' for available commands.\\n\"\n"
  },
  {
    "path": "pyprland/httpclient.py",
    "content": "\"\"\"HTTP client abstraction with aiohttp fallback to urllib.\n\nThis module provides a unified HTTP client interface that uses aiohttp when\navailable, falling back to Python's built-in urllib otherwise. This allows\nthe wallpapers/online feature to work without the optional aiohttp dependency.\n\nUsage:\n    from pyprland.httpclient import ClientSession, ClientTimeout, ClientError\n\n    async with ClientSession(timeout=ClientTimeout(total=30)) as session:\n        async with session.get(url, params={\"q\": \"test\"}) as response:\n            data = await response.json()\n\nFor type annotations, use the Fallback* types which define the interface:\n    from pyprland.httpclient import FallbackClientSession\n\n    def my_func(session: FallbackClientSession) -> None:\n        ...\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport warnings\nfrom http import HTTPStatus\nfrom typing import TYPE_CHECKING, Any\nfrom urllib.error import HTTPError, URLError\nfrom urllib.parse import urlencode\nfrom urllib.request import Request, urlopen\n\nif TYPE_CHECKING:\n    from collections.abc import Callable, Coroutine\n    from typing import Self\n\n# Try importing aiohttp\ntry:\n    import aiohttp\n\n    HAS_AIOHTTP = True\nexcept ImportError:\n    aiohttp = None  # type: ignore[assignment]\n    HAS_AIOHTTP = False\n\n_fallback_warned = False  # pylint: disable=invalid-name\n\n# HTTP status threshold for client/server errors\nHTTP_ERROR_THRESHOLD = HTTPStatus.BAD_REQUEST  # 400\n\n\n# --- Fallback Implementations ---\n\n\nclass FallbackClientError(Exception):\n    \"\"\"HTTP client error (fallback for aiohttp.ClientError).\"\"\"\n\n\nclass FallbackClientTimeout:\n    \"\"\"Timeout configuration (fallback for aiohttp.ClientTimeout).\"\"\"\n\n    def __init__(self, total: float = 30) -> None:\n        \"\"\"Initialize timeout configuration.\n\n        Args:\n            total: Total timeout in seconds.\n        \"\"\"\n        self.total = total\n\n\nclass FallbackResponse:\n    \"\"\"Response wrapper for urllib (mirrors aiohttp response interface).\"\"\"\n\n    def __init__(self, status: int, url: str, data: bytes) -> None:\n        \"\"\"Initialize response.\n\n        Args:\n            status: HTTP status code.\n            url: Final URL after redirects.\n            data: Response body as bytes.\n        \"\"\"\n        self.status = status\n        self.url = url\n        self._data = data\n\n    async def json(self) -> Any:\n        \"\"\"Parse response body as JSON.\n\n        Returns:\n            Parsed JSON data.\n        \"\"\"\n        return json.loads(self._data.decode(\"utf-8\"))\n\n    async def read(self) -> bytes:\n        \"\"\"Read response body as bytes.\n\n        Returns:\n            Response body.\n        \"\"\"\n        return self._data\n\n    def raise_for_status(self) -> None:\n        \"\"\"Raise ClientError if status code indicates an error.\"\"\"\n        if self.status >= HTTP_ERROR_THRESHOLD:\n            msg = f\"HTTP {self.status}\"\n            raise FallbackClientError(msg)\n\n\nclass _AsyncRequestContext:\n    \"\"\"Async context manager that executes request on enter.\"\"\"\n\n    def __init__(self, coro_fn: Callable[[], Coroutine[Any, Any, FallbackResponse]]) -> None:\n        \"\"\"Initialize context manager.\n\n        Args:\n            coro_fn: Coroutine function to execute on enter.\n        \"\"\"\n        self._coro_fn = coro_fn\n\n    async def __aenter__(self) -> FallbackResponse:\n        \"\"\"Execute request and return response.\"\"\"\n        return await self._coro_fn()\n\n    async def __aexit__(self, *args: object) -> None:\n        \"\"\"Exit context manager.\"\"\"\n\n\nclass FallbackClientSession:\n    \"\"\"Minimal aiohttp.ClientSession replacement using urllib.\"\"\"\n\n    def __init__(\n        self,\n        timeout: FallbackClientTimeout | None = None,\n        headers: dict[str, str] | None = None,\n    ) -> None:\n        \"\"\"Initialize session.\n\n        Args:\n            timeout: Timeout configuration.\n            headers: Default headers for all requests.\n        \"\"\"\n        global _fallback_warned  # noqa: PLW0603\n        if not _fallback_warned:\n            warnings.warn(\n                \"aiohttp not installed, using urllib fallback (slower)\",\n                UserWarning,\n                stacklevel=2,\n            )\n            _fallback_warned = True\n\n        self._timeout = timeout.total if timeout else 30\n        self._default_headers = headers or {}\n        self.closed = False\n\n    def get(\n        self,\n        url: str,\n        *,\n        params: dict[str, str] | None = None,\n        headers: dict[str, str] | None = None,\n        allow_redirects: bool = True,  # noqa: ARG002  # pylint: disable=unused-argument\n    ) -> _AsyncRequestContext:\n        \"\"\"Start a GET request.\n\n        Args:\n            url: URL to request.\n            params: Query parameters.\n            headers: Request headers (merged with session defaults).\n            allow_redirects: Whether to follow redirects (urllib always follows).\n\n        Returns:\n            Async context manager yielding the response.\n        \"\"\"\n        # Build full URL with params\n        if params:\n            url = f\"{url}?{urlencode(params)}\"\n\n        # Merge headers\n        all_headers = {**self._default_headers, **(headers or {})}\n\n        # Create request\n        request = Request(url, headers=all_headers, method=\"GET\")  # noqa: S310\n\n        # Execute in thread pool\n        async def _do_request() -> FallbackResponse:\n            try:\n                response = await asyncio.to_thread(urlopen, request, timeout=self._timeout)\n                data = response.read()\n                return FallbackResponse(\n                    status=response.status,\n                    url=response.url,  # Final URL after redirects\n                    data=data,\n                )\n            except HTTPError as e:\n                # HTTP error responses (4xx, 5xx)\n                return FallbackResponse(\n                    status=e.code,\n                    url=url,\n                    data=e.read() if e.fp else b\"\",\n                )\n            except URLError as e:\n                raise FallbackClientError(str(e.reason)) from e\n            except TimeoutError:\n                msg = \"Request timed out\"\n                raise FallbackClientError(msg) from None\n\n        # Return context manager that awaits the request\n        return _AsyncRequestContext(_do_request)\n\n    async def close(self) -> None:\n        \"\"\"Close the session.\"\"\"\n        self.closed = True\n\n    async def __aenter__(self) -> Self:\n        \"\"\"Enter async context manager.\"\"\"\n        return self\n\n    async def __aexit__(self, *args: object) -> None:\n        \"\"\"Exit async context manager.\"\"\"\n        await self.close()\n\n\ndef reset_fallback_warning() -> None:\n    \"\"\"Reset the fallback warning flag (for testing).\"\"\"\n    global _fallback_warned  # noqa: PLW0603\n    _fallback_warned = False  # pylint: disable=invalid-name\n\n\n# --- Unified Exports ---\n\nif HAS_AIOHTTP and aiohttp is not None:\n    ClientSession = aiohttp.ClientSession\n    ClientTimeout = aiohttp.ClientTimeout\n    ClientError = aiohttp.ClientError\nelse:\n    ClientSession = FallbackClientSession  # type: ignore[assignment,misc]\n    ClientTimeout = FallbackClientTimeout  # type: ignore[assignment,misc]\n    ClientError = FallbackClientError  # type: ignore[assignment,misc]\n\n__all__ = [\n    \"HAS_AIOHTTP\",\n    \"ClientError\",\n    \"ClientSession\",\n    \"ClientTimeout\",\n    \"FallbackClientError\",\n    \"FallbackClientSession\",\n    \"FallbackClientTimeout\",\n    \"FallbackResponse\",\n    \"reset_fallback_warning\",\n]\n"
  },
  {
    "path": "pyprland/ipc.py",
    "content": "\"\"\"IPC communication with Hyprland and Niri compositors.\n\nProvides async context managers for socket connections and request/response\nhandling. Includes retry logic for transient connection issues and event\nstream support for compositor events.\n\nKey exports:\n- hyprctl_connection: Context manager for Hyprland IPC\n- niri_connection: Context manager for Niri IPC\n- get_response: Send command and receive JSON response\n- get_event_stream: Open persistent event stream connection\n\"\"\"\n\n__all__ = [\n    \"get_response\",\n    \"hyprctl_connection\",\n    \"niri_connection\",\n    \"niri_request\",\n    \"retry_on_reset\",\n]\n\nimport asyncio\nimport json\nimport os\nfrom collections.abc import AsyncGenerator, Callable\nfrom contextlib import asynccontextmanager\nfrom logging import Logger\nfrom typing import Any, cast\n\nfrom .common import IPC_FOLDER, get_logger\nfrom .constants import IPC_MAX_RETRIES, IPC_RETRY_DELAY_MULTIPLIER\nfrom .models import JSONResponse, PyprError\n\n\nclass _IpcState:\n    \"\"\"Module-level state container to avoid global statements.\"\"\"\n\n    log: Logger | None = None\n    notify_method: str = \"auto\"\n\n\n_state = _IpcState()\n\nHYPRCTL = f\"{IPC_FOLDER}/.socket.sock\"\nEVENTS = f\"{IPC_FOLDER}/.socket2.sock\"\nNIRI_SOCKET = os.environ.get(\"NIRI_SOCKET\")\n\n\n@asynccontextmanager\nasync def hyprctl_connection(logger: Logger) -> AsyncGenerator[tuple[asyncio.StreamReader, asyncio.StreamWriter], None]:\n    \"\"\"Context manager for the hyprctl socket.\n\n    Args:\n        logger: Logger to use for error reporting\n    \"\"\"\n    try:\n        reader, writer = await asyncio.open_unix_connection(HYPRCTL)\n    except FileNotFoundError as e:\n        logger.critical(\"hyprctl socket not found! is it running ?\")\n        raise PyprError from e\n\n    try:\n        yield reader, writer\n    finally:\n        writer.close()\n        await writer.wait_closed()\n\n\n@asynccontextmanager\nasync def niri_connection(logger: Logger) -> AsyncGenerator[tuple[asyncio.StreamReader, asyncio.StreamWriter], None]:\n    \"\"\"Context manager for the niri socket.\n\n    Args:\n        logger: Logger to use for error reporting\n    \"\"\"\n    if not NIRI_SOCKET:\n        logger.critical(\"NIRI_SOCKET not set!\")\n        msg = \"Niri is not available\"\n        raise PyprError(msg)\n    try:\n        reader, writer = await asyncio.open_unix_connection(NIRI_SOCKET)\n    except FileNotFoundError as e:\n        logger.critical(\"niri socket not found! is it running ?\")\n        raise PyprError from e\n\n    try:\n        yield reader, writer\n    finally:\n        writer.close()\n        await writer.wait_closed()\n\n\ndef set_notify_method(method: str) -> None:\n    \"\"\"Set the notification method.\n\n    Args:\n        method: The method to use (\"auto\", \"native\", \"notify-send\")\n    \"\"\"\n    _state.notify_method = method\n\n\nasync def get_event_stream() -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:\n    \"\"\"Return a new event socket connection.\"\"\"\n    if NIRI_SOCKET:\n        async with niri_connection(_state.log or get_logger()) as (reader, writer):\n            writer.write(b'\"EventStream\"')\n            await writer.drain()\n            # We must return the reader/writer, so we detach them from the context manager?\n            # actually niri_connection closes on exit.\n            # We can't use the context manager here easily if we want to return the stream.\n            # Let's just duplicate the connection logic for this specific case or adapt niri_connection\n\n    if NIRI_SOCKET:\n        reader, writer = await asyncio.open_unix_connection(NIRI_SOCKET)\n        writer.write(b'\"EventStream\"')\n        await writer.drain()\n        return reader, writer\n\n    return await asyncio.open_unix_connection(EVENTS)\n\n\ndef retry_on_reset(func: Callable) -> Callable:\n    \"\"\"Retry on reset wrapper.\n\n    Args:\n        func: The function to wrap\n    \"\"\"\n\n    async def wrapper(*args, log: Logger | None = None, logger: Logger | None = None, **kwargs) -> Any:\n        # Support both 'log' and 'logger' parameter names\n        effective_log = log or logger\n        if effective_log is None and args and hasattr(args[0], \"log\"):\n            effective_log = args[0].log\n        assert effective_log is not None\n        exc = None\n        for count in range(IPC_MAX_RETRIES):\n            try:\n                # Pass as 'log' for backend methods, 'logger' for IPC functions\n                if \"log\" in func.__code__.co_varnames:\n                    return await func(*args, **kwargs, log=effective_log)\n                return await func(*args, **kwargs, logger=effective_log)\n            except ConnectionResetError as e:\n                exc = e\n                effective_log.warning(\"ipc connection problem, retrying...\")\n                await asyncio.sleep(IPC_RETRY_DELAY_MULTIPLIER * count)\n        effective_log.error(\"ipc connection failed.\")\n        raise ConnectionResetError from exc\n\n    return wrapper\n\n\n@retry_on_reset\nasync def niri_request(payload: str | dict | list, logger: Logger) -> JSONResponse:\n    \"\"\"Send request to Niri and return response.\"\"\"\n    async with niri_connection(logger) as (reader, writer):\n        writer.write(json.dumps(payload).encode())\n        await writer.drain()\n        response = await reader.readline()\n        if not response:\n            msg = \"Empty response from Niri\"\n            raise PyprError(msg)\n        return cast(\"JSONResponse\", json.loads(response))\n\n\nasync def get_response(command: bytes, logger: Logger) -> JSONResponse:\n    \"\"\"Get response of `command` from the IPC socket.\n\n    Args:\n        command: The command to send as bytes\n        logger: Logger to use for the connection\n    \"\"\"\n    async with hyprctl_connection(logger) as (reader, writer):\n        writer.write(command)\n        await writer.drain()\n        reader_data = await reader.read()\n\n    decoded_data = reader_data.decode(\"utf-8\", errors=\"replace\")\n    return cast(\"JSONResponse\", json.loads(decoded_data))\n\n\ndef init() -> None:\n    \"\"\"Initialize logging.\"\"\"\n    _state.log = get_logger(\"ipc\")\n"
  },
  {
    "path": "pyprland/ipc_paths.py",
    "content": "\"\"\"IPC path management and constants.\"\"\"\n\nimport contextlib\nimport os\nfrom pathlib import Path\n\n__all__ = [\n    \"HYPRLAND_INSTANCE_SIGNATURE\",\n    \"IPC_FOLDER\",\n    \"MINIMUM_ADDR_LEN\",\n    \"MINIMUM_FULL_ADDR_LEN\",\n    \"init_ipc_folder\",\n]\n\nHYPRLAND_INSTANCE_SIGNATURE = os.environ.get(\"HYPRLAND_INSTANCE_SIGNATURE\")\nNIRI_SOCKET = os.environ.get(\"NIRI_SOCKET\")\n\nMINIMUM_ADDR_LEN = 4  # Minimum length for address without \"0x\" prefix\nMINIMUM_FULL_ADDR_LEN = 3  # Minimum length for full address with \"0x\" prefix (e.g., \"0x1\")\n\nMAX_SOCKET_FILE_LEN = 15\nMAX_SOCKET_PATH_LEN = 107\n\n# Determine IPC_FOLDER based on environment priority: Hyprland > Niri > Standalone\n_ORIGINAL_IPC_FOLDER: str | None = None\n\nif HYPRLAND_INSTANCE_SIGNATURE:\n    # Hyprland environment\n    try:\n        _ORIGINAL_IPC_FOLDER = (\n            f\"{os.environ['XDG_RUNTIME_DIR']}/hypr/{HYPRLAND_INSTANCE_SIGNATURE}\"\n            if Path(f\"{os.environ.get('XDG_RUNTIME_DIR', '')}/hypr/{HYPRLAND_INSTANCE_SIGNATURE}\").exists()\n            else f\"/tmp/hypr/{HYPRLAND_INSTANCE_SIGNATURE}\"  # noqa: S108\n        )\n\n        if len(_ORIGINAL_IPC_FOLDER) >= MAX_SOCKET_PATH_LEN - MAX_SOCKET_FILE_LEN:\n            IPC_FOLDER = f\"/tmp/.pypr-{HYPRLAND_INSTANCE_SIGNATURE}\"  # noqa: S108\n        else:\n            IPC_FOLDER = _ORIGINAL_IPC_FOLDER\n\n    except KeyError:\n        # XDG_RUNTIME_DIR not set - use /tmp fallback\n        IPC_FOLDER = f\"/tmp/hypr/{HYPRLAND_INSTANCE_SIGNATURE}\"  # noqa: S108\n        _ORIGINAL_IPC_FOLDER = IPC_FOLDER\n\nelif NIRI_SOCKET:\n    # Niri environment - use parent directory of NIRI_SOCKET\n    IPC_FOLDER = str(Path(NIRI_SOCKET).parent)\n\nelse:\n    # Standalone fallback - no environment detected\n    IPC_FOLDER = os.environ.get(\"XDG_DATA_HOME\", str(Path(\"~/.local/share\").expanduser()))\n\n\ndef init_ipc_folder() -> None:\n    \"\"\"Initialize the IPC folder.\n\n    For Hyprland with shortened paths, creates a symlink.\n    For other cases, the folder should already exist or will be created by the daemon.\n    \"\"\"\n    if HYPRLAND_INSTANCE_SIGNATURE and _ORIGINAL_IPC_FOLDER and _ORIGINAL_IPC_FOLDER != IPC_FOLDER and not Path(IPC_FOLDER).exists():\n        with contextlib.suppress(OSError):\n            Path(IPC_FOLDER).symlink_to(_ORIGINAL_IPC_FOLDER)\n"
  },
  {
    "path": "pyprland/logging_setup.py",
    "content": "\"\"\"Logging setup and utilities.\"\"\"\n\nimport logging\nfrom typing import ClassVar\n\nfrom .ansi import LogStyles, make_style, should_colorize\nfrom .debug import is_debug, set_debug\n\n__all__ = [\n    \"LogObjects\",\n    \"get_logger\",\n    \"init_logger\",\n]\n\n\nclass LogObjects:\n    \"\"\"Reusable objects for loggers.\"\"\"\n\n    handlers: ClassVar[list[logging.Handler]] = []\n\n\ndef init_logger(filename: str | None = None, force_debug: bool = False) -> None:\n    \"\"\"Initialize the logging system.\n\n    Args:\n        filename: Optional filename to log to\n        force_debug: If True, force debug level\n    \"\"\"\n    if force_debug:\n        set_debug(True)\n\n    class ScreenLogFormatter(logging.Formatter):\n        \"\"\"A custom formatter, adding colors based on log level.\n\n        Respects NO_COLOR environment variable and TTY detection.\n        \"\"\"\n\n        LOG_FORMAT = r\"%(name)25s - %(message)s // %(filename)s:%(lineno)d\" if is_debug() else r\"%(message)s\"\n\n        def __init__(self) -> None:\n            super().__init__()\n            use_colors = should_colorize()\n            if use_colors:\n                warn_pre, warn_suf = make_style(*LogStyles.WARNING)\n                err_pre, err_suf = make_style(*LogStyles.ERROR)\n                crit_pre, crit_suf = make_style(*LogStyles.CRITICAL)\n            else:\n                warn_pre = warn_suf = err_pre = err_suf = crit_pre = crit_suf = \"\"\n\n            self._formatters = {\n                logging.DEBUG: logging.Formatter(self.LOG_FORMAT),\n                logging.INFO: logging.Formatter(self.LOG_FORMAT),\n                logging.WARNING: logging.Formatter(warn_pre + self.LOG_FORMAT + warn_suf),\n                logging.ERROR: logging.Formatter(err_pre + self.LOG_FORMAT + err_suf),\n                logging.CRITICAL: logging.Formatter(crit_pre + self.LOG_FORMAT + crit_suf),\n            }\n\n        def format(self, record: logging.LogRecord) -> str:\n            return self._formatters[record.levelno].format(record)\n\n    logging.basicConfig()\n    if filename:\n        file_handler = logging.FileHandler(filename)\n        file_handler.setFormatter(logging.Formatter(fmt=r\"%(asctime)s [%(levelname)s] %(name)s :: %(message)s :: %(filename)s:%(lineno)d\"))\n        LogObjects.handlers.append(file_handler)\n    stream_handler = logging.StreamHandler()\n    stream_handler.setFormatter(ScreenLogFormatter())\n    LogObjects.handlers.append(stream_handler)\n\n\ndef get_logger(name: str = \"pypr\", level: int | None = None) -> logging.Logger:\n    \"\"\"Return a named logger.\n\n    Args:\n        name (str): logger's name\n        level (int): logger's level (auto if not set)\n\n    Returns:\n        The logger instance\n    \"\"\"\n    logger = logging.getLogger(name)\n    if level is None:\n        logger.setLevel(logging.DEBUG if is_debug() else logging.WARNING)\n    else:\n        logger.setLevel(level)\n    logger.propagate = False\n    for handler in LogObjects.handlers:\n        logger.addHandler(handler)\n    logger.info('Logger \"%s\" initialized', name)\n    return logger\n"
  },
  {
    "path": "pyprland/manager.py",
    "content": "\"\"\"Core daemon orchestrator for Pyprland.\n\nThe Pyprland class manages:\n- Plugin lifecycle (loading, initialization, configuration, cleanup)\n- Backend selection (Hyprland, Niri, Wayland, X11 fallbacks)\n- Event dispatching from compositor to plugins\n- Command handling from CLI client via Unix socket\n- Task queuing for sequential plugin command execution\n\"\"\"\n\nimport asyncio\nimport contextlib\nimport importlib\nimport inspect\nimport os\nimport signal\nimport subprocess\nimport sys\nfrom collections.abc import Callable\nfrom functools import partial\nfrom typing import Any, cast\n\nfrom .adapters.backend import EnvironmentBackend\nfrom .adapters.hyprland import HyprlandBackend\nfrom .adapters.niri import NiriBackend\nfrom .adapters.proxy import BackendProxy\nfrom .adapters.wayland import WaylandBackend\nfrom .adapters.xorg import XorgBackend\nfrom .aioops import aiexists, aiunlink, graceful_cancel_tasks\nfrom .ansi import HandlerStyles, colorize\nfrom .common import SharedState, get_logger\nfrom .config import Configuration\nfrom .config_loader import ConfigLoader\nfrom .constants import (\n    CONTROL,\n    DEMO_NOTIFICATION_DURATION_MS,\n    ERROR_NOTIFICATION_DURATION_MS,\n    PYPR_DEMO,\n    TASK_TIMEOUT,\n)\nfrom .ipc import set_notify_method\nfrom .models import Environment, PyprError, ReloadReason, ResponsePrefix\nfrom .plugins.interface import Plugin\nfrom .plugins.pyprland.schema import PYPRLAND_CONFIG_SCHEMA\n\n__all__: list[str] = [\"Pyprland\"]\n\n\ndef remove_duplicate(names: list[str]) -> Callable:\n    \"\"\"Decorator that removes duplicated calls to handlers in `names`.\n\n    Will check arguments as well.\n\n    Args:\n        names: List of handler names to check for duplicates\n    \"\"\"\n\n    def _remove_duplicates(func: Callable) -> Callable:\n        \"\"\"Wrapper for the decorator.\"\"\"\n\n        async def _wrapper(self: \"Pyprland\", full_name: str, *args, **kwargs) -> tuple[bool, str]:\n            \"\"\"Wrapper for the function.\"\"\"\n            if full_name in names:\n                key = (full_name, args)\n                if key == self.dedup_last_call.get(full_name):\n                    return (True, \"\")\n                self.dedup_last_call[full_name] = key\n            return cast(\"tuple[bool, str]\", await func(self, full_name, *args, **kwargs))\n\n        return _wrapper\n\n    return _remove_duplicates\n\n\nclass Pyprland:  # pylint: disable=too-many-instance-attributes\n    \"\"\"Main app object.\"\"\"\n\n    server: asyncio.Server\n    event_reader: asyncio.StreamReader | None = None\n    stopped = False\n    tasks: list[asyncio.Task]\n    tasks_group: None | asyncio.TaskGroup = None\n    log_handler: Callable[[Plugin, str, tuple], None]\n    dedup_last_call: dict[str, tuple[str, tuple[str, ...]]]\n    _pyprland_conf: Configuration\n    _config_loader: ConfigLoader\n    _shared_backend: EnvironmentBackend | None\n    _backend_selected: bool\n    state: SharedState\n\n    def __init__(self) -> None:\n        self.pyprland_mutex_event = asyncio.Event()\n        self.pyprland_mutex_event.set()\n        self.config: dict[str, Any] = {}\n        self.tasks = []\n        self.plugins: dict[str, Plugin] = {}\n        self.log = get_logger()\n        self._config_loader = ConfigLoader(self.log)\n        self.queues: dict[str, asyncio.Queue] = {}\n        self.dedup_last_call = {}\n        self.state = SharedState()\n        self._shared_backend: EnvironmentBackend | None = None\n        self._backend_selected = False\n\n        # Try socket-based detection first (sync)\n        if os.environ.get(\"NIRI_SOCKET\"):\n            self.state.environment = Environment.NIRI\n            self._shared_backend = NiriBackend(self.state)\n            self._backend_selected = True\n        elif os.environ.get(\"HYPRLAND_INSTANCE_SIGNATURE\"):\n            self.state.environment = Environment.HYPRLAND\n            self._shared_backend = HyprlandBackend(self.state)\n            self._backend_selected = True\n        else:\n            # Fallback detection will happen in initialize() (async)\n            # Use a temporary backend for early error notifications\n            self._shared_backend = None\n\n        # Manager's own proxy for notifications and event parsing\n        # Will be updated in initialize() if fallback backend is selected\n        if self._shared_backend:\n            self.backend: BackendProxy = BackendProxy(self._shared_backend, self.log)\n        signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))\n\n    async def initialize(self) -> None:\n        \"\"\"Initialize the main structures.\"\"\"\n        # Complete backend selection if needed (fallback detection)\n        if not self._backend_selected:\n            await self._select_fallback_backend()\n\n        try:\n            await self.load_config()  # ensure sockets are connected first\n        except KeyError as e:\n            # Config file is invalid\n            txt = f\"Failed to load config, missing {e} section\"\n            self.log.critical(txt)\n            await self.backend.notify_error(txt, duration=ERROR_NOTIFICATION_DURATION_MS)\n            raise PyprError from e\n\n    async def _select_fallback_backend(self) -> None:\n        \"\"\"Select a fallback backend when no socket-based environment is detected.\n\n        Tries wlr-randr (Wayland) first, then xrandr (X11).\n        \"\"\"\n        # Try generic Wayland (wlr-randr)\n        if await WaylandBackend.is_available():\n            self.state.environment = Environment.WAYLAND\n            self._shared_backend = WaylandBackend(self.state)\n            self.log.info(\"Using generic Wayland backend (wlr-randr) - degraded mode\")\n        # Try X11 (xrandr)\n        elif await XorgBackend.is_available():\n            self.state.environment = Environment.XORG\n            self._shared_backend = XorgBackend(self.state)\n            self.log.info(\"Using X11/Xorg backend (xrandr) - degraded mode\")\n        else:\n            # No backend available\n            msg = \"No supported environment detected\"\n            self.log.error(\"%s. Requires Hyprland, Niri, wlr-randr (Wayland), or xrandr (X11).\", msg)\n            raise RuntimeError(msg)\n\n        self._backend_selected = True\n        self.backend = BackendProxy(self._shared_backend, self.log)\n\n    async def _load_single_plugin(self, name: str, init: bool) -> bool:\n        \"\"\"Load a single plugin, optionally calling `init`.\n\n        Args:\n            name: Plugin name\n            init: Whether to initialize the plugin\n        \"\"\"\n        if \".\" in name:\n            modname = name\n        elif \"external:\" in name:\n            modname = name.replace(\"external:\", \"\")\n        else:\n            modname = f\"pyprland.plugins.{name}\"\n        try:\n            plug = importlib.import_module(modname).Extension(name)\n            desktop = self._pyprland_conf.get_str(\"desktop\") or self.state.environment\n            if plug.environments and desktop not in plug.environments:\n                self.log.info(\"Skipping plugin %s: desktop %s not supported %s\", name, desktop, plug.environments)\n                return False\n            plug.state = self.state\n            # Each plugin gets its own BackendProxy with its own logger\n            assert self._shared_backend is not None, \"Backend not initialized\"\n            plug.backend = BackendProxy(self._shared_backend, plug.log)\n            if init:\n                await plug.init()\n                self.queues[name] = asyncio.Queue()\n                if self.tasks_group:\n                    self.tasks_group.create_task(self._plugin_runner_loop(name))\n            self.plugins[name] = plug\n        except ModuleNotFoundError as e:\n            self.log.exception(\"Unable to locate plugin called '%s'\", name)\n            await self.backend.notify_info(f'Config requires plugin \"{name}\" but pypr can\\'t find it: {e}')\n            return False\n        except (ImportError, AttributeError, TypeError, ValueError) as e:\n            await self.backend.notify_info(f\"Error loading plugin {name}: {e}\")\n            self.log.exception(\"Error loading plugin %s:\", name)\n            raise PyprError from e\n        return True\n\n    async def _init_plugin(self, name: str) -> None:\n        \"\"\"Initialize and configure a single plugin.\n\n        Args:\n            name: Plugin name\n        \"\"\"\n        try:\n            await self.plugins[name].load_config(self.config)\n            # Validate configuration if plugin has a schema\n            validation_errors = self.plugins[name].validate_config()\n            for error in validation_errors:\n                self.log.error(error)\n            if validation_errors:\n                await self.backend.notify_error(f\"Plugin '{name}' has {len(validation_errors)} config error(s). Check logs for details.\")\n            await asyncio.wait_for(self.plugins[name].on_reload(ReloadReason.INIT), timeout=TASK_TIMEOUT / 2)\n        except TimeoutError:\n            self.plugins[name].log.info(\"timed out on reload\")\n        except (AttributeError, TypeError, ValueError, RuntimeError, KeyError) as e:\n            await self.backend.notify_info(f\"Error initializing plugin {name}: {e}\")\n            self.log.exception(\"Error initializing plugin %s:\", name)\n            raise PyprError from e\n        else:\n            self.plugins[name].log.info(\"configured\")\n\n    async def __load_plugins_config(self, init: bool = True) -> None:\n        \"\"\"Load the plugins mentioned in the config.\n\n        If init is `True`, call the `init()` method on each plugin.\n\n        Args:\n            init: Whether to initialize the plugins\n        \"\"\"\n        sys.path.extend(self.config[\"pyprland\"].get(\"plugins_paths\", []))\n\n        init_pyprland = \"pyprland\" not in self.plugins\n\n        current_plugins = set(self.plugins.keys())\n        new_plugins = set([\"pyprland\"] + self.config[\"pyprland\"][\"plugins\"])\n        for name in current_plugins - new_plugins:\n            self.log.info(\"Unloading plugin %s\", name)\n            plugin = self.plugins.pop(name)\n            await plugin.exit()\n            if name in self.queues:\n                await self.queues.pop(name).put(None)\n\n        for name in [\"pyprland\"] + self.config[\"pyprland\"][\"plugins\"]:\n            if name not in self.plugins and not await self._load_single_plugin(name, init):\n                continue\n            if init:\n                await self._init_plugin(name)\n        if init_pyprland:\n            plug = self.plugins[\"pyprland\"]\n            plug.manager = self\n\n    async def load_config(self, init: bool = True) -> None:\n        \"\"\"Load the configuration (new plugins will be added & config updated).\n\n        if `init` is true, also initializes the plugins\n\n        Args:\n            init: Whether to initialize the plugins\n        \"\"\"\n        self.config = await self._config_loader.load()\n        assert self.config\n\n        # Send any deferred notifications from config loading (e.g., legacy path warnings)\n        for message, duration in self._config_loader.deferred_notifications:\n            await self.backend.notify_info(message, duration=duration)\n        self._config_loader.deferred_notifications.clear()\n\n        # Wrap pyprland section with schema for proper default handling\n        self._pyprland_conf = Configuration(\n            self.config.get(\"pyprland\", {}),\n            logger=self.log,\n            schema=PYPRLAND_CONFIG_SCHEMA,\n        )\n\n        notification_type = self._pyprland_conf.get_str(\"notification_type\")\n        if notification_type and notification_type != \"auto\":\n            set_notify_method(notification_type)\n\n        await self.__load_plugins_config(init=init)\n\n        # After plugins loaded, use the actual plugin's config (which has schema applied)\n        colored_logs = self.plugins[\"pyprland\"].config.get_bool(\"colored_handlers_log\")\n        self.log_handler = self.colored_log_handler if colored_logs else self.plain_log_handler\n\n    def plain_log_handler(self, plugin: Plugin, name: str, params: tuple[str]) -> None:\n        \"\"\"Log a handler method without color.\n\n        Args:\n            plugin: The plugin instance\n            name: The handler name\n            params: Parameters passed to the handler\n        \"\"\"\n        plugin.log.debug(\"%s%s\", name, params)\n\n    def colored_log_handler(self, plugin: Plugin, name: str, params: tuple[str]) -> None:\n        \"\"\"Log a handler method with color.\n\n        Args:\n            plugin: The plugin instance\n            name: The handler name\n            params: Parameters passed to the handler\n        \"\"\"\n        style = HandlerStyles.COMMAND if name.startswith(\"run_\") else HandlerStyles.EVENT\n        plugin.log.debug(colorize(f\"{name}{params}\", *style))\n\n    async def _run_plugin_handler(self, plugin: Plugin, full_name: str, params: tuple[str, ...]) -> tuple[bool, str]:\n        \"\"\"Run a single handler on a plugin.\n\n        Args:\n            plugin: The plugin instance\n            full_name: The full name of the handler\n            params: Parameters to pass to the handler\n\n        Returns:\n            A tuple of (success, message).\n            On success: message contains handler return value (if string) or empty.\n            On failure: message contains error description.\n        \"\"\"\n        self.log_handler(plugin, full_name, params)\n        try:\n            handler = getattr(plugin, full_name)\n            if inspect.iscoroutinefunction(handler):\n                result = await handler(*params)\n            else:\n                result = handler(*params)\n        except AssertionError as e:\n            self.log.exception(\"This could be a bug in Pyprland, if you think so, report on https://github.com/fdev31/pyprland/issues\")\n            error_msg = f\"Integrity check failed on {plugin.name}::{full_name}: {e}\"\n            await self.backend.notify_error(f\"Pypr {error_msg}\")\n            return (False, error_msg)\n        except Exception as e:  # pylint: disable=W0718\n            self.log.exception(\"%s::%s(%s) failed:\", plugin.name, full_name, params)\n            error_msg = f\"{plugin.name}::{full_name}: {e}\"\n            await self.backend.notify_error(f\"Pypr error {error_msg}\")\n            if os.environ.get(\"PYPRLAND_STRICT_ERRORS\"):\n                raise\n            return (False, error_msg)\n\n        return_data = result if isinstance(result, str) else \"\"\n        return (True, return_data)\n\n    async def _run_plugin_handler_with_result(\n        self, plugin: Plugin, full_name: str, params: tuple[str, ...], future: asyncio.Future[tuple[bool, str]]\n    ) -> None:\n        \"\"\"Run handler and set result on future for queued commands.\n\n        Args:\n            plugin: The plugin instance\n            full_name: The full name of the handler\n            params: Parameters to pass to the handler\n            future: Future to set result on\n        \"\"\"\n        try:\n            result = await self._run_plugin_handler(plugin, full_name, params)\n            if not future.done():\n                future.set_result(result)\n        except Exception as e:  # noqa: BLE001  # pylint: disable=broad-exception-caught\n            if not future.done():\n                future.set_result((False, f\"{plugin.name}::{full_name}: {e}\"))\n\n    async def _dispatch_to_plugin(self, plugin: Plugin, full_name: str, params: tuple[str, ...], wait: bool) -> tuple[bool, str]:\n        \"\"\"Dispatch a handler call to a non-pyprland plugin.\n\n        Args:\n            plugin: The plugin instance\n            full_name: The full name of the handler\n            params: Parameters to pass to the handler\n            wait: If True, wait for handler completion\n\n        Returns:\n            A tuple of (success, message).\n            On success: message contains handler return value (if string) or empty.\n            On failure: message contains error description.\n        \"\"\"\n        if wait:\n            # Commands: queue and wait for result\n            future: asyncio.Future[tuple[bool, str]] = asyncio.get_running_loop().create_future()\n            cmd_task = partial(self._run_plugin_handler_with_result, plugin, full_name, params, future)\n            await self.queues[plugin.name].put(cmd_task)\n            try:\n                success, msg = await asyncio.wait_for(future, timeout=TASK_TIMEOUT)\n                return (success, msg)  # noqa: TRY300\n            except TimeoutError:\n                error_msg = f\"{plugin.name}::{full_name}: Command timed out\"\n                self.log.exception(error_msg)\n                return (False, error_msg)\n        # Events: queue and continue (fire and forget)\n        event_task = partial(self._run_plugin_handler, plugin, full_name, params)\n        await self.queues[plugin.name].put(event_task)\n        return (True, \"\")\n\n    async def _handle_single_plugin(self, plugin: Plugin, full_name: str, params: tuple[str, ...], wait: bool) -> tuple[bool, str]:\n        \"\"\"Handle a single plugin's handler invocation.\n\n        Args:\n            plugin: The plugin instance\n            full_name: The full name of the handler\n            params: Parameters to pass to the handler\n            wait: If True, wait for handler completion\n\n        Returns:\n            A tuple of (success, message).\n        \"\"\"\n        if plugin.name == \"pyprland\":\n            # pyprland plugin executes directly (built-in commands)\n            return await self._run_plugin_handler(plugin, full_name, params)\n        if not plugin.aborted:\n            return await self._dispatch_to_plugin(plugin, full_name, params, wait)\n        return (True, \"\")\n\n    @remove_duplicate(names=[\"event_activewindow\", \"event_activewindowv2\"])\n    async def _call_handler(self, full_name: str, *params: str, notify: str = \"\", wait: bool = False) -> tuple[bool, bool, str]:\n        \"\"\"Call an event handler with params.\n\n        Args:\n            full_name: The full name of the handler\n            *params: Parameters to pass to the handler\n            notify: Notification message if handler not found\n            wait: If True, wait for handler completion and return result (for commands)\n\n        Returns:\n            A tuple of (handled, success, message).\n            - handled: True if at least one handler was found\n            - success: True if all handlers succeeded (only meaningful when handled=True)\n            - message: Error if failed, return data if succeeded (for commands with wait=True)\n        \"\"\"\n        handled = False\n        result_msg = \"\"\n        error_msg = \"\"\n        for plugin in self.plugins.values():\n            if not hasattr(plugin, full_name):\n                continue\n            handled = True\n            success, msg = await self._handle_single_plugin(plugin, full_name, params, wait)\n            if success:\n                if msg and not result_msg:\n                    result_msg = msg\n            elif not error_msg:\n                error_msg = msg\n        if notify and not handled:\n            error_msg = f'Unknown command \"{notify}\". Try \"help\" for available commands.'\n            await self.backend.notify_info(error_msg)\n        # Return (handled, success, message)\n        if error_msg:\n            return (handled, False, error_msg)\n        return (handled, True, result_msg)\n\n    async def read_events_loop(self) -> None:\n        \"\"\"Consume the event loop and calls corresponding handlers.\"\"\"\n        if self.event_reader is None:\n            return\n        while not self.stopped:\n            try:\n                data = (await self.event_reader.readline()).decode(errors=\"replace\")\n            except RuntimeError:\n                self.log.exception(\"Aborting event loop\")\n                return\n            except UnicodeDecodeError:\n                self.log.exception(\"Invalid unicode while reading events\")\n                continue\n            if not data:\n                self.log.critical(\"Reader starved\")\n                return\n\n            parsed_event = self.backend.parse_event(data)\n            if parsed_event:\n                await self._call_handler(*parsed_event)\n\n    async def exit_plugins(self) -> None:\n        \"\"\"Exit all plugins.\"\"\"\n        active_plugins = (p.exit() for p in self.plugins.values() if not p.aborted)\n        await asyncio.wait_for(asyncio.gather(*active_plugins), timeout=TASK_TIMEOUT / 2)\n\n    async def _abort_plugins(self, writer: asyncio.StreamWriter) -> None:\n        \"\"\"Abort all plugins and stop the server.\n\n        Args:\n            writer: The stream writer to close\n        \"\"\"\n        await self.exit_plugins()\n\n        # Cancel all tasks except ourselves (we're in self.tasks too)\n        current_task = asyncio.current_task()\n        other_tasks = [t for t in self.tasks if t is not current_task]\n        await graceful_cancel_tasks(other_tasks, timeout=1.0)\n\n        writer.close()\n        await writer.wait_closed()\n        for q in self.queues.values():\n            await q.put(None)\n        self.server.close()\n        # Ensure the process exits\n        await asyncio.sleep(1)\n        if await aiexists(CONTROL):\n            await aiunlink(CONTROL)\n        os._exit(0)\n\n    def _has_handler(self, handler_name: str) -> bool:\n        \"\"\"Check if any plugin has the given handler.\n\n        Args:\n            handler_name: The full handler name (e.g., \"run_wall_next\")\n\n        Returns:\n            True if at least one plugin has this handler\n        \"\"\"\n        return any(hasattr(plugin, handler_name) for plugin in self.plugins.values())\n\n    def _resolve_handler(self, tokens: list[str]) -> tuple[str, list[str]]:\n        \"\"\"Resolve command tokens to a handler name using cascading lookup.\n\n        Tries progressively more specific handler names, e.g., for [\"wall\", \"next\", \"foo\"]:\n        1. Try run_wall_next_foo\n        2. Try run_wall_next (found) -> remaining args: [\"foo\"]\n        3. Try run_wall -> remaining args: [\"next\", \"foo\"]\n\n        Args:\n            tokens: Command tokens from input (e.g., [\"wall\", \"next\", \"foo\"])\n\n        Returns:\n            Tuple of (handler_name, remaining_args).\n            If no handler found, returns (run_<first_token>, remaining_tokens).\n        \"\"\"\n        for i in range(len(tokens), 0, -1):\n            candidate = \"_\".join(tokens[:i])\n            handler_name = f\"run_{candidate}\"\n            if self._has_handler(handler_name):\n                return (handler_name, tokens[i:])\n\n        # No handler found - return first token as command for error handling\n        return (f\"run_{tokens[0]}\", tokens[1:])\n\n    async def _process_plugin_command(self, data: str) -> str:\n        \"\"\"Process a plugin command and return the response.\n\n        Args:\n            data: The command string\n\n        Returns:\n            Response string to send to client\n        \"\"\"\n        tokens = data.split()\n        if not tokens:\n            return f\"{ResponsePrefix.ERROR}: Empty command\\n\"\n\n        # Cascading lookup: try most specific handler first\n        full_name, remaining = self._resolve_handler(tokens)\n\n        # Join remaining tokens as single arg string (preserves current behavior)\n        args = (\" \".join(remaining),) if remaining else ()\n\n        # Extract command name for notification (without run_ prefix)\n        cmd = full_name[4:]\n\n        if PYPR_DEMO:\n            subprocess.run(  # noqa: ASYNC221\n                [\"notify-send\", \"-t\", str(DEMO_NOTIFICATION_DURATION_MS), data],\n                check=False,\n            )\n\n        handled, success, msg = await self._call_handler(full_name, *args, notify=cmd, wait=True)\n        if not handled:\n            self.log.warning(\"No such command: %s\", cmd)\n            return f\"{ResponsePrefix.ERROR}: {msg}\\n\"\n        if not success:\n            return f\"{ResponsePrefix.ERROR}: {msg}\\n\"\n        # Success - msg contains return data (if any)\n        if msg:\n            return f\"{ResponsePrefix.OK}\\n{msg}\"\n        return f\"{ResponsePrefix.OK}\\n\"\n\n    async def _handle_exit_cleanup(self, writer: asyncio.StreamWriter) -> None:\n        \"\"\"Handle exit command cleanup after plugin dispatch.\n\n        Args:\n            writer: The stream writer\n        \"\"\"\n        writer.write(f\"{ResponsePrefix.OK}\\n\".encode())\n        with contextlib.suppress(BrokenPipeError, ConnectionResetError):\n            await writer.drain()\n        self.tasks.append(asyncio.create_task(self._abort_plugins(writer)))\n\n    async def read_command(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:\n        \"\"\"Receive a socket command.\n\n        Args:\n            reader: The stream reader\n            writer: The stream writer\n        \"\"\"\n        data = (await reader.readline()).decode()\n\n        if not data:\n            self.log.warning(\"Empty command received\")\n            writer.write(f\"{ResponsePrefix.ERROR}: No command provided\\n\".encode())\n        else:\n            data = data.strip()\n            response = await self._process_plugin_command(data)\n            # Check if exit command was processed (sets self.stopped)\n            if self.stopped:\n                await self._handle_exit_cleanup(writer)\n                return\n            writer.write(response.encode())\n\n        with contextlib.suppress(BrokenPipeError, ConnectionResetError):\n            await writer.drain()\n        writer.close()\n\n    async def serve(self) -> None:\n        \"\"\"Run the server.\"\"\"\n        async with self.server:\n            await self.server.wait_closed()\n\n    async def _execute_queued_task(self, name: str, task: partial) -> None:\n        \"\"\"Execute a single queued task with timeout and error handling.\n\n        Args:\n            name: Plugin name for logging\n            task: The task to execute\n        \"\"\"\n        try:\n            await asyncio.wait_for(task(), timeout=TASK_TIMEOUT)\n        except asyncio.CancelledError:\n            self.log.warning(\"Task cancelled for plugin %s\", name)\n        except TimeoutError:\n            self.log.exception(\"Timeout running plugin %s::%s\", name, task)\n        except Exception:  # pylint: disable=W0718\n            self.log.exception(\"Unhandled error running plugin %s::%s\", name, task)\n            if os.environ.get(\"PYPRLAND_STRICT_ERRORS\"):\n                raise\n\n    async def _plugin_runner_loop(self, name: str) -> None:\n        \"\"\"Run tasks for a given plugin indefinitely.\n\n        Args:\n            name: Plugin name\n        \"\"\"\n        q = self.queues[name]\n        is_pyprland = name == \"pyprland\"\n\n        while not self.stopped:\n            if not is_pyprland:\n                await self.pyprland_mutex_event.wait()\n            try:\n                task = await q.get()\n                if task is None:\n                    return\n                if is_pyprland:\n                    self.pyprland_mutex_event.clear()\n            except RuntimeError:\n                self.log.exception(\"Aborting [%s] loop\", name)\n                return\n            await self._execute_queued_task(name, task)\n            if is_pyprland and q.empty():\n                self.pyprland_mutex_event.set()\n\n    async def plugins_runner(self) -> None:\n        \"\"\"Run plugins' task using the created `tasks` TaskGroup attribute.\"\"\"\n        async with asyncio.TaskGroup() as group:\n            self.tasks_group = group\n            for name in self.plugins:\n                self.tasks.append(group.create_task(self._plugin_runner_loop(name)))\n\n    async def run(self) -> None:\n        \"\"\"Run the server and the event listener.\"\"\"\n        tasks = [\n            asyncio.create_task(self.serve()),\n            asyncio.create_task(self.plugins_runner()),\n        ]\n        if self.event_reader:\n            tasks.append(asyncio.create_task(self.read_events_loop()))\n        await asyncio.gather(*tasks)\n"
  },
  {
    "path": "pyprland/models.py",
    "content": "\"\"\"Type definitions and data models for Pyprland.\n\nProvides TypedDict definitions matching Hyprland's JSON API responses:\n- ClientInfo: Window/client properties\n- MonitorInfo: Monitor/output properties\n- WorkspaceDf: Workspace identifier\n\nAlso includes:\n- Environment: Supported compositor/display server types\n- ResponsePrefix: Protocol constants for daemon-client communication\n- ExitCode: Standard CLI exit codes\n- ReloadReason: Context for plugin on_reload() calls\n- PyprError: Base exception for logged errors\n\"\"\"\n\nfrom dataclasses import dataclass\nfrom enum import Enum, IntEnum, StrEnum, auto\nfrom typing import TypedDict\n\nPlainTypes = float | str | dict[str, \"PlainTypes\"] | list[\"PlainTypes\"]\nJSONResponse = dict[str, PlainTypes] | list[dict[str, PlainTypes]] | PlainTypes\n\n\nclass RetensionTimes(float, Enum):\n    \"\"\"Cache retension times.\"\"\"\n\n    SHORT = 0.005\n    LONG = 0.05\n\n\nclass WorkspaceDf(TypedDict):\n    \"\"\"Workspace definition.\"\"\"\n\n    id: int\n    name: str\n\n\nClientInfo = TypedDict(\n    \"ClientInfo\",\n    {\n        \"address\": str,\n        \"mapped\": bool,\n        \"hidden\": bool,\n        \"at\": tuple[int, int],\n        \"size\": tuple[int, int],\n        \"workspace\": WorkspaceDf,\n        \"floating\": bool,\n        \"monitor\": int,\n        \"class\": str,\n        \"title\": str,\n        \"initialClass\": str,\n        \"initialTitle\": str,\n        \"pid\": int,\n        \"xwayland\": bool,\n        \"pinned\": bool,\n        \"fullscreen\": bool,\n        \"fullscreenMode\": int,\n        \"fakeFullscreen\": bool,\n        \"grouped\": list[str],\n        \"swallowing\": str,\n        \"focusHistoryID\": int,\n    },\n)\n\"\"\"Client information as returned by Hyprland.\"\"\"\n\n\nclass MonitorInfo(TypedDict):\n    \"\"\"Monitor information as returned by Hyprland.\"\"\"\n\n    id: int\n    name: str\n    description: str\n    make: str\n    model: str\n    serial: str\n    width: int\n    height: int\n    refreshRate: float\n    x: int\n    y: int\n    activeWorkspace: WorkspaceDf\n    specialWorkspace: WorkspaceDf\n    reserved: list[int]\n    scale: float\n    transform: int\n    focused: bool\n    dpmsStatus: bool\n    vrr: bool\n    activelyTearing: bool\n    disabled: bool\n    currentFormat: str\n    availableModes: list[str]\n\n    to_disable: bool\n\n\n@dataclass(order=True)\nclass VersionInfo:\n    \"\"\"Stores version information.\"\"\"\n\n    major: int = 0\n    minor: int = 0\n    micro: int = 0\n\n\nclass PyprError(BaseException):\n    \"\"\"Used for errors which already triggered logging.\"\"\"\n\n\n# Exit codes for client\nclass ExitCode(IntEnum):\n    \"\"\"Standard exit codes for pypr client.\"\"\"\n\n    SUCCESS = 0\n    USAGE_ERROR = 1  # No command provided, invalid arguments\n    ENV_ERROR = 2  # Missing environment variables\n    CONNECTION_ERROR = 3  # Cannot connect to daemon\n    COMMAND_ERROR = 4  # Command execution failed\n\n\n# Socket response protocol\nclass ResponsePrefix(StrEnum):\n    \"\"\"Response prefixes for daemon-client communication.\"\"\"\n\n    OK = \"OK\"\n    ERROR = \"ERROR\"\n\n\nclass ReloadReason(Enum):\n    \"\"\"Reason for plugin reload/reconfiguration.\n\n    Allows plugins to optimize behavior based on reload context:\n    - INIT: First load during daemon startup (after init())\n    - RELOAD: Configuration reload (pypr reload, pypr set, or plugin self-restart)\n    \"\"\"\n\n    INIT = auto()\n    RELOAD = auto()\n\n\nclass Environment(StrEnum):\n    \"\"\"Supported compositor/display server environments.\n\n    Used for:\n    - Plugin compatibility filtering (Plugin.environments)\n    - Runtime environment detection (SharedState.environment)\n    - Quickstart wizard environment selection\n    \"\"\"\n\n    HYPRLAND = \"hyprland\"\n    NIRI = \"niri\"\n    WAYLAND = \"wayland\"  # Generic Wayland (no specific compositor)\n    XORG = \"xorg\"  # X11/Xorg fallback\n"
  },
  {
    "path": "pyprland/plugins/__init__.py",
    "content": "\"\"\"Built-in plugins for Pyprland.\n\nThis package contains all bundled plugins. Each plugin module exports an\nExtension class that inherits from Plugin (pyprland.plugins.interface).\nPlugins are loaded dynamically based on the 'plugins' list in config.\n\"\"\"\n\n__all__ = [\"interface\"]\n"
  },
  {
    "path": "pyprland/plugins/experimental.py",
    "content": "\"\"\"Plugin template.\"\"\"\n\nfrom ..models import Environment\nfrom .interface import Plugin\n\n\nclass Extension(Plugin, environments=[Environment.HYPRLAND]):\n    \"\"\"Sample plugin template.\"\"\"\n"
  },
  {
    "path": "pyprland/plugins/expose.py",
    "content": "\"\"\"expose Brings every client window to screen for selection.\"\"\"\n\nfrom ..models import ClientInfo, Environment, ReloadReason\nfrom ..validation import ConfigField, ConfigItems\nfrom .interface import Plugin\n\n\nclass Extension(Plugin, environments=[Environment.HYPRLAND]):\n    \"\"\"Exposes all windows for a quick 'jump to' feature.\"\"\"\n\n    config_schema = ConfigItems(\n        ConfigField(\"include_special\", bool, default=False, description=\"Include windows from special workspaces\", category=\"basic\"),\n    )\n\n    exposed: list[ClientInfo]\n\n    async def on_reload(self, reason: ReloadReason = ReloadReason.RELOAD) -> None:\n        \"\"\"Initialize exposed list on reload.\"\"\"\n        _ = reason  # unused\n        self.exposed = []\n\n    @property\n    def exposed_clients(self) -> list[ClientInfo]:\n        \"\"\"Returns the list of clients currently using exposed mode.\"\"\"\n        if self.get_config_bool(\"include_special\"):\n            return self.exposed\n        return [c for c in self.exposed if c[\"workspace\"][\"id\"] > 0]\n\n    async def run_expose(self) -> None:\n        \"\"\"Expose every client on the active workspace.\n\n        If expose is active restores everything and move to the focused window\n        \"\"\"\n        if self.exposed:\n            commands = [\n                f\"movetoworkspacesilent {client['workspace']['name']},address:{client['address']}\" for client in self.exposed_clients\n            ]\n            commands.extend(\n                (\n                    \"togglespecialworkspace exposed\",\n                    f\"focuswindow address:{self.state.active_window}\",\n                )\n            )\n            await self.backend.execute(commands)\n            self.exposed = []\n        else:\n            self.exposed = await self.get_clients(workspace_bl=self.state.active_workspace)\n            commands = []\n            for client in self.exposed_clients:\n                commands.append(f\"movetoworkspacesilent special:exposed,address:{client['address']}\")\n            commands.append(\"togglespecialworkspace exposed\")\n            await self.backend.execute(commands)\n"
  },
  {
    "path": "pyprland/plugins/fcitx5_switcher.py",
    "content": "\"\"\"A plugin to auto-switch Fcitx5 input method status by window class/title.\"\"\"\n\nfrom ..models import Environment\nfrom ..validation import ConfigField, ConfigItems\nfrom .interface import Plugin\n\n\nclass Extension(Plugin, environments=[Environment.HYPRLAND]):\n    \"\"\"A plugin to auto-switch Fcitx5 input method status by window class/title.\"\"\"\n\n    config_schema = ConfigItems(\n        ConfigField(\"active_classes\", list, default=[], description=\"Window classes that should activate Fcitx5\", category=\"activation\"),\n        ConfigField(\"active_titles\", list, default=[], description=\"Window titles that should activate Fcitx5\", category=\"activation\"),\n        ConfigField(\n            \"inactive_classes\", list, default=[], description=\"Window classes that should deactivate Fcitx5\", category=\"deactivation\"\n        ),\n        ConfigField(\n            \"inactive_titles\", list, default=[], description=\"Window titles that should deactivate Fcitx5\", category=\"deactivation\"\n        ),\n    )\n\n    async def event_activewindowv2(self, _addr: str) -> None:\n        \"\"\"A plugin to auto-switch Fcitx5 input method status by window class/title.\n\n        Args:\n            _addr: The address of the active window\n        \"\"\"\n        _addr = \"0x\" + _addr\n\n        active_classes = self.get_config_list(\"active_classes\")\n        active_titles = self.get_config_list(\"active_titles\")\n        inactive_classes = self.get_config_list(\"inactive_classes\")\n        inactive_titles = self.get_config_list(\"inactive_titles\")\n\n        clients = await self.get_clients()\n        for client in clients:\n            if client[\"address\"] == _addr:\n                if client[\"class\"] in active_classes or client[\"title\"] in active_titles:\n                    await self.backend.execute([\"execr fcitx5-remote -o\"])\n                if client[\"class\"] in inactive_classes or client[\"title\"] in inactive_titles:\n                    await self.backend.execute([\"execr fcitx5-remote -c\"])\n"
  },
  {
    "path": "pyprland/plugins/fetch_client_menu.py",
    "content": "\"\"\"Select a client window and move it to the active workspace.\"\"\"\n\nfrom ..adapters.menus import MenuMixin\nfrom ..common import is_rotated\nfrom ..models import Environment, ReloadReason\nfrom ..validation import ConfigField, ConfigItems\nfrom .interface import Plugin\n\n\nclass Extension(MenuMixin, Plugin, environments=[Environment.HYPRLAND]):\n    \"\"\"Shows a menu to select and fetch a window to your active workspace.\"\"\"\n\n    config_schema = ConfigItems(\n        *MenuMixin.menu_config_schema,\n        ConfigField(\"separator\", str, default=\"|\", description=\"Separator between window number and title\", category=\"appearance\"),\n        ConfigField(\n            \"center_on_fetch\",\n            bool,\n            default=True,\n            description=\"Center the fetched window on the focused monitor (floating)\",\n            category=\"behavior\",\n        ),\n        ConfigField(\n            \"margin\", int, default=60, description=\"Margin from monitor edges in pixels when centering/resizing\", category=\"behavior\"\n        ),\n    )\n\n    _windows_origins: dict[str, str]\n\n    async def on_reload(self, reason: ReloadReason = ReloadReason.RELOAD) -> None:\n        \"\"\"Initialize windows origins dict on reload.\"\"\"\n        _ = reason  # unused\n        self._windows_origins = {}\n\n    async def _center_window_on_monitor(self, address: str) -> None:\n        \"\"\"Center a window on the focused monitor, resizing if needed.\n\n        Forces the window to float, resizes if it exceeds monitor bounds\n        (accounting for margin), and centers it on the focused monitor.\n        Handles rotated monitors by swapping width/height.\n\n        Args:\n            address: The window address to center.\n        \"\"\"\n        monitor = await self.get_focused_monitor_or_warn()\n        if monitor is None:\n            return\n\n        # Get window properties\n        client = await self.backend.get_client_props(addr=address)\n        if client is None:\n            self.log.warning(\"Could not get client properties for %s\", address)\n            return\n\n        # Force float if not already floating\n        if not client.get(\"floating\", False):\n            await self.backend.toggle_floating(address)\n            # Re-fetch client props after floating (size might change)\n            client = await self.backend.get_client_props(addr=address)\n            if client is None:\n                return\n\n        margin = self.get_config_int(\"margin\")\n\n        # Get monitor dimensions, handling rotation\n        mon_x = monitor[\"x\"]\n        mon_y = monitor[\"y\"]\n        mon_width = monitor[\"width\"]\n        mon_height = monitor[\"height\"]\n        scale = monitor.get(\"scale\", 1.0)\n\n        if is_rotated(monitor):\n            mon_width, mon_height = mon_height, mon_width\n\n        # Calculate available space (accounting for margin and scale)\n        available_width = int(mon_width / scale) - 2 * margin\n        available_height = int(mon_height / scale) - 2 * margin\n\n        # Get window size\n        win_size = client.get(\"size\", [0, 0])\n        win_width = win_size[0]\n        win_height = win_size[1]\n\n        # Resize if window is too large\n        needs_resize = False\n        new_width = win_width\n        new_height = win_height\n\n        if win_width > available_width:\n            new_width = available_width\n            needs_resize = True\n        if win_height > available_height:\n            new_height = available_height\n            needs_resize = True\n\n        if needs_resize:\n            await self.backend.resize_window(address, new_width, new_height)\n            win_width = new_width\n            win_height = new_height\n\n        # Calculate centered position\n        center_x = int(mon_x / scale) + (int(mon_width / scale) - win_width) // 2\n        center_y = int(mon_y / scale) + (int(mon_height / scale) - win_height) // 2\n\n        await self.backend.move_window(address, center_x, center_y)\n\n    # Commands\n\n    async def run_unfetch_client(self) -> None:\n        \"\"\"Return a window back to its origin.\"\"\"\n        addr = self.state.active_window\n        try:\n            origin = self._windows_origins[addr]\n        except KeyError:\n            await self.backend.notify_error(\"unknown window origin\")\n        else:\n            await self.backend.move_window_to_workspace(addr, origin)\n\n    async def run_fetch_client_menu(self) -> None:\n        \"\"\"Select a client window and move it to the active workspace.\"\"\"\n        await self.ensure_menu_configured()\n\n        clients = await self.get_clients(workspace_bl=self.state.active_workspace)\n\n        separator = self.get_config_str(\"separator\")\n\n        choice = await self.menu.run([f\"{i + 1} {separator} {c['title']}\" for i, c in enumerate(clients)])\n\n        if choice:\n            num = int(choice.split(None, 1)[0]) - 1\n            addr = clients[num][\"address\"]\n            self._windows_origins[addr] = clients[num][\"workspace\"][\"name\"]\n            await self.backend.move_window_to_workspace(addr, self.state.active_workspace, silent=False)\n\n            # Center the window on the focused monitor if configured\n            if self.get_config_bool(\"center_on_fetch\"):\n                await self._center_window_on_monitor(addr)\n"
  },
  {
    "path": "pyprland/plugins/gamemode.py",
    "content": "\"\"\"Gamemode plugin - toggle performance mode for gaming.\n\nProvides manual toggle and automatic detection of game windows.\nWhen game mode is enabled, disables animations, blur, shadows, gaps,\nand rounding for maximum performance.\n\"\"\"\n\nimport asyncio\nimport fnmatch\n\nfrom ..aioops import TaskManager\nfrom ..models import Environment, ReloadReason\nfrom ..validation import ConfigField, ConfigItems\nfrom .interface import Plugin\n\n\nclass Extension(Plugin, environments=[Environment.HYPRLAND]):\n    \"\"\"Toggle game mode (automatically) for improved performance.\n\n    When enabled, disables animations, blur, shadows, gaps, and rounding\n    for maximum performance. When disabled, reloads the hyprland config\n    to restore original settings.\n\n    Supports automatic detection of game windows based on window class\n    patterns (e.g., Steam games with class \"steam_app_*\").\n    \"\"\"\n\n    config_schema = ConfigItems(\n        ConfigField(\"border_size\", int, default=1, description=\"Border size when game mode is enabled\", category=\"basic\"),\n        ConfigField(\"notify\", bool, default=True, description=\"Show notification when toggling\", category=\"basic\"),\n        ConfigField(\n            \"auto\", bool, default=True, description=\"Automatically enable game mode when matching windows are detected\", category=\"basic\"\n        ),\n        ConfigField(\n            \"patterns\",\n            list,\n            default=[\"steam_app_*\"],\n            description=\"Glob patterns to match window class for auto mode\",\n            category=\"basic\",\n        ),\n        ConfigField(\"hysteresis\", float, default=1.0, description=\"Debounce delay in seconds before toggling game mode\", category=\"basic\"),\n    )\n\n    _enabled: bool = False\n    _game_windows: set[str]\n    _tasks: TaskManager\n\n    def __init__(self, name: str) -> None:\n        \"\"\"Initialize plugin state.\"\"\"\n        super().__init__(name)\n        self._game_windows = set()\n        self._tasks = TaskManager()\n        self._tasks.start()\n\n    def _matches_pattern(self, window_class: str) -> bool:\n        \"\"\"Check if window class matches any configured pattern.\n\n        Args:\n            window_class: The window class to check\n\n        Returns:\n            True if the class matches any pattern in the configured patterns list\n        \"\"\"\n        patterns = self.get_config_list(\"patterns\")\n        return any(fnmatch.fnmatch(window_class, pattern) for pattern in patterns)\n\n    async def exit(self) -> None:\n        \"\"\"Stop pending tasks on plugin shutdown.\"\"\"\n        await self._tasks.stop()\n\n    async def _update_gamemode(self) -> None:\n        \"\"\"Debounce game mode transitions using hysteresis.\n\n        Determines the desired state from _game_windows and schedules a\n        delayed transition if it differs from the current state.\n        If the desired state already matches, cancels any pending transition.\n        \"\"\"\n        should_enable = bool(self._game_windows)\n        if should_enable == self._enabled:\n            self._tasks.cancel_keyed(\"gamemode\")\n            return\n\n        hysteresis = self.get_config_float(\"hysteresis\")\n        if hysteresis:\n            self._tasks.cancel_keyed(\"gamemode\")\n\n            async def _task(enable: bool, delay: float) -> None:\n                await asyncio.sleep(delay)\n                if enable:\n                    await self._enable_gamemode()\n                else:\n                    await self._disable_gamemode()\n\n            self._tasks.create(_task(should_enable, hysteresis), key=\"gamemode\")\n        elif should_enable:\n            await self._enable_gamemode()\n        else:\n            await self._disable_gamemode()\n\n    async def _enable_gamemode(self, notify: bool = True) -> None:\n        \"\"\"Enable game mode (disable visual effects).\n\n        Args:\n            notify: Whether to show notification (default: True)\n        \"\"\"\n        if self._enabled:\n            return\n\n        border_size = self.get_config_int(\"border_size\")\n        await self.backend.execute(\n            [\n                \"animations:enabled 0\",\n                \"decoration:shadow:enabled 0\",\n                \"decoration:blur:enabled 0\",\n                \"decoration:fullscreen_opacity 1\",\n                \"general:gaps_in 0\",\n                \"general:gaps_out 0\",\n                f\"general:border_size {border_size}\",\n                \"decoration:rounding 0\",\n            ],\n            base_command=\"keyword\",\n        )\n        self._enabled = True\n\n        if notify and self.get_config_bool(\"notify\"):\n            await self.backend.notify_info(\"Gamemode [ON]\")\n\n    async def _disable_gamemode(self, notify: bool = True) -> None:\n        \"\"\"Disable game mode (reload config to restore).\n\n        Args:\n            notify: Whether to show notification (default: True)\n        \"\"\"\n        if not self._enabled:\n            return\n\n        await self.backend.execute(\"config-only\", base_command=\"reload\")\n        self._enabled = False\n\n        if notify and self.get_config_bool(\"notify\"):\n            await self.backend.notify_info(\"Gamemode [OFF]\")\n\n    async def on_reload(self, reason: ReloadReason = ReloadReason.RELOAD) -> None:\n        \"\"\"Initialize state by checking current animations setting.\"\"\"\n        if reason != ReloadReason.INIT:\n            return\n\n        # Check current animations state to determine if game mode is already on\n        option = await self.backend.execute_json(\"getoption animations:enabled\")\n        self._enabled = option.get(\"int\", 1) == 0\n\n        # Scan existing clients for game windows if auto is enabled\n        if self.get_config_bool(\"auto\"):\n            clients = await self.backend.get_clients()\n            for client in clients:\n                window_class = str(client.get(\"class\", \"\"))\n                if self._matches_pattern(window_class):\n                    # Address comes with 0x prefix, store without it for consistency with events\n                    addr = str(client.get(\"address\", \"\")).replace(\"0x\", \"\")\n                    if addr:\n                        self._game_windows.add(addr)\n\n            # Enable if games are already running and game mode is not on\n            if self._game_windows and not self._enabled:\n                await self._enable_gamemode()\n\n    async def run_gamemode(self) -> None:\n        \"\"\"Toggle game mode (disables animations, blur, shadows, gaps, rounding).\"\"\"\n        if self._enabled:\n            await self._disable_gamemode()\n        else:\n            await self._enable_gamemode()\n\n    async def event_openwindow(self, params: str) -> None:\n        \"\"\"Handle window open - check for game windows.\n\n        Args:\n            params: Format \"address,workspace,class,title\"\n        \"\"\"\n        if not self.get_config_bool(\"auto\"):\n            return\n\n        # Parse params: \"address,workspace,class,title\"\n        parts = params.split(\",\", 3)\n        addr, _, window_class = parts[0], parts[1], parts[2]\n\n        if self._matches_pattern(window_class):\n            self._game_windows.add(addr)\n            await self._update_gamemode()\n\n    async def event_closewindow(self, addr: str) -> None:\n        \"\"\"Handle window close - disable game mode if no games left.\n\n        Args:\n            addr: Window address as hex string (without 0x prefix)\n        \"\"\"\n        if addr not in self._game_windows:\n            return\n\n        self._game_windows.discard(addr)\n\n        if not self._game_windows:\n            await self._update_gamemode()\n"
  },
  {
    "path": "pyprland/plugins/interface.py",
    "content": "\"\"\"Base Plugin class and infrastructure for Pyprland plugins.\n\nThe Plugin class provides:\n- Typed configuration accessors (get_config_bool, get_config_int, etc.)\n- Schema-based validation via config_schema attribute\n- Lifecycle hooks (init, on_reload, exit)\n- Backend access for compositor operations\n- Shared state access for cross-plugin coordination\n\nPlugin authors should inherit from Plugin and implement event_* and run_*\nmethods to handle compositor events and CLI commands respectively.\n\"\"\"\n\nimport contextlib\nimport logging\nfrom dataclasses import dataclass\nfrom typing import TYPE_CHECKING, Any, ClassVar, TypeAlias\n\nfrom ..adapters.proxy import BackendProxy\nfrom ..common import SharedState, get_logger\nfrom ..config import Configuration, coerce_to_bool\nfrom ..models import ClientInfo, Environment, MonitorInfo, ReloadReason\nfrom ..validation import ConfigItems, ConfigValidator\n\nif TYPE_CHECKING:\n    from ..manager import Pyprland\n\nConfigValue = int | float | str | list[Any] | dict[Any, Any]\n\"\"\"Type alias for values returned by get_config.\"\"\"\n\nEnvironments: TypeAlias = list[Environment]\n\"\"\"Type alias for the Plugin.environments class variable.\"\"\"\n\n\n@dataclass\nclass PluginContext:\n    \"\"\"Context from a plugin, for use by helper classes.\n\n    Groups the commonly needed plugin attributes (log, state, backend)\n    to reduce instance attribute count in helper classes.\n    \"\"\"\n\n    log: logging.Logger\n    state: SharedState\n    backend: BackendProxy\n\n\nclass Plugin:\n    \"\"\"Base class for any pyprland plugin.\n\n    Configuration Access:\n        Use the typed accessor methods for reading configuration values:\n        - get_config_str(name) - for string values\n        - get_config_int(name) - for integer values\n        - get_config_float(name) - for float values\n        - get_config_bool(name) - for boolean values\n        - get_config_list(name) - for list values\n        - get_config_dict(name) - for dict values\n\n        All config keys must be defined in config_schema for validation and defaults.\n\n    Subclass Usage:\n        Specify supported environments via class parameter:\n        >>> class Extension(Plugin, environments=[Environment.HYPRLAND]):\n        ...     pass\n    \"\"\"\n\n    aborted = False\n\n    environments: ClassVar[Environments] = []\n    \" The supported environments for this plugin. Empty list means all environments. \"\n\n    backend: BackendProxy\n    \" The environment backend \"\n\n    manager: \"Pyprland | None\"\n    \" Reference to the plugin manager (set for pyprland plugin only) \"\n\n    config_schema: ConfigItems\n    \"\"\"Schema defining expected configuration fields. Override in subclasses to enable validation.\"\"\"\n\n    def __init_subclass__(cls, environments: Environments | None = None, **kwargs: Any) -> None:\n        \"\"\"Set plugin environments via class parameter.\n\n        Args:\n            environments: List of supported environments for this plugin\n            **kwargs: Additional keyword arguments for super().__init_subclass__\n        \"\"\"\n        super().__init_subclass__(**kwargs)\n        if environments is not None:\n            cls.environments = environments\n\n    def get_config(self, name: str) -> ConfigValue:\n        \"\"\"Get a configuration value by name.\n\n        Args:\n            name: Configuration key name (must be defined in config_schema)\n\n        Returns:\n            The configuration value\n\n        Raises:\n            KeyError: If the key is not defined in config_schema\n        \"\"\"\n        # Configuration.get() already handles schema defaults via set_schema()\n        value = self.config.get(name)\n\n        if value is not None:\n            return value\n\n        # Value is None - need schema for type-appropriate default\n        schema = self.config_schema.get(name)\n        if not schema:\n            msg = f\"Unknown config key '{name}' - not defined in config_schema\"\n            raise KeyError(msg)\n\n        first_type = schema.field_type[0] if isinstance(schema.field_type, (list, tuple)) else schema.field_type\n        return first_type()  # type: ignore[no-any-return]\n\n    def get_config_str(self, name: str) -> str:\n        \"\"\"Get a string configuration value by name.\"\"\"\n        return str(self.get_config(name))\n\n    def get_config_int(self, name: str) -> int:\n        \"\"\"Get an integer configuration value by name.\n\n        Args:\n            name: Configuration key name\n\n        Returns:\n            The integer value, or 0 if conversion fails\n        \"\"\"\n        value = self.get_config(name)\n        try:\n            return int(value)  # type: ignore[arg-type]\n        except (ValueError, TypeError):\n            self.log.warning(\"Invalid integer value for %s: %s, using 0\", name, value)\n            return 0\n\n    def get_config_float(self, name: str) -> float:\n        \"\"\"Get a float configuration value by name.\n\n        Args:\n            name: Configuration key name\n\n        Returns:\n            The float value, or 0.0 if conversion fails\n        \"\"\"\n        value = self.get_config(name)\n        try:\n            return float(value)  # type: ignore[arg-type]\n        except (ValueError, TypeError):\n            self.log.warning(\"Invalid float value for %s: %s, using 0.0\", name, value)\n            return 0.0\n\n    def get_config_bool(self, name: str) -> bool:\n        \"\"\"Get a boolean configuration value by name.\n\n        Handles loose typing: strings like \"false\", \"no\", \"off\", \"0\", \"disabled\"\n        are treated as False.\n        \"\"\"\n        return coerce_to_bool(self.get_config(name))\n\n    def get_config_list(self, name: str) -> list[Any]:\n        \"\"\"Get a list configuration value by name.\"\"\"\n        result = self.get_config(name)\n        assert isinstance(result, list), f\"Expected list for {name}, got {type(result)}\"\n        return result\n\n    def get_config_dict(self, name: str) -> dict[str, Any]:\n        \"\"\"Get a dict configuration value by name.\"\"\"\n        result = self.get_config(name)\n        assert isinstance(result, dict), f\"Expected dict for {name}, got {type(result)}\"\n        return result\n\n    config: Configuration\n    \" This plugin configuration section as a `dict` object \"\n\n    state: SharedState\n    \" The shared state object \"\n\n    def __init__(self, name: str) -> None:\n        \"\"\"Create a new plugin `name` and the matching logger.\"\"\"\n        self.name = name\n        \"\"\" the plugin name \"\"\"\n        if not hasattr(self, \"config_schema\"):\n            self.config_schema = ConfigItems()\n        self.log = get_logger(name)\n        \"\"\" the logger to use for this plugin \"\"\"\n        self.config = Configuration({}, logger=self.log)\n        self.manager = None\n\n    # Functions to override\n\n    async def init(self) -> None:\n        \"\"\"Initialize the plugin.\n\n        Note that the `config` attribute isn't ready yet when this is called.\n        \"\"\"\n\n    async def on_reload(self, reason: ReloadReason = ReloadReason.RELOAD) -> None:\n        \"\"\"Apply configuration after it has been loaded.\n\n        Called both during initial plugin setup and when configuration is reloaded.\n\n        Args:\n            reason: Why the reload was triggered\n                - INIT: First load during daemon startup\n                - RELOAD: Config reload via 'pypr reload', 'pypr set', or self-restart\n\n        Example:\n            async def on_reload(self, reason: ReloadReason = ReloadReason.RELOAD) -> None:\n                if reason == ReloadReason.INIT:\n                    # Expensive one-time setup\n                    self.monitors = await self.backend.get_monitors()\n                # Always apply config\n                self._apply_config()\n        \"\"\"\n\n    async def exit(self) -> None:\n        \"\"\"Empty exit function.\"\"\"\n\n    # Generic implementations\n\n    async def load_config(self, config: dict[str, Any]) -> None:\n        \"\"\"Load the configuration section from the passed `config`.\"\"\"\n        self.config.clear()\n        with contextlib.suppress(KeyError):\n            self.config.update(config[self.name])\n        # Apply schema for default value lookups\n        if self.config_schema:\n            self.config.set_schema(self.config_schema)\n\n    def validate_config(self) -> list[str]:\n        \"\"\"Validate the current configuration against the schema.\n\n        Override config_schema in subclasses to define expected fields.\n        Validation runs automatically during plugin loading if schema is defined.\n\n        Returns:\n            List of validation error messages (empty if valid)\n        \"\"\"\n        if not self.config_schema:\n            return []\n\n        validator = ConfigValidator(self.config, self.name, self.log)\n        errors = validator.validate(self.config_schema)\n        validator.warn_unknown_keys(self.config_schema)\n        return errors\n\n    @classmethod\n    def validate_config_static(cls, plugin_name: str, config: dict) -> list[str]:\n        \"\"\"Validate configuration without instantiating the plugin.\n\n        Override in subclasses for custom validation logic.\n        Called by 'pypr validate' CLI command.\n\n        Args:\n            plugin_name: Name of the plugin (for error messages)\n            config: The plugin's configuration dict\n\n        Returns:\n            List of validation error messages (empty if valid)\n        \"\"\"\n        if not cls.config_schema:\n            return []\n        log = logging.getLogger(f\"pyprland.plugins.{plugin_name}\")\n        validator = ConfigValidator(config, plugin_name, log)\n        return validator.validate(cls.config_schema)\n\n    async def get_clients(\n        self,\n        mapped: bool = True,\n        workspace: None | str = None,\n        workspace_bl: str | None = None,\n    ) -> list[ClientInfo]:\n        \"\"\"Return the client list, optionally returns only mapped clients or from a given workspace.\n\n        Args:\n            mapped: Filter for mapped clients\n            workspace: Filter for specific workspace name\n            workspace_bl: Filter to blacklist a specific workspace name\n        \"\"\"\n        return await self.backend.get_clients(mapped, workspace, workspace_bl)\n\n    async def get_focused_monitor_or_warn(self, context: str = \"\") -> MonitorInfo | None:\n        \"\"\"Get the focused monitor, logging a warning if none found.\n\n        This is a common helper to reduce repeated try/except patterns\n        for RuntimeError when calling get_monitor_props().\n\n        Args:\n            context: Optional context for the warning message (e.g., \"centered layout\")\n\n        Returns:\n            MonitorInfo if found, None otherwise\n        \"\"\"\n        try:\n            return await self.backend.get_monitor_props()\n        except RuntimeError:\n            msg = \"No focused monitor found\"\n            if context:\n                msg = f\"{msg} for {context}\"\n            self.log.warning(msg)\n            return None\n"
  },
  {
    "path": "pyprland/plugins/layout_center.py",
    "content": "\"\"\"Implements a \"Centered\" layout.\n\n- windows are normally tiled but one\n- the active window is floating and centered\n- you can cycle the active window, keeping the same layout type\n- layout can be toggled any time\n\"\"\"\n\nfrom collections import defaultdict\nfrom collections.abc import Callable\nfrom functools import partial\nfrom typing import Any, cast\n\nfrom ..common import is_rotated\nfrom ..constants import MIN_CLIENTS_FOR_LAYOUT\nfrom ..models import ClientInfo, Environment, ReloadReason\nfrom ..validation import ConfigField, ConfigItems\nfrom .interface import Plugin\n\n\nclass Extension(Plugin, environments=[Environment.HYPRLAND]):\n    \"\"\"A workspace layout where one window is centered and maximized while others are in the background.\"\"\"\n\n    config_schema = ConfigItems(\n        ConfigField(\"margin\", int, default=60, description=\"Margin around the centered window in pixels\", category=\"basic\"),\n        ConfigField(\n            \"offset\", (str, list, tuple), default=[0, 0], description=\"Offset of the centered window as 'X Y' or [X, Y]\", category=\"basic\"\n        ),\n        ConfigField(\"style\", list, default=[], description=\"Window rules to apply to the centered window\", category=\"basic\"),\n        ConfigField(\"captive_focus\", bool, default=False, description=\"Keep focus on the centered window\", category=\"behavior\"),\n        ConfigField(\n            \"on_new_client\",\n            str,\n            default=\"focus\",\n            choices=[\"focus\", \"background\", \"close\"],\n            description=\"Behavior when a new window opens\",\n            category=\"behavior\",\n        ),\n        ConfigField(\"next\", str, description=\"Command to run when 'next' is called and layout is disabled\", category=\"commands\"),\n        ConfigField(\"prev\", str, description=\"Command to run when 'prev' is called and layout is disabled\", category=\"commands\"),\n        ConfigField(\"next2\", str, description=\"Alternative command for 'next'\", category=\"commands\"),\n        ConfigField(\"prev2\", str, description=\"Alternative command for 'prev'\", category=\"commands\"),\n    )\n\n    workspace_info: dict[str, dict[str, Any]]\n    last_index = 0\n    command_handlers: dict[str, Callable]\n\n    async def init(self) -> None:\n        \"\"\"Initialize the plugin.\"\"\"\n        self.workspace_info = defaultdict(lambda: {\"enabled\": False, \"addr\": \"\"})\n        self.command_handlers = {\n            \"toggle\": self._run_toggle,\n            \"next\": partial(self._run_changefocus, 1, default_override=\"next\"),\n            \"prev\": partial(self._run_changefocus, -1, default_override=\"prev\"),\n            \"next2\": partial(self._run_changefocus, 1, default_override=\"next2\"),\n            \"prev2\": partial(self._run_changefocus, -1, default_override=\"prev2\"),\n        }\n\n    # Events\n\n    async def event_openwindow(self, windescr: str) -> None:\n        \"\"\"Re-set focus to main if a window is opened.\n\n        Args:\n            windescr: The window description\n        \"\"\"\n        if not self.enabled:\n            return\n        win_addr = \"0x\" + windescr.split(\",\", 1)[0]\n\n        behavior = self.get_config_str(\"on_new_client\")\n        new_client: ClientInfo | None = None\n        clients = await self.get_clients()\n        new_client_idx = 0\n        for i, cli in enumerate(clients):\n            if cli[\"address\"] == win_addr:\n                if cli[\"floating\"]:\n                    # Ignore floating windows\n                    return\n                new_client = cli\n                new_client_idx = i\n                break\n\n        if new_client:\n            self.last_index = new_client_idx\n            if behavior == \"background\":\n                # focus the main client\n                await self.backend.focus_window(self.main_window_addr)\n            elif behavior == \"close\":\n                await self._run_toggle()\n            else:  # foreground\n                # make the new client the main window\n                await self.unprepare_window(clients)\n                self.main_window_addr = win_addr\n                await self.prepare_window(clients)\n\n    async def event_activewindowv2(self, _: str) -> None:\n        \"\"\"Keep track of focused client.\n\n        Args:\n            _: The window address (unused)\n        \"\"\"\n        captive = self.get_config_bool(\"captive_focus\")\n        is_not_active = self.state.active_window != self.main_window_addr\n        if captive and self.enabled and is_not_active:\n            try:\n                next(c for c in await self.get_clients() if c[\"address\"] == self.state.active_window)\n            except StopIteration:\n                pass\n            else:\n                await self.backend.focus_window(self.main_window_addr)\n\n    async def event_closewindow(self, addr: str) -> None:\n        \"\"\"Disable when the main window is closed.\n\n        Args:\n            addr: The window address\n        \"\"\"\n        addr = \"0x\" + addr\n        clients = [c for c in await self.get_clients() if c[\"address\"] != addr]\n        if self.enabled and await self._sanity_check(clients):\n            closed_main = self.main_window_addr == addr\n            if self.enabled and closed_main:\n                self.log.debug(\"main window closed, focusing next\")\n                await self._run_changefocus(1)\n\n    # Command\n\n    async def run_layout_center(self, what: str) -> None:\n        \"\"\"<toggle|next|prev|next2|prev2> turn on/off or change the active window.\n\n        Args:\n            what: The action to perform\n                - toggle: Enable/disable the centered layout\n                - next/prev: Focus the next/previous window in the stack\n                - next2/prev2: Alternative focus commands (configurable)\n        \"\"\"\n        fn = self.command_handlers.get(what)\n        if fn:\n            await fn()\n        else:\n            await self.backend.notify_error(f\"unknown layout_center command: {what}\")\n\n    async def on_reload(self, reason: ReloadReason = ReloadReason.RELOAD) -> None:\n        \"\"\"Loads the configuration and apply the tag style.\"\"\"\n        _ = reason  # unused\n        if not self.get_config_list(\"style\"):\n            return\n        await self.backend.execute(\"windowrule tag -layout_center\", base_command=\"keyword\")\n        commands = [f\"windowrule {rule}, match:tag layout_center\" for rule in self.get_config_list(\"style\")]\n        if commands:\n            await self.backend.execute(commands, base_command=\"keyword\")\n\n    # Utils\n\n    async def get_clients(\n        self,\n        mapped: bool = True,\n        workspace: str | None = None,\n        workspace_bl: str | None = None,\n    ) -> list[ClientInfo]:\n        \"\"\"Return the client list in the currently active workspace.\"\"\"\n        _ = workspace\n        clients = await super().get_clients(mapped=mapped, workspace=self.state.active_workspace, workspace_bl=workspace_bl)\n        clients.sort(key=lambda c: c[\"address\"])\n        return clients\n\n    async def unprepare_window(self, clients: list[ClientInfo] | None = None) -> None:\n        \"\"\"Set the window as normal.\n\n        Args:\n            clients: The list of clients\n        \"\"\"\n        if not clients:\n            clients = await self.get_clients()\n        addr = self.main_window_addr\n        for cli in clients:\n            if cli[\"address\"] == addr and cli[\"floating\"]:\n                await self.backend.toggle_floating(addr)\n                if self.get_config_list(\"style\"):\n                    await self.backend.execute(f\"tagwindow -layout_center address:{addr}\")\n                break\n\n    async def prepare_window(self, clients: list[ClientInfo] | None = None) -> None:\n        \"\"\"Set the window as centered.\n\n        Args:\n            clients: The list of clients\n        \"\"\"\n        if not clients:\n            clients = await self.get_clients()\n        addr = self.main_window_addr\n        for cli in clients:\n            if cli[\"address\"] == addr and not cli[\"floating\"]:\n                await self.backend.toggle_floating(addr)\n                if self.get_config_list(\"style\"):\n                    await self.backend.execute(f\"tagwindow +layout_center address:{addr}\")\n                break\n\n        geometry = await self._calculate_centered_geometry(self.margin, self.offset)\n        if geometry is None:\n            return\n        x, y, width, height = geometry\n\n        await self.backend.resize_window(addr, width, height)\n        await self.backend.move_window(addr, x, y)\n\n    async def _calculate_centered_geometry(\n        self, margin_conf: int | tuple[int, int], offset_conf: tuple[int, int]\n    ) -> tuple[int, int, int, int] | None:\n        \"\"\"Calculate the geometry (x, y, width, height) for the centered window.\n\n        Args:\n            margin_conf: The margin configuration\n            offset_conf: The offset configuration\n\n        Returns:\n            Tuple of (x, y, width, height) or None if no focused monitor found.\n        \"\"\"\n        x, y = offset_conf\n        margin: tuple[int, int] = (margin_conf, margin_conf) if isinstance(margin_conf, int) else margin_conf\n\n        monitor = await self.get_focused_monitor_or_warn(\"centered geometry calculation\")\n        if monitor is None:\n            return None\n\n        scale = monitor[\"scale\"]\n        width = monitor[\"width\"] - (2 * margin[0])\n        height = monitor[\"height\"] - (2 * margin[1])\n        if is_rotated(monitor):\n            width, height = height, width\n        final_x = x + monitor[\"x\"] + (margin[0] / scale)\n        final_y = y + monitor[\"y\"] + (margin[1] / scale)\n        return int(final_x), int(final_y), int(width / scale), int(height / scale)\n\n    # Subcommands\n\n    async def _sanity_check(self, clients: list[ClientInfo] | None = None) -> bool:\n        \"\"\"Auto-disable if needed & return enabled status.\n\n        Args:\n            clients: The list of clients\n        \"\"\"\n        clients = clients or await self.get_clients()\n        if len(clients) < MIN_CLIENTS_FOR_LAYOUT:\n            # If < 2 clients, disable the layout & stop\n            self.log.info(\"disabling (clients starvation)\")\n            await self.unprepare_window()\n            self.enabled = False\n        return self.enabled\n\n    async def _run_changefocus(self, direction: int, default_override: str | None = None) -> None:\n        \"\"\"Change the focus in the given direction (-1 or 1).\n\n        Args:\n            direction: The direction to change focus\n            default_override: The default override command\n        \"\"\"\n        if self.enabled:\n            clients = [cli for cli in await self.get_clients() if not cli.get(\"floating\") or cli[\"address\"] == self.main_window_addr]\n            if await self._sanity_check(clients):\n                addresses = [c[\"address\"] for c in clients]\n                try:\n                    idx = addresses.index(self.main_window_addr)\n                except ValueError:\n                    idx = self.last_index\n\n                # Use modulo arithmetic for cyclic focus\n                index = (idx + direction) % len(clients)\n\n                new_client = clients[index]\n                await self.unprepare_window(clients)\n                self.main_window_addr = new_client[\"address\"]\n                await self.backend.focus_window(self.main_window_addr)\n                self.last_index = index\n                await self.prepare_window(clients)\n        elif default_override:\n            command = self.get_config(default_override)\n            if command:\n                await self.backend.execute(str(command))\n\n    async def _run_toggle(self) -> None:\n        \"\"\"Toggle the center layout.\"\"\"\n        disabled = not self.enabled\n        if disabled:\n            self.main_window_addr = self.state.active_window\n            await self.prepare_window()\n        else:\n            await self.unprepare_window()\n\n        self.enabled = disabled\n\n    # Properties\n\n    @property\n    def offset(self) -> tuple[int, int]:\n        \"\"\"Returns the centered window offset.\"\"\"\n        offset = self.get_config(\"offset\")\n        if isinstance(offset, str):\n            x, y = (int(i) for i in offset.split() if i.strip())\n            return (x, y)\n        return cast(\"tuple[int, int]\", offset)\n\n    @property\n    def margin(self) -> int:\n        \"\"\"Returns the margin of the centered window.\"\"\"\n        return self.get_config_int(\"margin\")\n\n    # enabled\n    @property\n    def enabled(self) -> bool:\n        \"\"\"Is center layout enabled on the active workspace ?.\"\"\"\n        return cast(\"bool\", self.workspace_info[self.state.active_workspace][\"enabled\"])\n\n    @enabled.setter\n    def enabled(self, value: bool) -> None:\n        \"\"\"Set if center layout enabled on the active workspace.\"\"\"\n        self.workspace_info[self.state.active_workspace][\"enabled\"] = value\n\n    # main_window_addr\n\n    @property\n    def main_window_addr(self) -> str:\n        \"\"\"Get active workspace's centered window address.\"\"\"\n        return cast(\"str\", self.workspace_info[self.state.active_workspace][\"addr\"])\n\n    @main_window_addr.setter\n    def main_window_addr(self, value: str) -> None:\n        \"\"\"Set active workspace's centered window address.\"\"\"\n        self.workspace_info[self.state.active_workspace][\"addr\"] = value\n"
  },
  {
    "path": "pyprland/plugins/lost_windows.py",
    "content": "\"\"\"Moves unreachable client windows to the currently focused workspace.\"\"\"\n\nfrom ..models import ClientInfo, Environment, MonitorInfo\nfrom .interface import Plugin\n\n\ndef contains(monitor: MonitorInfo, window: ClientInfo) -> bool:\n    \"\"\"Tell if a window is visible in a monitor.\n\n    Args:\n        monitor: The monitor info\n        window: The window info\n    \"\"\"\n    if not (window[\"at\"][0] >= monitor[\"x\"] and window[\"at\"][0] < monitor[\"x\"] + monitor[\"width\"]):\n        return False\n    return bool(window[\"at\"][1] >= monitor[\"y\"] and window[\"at\"][1] < monitor[\"y\"] + monitor[\"height\"])\n\n\nclass Extension(Plugin, environments=[Environment.HYPRLAND]):\n    \"\"\"Brings lost floating windows (which are out of reach) to the current workspace.\"\"\"\n\n    async def run_attract_lost(self) -> None:\n        \"\"\"Brings lost floating windows to the current workspace.\"\"\"\n        monitors = await self.backend.get_monitors()\n        windows = await self.get_clients()\n        lost = [win for win in windows if win[\"floating\"] and not any(contains(mon, win) for mon in monitors)]\n        focused = await self.get_focused_monitor_or_warn()\n        if focused is None:\n            return\n        interval = focused[\"width\"] / (1 + len(lost))\n        interval_y = focused[\"height\"] / (1 + len(lost))\n        batch = []\n        workspace: int = focused[\"activeWorkspace\"][\"id\"]\n        margin = interval // 2\n        margin_y = interval_y // 2\n        for i, window in enumerate(lost):\n            pos_x = int(margin + focused[\"x\"] + i * interval)\n            pos_y = int(margin_y + focused[\"y\"] + i * interval_y)\n            batch.append(f\"movetoworkspacesilent {workspace},pid:{window['pid']}\")\n            batch.append(f\"movewindowpixel exact {pos_x} {pos_y},pid:{window['pid']}\")\n        await self.backend.execute(batch)\n"
  },
  {
    "path": "pyprland/plugins/magnify.py",
    "content": "\"\"\"Toggles workspace zooming.\"\"\"\n\nimport asyncio\nfrom collections.abc import Iterable\n\nfrom ..models import Environment, ReloadReason, VersionInfo\nfrom ..validation import ConfigField, ConfigItems\nfrom .interface import Plugin\n\n\nclass Extension(Plugin, environments=[Environment.HYPRLAND]):\n    \"\"\"Toggles zooming of viewport or sets a specific scaling factor.\"\"\"\n\n    config_schema = ConfigItems(\n        ConfigField(\"factor\", float, default=2.0, description=\"Zoom factor when toggling\", category=\"basic\"),\n        ConfigField(\"duration\", int, default=0, description=\"Animation duration in frames (0 to disable)\", category=\"basic\"),\n    )\n\n    zoomed = False\n\n    cur_factor = 1.0\n\n    keyword = \"\"\n\n    async def on_reload(self, reason: ReloadReason = ReloadReason.RELOAD) -> None:\n        \"\"\"Initialization code.\"\"\"\n        _ = reason  # unused\n        if self.state.hyprland_version < VersionInfo(0, 40, 1):\n            self.keyword = \"misc:cursor_zoom_factor\"\n        else:\n            self.keyword = \"cursor:zoom_factor\"\n\n    def ease_out_quad(self, step: float, start: int, end: int, duration: int) -> float:\n        \"\"\"Easing function for animations.\n\n        Args:\n            step: The current step\n            start: The start value\n            end: The end value\n            duration: The duration\n        \"\"\"\n        step /= duration\n        return -end * step * (step - 2) + start\n\n    def animated_eased_zoom(self, start: int, end: int, duration: int) -> Iterable[float]:\n        \"\"\"Add easing to an animation.\n\n        This function is a generator that yields the next value of the animation\n\n        Args:\n            start (float): starting value\n            end (float): ending value\n            duration (int): duration of the animation\n        \"\"\"\n        for i in range(duration):\n            yield self.ease_out_quad(i, start, end - start, duration)\n\n    async def run_zoom(self, *args) -> None:\n        \"\"\"[factor] zooms to \"factor\" or toggles zoom level if factor is omitted.\n\n        If factor is omitted, it toggles between the configured zoom level and no zoom.\n        Factor can be relative (e.g. +0.5 or -0.5).\n        \"\"\"\n        duration = self.get_config_int(\"duration\")\n        animated = bool(duration)\n        prev_factor = self.cur_factor\n        expo = False\n        if args:  # set or update the factor\n            relative = args[0][0] in \"+-\"\n            expo = len(args[0]) > 1 and args[0][1] in \"+-\"\n            value = float(args[0][1:]) if expo else float(args[0])\n\n            # compute the factor\n            if relative:\n                self.cur_factor += value\n            else:\n                self.cur_factor = value\n\n            # sanity check\n            self.cur_factor = max(self.cur_factor, 1)\n        elif self.zoomed:\n            self.cur_factor = 1\n        else:\n            self.cur_factor = self.get_config_float(\"factor\")\n\n        self.cur_factor = max(self.cur_factor, 1)\n\n        if animated:\n            start = int((2.0 ** (prev_factor - 1) if expo else prev_factor) * 10)\n            end = int((2.0 ** (self.cur_factor - 1) if expo else self.cur_factor) * 10)\n            for i in self.animated_eased_zoom(start, end, duration):\n                await self.backend.execute(f\"{self.keyword} {i / 10:.4f}\", base_command=\"keyword\")\n                await asyncio.sleep(1.0 / 60)\n        self.zoomed = self.cur_factor != 1\n        factor = 2 ** (self.cur_factor - 1) if expo else self.cur_factor\n        await self.backend.execute(f\"{self.keyword} {factor:.4f}\", base_command=\"keyword\")\n"
  },
  {
    "path": "pyprland/plugins/menubar.py",
    "content": "\"\"\"Run a bar.\"\"\"\n\nimport contextlib\nfrom time import time\nfrom typing import TYPE_CHECKING, cast\n\nfrom ..aioops import TaskManager, aiexists\nfrom ..common import apply_variables\nfrom ..models import Environment, ReloadReason\nfrom ..process import ManagedProcess\nfrom ..validation import ConfigField, ConfigItems\nfrom .interface import Plugin\n\nif TYPE_CHECKING:\n    from ..adapters.proxy import BackendProxy\n\nCRASH_COOLDOWN = 120  # seconds - crashes within this trigger backoff\nMAX_BACKOFF_DELAY = 60  # cap at 60 seconds\nBASE_DELAY = 2  # base for exponential calculation\nIDLE_LOOP_INTERVAL = 10\n\n\ndef get_pid_from_layers_hyprland(layers: dict) -> bool | int:\n    \"\"\"Get the PID of the bar from Hyprland layers.\n\n    Args:\n        layers: The layers dictionary from hyprctl\n\n    Returns:\n        PID if bar found with valid PID, False otherwise\n    \"\"\"\n    for screen in layers:\n        for layer in layers[screen][\"levels\"].values():\n            for instance in layer:\n                if instance[\"namespace\"].startswith(\"bar-\"):\n                    pid = instance.get(\"pid\", 0)\n                    return pid if pid > 0 else False\n    return False\n\n\ndef is_bar_in_layers_niri(layers: list) -> bool:\n    \"\"\"Check if a bar exists in Niri layers.\n\n    Args:\n        layers: List of LayerSurface from Niri\n\n    Note: Niri's LayerSurface doesn't include PID, so we can only\n    detect presence, not recover the PID.\n    \"\"\"\n    return any(layer.get(\"namespace\", \"\").startswith(\"bar-\") for layer in layers)\n\n\nasync def is_bar_alive(\n    pid: int,\n    backend: \"BackendProxy\",\n    environment: str,\n) -> int | bool:\n    \"\"\"Check if the bar is running.\n\n    Args:\n        pid: The process ID\n        backend: The environment backend\n        environment: Current environment (\"hyprland\" or \"niri\")\n    \"\"\"\n    # First check /proc - works for any spawned process\n    is_running = await aiexists(f\"/proc/{pid}\")\n    if is_running:\n        return pid\n\n    # Try to detect via layers query\n    if environment == \"niri\":\n        with contextlib.suppress(OSError, AssertionError, KeyError):\n            layers = await backend.execute_json(\"Layers\")\n            if is_bar_in_layers_niri(layers):\n                # Bar exists but we lost PID tracking\n                # Return True to prevent respawn\n                return True\n    else:\n        # Hyprland\n        with contextlib.suppress(OSError, AssertionError, KeyError):\n            layers = await backend.execute_json(\"layers\")\n            found_pid = get_pid_from_layers_hyprland(layers)\n            if found_pid:\n                return found_pid\n\n    return False\n\n\nclass Extension(Plugin, environments=[Environment.HYPRLAND, Environment.NIRI]):\n    \"\"\"Improves multi-monitor handling of the status bar and restarts it on crashes.\"\"\"\n\n    config_schema = ConfigItems(\n        ConfigField(\n            \"command\",\n            str,\n            default=\"uwsm app -- ashell\",\n            description=\"Command to run the bar (supports [monitor] variable)\",\n            required=True,\n            category=\"basic\",\n        ),\n        ConfigField(\"monitors\", list, default=[], description=\"Preferred monitors list in order of priority\", category=\"basic\"),\n    )\n\n    monitors: set[str]\n    proc: ManagedProcess | None = None\n    cur_monitor: str | None = \"\"\n    _tasks: TaskManager\n    _consecutive_quick_crashes: int = 0\n\n    def __init__(self, name: str) -> None:\n        \"\"\"Initialize the plugin.\"\"\"\n        super().__init__(name)\n        self._tasks = TaskManager()\n\n    async def on_reload(self, reason: ReloadReason = ReloadReason.RELOAD) -> None:\n        \"\"\"Start the process.\"\"\"\n        _ = reason  # unused\n        await self.stop()\n        self._consecutive_quick_crashes = 0\n        self._run_program()\n\n    def _run_program(self) -> None:\n        \"\"\"Create ongoing task restarting gbar in case of crash.\"\"\"\n        self._tasks.start()\n\n        async def _run_loop() -> None:\n            pid: int | bool = 0\n            while self._tasks.running:\n                if pid:\n                    pid = await is_bar_alive(pid if isinstance(pid, int) else 0, self.backend, self.state.environment)\n                    if pid:\n                        if await self._tasks.sleep(IDLE_LOOP_INTERVAL):\n                            break\n                        continue\n\n                await self.set_best_monitor()\n                command = self.get_config_str(\"command\")\n                cmd = apply_variables(\n                    command,\n                    {\"monitor\": self.cur_monitor or \"\"},\n                )\n                start_time = time()\n                self.proc = ManagedProcess()\n                await self.proc.start(cmd)\n                pid = self.proc.pid or 0\n                await self.proc.wait()\n\n                now = time()\n                elapsed_time = now - start_time\n\n                if elapsed_time >= CRASH_COOLDOWN:\n                    # Stable run (2+ min) - reset counter, restart immediately\n                    self._consecutive_quick_crashes = 0\n                    delay = 0\n                else:\n                    # Crash within 2 min - apply backoff\n                    self._consecutive_quick_crashes += 1\n                    if self._consecutive_quick_crashes == 1:\n                        delay = 0  # first crash: immediate\n                    else:\n                        # 2nd: 2s, 3rd: 4s, 4th: 8s, 5th: 16s, 6th: 32s, 7th+: 60s\n                        delay = min(BASE_DELAY * (2 ** (self._consecutive_quick_crashes - 2)), MAX_BACKOFF_DELAY)\n\n                text = f\"Menu Bar crashed, restarting in {delay}s.\" if delay > 0 else \"Menu Bar crashed, restarting immediately.\"\n                self.log.warning(text)\n                if delay:\n                    await self.backend.notify_info(text)\n                if await self._tasks.sleep(delay or 0.1):\n                    break\n\n        self._tasks.create(_run_loop())\n\n    def is_running(self) -> bool:\n        \"\"\"Check if the bar is currently running.\"\"\"\n        return self.proc is not None and self._tasks.running\n\n    async def run_bar(self, args: str) -> None:\n        \"\"\"[restart|stop|toggle] Start (default), restart, stop or toggle the menu bar.\n\n        Args:\n            args: The action to perform\n                - (empty): Start the bar\n                - restart: Stop and restart the bar\n                - stop: Stop the bar\n                - toggle: Toggle the bar on/off\n        \"\"\"\n        if args.startswith(\"toggle\"):\n            if self.is_running():\n                await self.stop()\n            else:\n                await self.on_reload()\n            return\n\n        await self.stop()\n        if not args.startswith(\"stop\"):\n            await self.on_reload()\n\n    async def set_best_monitor(self) -> None:\n        \"\"\"Set the best monitor to use in `cur_monitor`.\"\"\"\n        self.cur_monitor = await self.get_best_monitor()\n        if not self.cur_monitor:\n            if not self.state.active_monitors:\n                self.log.error(\"No monitors available for bar\")\n                return\n            self.cur_monitor = self.state.active_monitors[0]\n            await self.backend.notify_info(f\"menubar: No preferred monitor found, using {self.cur_monitor}\")\n\n    async def get_best_monitor(self) -> str:\n        \"\"\"Get best monitor according to preferred list.\"\"\"\n        preferred_monitors = self.get_config_list(\"monitors\")\n\n        if self.state.environment == Environment.NIRI:\n            # Niri: outputs is a dict, enabled outputs have current_mode set\n            outputs = await self.backend.execute_json(\"outputs\")\n            names = [name for name, data in outputs.items() if data.get(\"current_mode\") is not None]\n        else:\n            # Hyprland\n            monitors = await self.backend.get_monitors()\n            names = [m[\"name\"] for m in monitors if m.get(\"currentFormat\") != \"Invalid\"]\n\n        for monitor in preferred_monitors:\n            if monitor in names:\n                return cast(\"str\", monitor)\n        return \"\"\n\n    async def event_monitoradded(self, monitor: str) -> None:\n        \"\"\"Switch bar in case the monitor is preferred.\n\n        Args:\n            monitor: The monitor name\n        \"\"\"\n        if self.cur_monitor:\n            preferred = self.get_config_list(\"monitors\")\n            cur_idx = preferred.index(self.cur_monitor) if self.cur_monitor else 999\n            if monitor not in preferred:\n                return\n            new_idx = preferred.index(monitor)\n            if 0 <= new_idx < cur_idx:\n                await self.stop()\n                await self.on_reload()\n\n    async def niri_outputschanged(self, _data: dict) -> None:\n        \"\"\"Handle Niri output changes.\n\n        Args:\n            _data: Event data from Niri (unused)\n        \"\"\"\n        if not self.cur_monitor:\n            return\n\n        preferred = self.get_config_list(\"monitors\")\n        cur_idx = preferred.index(self.cur_monitor) if self.cur_monitor in preferred else 999\n\n        # Check if a more preferred monitor appeared\n        try:\n            outputs = await self.backend.execute_json(\"outputs\")\n            for name in outputs:\n                if name in preferred:\n                    new_idx = preferred.index(name)\n                    if 0 <= new_idx < cur_idx:\n                        # A more preferred monitor appeared\n                        await self.stop()\n                        await self.on_reload()\n                        return\n        except (OSError, RuntimeError) as e:\n            self.log.warning(\"Error checking outputs: %s\", e)\n\n    async def exit(self) -> None:\n        \"\"\"Stop the process.\"\"\"\n        await self.stop()\n\n    async def stop(self) -> None:\n        \"\"\"Stop the process and supervision task.\"\"\"\n        await self._tasks.stop()\n        if self.proc:\n            await self.proc.stop()\n            self.proc = None\n"
  },
  {
    "path": "pyprland/plugins/mixins.py",
    "content": "\"\"\"Reusable mixins for common plugin functionality.\n\nMonitorTrackingMixin:\n    Automatically tracks monitor add/remove events, maintaining a list\n    of active monitors. Works with both regular plugins (self.monitors)\n    and core plugins (self.state.monitors).\n\"\"\"\n\nfrom logging import Logger\nfrom typing import cast\n\n\nclass MonitorListDescriptor:\n    \"\"\"Descriptor that resolves monitor list location at runtime.\n\n    Priority order:\n    1. self.monitors (instance attribute) - for regular plugins\n    2. self.state.monitors - for core plugins storing in SharedState\n\n    This allows plugins to choose where to store monitors:\n    - Regular plugins: set self.monitors = [...] in init()\n    - Core plugins: use self.state.monitors (SharedState)\n    \"\"\"\n\n    def __get__(self, obj: object, _objtype: type | None = None) -> list[str]:\n        \"\"\"Get the monitor list from the appropriate location.\"\"\"\n        if obj is None:\n            return []\n        # Check for instance attribute 'monitors' first (not class attribute)\n        if \"monitors\" in obj.__dict__:\n            return cast(\"list[str]\", obj.__dict__[\"monitors\"])\n        # Fall back to state.monitors for core plugins\n        if hasattr(obj, \"state\") and hasattr(obj.state, \"monitors\"):\n            return cast(\"list[str]\", obj.state.monitors)\n        return []\n\n    def __set__(self, obj: object, value: list[str]) -> None:\n        \"\"\"Set the monitor list in the appropriate location.\"\"\"\n        # If monitors is already an instance attribute, update it\n        if \"monitors\" in obj.__dict__:\n            obj.__dict__[\"monitors\"] = value\n        # Otherwise, use state.monitors if available\n        elif hasattr(obj, \"state\") and hasattr(obj.state, \"monitors\"):\n            obj.state.monitors = value\n        else:\n            obj.__dict__[\"monitors\"] = value\n\n\nclass MonitorTrackingMixin:\n    \"\"\"Mixin for plugins that need to track monitor add/remove events.\n\n    This mixin automatically detects where to store the monitor list:\n    - If self.state.monitors exists (core plugins), uses that\n    - Otherwise uses self.monitors (regular plugins)\n\n    Example usage for regular plugins:\n        class Extension(MonitorTrackingMixin, Plugin):\n            monitors: list[str] = []\n\n            async def init(self):\n                self.monitors = [m[\"name\"] for m in await self.backend.get_monitors()]\n\n    Example usage for core plugins (storing in SharedState):\n        class HyprlandStateMixin(MonitorTrackingMixin):\n            # self.state.monitors is used automatically\n            pass\n    \"\"\"\n\n    # These attributes are provided by the Plugin class\n    log: Logger\n\n    # Descriptor that resolves to the correct monitor list\n    _monitors = MonitorListDescriptor()\n\n    async def event_monitoradded(self, name: str) -> None:\n        \"\"\"Track monitor addition.\n\n        Args:\n            name: The monitor name\n        \"\"\"\n        self._monitors.append(name)\n\n    async def event_monitorremoved(self, name: str) -> None:\n        \"\"\"Track monitor removal.\n\n        Args:\n            name: The monitor name\n        \"\"\"\n        try:\n            self._monitors.remove(name)\n        except ValueError:\n            self.log.warning(\"Monitor %s not found in state - can't be removed\", name)\n\n\n# Keep backwards compatibility alias\nStateMonitorTrackingMixin = MonitorTrackingMixin\n"
  },
  {
    "path": "pyprland/plugins/monitors/__init__.py",
    "content": "\"\"\"The monitors plugin.\"\"\"\n\nimport asyncio\nfrom typing import Any\n\nfrom ...adapters.niri import niri_output_to_monitor_info\nfrom ...aioops import DebouncedTask\nfrom ...models import Environment, MonitorInfo, ReloadReason\nfrom ...process import create_subprocess\nfrom ...validation import ConfigField, ConfigItems\nfrom ..interface import Plugin\nfrom .commands import (\n    build_hyprland_command,\n    build_niri_disable_action,\n    build_niri_position_action,\n    build_niri_scale_action,\n    build_niri_transform_action,\n)\nfrom .layout import (\n    build_graph,\n    compute_positions,\n    find_cycle_path,\n)\nfrom .resolution import get_monitor_by_pattern, resolve_placement_config\nfrom .schema import MONITOR_PROPS_SCHEMA, validate_placement_keys\n\n\nclass Extension(Plugin, environments=[Environment.HYPRLAND, Environment.NIRI]):\n    \"\"\"Allows relative placement and configuration of monitors.\"\"\"\n\n    config_schema = ConfigItems(\n        ConfigField(\"startup_relayout\", bool, default=True, description=\"Relayout monitors on startup\", category=\"behavior\"),\n        ConfigField(\n            \"relayout_on_config_change\", bool, default=True, description=\"Relayout when Hyprland config is reloaded\", category=\"behavior\"\n        ),\n        ConfigField(\n            \"new_monitor_delay\", float, default=1.0, description=\"Delay in seconds before handling new monitor\", category=\"behavior\"\n        ),\n        ConfigField(\n            \"unknown\", str, default=\"\", description=\"Command to run when an unknown monitor is detected\", category=\"external_commands\"\n        ),\n        ConfigField(\n            \"placement\",\n            dict,\n            required=True,\n            default={},\n            description=\"Monitor placement rules (pattern -> positioning rules)\",\n            children=MONITOR_PROPS_SCHEMA,\n            validator=validate_placement_keys,\n            children_allow_extra=True,  # Allow dynamic placement keys (leftOf, topOf, etc.)\n            category=\"placement\",\n        ),\n        ConfigField(\n            \"hotplug_commands\",\n            dict,\n            default={},\n            description=\"Commands to run when specific monitors are plugged (pattern -> command)\",\n            category=\"external_commands\",\n        ),\n        ConfigField(\n            \"hotplug_command\", str, default=\"\", description=\"Command to run when any monitor is plugged\", category=\"external_commands\"\n        ),\n    )\n\n    _mon_by_pat_cache: dict[str, MonitorInfo]\n    _relayout_debouncer: DebouncedTask\n\n    async def on_reload(self, reason: ReloadReason = ReloadReason.RELOAD) -> None:\n        \"\"\"Reload the plugin.\"\"\"\n        _ = reason  # unused\n        self._mon_by_pat_cache = {}\n        self._relayout_debouncer = DebouncedTask(ignore_window=3.0)\n        self._clear_mon_by_pat_cache()\n        monitors = await self.backend.get_monitors(include_disabled=True)\n\n        for mon in self.state.monitors:\n            await self._hotplug_command(monitors, name=mon)\n\n        if self.get_config_bool(\"startup_relayout\"):\n\n            async def _delayed_relayout() -> None:\n                await self._run_relayout(monitors)\n                await asyncio.sleep(1)\n                await self._run_relayout()\n\n            await _delayed_relayout()\n\n    async def event_configreloaded(self, _: str = \"\") -> None:\n        \"\"\"Relayout screens after settings has been lost.\"\"\"\n        if not self.get_config_bool(\"relayout_on_config_change\"):\n            return\n        self._relayout_debouncer.schedule(self._delayed_relayout, delay=1.0)\n\n    async def _delayed_relayout(self) -> None:\n        \"\"\"Delayed relayout that runs twice with 1s gap.\"\"\"\n        for _i in range(2):\n            await asyncio.sleep(1)\n            await self._run_relayout()\n\n    async def event_monitoradded(self, name: str) -> None:\n        \"\"\"Triggers when a monitor is plugged.\n\n        Args:\n            name: The name of the added monitor\n        \"\"\"\n        delay = self.get_config_float(\"new_monitor_delay\")\n        await asyncio.sleep(delay)\n        monitors = await self.backend.get_monitors(include_disabled=True)\n        await self._hotplug_command(monitors, name)\n\n        if not await self._run_relayout(monitors):\n            default_command = self.get_config_str(\"unknown\")\n            if default_command:\n                await create_subprocess(default_command)\n\n    async def niri_outputschanged(self, _data: dict) -> None:\n        \"\"\"Handle Niri output changes.\n\n        Args:\n            _data: Event data from Niri (unused)\n        \"\"\"\n        delay = self.get_config_float(\"new_monitor_delay\")\n        await asyncio.sleep(delay)\n        await self._run_relayout()\n\n    async def run_relayout(self) -> bool:\n        \"\"\"Recompute & apply every monitors's layout.\"\"\"\n        return await self._run_relayout()\n\n    async def _run_relayout(self, monitors: list[MonitorInfo] | None = None) -> bool:\n        \"\"\"Recompute & apply every monitors's layout.\n\n        Args:\n            monitors: Optional list of monitors to use. If not provided, fetches current state.\n        \"\"\"\n        if self.state.environment == Environment.NIRI:\n            return await self._run_relayout_niri()\n\n        if monitors is None:\n            monitors = await self.backend.get_monitors(include_disabled=True)\n\n        self._clear_mon_by_pat_cache()\n\n        # 1. Resolve configuration\n        config = self._resolve_names(monitors)\n        if not config:\n            self.log.debug(\"No configuration item is applicable\")\n            return False\n\n        self.log.debug(\"Using %s\", config)\n\n        monitors_by_name = {m[\"name\"]: m for m in monitors}\n\n        # Mark monitors to disable and get enabled monitors\n        enabled_monitors_by_name = self._mark_disabled_monitors(config, monitors_by_name)\n\n        # 2. Build dependency graph\n        tree, in_degree = self._build_graph(config, enabled_monitors_by_name)\n\n        # 3. Compute Layout\n        positions = self._compute_positions(enabled_monitors_by_name, tree, in_degree, config)\n\n        # 4 & 5. Normalize and Apply\n        return await self._apply_layout(positions, monitors_by_name, config)\n\n    async def _run_relayout_niri(self) -> bool:\n        \"\"\"Niri implementation of relayout.\"\"\"\n        outputs = await self.backend.execute_json(\"outputs\")\n\n        monitors: list[MonitorInfo] = [niri_output_to_monitor_info(name, data) for name, data in outputs.items()]\n\n        self._clear_mon_by_pat_cache()\n\n        # 1. Resolve configuration\n        config = self._resolve_names(monitors)\n        if not config:\n            self.log.debug(\"No configuration item is applicable\")\n            return False\n\n        monitors_by_name = {m[\"name\"]: m for m in monitors}\n\n        # Mark monitors to disable and get enabled monitors\n        enabled_monitors_by_name = self._mark_disabled_monitors(config, monitors_by_name)\n\n        # 2. Build dependency graph\n        tree, in_degree = self._build_graph(config, enabled_monitors_by_name)\n\n        # 3. Compute Layout\n        positions = self._compute_positions(enabled_monitors_by_name, tree, in_degree, config)\n\n        # 4 & 5. Apply\n        return await self._apply_layout(positions, monitors_by_name, config)\n\n    def _mark_disabled_monitors(\n        self,\n        config: dict[str, Any],\n        monitors_by_name: dict[str, MonitorInfo],\n    ) -> dict[str, MonitorInfo]:\n        \"\"\"Mark monitors to disable and return enabled monitors.\n\n        Args:\n            config: Configuration dictionary containing optional 'disables' lists\n            monitors_by_name: Mapping of monitor names to info (modified in-place)\n\n        Returns:\n            Dictionary of enabled monitors (excludes those marked to_disable)\n        \"\"\"\n        monitors_to_disable: set[str] = set()\n        for cfg in config.values():\n            if \"disables\" in cfg:\n                monitors_to_disable.update(cfg[\"disables\"])\n\n        for name in monitors_to_disable:\n            if name in monitors_by_name:\n                monitors_by_name[name][\"to_disable\"] = True\n\n        return {k: v for k, v in monitors_by_name.items() if not v.get(\"to_disable\")}\n\n    def _build_graph(\n        self, config: dict[str, Any], monitors_by_name: dict[str, MonitorInfo]\n    ) -> tuple[dict[str, list[tuple[str, str]]], dict[str, int]]:\n        \"\"\"Build the dependency graph for monitor layout.\n\n        Args:\n            config: Configuration dictionary\n            monitors_by_name: Mapping of monitor names to info\n        \"\"\"\n        tree, in_degree, multi_target_info = build_graph(config, monitors_by_name)\n\n        # Log warnings for multiple targets\n        for name, rule_name, target_names in multi_target_info:\n            self.log.debug(\n                \"Multiple targets for %s.%s: %s - using first: %s\",\n                name,\n                rule_name,\n                target_names,\n                target_names[0],\n            )\n\n        return tree, in_degree\n\n    def _compute_positions(\n        self,\n        monitors_by_name: dict[str, MonitorInfo],\n        tree: dict[str, list[tuple[str, str]]],\n        in_degree: dict[str, int],\n        config: dict[str, Any],\n    ) -> dict[str, tuple[int, int]]:\n        \"\"\"Compute the positions of all monitors.\n\n        Args:\n            monitors_by_name: Mapping of monitor names to info\n            tree: Dependency graph\n            in_degree: In-degree of each node in the graph\n            config: Configuration dictionary\n        \"\"\"\n        positions, unprocessed = compute_positions(monitors_by_name, tree, in_degree, config)\n\n        # Check for unprocessed monitors (indicates circular dependencies)\n        if unprocessed:\n            cycle_info = find_cycle_path(config, unprocessed)\n            self.log.warning(\n                \"Circular dependency detected: %s. Ensure at least one monitor has no placement rule (anchor).\",\n                cycle_info,\n            )\n\n        return positions\n\n    async def _apply_layout(\n        self,\n        positions: dict[str, tuple[int, int]],\n        monitors_by_name: dict[str, MonitorInfo],\n        config: dict[str, Any],\n    ) -> bool:\n        \"\"\"Apply the computed layout.\n\n        Args:\n            positions: Computed (x, y) positions for each monitor\n            monitors_by_name: Mapping of monitor names to info\n            config: Configuration dictionary\n        \"\"\"\n        has_disabled = any(m.get(\"to_disable\") for m in monitors_by_name.values())\n        if not positions and not has_disabled:\n            return False\n\n        if self.state.environment == Environment.NIRI:\n            return await self._apply_niri_layout(positions, monitors_by_name, config)\n\n        if positions:\n            min_x = min(x for x, y in positions.values())\n            min_y = min(y for x, y in positions.values())\n        else:\n            min_x = 0\n            min_y = 0\n\n        cmds = []\n        for name, (x, y) in positions.items():\n            mon = monitors_by_name[name]\n            mon[\"x\"] = x - min_x\n            mon[\"y\"] = y - min_y\n            mon_config = config.get(name, {})\n            cmd = build_hyprland_command(mon, mon_config)\n            cmds.append(cmd)\n\n        for name, mon in monitors_by_name.items():\n            if mon.get(\"to_disable\"):\n                cmds.append(f\"monitor {name},disable\")\n\n        # Set ignore window before triggering config reload via hyprctl keyword\n        self._relayout_debouncer.set_ignore_window()\n\n        for cmd in cmds:\n            self.log.debug(cmd)\n            await self.backend.execute(cmd, base_command=\"keyword\")\n        return True\n\n    async def _apply_niri_layout(\n        self,\n        positions: dict[str, tuple[int, int]],\n        monitors_by_name: dict[str, MonitorInfo],\n        config: dict[str, Any],\n    ) -> bool:\n        \"\"\"Apply Niri layout.\n\n        Args:\n            positions: Computed (x, y) positions for each monitor\n            monitors_by_name: Mapping of monitor names to info\n            config: Configuration dictionary\n        \"\"\"\n        # Handle disabled monitors first\n        for name, mon in monitors_by_name.items():\n            if mon.get(\"to_disable\"):\n                await self.backend.execute(build_niri_disable_action(name))\n\n        # Apply positions and settings for enabled monitors\n        for name, (x, y) in positions.items():\n            # Set position\n            await self.backend.execute(build_niri_position_action(name, x, y))\n\n            mon_config = config.get(name, {})\n\n            # Set scale if configured\n            scale = mon_config.get(\"scale\")\n            if scale:\n                await self.backend.execute(build_niri_scale_action(name, scale))\n\n            # Set transform if configured\n            transform = mon_config.get(\"transform\")\n            if transform is not None:\n                await self.backend.execute(build_niri_transform_action(name, transform))\n\n        return True\n\n    def _resolve_names(self, monitors: list[MonitorInfo]) -> dict[str, Any]:\n        \"\"\"Resolve configuration patterns to actual monitor names.\n\n        Args:\n            monitors: List of available monitors\n        \"\"\"\n        return resolve_placement_config(\n            self.get_config_dict(\"placement\"),\n            monitors,\n            self._mon_by_pat_cache,\n        )\n\n    async def _hotplug_command(self, monitors: list[MonitorInfo], name: str) -> None:\n        \"\"\"Run the hotplug command for the monitor.\n\n        Args:\n            monitors: List of available monitors\n            name: Name of the hotplugged monitor\n        \"\"\"\n        monitors_by_descr = {m[\"description\"]: m for m in monitors}\n        monitors_by_name = {m[\"name\"]: m for m in monitors}\n        for descr, command in self.get_config_dict(\"hotplug_commands\").items():\n            mon = get_monitor_by_pattern(descr, monitors_by_descr, monitors_by_name, self._mon_by_pat_cache)\n            if mon and mon[\"name\"] == name:\n                await create_subprocess(command)\n                break\n        single_command = self.get_config_str(\"hotplug_command\")\n        if single_command:\n            await create_subprocess(single_command)\n\n    def _clear_mon_by_pat_cache(self) -> None:\n        \"\"\"Clear the cache.\"\"\"\n        self._mon_by_pat_cache = {}\n"
  },
  {
    "path": "pyprland/plugins/monitors/commands.py",
    "content": "\"\"\"Command building for Hyprland and Niri backends.\"\"\"\n\nfrom typing import Any\n\nfrom ...models import MonitorInfo\n\nNIRI_TRANSFORM_NAMES = [\n    \"Normal\",\n    \"90\",\n    \"180\",\n    \"270\",\n    \"Flipped\",\n    \"Flipped90\",\n    \"Flipped180\",\n    \"Flipped270\",\n]\n\n\ndef build_hyprland_command(monitor: MonitorInfo, config: dict[str, Any]) -> str:\n    \"\"\"Build Hyprland monitor command string.\n\n    Args:\n        monitor: Monitor information (must have x, y set)\n        config: Monitor-specific configuration\n\n    Returns:\n        Command string like \"monitor DP-1,1920x1080@60,0x0,1.0,transform,0\"\n    \"\"\"\n    name = monitor[\"name\"]\n    rate = config.get(\"rate\", monitor[\"refreshRate\"])\n    res = config.get(\"resolution\", f\"{monitor['width']}x{monitor['height']}\")\n    if isinstance(res, list):\n        res = f\"{res[0]}x{res[1]}\"\n    scale = config.get(\"scale\", monitor[\"scale\"])\n    position = f\"{monitor['x']}x{monitor['y']}\"\n    transform = config.get(\"transform\", monitor[\"transform\"])\n    return f\"monitor {name},{res}@{rate},{position},{scale},transform,{transform}\"\n\n\ndef build_niri_position_action(name: str, x_pos: int, y_pos: int) -> dict:\n    \"\"\"Build Niri position action.\n\n    Args:\n        name: Output name\n        x_pos: X coordinate\n        y_pos: Y coordinate\n\n    Returns:\n        Niri action dict for setting position\n    \"\"\"\n    return {\"Output\": {\"output\": name, \"action\": {\"Position\": {\"Specific\": {\"x\": x_pos, \"y\": y_pos}}}}}\n\n\ndef build_niri_scale_action(name: str, scale: float) -> dict:\n    \"\"\"Build Niri scale action.\n\n    Args:\n        name: Output name\n        scale: Scale factor\n\n    Returns:\n        Niri action dict for setting scale\n    \"\"\"\n    return {\"Output\": {\"output\": name, \"action\": {\"Scale\": {\"Specific\": float(scale)}}}}\n\n\ndef build_niri_transform_action(name: str, transform: int | str) -> dict:\n    \"\"\"Build Niri transform action.\n\n    Args:\n        name: Output name\n        transform: Transform value (int 0-7 or string)\n\n    Returns:\n        Niri action dict for setting transform\n    \"\"\"\n    if isinstance(transform, int) and 0 <= transform < len(NIRI_TRANSFORM_NAMES):\n        transform_str = NIRI_TRANSFORM_NAMES[transform]\n    else:\n        transform_str = str(transform)\n    return {\"Output\": {\"output\": name, \"action\": {\"Transform\": {\"transform\": transform_str}}}}\n\n\ndef build_niri_disable_action(name: str) -> dict:\n    \"\"\"Build Niri disable action.\n\n    Args:\n        name: Output name\n\n    Returns:\n        Niri action dict for disabling output\n    \"\"\"\n    return {\"Output\": {\"output\": name, \"action\": \"Off\"}}\n"
  },
  {
    "path": "pyprland/plugins/monitors/layout.py",
    "content": "\"\"\"Layout positioning logic.\"\"\"\n\nfrom collections import defaultdict\nfrom typing import Any\n\nfrom ...models import MonitorInfo\nfrom .schema import MONITOR_PROPS\n\nMAX_CYCLE_PATH_LENGTH = 10\n\n\ndef get_dims(mon: MonitorInfo, config: dict[str, Any] | None = None) -> tuple[int, int]:\n    \"\"\"Return the dimensions of the monitor.\n\n    Args:\n        mon: The monitor information.\n        config: The monitor configuration.\n\n    Returns:\n        tuple[int, int]: The (width, height) of the monitor.\n    \"\"\"\n    if config is None:\n        config = {}\n    scale = config.get(\"scale\", mon[\"scale\"])\n    transform = config.get(\"transform\", mon[\"transform\"])\n    width = mon[\"width\"]\n    height = mon[\"height\"]\n\n    res = config.get(\"resolution\")\n    if res:\n        try:\n            if isinstance(res, str) and \"x\" in res:\n                width, height = map(int, res.split(\"x\"))\n            elif isinstance(res, list | tuple):\n                width, height = int(res[0]), int(res[1])\n        except (ValueError, IndexError):\n            pass\n\n    width = int(width / scale)\n    height = int(height / scale)\n\n    if transform in [1, 3, 5, 7]:\n        return height, width\n    return width, height\n\n\ndef _place_left(ref_rect: tuple[int, int, int, int], mon_dim: tuple[int, int], rule: str) -> tuple[int, int]:\n    \"\"\"Place the monitor to the left of the reference.\n\n    Args:\n        ref_rect: The (x, y, width, height) of the reference monitor.\n        mon_dim: The (width, height) of the monitor to place.\n        rule: The placement rule (e.g. \"left\", \"left-center\", \"left-end\").\n\n    Returns:\n        tuple[int, int]: The (x, y) coordinates for the new monitor.\n    \"\"\"\n    ref_x, ref_y, _ref_w, ref_h = ref_rect\n    mon_w, mon_h = mon_dim\n    x = ref_x - mon_w\n    y = ref_y\n    if \"end\" in rule:\n        y = ref_y + ref_h - mon_h\n    elif \"center\" in rule or \"middle\" in rule:\n        y = ref_y + (ref_h - mon_h) // 2\n    return int(x), int(y)\n\n\ndef _place_right(ref_rect: tuple[int, int, int, int], mon_dim: tuple[int, int], rule: str) -> tuple[int, int]:\n    \"\"\"Place the monitor to the right of the reference.\n\n    Args:\n        ref_rect: The (x, y, width, height) of the reference monitor.\n        mon_dim: The (width, height) of the monitor to place.\n        rule: The placement rule (e.g. \"right\", \"right-center\", \"right-end\").\n\n    Returns:\n        tuple[int, int]: The (x, y) coordinates for the new monitor.\n    \"\"\"\n    ref_x, ref_y, ref_w, ref_h = ref_rect\n    _mon_w, mon_h = mon_dim\n    x = ref_x + ref_w\n    y = ref_y\n    if \"end\" in rule:\n        y = ref_y + ref_h - mon_h\n    elif \"center\" in rule or \"middle\" in rule:\n        y = ref_y + (ref_h - mon_h) // 2\n    return int(x), int(y)\n\n\ndef _place_top(ref_rect: tuple[int, int, int, int], mon_dim: tuple[int, int], rule: str) -> tuple[int, int]:\n    \"\"\"Place the monitor to the top of the reference.\n\n    Args:\n        ref_rect: The (x, y, width, height) of the reference monitor.\n        mon_dim: The (width, height) of the monitor to place.\n        rule: The placement rule (e.g. \"top\", \"top-center\", \"top-end\").\n\n    Returns:\n        tuple[int, int]: The (x, y) coordinates for the new monitor.\n    \"\"\"\n    ref_x, ref_y, ref_w, _ref_h = ref_rect\n    mon_w, mon_h = mon_dim\n    y = ref_y - mon_h\n    x = ref_x\n    if \"end\" in rule:\n        x = ref_x + ref_w - mon_w\n    elif \"center\" in rule or \"middle\" in rule:\n        x = ref_x + (ref_w - mon_w) // 2\n    return int(x), int(y)\n\n\ndef _place_bottom(ref_rect: tuple[int, int, int, int], mon_dim: tuple[int, int], rule: str) -> tuple[int, int]:\n    \"\"\"Place the monitor to the bottom of the reference.\n\n    Args:\n        ref_rect: The (x, y, width, height) of the reference monitor.\n        mon_dim: The (width, height) of the monitor to place.\n        rule: The placement rule (e.g. \"bottom\", \"bottom-center\", \"bottom-end\").\n\n    Returns:\n        tuple[int, int]: The (x, y) coordinates for the new monitor.\n    \"\"\"\n    ref_x, ref_y, ref_w, ref_h = ref_rect\n    mon_w, _mon_h = mon_dim\n    y = ref_y + ref_h\n    x = ref_x\n    if \"end\" in rule:\n        x = ref_x + ref_w - mon_w\n    elif \"center\" in rule or \"middle\" in rule:\n        x = ref_x + (ref_w - mon_w) // 2\n    return int(x), int(y)\n\n\ndef compute_xy(\n    ref_rect: tuple[int, int, int, int],\n    mon_dim: tuple[int, int],\n    rule: str,\n) -> tuple[int, int]:\n    \"\"\"Compute position of a monitor relative to a reference monitor.\n\n    Args:\n        ref_rect: The (x, y, width, height) of the reference monitor.\n        mon_dim: The (width, height) of the monitor to place.\n        rule: The placement rule (e.g. \"left\", \"right\", \"top-center\").\n\n    Returns:\n        tuple[int, int]: The (x, y) coordinates for the new monitor.\n    \"\"\"\n    rule = rule.lower().replace(\"_\", \"\").replace(\"-\", \"\")\n\n    if \"left\" in rule:\n        return _place_left(ref_rect, mon_dim, rule)\n    if \"right\" in rule:\n        return _place_right(ref_rect, mon_dim, rule)\n    if \"top\" in rule:\n        return _place_top(ref_rect, mon_dim, rule)\n    if \"bottom\" in rule:\n        return _place_bottom(ref_rect, mon_dim, rule)\n\n    return ref_rect[0], ref_rect[1]\n\n\n# --- Graph and Position Computation ---\n\n\ndef build_graph(\n    config: dict[str, Any],\n    monitors_by_name: dict[str, MonitorInfo],\n) -> tuple[dict[str, list[tuple[str, str]]], dict[str, int], list[tuple[str, str, list[str]]]]:\n    \"\"\"Build the dependency graph for monitor layout.\n\n    Args:\n        config: Configuration dictionary (resolved monitor names -> rules)\n        monitors_by_name: Mapping of monitor names to info\n\n    Returns:\n        Tuple of:\n        - tree: Dependency graph (parent -> list of (child, rule))\n        - in_degree: In-degree count for each monitor\n        - multi_target_info: List of (name, rule, targets) for logging when multiple targets specified\n    \"\"\"\n    tree: dict[str, list[tuple[str, str]]] = defaultdict(list)\n    in_degree: dict[str, int] = defaultdict(int)\n    multi_target_info: list[tuple[str, str, list[str]]] = []\n\n    for name in monitors_by_name:\n        in_degree[name] = 0\n\n    for name, rules in config.items():\n        for rule_name, target_names in rules.items():\n            if rule_name in MONITOR_PROPS or rule_name == \"disables\":\n                continue\n            if len(target_names) > 1:\n                multi_target_info.append((name, rule_name, target_names))\n            target_name = target_names[0] if target_names else None\n            if target_name and target_name in monitors_by_name:\n                tree[target_name].append((name, rule_name))\n                in_degree[name] += 1\n\n    return tree, in_degree, multi_target_info\n\n\ndef compute_positions(\n    monitors_by_name: dict[str, MonitorInfo],\n    tree: dict[str, list[tuple[str, str]]],\n    in_degree: dict[str, int],\n    config: dict[str, Any],\n) -> tuple[dict[str, tuple[int, int]], list[str]]:\n    \"\"\"Compute the positions of all monitors using topological sort.\n\n    Args:\n        monitors_by_name: Mapping of monitor names to info\n        tree: Dependency graph (parent -> list of (child, rule))\n        in_degree: In-degree count for each monitor\n        config: Configuration dictionary\n\n    Returns:\n        Tuple of:\n        - positions: Computed (x, y) positions for each monitor\n        - unprocessed: List of monitor names that couldn't be positioned (cycle detected)\n    \"\"\"\n    queue = [name for name in monitors_by_name if in_degree[name] == 0]\n    positions: dict[str, tuple[int, int]] = {}\n    for name in queue:\n        positions[name] = (monitors_by_name[name][\"x\"], monitors_by_name[name][\"y\"])\n\n    processed = set()\n    while queue:\n        ref_name = queue.pop(0)\n        if ref_name in processed:\n            continue\n        processed.add(ref_name)\n\n        for child_name, rule in tree[ref_name]:\n            ref_rect = (\n                *positions[ref_name],\n                *get_dims(monitors_by_name[ref_name], config.get(ref_name, {})),\n            )\n\n            mon_dim = get_dims(monitors_by_name[child_name], config.get(child_name, {}))\n\n            positions[child_name] = compute_xy(\n                ref_rect,\n                mon_dim,\n                rule,\n            )\n\n            in_degree[child_name] -= 1\n            if in_degree[child_name] == 0:\n                queue.append(child_name)\n\n    # Return unprocessed monitors (indicates circular dependencies)\n    unprocessed = [name for name in monitors_by_name if name not in positions]\n    return positions, unprocessed\n\n\ndef find_cycle_path(config: dict[str, Any], unprocessed: list[str]) -> str:\n    \"\"\"Find and format the cycle path for unprocessed monitors.\n\n    Args:\n        config: Configuration dictionary\n        unprocessed: List of monitor names that couldn't be positioned\n\n    Returns:\n        Human-readable cycle path string\n    \"\"\"\n    # Build reverse lookup: monitor -> target it depends on\n    depends_on: dict[str, str] = {}\n    for name, rules in config.items():\n        for rule_name, target_names in rules.items():\n            if rule_name in MONITOR_PROPS or rule_name == \"disables\":\n                continue\n            if target_names:\n                depends_on[name] = target_names[0]\n\n    # Trace cycle starting from first unprocessed monitor\n    start = unprocessed[0]\n    path = [start]\n    current = depends_on.get(start)\n\n    while current and current not in path and len(path) < MAX_CYCLE_PATH_LENGTH:\n        path.append(current)\n        current = depends_on.get(current)\n\n    if current and current in path:\n        # Found the cycle - show it\n        cycle_start = path.index(current)\n        cycle = [*path[cycle_start:], current]\n        return \" -> \".join(cycle)\n\n    # No clear cycle found, just list unprocessed\n    return f\"unpositioned monitors: {', '.join(unprocessed)}\"\n"
  },
  {
    "path": "pyprland/plugins/monitors/resolution.py",
    "content": "\"\"\"Monitor pattern matching and name resolution.\"\"\"\n\nfrom typing import Any\n\nfrom ...models import MonitorInfo\nfrom .schema import MONITOR_PROPS\n\n\ndef get_monitor_by_pattern(\n    pattern: str,\n    description_db: dict[str, MonitorInfo],\n    name_db: dict[str, MonitorInfo],\n    cache: dict[str, MonitorInfo] | None = None,\n) -> MonitorInfo | None:\n    \"\"\"Find a monitor by pattern (exact name or description substring).\n\n    Args:\n        pattern: Pattern to search for (monitor name or description substring)\n        description_db: Mapping of descriptions to monitor info\n        name_db: Mapping of names to monitor info\n        cache: Optional cache to store/retrieve results\n\n    Returns:\n        MonitorInfo if found, None otherwise\n    \"\"\"\n    if cache is not None:\n        cached = cache.get(pattern)\n        if cached:\n            return cached\n\n    result: MonitorInfo | None = None\n\n    if pattern in name_db:\n        result = name_db[pattern]\n    else:\n        for full_descr, mon in description_db.items():\n            if pattern in full_descr:\n                result = mon\n                break\n\n    if result is not None and cache is not None:\n        cache[pattern] = result\n\n    return result\n\n\ndef resolve_placement_config(\n    placement_config: dict[str, Any],\n    monitors: list[MonitorInfo],\n    cache: dict[str, MonitorInfo] | None = None,\n) -> dict[str, dict[str, Any]]:\n    \"\"\"Resolve configuration patterns to actual monitor names.\n\n    Takes placement configuration with patterns (monitor names or description\n    substrings) and resolves them to actual connected monitor names.\n\n    Args:\n        placement_config: Raw placement configuration from plugin config\n        monitors: List of available monitors\n        cache: Optional cache for pattern lookups\n\n    Returns:\n        Configuration dict keyed by actual monitor names with resolved targets\n    \"\"\"\n    monitors_by_descr = {m[\"description\"]: m for m in monitors}\n    monitors_by_name = {m[\"name\"]: m for m in monitors}\n\n    cleaned_config: dict[str, dict[str, Any]] = {}\n\n    for pat, rules in placement_config.items():\n        # Find the subject monitor\n        mon = get_monitor_by_pattern(pat, monitors_by_descr, monitors_by_name, cache)\n        if not mon:\n            continue\n\n        name = mon[\"name\"]\n        cleaned_config[name] = {}\n\n        for rule_key, rule_val in rules.items():\n            if rule_key in MONITOR_PROPS:\n                cleaned_config[name][rule_key] = rule_val\n                continue\n\n            # Resolve target monitors in the rule\n            targets = []\n            for target_pat in [rule_val] if isinstance(rule_val, str) else rule_val:\n                target_mon = get_monitor_by_pattern(target_pat, monitors_by_descr, monitors_by_name, cache)\n                if target_mon:\n                    targets.append(target_mon[\"name\"])\n\n            if targets:\n                cleaned_config[name][rule_key] = targets\n\n    return cleaned_config\n"
  },
  {
    "path": "pyprland/plugins/monitors/schema.py",
    "content": "\"\"\"Configuration schema for monitors plugin.\"\"\"\n\nfrom typing import Any\n\nfrom ...validation import ConfigField, ConfigItems\n\n# Valid placement directions for custom validator\nPLACEMENT_DIRECTIONS = {\"left\", \"right\", \"top\", \"bottom\"}\n\n# Static monitor properties (used by both schema validation and layout logic)\nMONITOR_PROPS = {\"resolution\", \"rate\", \"scale\", \"transform\"}\n\n# Schema for monitor properties within placement config\nMONITOR_PROPS_SCHEMA = ConfigItems(\n    ConfigField(\"scale\", float, description=\"UI scale factor\", category=\"display\"),\n    ConfigField(\"rate\", (int, float), description=\"Refresh rate in Hz\", category=\"display\"),\n    ConfigField(\n        \"resolution\",\n        (str, list),\n        description=\"Display resolution (e.g., '2560x1440' or [2560, 1440])\",\n        category=\"display\",\n    ),\n    ConfigField(\n        \"transform\",\n        int,\n        choices=[0, 1, 2, 3, 4, 5, 6, 7],\n        description=\"Rotation/flip transform\",\n        category=\"display\",\n    ),\n    ConfigField(\n        \"disables\",\n        list,\n        description=\"List of monitors to disable when this monitor is connected\",\n        category=\"behavior\",\n    ),\n)\n\n\ndef validate_placement_keys(value: dict[str, Any]) -> list[str]:\n    \"\"\"Validator for dynamic placement keys (leftOf, topCenterOf, etc).\n\n    Static properties (scale, rate, resolution, transform, disables) are\n    validated by the children schema. This validator handles the dynamic\n    placement direction rules.\n\n    Args:\n        value: The placement configuration dictionary\n\n    Returns:\n        List of validation errors\n    \"\"\"\n    errors = []\n    # Get known static property names from schema\n    known_props = MONITOR_PROPS.union({\"disables\"})\n\n    for monitor_pattern, rules in value.items():\n        if not isinstance(rules, dict):\n            continue\n        for key, val in rules.items():\n            # Skip known static properties (validated by children schema)\n            if key in known_props:\n                continue\n            # Check if it's a valid placement direction\n            key_lower = key.lower().replace(\"_\", \"\")\n            if not any(key_lower.startswith(d) for d in PLACEMENT_DIRECTIONS):\n                errors.append(f\"Invalid placement rule '{key}' for '{monitor_pattern}'\")\n            # Validate placement target value type\n            elif not isinstance(val, str) and not (isinstance(val, list) and all(isinstance(o, str) for o in val)):\n                errors.append(f\"Invalid placement value for '{monitor_pattern}.{key}': expected string or list of strings\")\n\n    return errors\n"
  },
  {
    "path": "pyprland/plugins/protocols.py",
    "content": "\"\"\"Protocol definitions for plugin event handlers.\n\nThis module provides Protocol classes that document the expected signatures\nfor event handlers. Plugins don't need to inherit these - they exist for:\n\n1. Documentation of expected event handler signatures\n2. Optional mypy validation when plugins choose to inherit\n3. Reference for test validation of handler signatures\n\nUsage for plugin authors who want mypy validation::\n\n    from pyprland.plugins.protocols import HyprlandEvents\n\n    class Extension(HyprlandEvents, Plugin):\n        async def event_monitoradded(self, name: str) -> None:\n            ...  # mypy validates signature matches Protocol\n\"\"\"\n\nfrom typing import Any, Protocol, runtime_checkable\n\n\n@runtime_checkable\nclass HyprlandEvents(Protocol):\n    \"\"\"Protocol defining Hyprland event handler signatures.\n\n    All event handlers receive a single string parameter.\n    For events with no data (like configreloaded), an empty string is passed.\n\n    See https://wiki.hyprland.org/IPC/ for the full list of Hyprland events.\n    \"\"\"\n\n    async def event_activewindowv2(self, addr: str) -> None:\n        \"\"\"Window focus changed.\n\n        Args:\n            addr: Window address as hex string (without 0x prefix)\n        \"\"\"\n        ...\n\n    async def event_changefloatingmode(self, args: str) -> None:\n        \"\"\"Window floating mode changed.\n\n        Args:\n            args: Format \"address,0|1\" - address and floating state\n        \"\"\"\n        ...\n\n    async def event_closewindow(self, addr: str) -> None:\n        \"\"\"Window closed.\n\n        Args:\n            addr: Window address as hex string (without 0x prefix)\n        \"\"\"\n        ...\n\n    async def event_configreloaded(self, data: str = \"\") -> None:\n        \"\"\"Hyprland config reloaded.\n\n        Args:\n            data: Empty string (Hyprland sends no data for this event)\n        \"\"\"\n        ...\n\n    async def event_focusedmon(self, mon: str) -> None:\n        \"\"\"Monitor focus changed.\n\n        Args:\n            mon: Format \"monitorname,workspacename\"\n        \"\"\"\n        ...\n\n    async def event_monitoradded(self, name: str) -> None:\n        \"\"\"Monitor connected.\n\n        Args:\n            name: Monitor name (e.g., \"DP-1\", \"HDMI-A-1\")\n        \"\"\"\n        ...\n\n    async def event_monitorremoved(self, name: str) -> None:\n        \"\"\"Monitor disconnected.\n\n        Args:\n            name: Monitor name\n        \"\"\"\n        ...\n\n    async def event_openwindow(self, params: str) -> None:\n        \"\"\"Window opened.\n\n        Args:\n            params: Format \"address,workspace,class,title\"\n        \"\"\"\n        ...\n\n    async def event_workspace(self, workspace: str) -> None:\n        \"\"\"Workspace changed.\n\n        Args:\n            workspace: Workspace name (can be number or string)\n        \"\"\"\n        ...\n\n\n@runtime_checkable\nclass NiriEvents(Protocol):\n    \"\"\"Protocol defining Niri event handler signatures.\n\n    Niri events pass JSON-parsed dict data.\n    \"\"\"\n\n    async def niri_outputschanged(self, data: dict[str, Any]) -> None:\n        \"\"\"Outputs configuration changed.\n\n        Args:\n            data: Event data dictionary from Niri\n        \"\"\"\n        ...\n"
  },
  {
    "path": "pyprland/plugins/pyprland/__init__.py",
    "content": "\"\"\"Core plugin for state management.\n\nThis plugin is not a real plugin - it provides core features and caching\nof commonly requested structures. It handles initialization and state\ntracking for both Hyprland and Niri environments.\n\"\"\"\n\nimport json\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, Any\n\nfrom ...commands.discovery import extract_commands_from_object\nfrom ...completions import handle_compgen\nfrom ...config import BOOL_FALSE_STRINGS, BOOL_TRUE_STRINGS\nfrom ...doc import format_config_field_doc, format_plugin_doc, format_plugin_list\nfrom ...help import get_command_help, get_help\nfrom ...models import Environment, ReloadReason, VersionInfo\nfrom ...validation import ConfigField, ConfigItems\nfrom ...version import VERSION\nfrom ..interface import Plugin\nfrom ..scratchpads.schema import SCRATCHPAD_SCHEMA\nfrom .hyprland_core import HyprlandStateMixin\nfrom .niri_core import NiriStateMixin\nfrom .schema import PYPRLAND_CONFIG_SCHEMA\n\nif TYPE_CHECKING:\n    from ...manager import Pyprland\n\nDEFAULT_VERSION = VersionInfo(9, 9, 9)\n\n\nclass Extension(HyprlandStateMixin, NiriStateMixin, Plugin):\n    \"\"\"Internal built-in plugin allowing caching states and implementing special commands.\"\"\"\n\n    config_schema = PYPRLAND_CONFIG_SCHEMA\n    manager: \"Pyprland\"  # Set by manager during init\n\n    async def init(self) -> None:\n        \"\"\"Initialize the plugin.\"\"\"\n        self.state.active_window = \"\"\n\n        if self.state.environment == Environment.NIRI:\n            await self._init_niri()\n        else:\n            await self._init_hyprland()\n\n    async def on_reload(self, reason: ReloadReason = ReloadReason.RELOAD) -> None:\n        \"\"\"Reload the plugin.\"\"\"\n        _ = reason  # unused\n        self.state.variables = self.get_config_dict(\"variables\")\n        version_override = self.get_config_str(\"hyprland_version\")\n        if version_override:\n            self._set_hyprland_version(version_override)\n\n    def run_version(self) -> str:\n        \"\"\"Show the pyprland version.\"\"\"\n        return f\"{VERSION}\\n\"\n\n    def run_dumpjson(self) -> str:\n        \"\"\"Dump the configuration in JSON format (after includes are processed).\"\"\"\n        return json.dumps(self.manager.config, indent=2)\n\n    def run_help(self, command: str = \"\") -> str:\n        \"\"\"[command] Show available commands or detailed help.\n\n        Usage:\n          pypr help           List all commands\n          pypr help <command> Show detailed help\n        \"\"\"\n        return get_command_help(self.manager, command) if command else get_help(self.manager)\n\n    def run_doc(self, args: str = \"\") -> str:\n        \"\"\"[plugin.option] Show plugin and configuration documentation.\n\n        Usage:\n          pypr doc                 List all plugins\n          pypr doc <plugin>        Show plugin documentation\n          pypr doc <plugin.option> Show config option details\n          pypr doc <plugin> <opt>  Same as plugin.option\n\n        Examples:\n          pypr doc scratchpads\n          pypr doc scratchpads.animation\n          pypr doc wallpapers path\n        \"\"\"\n        if not args:\n            return format_plugin_list(self.manager.plugins)\n\n        # Parse plugin.option or plugin option syntax\n        parts = args.split()\n        if len(parts) == 1 and \".\" in parts[0]:\n            # plugin.option format\n            parts = parts[0].split(\".\", 1)\n\n        plugin_name = parts[0]\n        option_name = parts[1] if len(parts) > 1 else None\n\n        if plugin_name not in self.manager.plugins:\n            # List available plugins\n            available = [p for p in self.manager.plugins if p != \"pyprland\"]\n            msg = f\"Unknown plugin: {plugin_name}\\nAvailable: {', '.join(sorted(available))}\"\n            raise ValueError(msg)\n\n        plugin = self.manager.plugins[plugin_name]\n\n        if option_name:\n            return self._get_option_doc(plugin_name, plugin, option_name)\n\n        # Show full plugin doc\n        commands = list(extract_commands_from_object(plugin, source=plugin_name))\n\n        # Special case: scratchpads uses per-item schema\n        if plugin_name == \"scratchpads\":\n            return format_plugin_doc(plugin, commands, schema_override=SCRATCHPAD_SCHEMA, config_prefix=\"[name].\")\n\n        return format_plugin_doc(plugin, commands)\n\n    def _get_option_doc(self, plugin_name: str, plugin: Plugin, option_name: str) -> str:\n        \"\"\"Get documentation for a specific config option.\n\n        Args:\n            plugin_name: Name of the plugin\n            plugin: The plugin instance\n            option_name: Name of the config option\n\n        Returns:\n            Formatted documentation string\n\n        Raises:\n            ValueError: If option not found\n        \"\"\"\n        # Special case: scratchpads has per-item schema instead of top-level\n        if plugin_name == \"scratchpads\":\n            field = SCRATCHPAD_SCHEMA.get(option_name)\n            if field:\n                return format_config_field_doc(f\"{plugin_name}.[name]\", field)\n            # Option not found in scratchpads schema\n            available_opts = [f.name for f in SCRATCHPAD_SCHEMA]\n            msg = f\"Unknown option '{option_name}' in {plugin_name}\\n\"\n            msg += f\"Available: {', '.join(sorted(available_opts))}\"\n            raise ValueError(msg)\n\n        # Normal plugins with top-level schema\n        schema: ConfigItems | None = getattr(plugin, \"config_schema\", None)\n        if not schema:\n            msg = f\"Plugin '{plugin_name}' has no configuration schema\"\n            raise ValueError(msg)\n\n        field = schema.get(option_name)\n        if not field:\n            # List available options\n            available_opts = [f.name for f in schema]\n            msg = f\"Unknown option '{option_name}' in {plugin_name}\\n\"\n            msg += f\"Available: {', '.join(sorted(available_opts))}\"\n            raise ValueError(msg)\n\n        return format_config_field_doc(plugin_name, field)\n\n    async def run_reload(self) -> None:\n        \"\"\"Reload the configuration file.\n\n        New plugins will be loaded and configuration options will be updated.\n        Most plugins will use the new values on the next command invocation.\n        \"\"\"\n        await self.manager.load_config()\n\n    def run_compgen(self, args: str = \"\") -> str:\n        \"\"\"<shell> [default|path] Generate shell completions.\n\n        Usage:\n          pypr compgen <shell>            Output script to stdout\n          pypr compgen <shell> default    Install to default user path\n          pypr compgen <shell> ~/path     Install to home-relative path\n          pypr compgen <shell> /abs/path  Install to absolute path\n\n        Examples:\n          pypr compgen zsh > ~/.zsh/completions/_pypr\n          pypr compgen bash default\n        \"\"\"\n        success, result = handle_compgen(self.manager, args)\n        if not success:\n            raise ValueError(result)\n        return result\n\n    def run_exit(self) -> None:\n        \"\"\"Terminate the pyprland daemon.\"\"\"\n        self.manager.stopped = True\n\n    def _parse_config_path(self, path: str) -> tuple[str, list[str]]:\n        \"\"\"Parse a dot-separated config path into plugin name and keys.\n\n        Args:\n            path: Dot-separated config path (e.g., 'wallpapers.online_ratio')\n\n        Returns:\n            Tuple of (plugin_name, [key_path_parts])\n\n        Raises:\n            ValueError: If path format is invalid\n        \"\"\"\n        min_parts = 2\n        parts = path.split(\".\")\n        if len(parts) < min_parts:\n            msg = f\"Invalid path '{path}': use 'plugin.key' format\"\n            raise ValueError(msg)\n        return parts[0], parts[1:]\n\n    def _get_nested_value(self, data: dict, keys: list[str]) -> Any:\n        \"\"\"Get a nested value from a dict using a list of keys.\n\n        Args:\n            data: The dictionary to traverse\n            keys: List of keys to follow\n\n        Returns:\n            The value at the nested path\n\n        Raises:\n            KeyError: If any key in the path doesn't exist\n        \"\"\"\n        current: Any = data\n        for key in keys:\n            if not isinstance(current, dict):\n                msg = f\"Cannot access '{key}' on non-dict value\"\n                raise KeyError(msg)\n            current = current[key]\n        return current\n\n    def _set_nested_value(self, data: dict, keys: list[str], value: Any) -> None:\n        \"\"\"Set a nested value in a dict using a list of keys.\n\n        Args:\n            data: The dictionary to modify\n            keys: List of keys to follow (creates intermediate dicts if needed)\n            value: The value to set\n        \"\"\"\n        current = data\n        for key in keys[:-1]:\n            if key not in current:\n                current[key] = {}\n            current = current[key]\n        current[keys[-1]] = value\n\n    def _delete_nested_value(self, data: dict, keys: list[str]) -> bool:\n        \"\"\"Delete a nested value from a dict.\n\n        Args:\n            data: The dictionary to modify\n            keys: List of keys to follow\n\n        Returns:\n            True if deleted, False if key didn't exist\n        \"\"\"\n        current = data\n        for key in keys[:-1]:\n            if key not in current or not isinstance(current[key], dict):\n                return False\n            current = current[key]\n        if keys[-1] in current:\n            del current[keys[-1]]\n            return True\n        return False\n\n    def _get_field_schema(self, plugin_name: str, keys: list[str]) -> ConfigField | None:\n        \"\"\"Get the schema field for a config path.\n\n        Args:\n            plugin_name: Name of the plugin\n            keys: List of keys within the plugin config\n\n        Returns:\n            ConfigField if found, None otherwise\n        \"\"\"\n        if plugin_name not in self.manager.plugins:\n            return None\n\n        plugin = self.manager.plugins[plugin_name]\n        current_schema: ConfigItems | None = getattr(plugin, \"config_schema\", None)\n        if not current_schema:\n            return None\n\n        # Navigate through nested schema\n        for i, key in enumerate(keys):\n            field = current_schema.get(key)\n            if not field:\n                return None\n            if i < len(keys) - 1:\n                # Need to go deeper\n                if field.children:\n                    current_schema = field.children\n                else:\n                    return None\n            else:\n                return field\n        return None\n\n    def _coerce_value(self, value_str: str, field: ConfigField | None, current_value: Any) -> Any:\n        \"\"\"Coerce a string value to the appropriate type.\n\n        Args:\n            value_str: The string value from user input\n            field: Schema field if available\n            current_value: Current value for type inference fallback\n\n        Returns:\n            The coerced value\n        \"\"\"\n        # Handle None/unset\n        if value_str.lower() == \"none\":\n            return None\n\n        # Determine target type\n        target_type = self._get_target_type(field, current_value)\n\n        # Coerce based on type\n        if target_type is None:\n            return value_str\n\n        return self._coerce_to_type(value_str, target_type)\n\n    def _get_target_type(self, field: ConfigField | None, current_value: Any) -> type | None:\n        \"\"\"Get target type from schema field or current value.\"\"\"\n        if field:\n            ft = field.field_type\n            return ft[0] if isinstance(ft, tuple) else ft\n        if current_value is not None:\n            return type(current_value)\n        return None\n\n    def _coerce_to_type(self, value_str: str, target_type: type) -> Any:  # noqa: PLR0911\n        \"\"\"Coerce string to specific type.\"\"\"\n        if target_type is bool:\n            return self._parse_bool(value_str)\n\n        if target_type is int:\n            return int(value_str)\n\n        if target_type is float:\n            return float(value_str)\n\n        if target_type is list:\n            return self._parse_list(value_str)\n\n        if target_type is dict:\n            return json.loads(value_str)\n\n        if target_type is Path or (isinstance(target_type, type) and issubclass(target_type, Path)):\n            return value_str  # Paths are stored as strings\n\n        # Default: string\n        return value_str\n\n    def _parse_bool(self, value_str: str) -> bool:\n        \"\"\"Parse boolean from string.\"\"\"\n        lower = value_str.lower().strip()\n        if lower in BOOL_TRUE_STRINGS:\n            return True\n        if lower in BOOL_FALSE_STRINGS:\n            return False\n        msg = f\"Invalid boolean: '{value_str}'\"\n        raise ValueError(msg)\n\n    def _parse_list(self, value_str: str) -> list:\n        \"\"\"Parse list from JSON or comma-separated string.\"\"\"\n        try:\n            parsed = json.loads(value_str)\n            if isinstance(parsed, list):\n                return parsed\n        except json.JSONDecodeError:\n            pass\n        # Comma-separated fallback\n        return [item.strip() for item in value_str.split(\",\")]\n\n    def run_get(self, path: str) -> str:\n        \"\"\"<plugin.key> Get a configuration value.\n\n        Args:\n            path: Dot-separated path (e.g., 'wallpapers.online_ratio')\n\n        Examples:\n            pypr get wallpapers.online_ratio\n            pypr get scratchpads.term.command\n        \"\"\"\n        try:\n            plugin_name, keys = self._parse_config_path(path)\n        except ValueError as e:\n            raise ValueError(str(e)) from e\n\n        if plugin_name not in self.manager.config:\n            msg = f\"Plugin '{plugin_name}' not found in config\"\n            raise ValueError(msg)\n\n        plugin_config = self.manager.config[plugin_name]\n\n        try:\n            value = self._get_nested_value(plugin_config, keys)\n        except KeyError:\n            # Try schema default\n            field = self._get_field_schema(plugin_name, keys)\n            if field and field.default is not None:\n                value = field.default\n            else:\n                msg = f\"Key '{'.'.join(keys)}' not found in {plugin_name}\"\n                raise ValueError(msg) from None\n\n        # Format output\n        if isinstance(value, (dict, list)):\n            return json.dumps(value, indent=2)\n        return str(value)\n\n    async def run_set(self, args: str) -> str:  # noqa: C901\n        \"\"\"<plugin.key> <value> Set a configuration value.\n\n        Args:\n            args: Path and value (e.g., 'wallpapers.online_ratio 0.5')\n\n        Use 'None' to unset a non-required option.\n\n        Examples:\n            pypr set wallpapers.online_ratio 0.5\n            pypr set wallpapers.path /new/path\n            pypr set scratchpads.term.lazy true\n            pypr set wallpapers.online_ratio None\n        \"\"\"\n        min_parts = 2\n        parts = args.split(None, 1)\n        if len(parts) < min_parts:\n            msg = \"Usage: pypr set <plugin.key> <value>\"\n            raise ValueError(msg)\n\n        path, value_str = parts\n\n        try:\n            plugin_name, keys = self._parse_config_path(path)\n        except ValueError as e:\n            raise ValueError(str(e)) from e\n\n        if plugin_name not in self.manager.plugins:\n            msg = f\"Plugin '{plugin_name}' not found\"\n            raise ValueError(msg)\n\n        # Get schema info\n        field = self._get_field_schema(plugin_name, keys)\n\n        # Get current value for type inference\n        plugin_config = self.manager.config.get(plugin_name, {})\n        try:\n            current_value = self._get_nested_value(plugin_config, keys)\n        except KeyError:\n            current_value = None\n\n        # Coerce value\n        try:\n            new_value = self._coerce_value(value_str, field, current_value)\n        except (ValueError, json.JSONDecodeError) as e:\n            msg = f\"Invalid value: {e}\"\n            raise ValueError(msg) from e\n\n        # Handle None (unset)\n        if new_value is None:\n            if field and field.required:\n                msg = f\"Cannot unset required field '{'.'.join(keys)}'\"\n                raise ValueError(msg)\n            if plugin_name not in self.manager.config:\n                return f\"{path} already unset\"\n            if self._delete_nested_value(self.manager.config[plugin_name], keys):\n                # Reload plugin\n                plugin = self.manager.plugins[plugin_name]\n                await plugin.load_config(self.manager.config)\n                await plugin.on_reload()\n                return f\"{path} unset\"\n            return f\"{path} already unset\"\n\n        # Set the value\n        if plugin_name not in self.manager.config:\n            self.manager.config[plugin_name] = {}\n        self._set_nested_value(self.manager.config[plugin_name], keys, new_value)\n\n        # Reload the affected plugin\n        plugin = self.manager.plugins[plugin_name]\n        await plugin.load_config(self.manager.config)\n        await plugin.on_reload()\n\n        # Format response\n        if isinstance(new_value, (dict, list)):\n            return f\"{path} = {json.dumps(new_value)}\"\n        return f\"{path} = {new_value}\"\n"
  },
  {
    "path": "pyprland/plugins/pyprland/hyprland_core.py",
    "content": "\"\"\"Hyprland-specific state management.\"\"\"\n\nimport json\nfrom typing import Any, cast\n\nfrom ...models import PyprError, VersionInfo\nfrom ..mixins import StateMonitorTrackingMixin\n\nDEFAULT_VERSION = VersionInfo(9, 9, 9)\n\n\nclass HyprlandStateMixin(StateMonitorTrackingMixin):\n    \"\"\"Mixin providing Hyprland-specific state management.\n\n    This mixin is designed to be used with the Extension class and provides\n    all Hyprland-specific initialization and event handling.\n    \"\"\"\n\n    # These attributes are provided by the Extension class\n    backend: Any\n    log: Any\n    state: Any\n    notify_error: Any\n\n    async def _init_hyprland(self) -> None:\n        \"\"\"Initialize Hyprland-specific state.\"\"\"\n        # Examples:\n        # \"tag\": \"v0.40.0-127-g4e42107d\", (for git)\n        # \"tag\": \"v0.40.0\", (stable)\n        version_str = \"\"\n        auto_increment = False\n        version_info = {}\n        try:\n            version_info = await self.backend.execute_json(\"version\")\n            assert isinstance(version_info, dict)\n        except (FileNotFoundError, json.JSONDecodeError, PyprError):\n            self.log.warning(\"Fail to parse hyprctl version\")\n        else:\n            _tag = version_info.get(\"tag\")\n\n            if _tag and _tag != \"unknown\":\n                assert isinstance(_tag, str)\n                version_str = _tag.split(\"-\", 1)[0]\n                if len(version_str) < len(_tag):\n                    auto_increment = True\n            else:\n                version_str = cast(\"str\", version_info.get(\"version\"))\n\n        if version_str:\n            try:\n                self._set_hyprland_version(\n                    version_str.removeprefix(\"v\"),\n                    auto_increment,\n                )\n            except (ValueError, IndexError):\n                self.log.exception('Fail to parse version tag \"%s\"', version_str)\n                await self.backend.notify_error(f\"Failed to parse hyprctl version tag: {version_str}\")\n                version_str = \"\"\n\n        if not version_str:\n            self.log.warning(\"Fail to parse version information: %s - using default\", version_info)\n            self.state.hyprland_version = DEFAULT_VERSION\n\n        try:\n            self.state.active_workspace = (await self.backend.execute_json(\"activeworkspace\"))[\"name\"]\n            monitors = await self.backend.get_monitors(include_disabled=True)\n            self.state.monitors = [mon[\"name\"] for mon in monitors]\n            self.state.set_disabled_monitors({mon[\"name\"] for mon in monitors if mon.get(\"disabled\", False)})\n            self.state.active_monitor = next((mon[\"name\"] for mon in monitors if mon[\"focused\"]), \"unknown\")\n        except (FileNotFoundError, PyprError):\n            self.log.warning(\"Hyprland socket not found, assuming no hyprland\")\n            self.state.active_workspace = \"unknown\"\n            self.state.monitors = []\n            self.state.set_disabled_monitors(set())\n            self.state.active_monitor = \"unknown\"\n\n    async def event_configreloaded(self, _: str = \"\") -> None:\n        \"\"\"Reconcile monitor state after config reload.\n\n        Re-fetches all monitors to update the disabled monitors set,\n        since monitor enable/disable happens via config changes.\n        \"\"\"\n        try:\n            monitors = await self.backend.get_monitors(include_disabled=True)\n            self.state.monitors = [mon[\"name\"] for mon in monitors]\n            self.state.set_disabled_monitors({mon[\"name\"] for mon in monitors if mon.get(\"disabled\", False)})\n        except (FileNotFoundError, PyprError):\n            self.log.warning(\"Failed to reconcile monitors after config reload\")\n\n    async def event_activewindowv2(self, addr: str) -> None:\n        \"\"\"Keep track of the focused client.\n\n        Args:\n            addr: The window address\n        \"\"\"\n        if not addr:\n            self.log.debug(\"no active window\")\n            self.state.active_window = \"\"\n        else:\n            self.state.active_window = \"0x\" + addr\n            self.log.debug(\"active_window = %s\", self.state.active_window)\n\n    async def event_workspace(self, workspace: str) -> None:\n        \"\"\"Track the active workspace.\n\n        Args:\n            workspace: The workspace name\n        \"\"\"\n        self.state.active_workspace = workspace\n        self.log.debug(\"active_workspace = %s\", self.state.active_workspace)\n\n    async def event_focusedmon(self, mon: str) -> None:\n        \"\"\"Track the active monitor.\n\n        Args:\n            mon: The monitor description (name,workspace)\n        \"\"\"\n        self.state.active_monitor, self.state.active_workspace = mon.rsplit(\",\", 1)\n        self.log.debug(\"active_monitor = %s\", self.state.active_monitor)\n\n    def _set_hyprland_version(self, version_str: str, auto_increment: bool = False) -> None:\n        \"\"\"Set the hyprland version.\n\n        Args:\n            version_str: The version string\n            auto_increment: Whether to auto-increment the version\n        \"\"\"\n        split_version = [int(i) for i in version_str.split(\".\")[:3]]\n        if auto_increment:\n            split_version[-1] += 1\n        self.state.hyprland_version = VersionInfo(*split_version)\n"
  },
  {
    "path": "pyprland/plugins/pyprland/niri_core.py",
    "content": "\"\"\"Niri-specific state management.\"\"\"\n\nfrom typing import Any\n\nfrom ...models import Environment, PyprError, VersionInfo\n\nDEFAULT_VERSION = VersionInfo(9, 9, 9)\n\n\nclass NiriStateMixin:\n    \"\"\"Mixin providing Niri-specific state management.\n\n    This mixin is designed to be used with the Extension class and provides\n    all Niri-specific initialization and event handling.\n    \"\"\"\n\n    # These attributes are provided by the Extension class\n    backend: Any\n    log: Any\n    state: Any\n\n    async def _init_niri(self) -> None:\n        \"\"\"Initialize Niri-specific state.\"\"\"\n        try:\n            self.state.active_workspace = \"unknown\"  # Niri workspaces are dynamic/different\n            outputs = await self.backend.execute_json(\"outputs\")\n            self.state.monitors = list(outputs.keys())\n            # Disabled outputs have current_mode set to None\n            self.state.set_disabled_monitors({name for name, data in outputs.items() if data.get(\"current_mode\") is None})\n            self.state.active_monitor = next(\n                (name for name, data in outputs.items() if data.get(\"is_focused\")),\n                \"unknown\",\n            )\n            # Set a dummy version for Niri since we don't have version info yet\n            self.state.hyprland_version = DEFAULT_VERSION\n        except (FileNotFoundError, PyprError):\n            self.log.warning(\"Niri socket not found or failed to query\")\n            self.state.active_workspace = \"unknown\"\n            self.state.monitors = []\n            self.state.set_disabled_monitors(set())\n            self.state.active_monitor = \"unknown\"\n\n    async def niri_outputschanged(self, _data: dict) -> None:\n        \"\"\"Track monitors on Niri.\n\n        Args:\n            _data: The event data (unused)\n        \"\"\"\n        if self.state.environment == Environment.NIRI:\n            try:\n                outputs = await self.backend.execute_json(\"outputs\")\n                self.state.monitors = list(outputs.keys())\n                # Disabled outputs have current_mode set to None\n                self.state.set_disabled_monitors({name for name, data in outputs.items() if data.get(\"current_mode\") is None})\n                self.state.active_monitor = next(\n                    (name for name, data in outputs.items() if data.get(\"is_focused\")),\n                    \"unknown\",\n                )\n            except (OSError, RuntimeError) as e:\n                self.log.warning(\"Failed to update monitors from Niri event: %s\", e)\n"
  },
  {
    "path": "pyprland/plugins/pyprland/schema.py",
    "content": "\"\"\"Configuration schema for the pyprland core plugin.\n\nThis module is separate to allow manager.py to import the schema\nwithout circular import issues.\n\"\"\"\n\nfrom pathlib import Path\n\nfrom pyprland.validation import ConfigField, ConfigItems\n\nPYPRLAND_CONFIG_SCHEMA = ConfigItems(\n    ConfigField(\"plugins\", list, required=True, description=\"List of plugins to load\", category=\"basic\"),\n    ConfigField(\n        \"include\",\n        list[Path],\n        required=False,\n        description=\"Additional config files or folders to include\",\n        category=\"basic\",\n        is_directory=True,\n    ),\n    ConfigField(\n        \"plugins_paths\",\n        list[Path],\n        default=[],\n        description=\"Additional paths to search for third-party plugins\",\n        category=\"basic\",\n        is_directory=True,\n    ),\n    ConfigField(\n        \"colored_handlers_log\",\n        bool,\n        default=True,\n        description=\"Enable colored log output for event handlers (debugging)\",\n        category=\"advanced\",\n    ),\n    ConfigField(\n        \"notification_type\",\n        str,\n        default=\"auto\",\n        description=\"Notification method: 'auto', 'notify-send', or 'native'\",\n        category=\"basic\",\n    ),\n    ConfigField(\n        \"variables\",\n        dict,\n        default={},\n        description=\"User-defined variables for string substitution (see Variables page)\",\n        category=\"advanced\",\n    ),\n    ConfigField(\n        \"hyprland_version\",\n        str,\n        default=\"\",\n        description=\"Override auto-detected Hyprland version (e.g., '0.40.0')\",\n        category=\"advanced\",\n    ),\n    ConfigField(\n        \"desktop\",\n        str,\n        default=\"\",\n        description=\"Override auto-detected desktop environment (e.g., 'hyprland', 'niri'). Empty means auto-detect.\",\n        category=\"advanced\",\n    ),\n)\n"
  },
  {
    "path": "pyprland/plugins/scratchpads/__init__.py",
    "content": "\"\"\"Scratchpads addon.\"\"\"\n\nimport asyncio\nimport contextlib\nfrom functools import partial\nfrom typing import cast\n\nfrom ...adapters.units import convert_coords\nfrom ...aioops import TaskManager\nfrom ...common import MINIMUM_FULL_ADDR_LEN, is_rotated\nfrom ...models import ClientInfo, Environment, MonitorInfo, ReloadReason, VersionInfo\nfrom ..interface import Plugin\nfrom .animations import Placement\nfrom .common import ONE_FRAME, FocusTracker, HideFlavors\nfrom .events import EventsMixin\nfrom .helpers import (\n    compute_offset,\n    get_active_space_identifier,\n    get_all_space_identifiers,\n    get_match_fn,\n    mk_scratch_name,\n)\nfrom .lifecycle import LifecycleMixin\nfrom .lookup import ScratchDB\nfrom .objects import Scratch\nfrom .schema import get_template_names, is_pure_template, validate_scratchpad_config\nfrom .transitions import TransitionsMixin\nfrom .windowruleset import WindowRuleSet\n\n\nclass Extension(LifecycleMixin, EventsMixin, TransitionsMixin, Plugin, environments=[Environment.HYPRLAND]):\n    \"\"\"Makes your applications into dropdowns & togglable popups.\"\"\"\n\n    procs: dict[str, asyncio.subprocess.Process]\n    scratches: ScratchDB\n\n    workspace = \"\"  # Currently active workspace\n    monitor = \"\"  # Currently active monitor\n\n    _tasks: TaskManager  # Task manager for hysteresis and other background tasks\n    focused_window_tracking: dict[str, FocusTracker]\n    previously_focused_window: str = \"\"\n    last_focused: Scratch | None = None\n\n    def __init__(self, name: str) -> None:\n        super().__init__(name)\n        self.procs = {}\n        self.scratches = ScratchDB()\n        self.focused_window_tracking = {}\n        self._tasks = TaskManager()\n        self._tasks.start()\n\n    async def exit(self) -> None:\n        \"\"\"Exit hook.\"\"\"\n        # Stop all managed tasks (hysteresis, etc.)\n        await self._tasks.stop()\n\n        async def die_in_piece(scratch: Scratch) -> None:\n            if scratch.uid in self.procs:\n                proc = self.procs[scratch.uid]\n\n                try:\n                    proc.terminate()\n                except ProcessLookupError:\n                    pass\n                else:\n                    for _ in range(10):\n                        if not await scratch.is_alive():\n                            break\n                        await asyncio.sleep(0.1)\n                    if await scratch.is_alive():\n                        with contextlib.suppress(ProcessLookupError):\n                            proc.kill()\n                await proc.wait()\n\n        await asyncio.gather(*(die_in_piece(scratch) for scratch in self.scratches.values()))\n\n    def validate_config(self) -> list[str]:\n        \"\"\"Validate tcratchpads configuration.\"\"\"\n        errors: list[str] = []\n\n        template_names = get_template_names(self.config)\n\n        for name, scratch_config in self.config.iter_subsections():\n            # Skip per-monitor subsections (e.g. \"term.monitor.DP-1\") - validated within schema\n            if \".\" in name:\n                continue\n\n            errors.extend(validate_scratchpad_config(name, scratch_config, is_template=is_pure_template(name, self.config, template_names)))\n\n        return errors\n\n    @classmethod\n    def validate_config_static(cls, _plugin_name: str, config: dict) -> list[str]:\n        \"\"\"Validate scratchpads configuration without instantiation.\"\"\"\n        errors: list[str] = []\n\n        template_names = get_template_names(config)\n\n        for name, scratch_config in config.items():\n            if not isinstance(scratch_config, dict) or \".\" in name:\n                continue\n            errors.extend(validate_scratchpad_config(name, scratch_config, is_template=is_pure_template(name, config, template_names)))\n        return errors\n\n    async def on_reload(self, reason: ReloadReason = ReloadReason.RELOAD) -> None:\n        \"\"\"Config loader.\"\"\"\n        _ = reason  # unused\n        # Sanity checks\n        _scratch_classes: dict[str, str] = {}\n\n        for uid, scratch in filter(lambda x: x[1].get(\"class\"), self.config.items()):\n            _klass = scratch[\"class\"]\n            if _klass in _scratch_classes:\n                text = \"Scratch class %s is duplicated (in %s and %s)\"\n                args = (\n                    _klass,\n                    uid,\n                    _scratch_classes[_klass],\n                )\n                self.log.error(text, *args)\n                await self.backend.notify_error(text % args)\n            _scratch_classes[_klass] = uid\n\n        # Skip pure templates - they only provide defaults to other scratchpads\n        # via \"use\" and should not be registered as togglable scratchpads.\n        # Their config data stays in self.config so _make_initial_config() can\n        # still resolve inheritance.\n        template_names = get_template_names(self.config)\n\n        # Create new scratches with fresh config items\n        scratches = {\n            name: Scratch(name, self.config, self)\n            for name, options in self.config.iter_subsections()\n            if not is_pure_template(name, self.config, template_names)\n        }\n\n        scratches_to_spawn = set()\n        for name, new_scratch in scratches.items():\n            scratch = self.scratches.get(name)\n            if scratch:  # if existing scratch exists, overrides the conf object\n                scratch.set_config(self.config)\n            else:\n                # else register it\n                self.scratches.register(new_scratch, name)\n                is_lazy = new_scratch.conf.get_bool(\"lazy\")\n                if not is_lazy:\n                    scratches_to_spawn.add(name)\n\n        # Register a noanim windowrule for the pypr_noanim tag.\n        # This is used to skip Hyprland's window animation during offscreen pre-positioning\n        # so that the show transition is instant (no need for show_delay).\n        await self.backend.execute(\"windowrule no_anim on, match:tag pypr_noanim\", base_command=\"keyword\")\n\n        for name in scratches_to_spawn:\n            if await self.ensure_alive(name):\n                scratch = self.scratches.get(name)\n                assert scratch\n                scratch.meta.should_hide = True\n            else:\n                self.log.error(\"Failure starting %s\", name)\n\n        for scratch in list(self.scratches.get_by_state(\"configured\")):\n            assert scratch\n            self.scratches.clear_state(scratch, \"configured\")\n\n        # Mark scratchpad special workspaces as persistent\n        persistent_commands = [f\"workspace {mk_scratch_name(s.uid)}, persistent:true\" for s in self.scratches.values()]\n        if persistent_commands:\n            await self.backend.execute(persistent_commands, base_command=\"keyword\")\n\n    async def _unset_windowrules(self, scratch: Scratch) -> None:\n        \"\"\"Unset the windowrules.\n\n        Args:\n            scratch: The scratchpad object\n        \"\"\"\n        defined_class = scratch.conf.get(\"class\")\n        if defined_class:\n            if self.state.hyprland_version < VersionInfo(0, 53, 0):\n                if self.state.hyprland_version > VersionInfo(0, 47, 2):\n                    await self.backend.set_keyword(f\"windowrule unset, class: {defined_class}\")\n                else:\n                    await self.backend.set_keyword(f\"windowrule unset, ^({defined_class})$\")\n            else:\n                await self.backend.set_keyword(f\"windowrule[{scratch.uid}]:enable false\")\n\n    async def _configure_windowrules(self, scratch: Scratch) -> None:\n        \"\"\"Set initial client window state (sets windowrules).\n\n        Args:\n            scratch: The scratchpad object\n        \"\"\"\n        self.scratches.set_state(scratch, \"configured\")\n        animation_type: str = scratch.conf.get_str(\"animation\").lower()\n        defined_class: str = scratch.conf.get_str(\"class\")\n        skipped_windowrules: list[str] = cast(\"list\", scratch.conf.get(\"skip_windowrules\"))\n        wr = WindowRuleSet(self.state)\n        wr.set_class(defined_class)\n        wr.set_name(scratch.uid)\n        if defined_class:\n            forced_monitor = scratch.conf.get(\"force_monitor\")\n            if forced_monitor and forced_monitor not in self.state.active_monitors:\n                self.log.error(\"forced monitor %s doesn't exist\", forced_monitor)\n                await self.backend.notify_error(f\"Monitor '{forced_monitor}' doesn't exist, check {scratch.uid}'s scratch configuration\")\n                forced_monitor = None\n            monitor = await self.backend.get_monitor_props(name=cast(\"str | None\", forced_monitor))\n            width, height = convert_coords(scratch.conf.get_str(\"size\"), monitor)\n\n            if \"float\" not in skipped_windowrules:\n                if self.state.hyprland_version < VersionInfo(0, 53, 0):\n                    wr.set(\"float\", \"\")\n                else:\n                    wr.set(\"float\", \"on\")\n            if \"workspace\" not in skipped_windowrules:\n                wr.set(\"workspace\", f\"{mk_scratch_name(scratch.uid)} silent\")\n            set_aspect = \"aspect\" not in skipped_windowrules\n\n            if animation_type:\n                margin_x = (monitor[\"width\"] - width) // 2\n                margin_y = (monitor[\"height\"] - height) // 2\n\n                if is_rotated(monitor):\n                    margin_x, margin_y = margin_y, margin_x\n\n                t_pos = {\n                    \"fromtop\": f\"{margin_x} -200%\",\n                    \"frombottom\": f\"{margin_x} 200%\",\n                    \"fromright\": f\"200% {margin_y}\",\n                    \"fromleft\": f\"-200% {margin_y}\",\n                }[animation_type]\n                if set_aspect:\n                    wr.set(\"move\", t_pos)\n\n            if set_aspect:\n                wr.set(\"size\", f\"{width} {height}\")\n\n            await self.backend.execute(wr.get_content(), base_command=\"keyword\")\n\n    def cancel_task(self, uid: str) -> bool:\n        \"\"\"Cancel a hysteresis task.\n\n        Args:\n            uid: The scratchpad name\n\n        Returns:\n            True if task was cancelled, False if no task existed\n        \"\"\"\n        cancelled = self._tasks.cancel_keyed(uid)\n        if cancelled:\n            self.log.debug(\"Canceled previous task for %s\", uid)\n        return cancelled\n\n    async def _detach_window(self, scratch: Scratch, address: str) -> None:\n        \"\"\"Detach a window from a scratchpad.\n\n        Args:\n            scratch: The scratchpad to detach from\n            address: The window address to detach\n        \"\"\"\n        scratch.extra_addr.remove(address)\n        if scratch.conf.get(\"pinned\"):\n            await self.backend.pin_window(address)  # toggles pin off\n\n    async def _attach_window(self, scratch: Scratch, address: str) -> None:\n        \"\"\"Attach a window to a scratchpad.\n\n        Args:\n            scratch: The scratchpad to attach to\n            address: The window address to attach\n        \"\"\"\n        # Remove from any other scratchpad first\n        for s in self.scratches.values():\n            if address in s.extra_addr:\n                s.extra_addr.remove(address)\n        scratch.extra_addr.add(address)\n        if scratch.conf.get(\"pinned\"):\n            await self.backend.pin_window(address)\n        # If scratchpad is visible, move the attached window to same workspace\n        if scratch.visible and scratch.client_info is not None:\n            workspace = scratch.client_info.get(\"workspace\", {}).get(\"name\", \"\")\n            if workspace:\n                await self.backend.move_window_to_workspace(address, workspace)\n\n    async def run_attach(self) -> None:\n        \"\"\"Attach the focused window to the last focused scratchpad.\"\"\"\n        if not self.last_focused:\n            await self.backend.notify_error(\"No scratchpad was focused\")\n            return\n        focused = self.state.active_window\n        if not focused or len(focused) < MINIMUM_FULL_ADDR_LEN:\n            await self.backend.notify_error(\"No valid window focused\")\n            return\n        scratch = self.last_focused\n        if focused == scratch.full_address:\n            await self.backend.notify_info(f\"Scratch {scratch.uid} can't attach or detach to itself\")\n            return\n        if not scratch.visible:\n            await self.run_show(scratch.uid)\n\n        if focused in scratch.extra_addr:\n            await self._detach_window(scratch, focused)\n        else:\n            await self._attach_window(scratch, focused)\n\n    async def run_toggle(self, uid_or_uids: str) -> None:\n        \"\"\"<name> toggles visibility of scratchpad \"name\" (supports multiple names).\n\n        Args:\n            uid_or_uids: Space-separated scratchpad name(s)\n\n        Example:\n            pypr toggle term\n            pypr toggle term music\n        \"\"\"\n        uids = list(filter(bool, map(str.strip, uid_or_uids.split()))) if \" \" in uid_or_uids else [uid_or_uids.strip()]\n\n        for uid in uids:\n            self.cancel_task(uid)\n\n        assert uids\n        first_scratch = self.scratches.get(uids[0])\n        if not first_scratch:\n            self.log.warning(\"%s doesn't exist, can't toggle.\", uids[0])\n            await self.backend.notify_error(f\"Scratchpad '{uids[0]}' not found, check your configuration & the toggle parameter\")\n            return\n\n        self.log.debug(\n            \"visibility_check: %s == %s\",\n            first_scratch.meta.space_identifier,\n            get_active_space_identifier(self.state),\n        )\n        if first_scratch.conf.get_bool(\"alt_toggle\"):\n            # Needs to be on any monitor (if workspace matches)\n            extra_visibility_check = first_scratch.meta.space_identifier in await get_all_space_identifiers(\n                await self.backend.get_monitors()\n            )\n        else:\n            # Needs to be on the active monitor+workspace\n            extra_visibility_check = first_scratch.meta.space_identifier == get_active_space_identifier(\n                self.state\n            )  # Visible on the currently focused monitor\n\n        is_visible = first_scratch.visible and (\n            first_scratch.forced_monitor or extra_visibility_check\n        )  # Always showing on the same monitor\n        tasks = []\n\n        for uid in uids:\n            item = self.scratches.get(uid)\n            if not item:\n                self.log.warning(\"%s is not configured\", uid)\n            else:\n                self.log.debug(\"%s visibility: %s and %s\", uid, is_visible, item.visible)\n                if is_visible and await item.is_alive():\n                    tasks.append(partial(self.run_hide, uid))\n                else:\n                    tasks.append(partial(self.run_show, uid))\n        await asyncio.gather(*(asyncio.create_task(t()) for t in tasks))\n\n    async def run_show(self, uid: str) -> None:\n        \"\"\"<name> shows scratchpad \"name\" (accepts \"*\").\n\n        Args:\n            uid: The scratchpad name, or \"*\" to show all hidden scratchpads\n        \"\"\"\n        if uid == \"*\":\n            await asyncio.gather(*(self.run_show(s.uid) for s in self.scratches.values() if not s.visible))\n            return\n        scratch = self.scratches.get(uid)\n\n        if not scratch:\n            self.log.warning(\"%s doesn't exist, can't hide.\", uid)\n            await self.backend.notify_error(f\"Scratchpad '{uid}' not found, check your configuration or the show parameter\")\n            return\n\n        self.cancel_task(uid)\n\n        self.log.info(\"Showing %s\", uid)\n        was_alive = await scratch.is_alive()\n        if not await self.ensure_alive(uid):\n            self.log.error(\"Failed to show %s, aborting.\", uid)\n            return\n\n        if not was_alive:\n            was_alive = await scratch.is_alive()\n\n        excluded_ids = scratch.conf.get(\"excludes\")\n        restore_excluded = scratch.conf.get_bool(\"restore_excluded\")\n        if excluded_ids in (\"*\", [\"*\"]):\n            excluded_ids = [excluded.uid for excluded in self.scratches.values() if excluded.uid != scratch.uid]\n        elif excluded_ids is None:\n            excluded_ids = []\n\n        await self._hide_excluded(scratch, cast(\"list[str]\", excluded_ids), restore_excluded)\n\n        await scratch.initialize(self)\n\n        scratch.visible = True\n        scratch.meta.space_identifier = get_active_space_identifier(self.state)\n        monitor = await self.backend.get_monitor_props(name=scratch.forced_monitor)\n\n        assert monitor\n        assert scratch.full_address, \"No address !\"\n\n        await self._show_transition(scratch, monitor, was_alive)\n        scratch.monitor = monitor[\"name\"]\n\n    async def _hide_excluded(self, scratch: Scratch, excluded_ids: list[str], restore_excluded: bool) -> None:\n        \"\"\"Hide excluded scratchpads before showing the requested one.\"\"\"\n        for e_uid in excluded_ids:\n            excluded = self.scratches.get(e_uid)\n            assert excluded\n            if excluded.visible:\n                try:\n                    await self.run_hide(e_uid, flavor=HideFlavors.TRIGGERED_BY_AUTOHIDE | HideFlavors.IGNORE_TILED)\n                except (KeyError, RuntimeError):\n                    self.log.warning(\"Failed to hide excluded scratchpad %s, continuing\", e_uid)\n                if restore_excluded:\n                    scratch.excluded_scratches.append(e_uid)\n\n    async def _handle_multiwindow(self, scratch: Scratch, clients: list[ClientInfo]) -> bool:\n        \"\"\"Collect every matching client for the scratchpad and add them to extra_addr if needed.\n\n        Args:\n            scratch: The scratchpad object\n            clients: The list of clients\n        \"\"\"\n        if not scratch.conf.get_bool(\"multi\"):\n            return False\n        try:\n            match_by, match_value = scratch.get_match_props()\n        except KeyError:\n            return False\n        match_fn = get_match_fn(match_by, match_value)\n        hit = False\n        for client in clients:\n            if client[\"address\"] == scratch.full_address:\n                continue\n            if match_fn(client[match_by], match_value):  # type: ignore[literal-required]\n                address = client[\"address\"]\n                if address not in scratch.extra_addr:\n                    scratch.extra_addr.add(address)\n                    if scratch.conf.get(\"pinned\"):\n                        await self.backend.pin_window(address)\n                    hit = True\n        return hit\n\n    async def run_hide(self, uid: str, flavor: HideFlavors = HideFlavors.NONE) -> None:\n        \"\"\"<name> hides scratchpad \"name\" (accepts \"*\").\n\n        Args:\n            uid: The scratchpad name, or \"*\" to hide all visible scratchpads\n            flavor: Internal hide behavior flags (default: NONE)\n        \"\"\"\n        if uid == \"*\":\n            await asyncio.gather(*(self.run_hide(s.uid) for s in self.scratches.values() if s.visible))\n            return\n\n        scratch = self.scratches.get(uid)\n\n        if not scratch:\n            await self.backend.notify_error(f\"Scratchpad '{uid}' not found, check your configuration or the hide parameter\")\n            self.log.warning(\"%s is not configured\", uid)\n            return\n\n        if not await scratch.is_alive():\n            scratch.visible = False  # self-correct stale state\n            return\n\n        if scratch.client_info is not None and flavor & HideFlavors.IGNORE_TILED and not scratch.client_info[\"floating\"]:\n            return\n\n        active_window = self.state.active_window\n        active_workspace = self.state.active_workspace\n\n        if not scratch.visible and not flavor & HideFlavors.FORCED and not flavor & HideFlavors.TRIGGERED_BY_AUTOHIDE:\n            await self.backend.notify_error(f\"Scratchpad '{uid}' is not visible, will not hide.\")\n            self.log.warning(\"%s is already hidden\", uid)\n            return\n\n        await self._hide_scratch(scratch, active_window, active_workspace)\n\n    async def _hide_scratch(self, scratch: Scratch, active_window: str, active_workspace: str) -> None:\n        \"\"\"Perform the actual hide operation.\n\n        Args:\n            scratch: The scratchpad object\n            active_window: The active window address\n            active_workspace: The active workspace name\n        \"\"\"\n        clients = await self.backend.execute_json(\"clients\")\n        try:\n            await scratch.update_client_info(clients=clients)\n        except KeyError:\n            self.log.warning(\"Client window for %s vanished during hide, resetting state\", scratch.uid)\n            scratch.visible = False\n            scratch.client_info = None\n            return\n        assert scratch.client_info is not None\n        ref_position = scratch.client_info[\"at\"]\n        monitor_info = scratch.meta.monitor_info\n        if monitor_info is None:\n            self.log.error(\"Cannot hide %s: no monitor_info available\", scratch.uid)\n            return\n\n        configured_position = scratch.conf.get_str(\"position\")\n        if configured_position:\n            pos_x, pos_y = convert_coords(configured_position, monitor_info)\n            ref_position = (pos_x + monitor_info[\"x\"], pos_y + monitor_info[\"y\"])\n\n        scratch.meta.extra_positions[scratch.address] = compute_offset(ref_position, (monitor_info[\"x\"], monitor_info[\"y\"]))\n        # Store current window size per-monitor for preserve_aspect\n        if scratch.conf.get_bool(\"preserve_aspect\") and \"size\" in scratch.client_info:\n            scratch.meta.saved_size[scratch.address] = (scratch.client_info[\"size\"][0], scratch.client_info[\"size\"][1])\n        # collects window which have been created by the app\n        if scratch.conf.get_bool(\"multi\"):\n            await self._save_multiwindow_state(scratch, clients, ref_position)\n        scratch.visible = False\n        scratch.meta.should_hide = False\n        self.log.info(\"Hiding %s\", scratch.uid)\n        await self._pin_scratch(scratch)\n\n        if scratch.conf.get_bool(\"close_on_hide\"):\n            await self.backend.close_window(scratch.full_address, silent=True)\n\n            for addr in scratch.extra_addr:\n                await self.backend.close_window(addr)\n        else:\n            await self._move_clients_out(scratch, monitor_info)\n\n        for e_uid in scratch.excluded_scratches:\n            await self.run_show(e_uid)\n        scratch.excluded_scratches.clear()\n        await self._handle_focus_tracking(scratch, active_window, active_workspace, clients)\n\n    async def _save_multiwindow_state(self, scratch: Scratch, clients: list[ClientInfo], ref_position: tuple[int, int]) -> None:\n        \"\"\"Save positions (and sizes when preserve_aspect is on) for multi-window scratchpads.\n\n        Args:\n            scratch: The scratchpad object\n            clients: Full list of Hyprland clients\n            ref_position: Reference position used to compute offsets\n        \"\"\"\n        await self._handle_multiwindow(scratch, clients)\n        positions = {}\n        for sub_client in clients:\n            if sub_client[\"address\"] in scratch.extra_addr:\n                positions[sub_client[\"address\"]] = compute_offset(sub_client[\"at\"], ref_position)\n        scratch.meta.extra_positions.update(positions)\n        if scratch.conf.get_bool(\"preserve_aspect\"):\n            for sub_client in clients:\n                if sub_client[\"address\"] in scratch.extra_addr and \"size\" in sub_client:\n                    scratch.meta.saved_size[sub_client[\"address\"]] = (sub_client[\"size\"][0], sub_client[\"size\"][1])\n\n    async def _move_clients_out(self, scratch: Scratch, monitor: MonitorInfo) -> None:\n        \"\"\"Move all clients of a scratchpad out of its workspace.\n\n        Args:\n            scratch: The scratchpad object\n            monitor: The active monitor info\n        \"\"\"\n        clients_addr = [scratch.full_address, *scratch.extra_addr]\n        have_animation = bool(scratch.animation_type)\n        if have_animation:\n            await self._hide_transition(scratch, monitor)\n            # Batch noanim tag + far off-screen reposition in a single IPC call.\n            # This prevents ghost frames when hide_delay is shorter than the\n            # actual Hyprland animation: even if the slide-off is still in\n            # progress, the window jumps instantly (noanim) to a position far\n            # beyond any visible monitor, so no flicker is visible.\n            noanim_commands: list[str] = [f\"tagwindow +pypr_noanim address:{addr}\" for addr in clients_addr]\n            if scratch.client_ready:\n                assert scratch.client_info is not None\n                off_x, off_y = Placement.get_offscreen(\n                    scratch.animation_type,\n                    monitor,\n                    scratch.client_info,\n                    scratch.conf.get_int(\"margin\"),\n                )\n                noanim_commands.extend(f\"movewindowpixel exact {off_x} {off_y},address:{addr}\" for addr in clients_addr)\n            await self.backend.execute(noanim_commands)\n\n        for addr in clients_addr:\n            await self.backend.move_window_to_workspace(addr, mk_scratch_name(scratch.uid), silent=True)\n\n        if have_animation:\n            await asyncio.sleep(ONE_FRAME)  # Ensure the windows are on the new workspace before unsetting noanim\n            await self.backend.execute([f\"tagwindow -pypr_noanim address:{addr}\" for addr in clients_addr])\n\n    async def _handle_focus_tracking(self, scratch: Scratch, active_window: str, active_workspace: str, clients: ClientInfo | dict) -> None:\n        \"\"\"Handle focus tracking.\n\n        Args:\n            scratch: The scratchpad object\n            active_window: The active window address\n            active_workspace: The active workspace name\n            clients: The list of clients\n        \"\"\"\n        if not scratch.conf.get_bool(\"smart_focus\"):\n            return\n        for track in self.focused_window_tracking.values():\n            if scratch.have_address(track.prev_focused_window):\n                track.clear()\n        tracker = self.focused_window_tracking.get(scratch.uid)\n        if tracker and not tracker.prev_focused_window_wrkspc.startswith(\"special:\"):\n            same_workspace = tracker.prev_focused_window_wrkspc == active_workspace\n            t_pfw = tracker.prev_focused_window\n            client = next(filter(lambda d: d.get(\"address\") == t_pfw, cast(\"list[dict]\", clients)), None)\n            if (\n                client\n                and scratch.have_address(active_window)\n                and same_workspace\n                and not scratch.have_address(tracker.prev_focused_window)\n                and not client[\"workspace\"][\"name\"].startswith(\"special\")\n            ):\n                self.log.debug(\"Previous scratch: %s\", self.scratches.get(addr=tracker.prev_focused_window))\n                await self.backend.focus_window(tracker.prev_focused_window)\n"
  },
  {
    "path": "pyprland/plugins/scratchpads/animations.py",
    "content": "\"\"\"Placement for absolute window positioning.\"\"\"\n\n__all__ = [\"Placement\"]\n\nimport enum\nfrom typing import cast\n\nfrom ...adapters.units import convert_monitor_dimension\nfrom ...models import ClientInfo, MonitorInfo\nfrom .helpers import get_size\n\n\nclass AnimationTarget(enum.Enum):\n    \"\"\"Animation type (selects between main window and satellite windows).\"\"\"\n\n    MAIN = \"main\"\n    EXTRA = \"extra\"\n    ALL = \"all\"\n\n\nclass Placement:  # {{{\n    \"\"\"Animation store.\"\"\"\n\n    # main function\n    @staticmethod\n    def get(animation_type: str, monitor: MonitorInfo, client: ClientInfo, margin: int) -> tuple[int, int]:\n        \"\"\"Get destination coordinate for the provided animation type.\n\n        Args:\n            animation_type: Type of animation (fromtop, frombottom, etc.)\n            monitor: Monitor information\n            client: Client window information\n            margin: Margin to apply\n        \"\"\"\n        return cast(\"tuple[int, int]\", getattr(Placement, animation_type)(monitor, client, margin))\n\n    @staticmethod\n    def get_offscreen(animation_type: str, monitor: MonitorInfo, client: ClientInfo, margin: int) -> tuple[int, int]:\n        \"\"\"Get off-screen position for the given animation type.\n\n        Computes the final on-screen position via `get()`, then pushes the\n        window far off-screen along the animation axis.  An extra monitor\n        dimension is subtracted/added so the window doesn't appear on an\n        adjacent monitor in multi-monitor setups.\n\n        Args:\n            animation_type: Type of animation (fromtop, frombottom, etc.)\n            monitor: Monitor information\n            client: Client window information\n            margin: Margin to apply\n        \"\"\"\n        fx, fy = Placement.get(animation_type, monitor, client, margin)\n        mon_x, mon_y = monitor[\"x\"], monitor[\"y\"]\n        mon_w, mon_h = get_size(monitor)\n        client_w, client_h = client[\"size\"]\n        offscreen_map = {\n            \"fromtop\": (fx, mon_y - client_h - mon_h),\n            \"frombottom\": (fx, mon_y + mon_h + mon_h),\n            \"fromleft\": (mon_x - client_w - mon_w, fy),\n            \"fromright\": (mon_x + mon_w + mon_w, fy),\n        }\n        return offscreen_map[animation_type]\n\n    # animation types\n    @staticmethod\n    def fromtop(monitor: MonitorInfo, client: ClientInfo, margin: int) -> tuple[int, int]:\n        \"\"\"Slide from/to top.\n\n        Args:\n            monitor: Monitor information\n            client: Client window information\n            margin: Margin to apply\n        \"\"\"\n        mon_x = monitor[\"x\"]\n        mon_y = monitor[\"y\"]\n        mon_width, mon_height = get_size(monitor)\n\n        client_width = client[\"size\"][0]\n        margin_x = int((mon_width - client_width) / 2) + mon_x\n\n        corrected_margin = convert_monitor_dimension(margin, mon_height, monitor)\n\n        return (margin_x, mon_y + corrected_margin)\n\n    @staticmethod\n    def frombottom(monitor: MonitorInfo, client: ClientInfo, margin: int) -> tuple[int, int]:\n        \"\"\"Slide from/to bottom.\n\n        Args:\n            monitor: Monitor information\n            client: Client window information\n            margin: Margin to apply\n        \"\"\"\n        mon_x = monitor[\"x\"]\n        mon_y = monitor[\"y\"]\n        mon_width, mon_height = get_size(monitor)\n\n        client_width = client[\"size\"][0]\n        client_height = client[\"size\"][1]\n        margin_x = int((mon_width - client_width) / 2) + mon_x\n\n        corrected_margin = convert_monitor_dimension(margin, mon_height, monitor)\n\n        return (margin_x, mon_y + mon_height - client_height - corrected_margin)\n\n    @staticmethod\n    def fromleft(monitor: MonitorInfo, client: ClientInfo, margin: int) -> tuple[int, int]:\n        \"\"\"Slide from/to left.\n\n        Args:\n            monitor: Monitor information\n            client: Client window information\n            margin: Margin to apply\n        \"\"\"\n        mon_x = monitor[\"x\"]\n        mon_y = monitor[\"y\"]\n        mon_width, mon_height = get_size(monitor)\n\n        client_height = client[\"size\"][1]\n        margin_y = int((mon_height - client_height) / 2) + mon_y\n\n        corrected_margin = convert_monitor_dimension(margin, mon_width, monitor)\n\n        return (corrected_margin + mon_x, margin_y)\n\n    @staticmethod\n    def fromright(monitor: MonitorInfo, client: ClientInfo, margin: int) -> tuple[int, int]:\n        \"\"\"Slide from/to right.\n\n        Args:\n            monitor: Monitor information\n            client: Client window information\n            margin: Margin to apply\n        \"\"\"\n        mon_x = monitor[\"x\"]\n        mon_y = monitor[\"y\"]\n        mon_width, mon_height = get_size(monitor)\n\n        client_width = client[\"size\"][0]\n        client_height = client[\"size\"][1]\n        margin_y = int((mon_height - client_height) / 2) + mon_y\n\n        corrected_margin = convert_monitor_dimension(margin, mon_width, monitor)\n\n        return (mon_width - client_width - corrected_margin + mon_x, margin_y)\n\n\n# }}}\n"
  },
  {
    "path": "pyprland/plugins/scratchpads/common.py",
    "content": "\"\"\"Common types for scratchpads.\"\"\"\n\nfrom dataclasses import dataclass\nfrom enum import Flag, auto\n\nONE_FRAME = 1 / 50  # minimum delay to let the backend process commands\n\n\nclass HideFlavors(Flag):\n    \"\"\"Flags for different hide behavior.\"\"\"\n\n    NONE = auto()\n    FORCED = auto()\n    TRIGGERED_BY_AUTOHIDE = auto()\n    IGNORE_TILED = auto()\n\n\n@dataclass\nclass FocusTracker:\n    \"\"\"Focus tracking object.\"\"\"\n\n    prev_focused_window: str\n    prev_focused_window_wrkspc: str\n\n    def clear(self) -> None:\n        \"\"\"Clear the tracking.\"\"\"\n        self.prev_focused_window = \"\"\n        self.prev_focused_window_wrkspc = \"\"\n"
  },
  {
    "path": "pyprland/plugins/scratchpads/events.py",
    "content": "\"\"\"Scratchpad event handlers mixin.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport time\nfrom typing import TYPE_CHECKING, cast\n\nfrom ...common import MINIMUM_ADDR_LEN\nfrom .common import HideFlavors\nfrom .helpers import get_match_fn, mk_scratch_name\n\nif TYPE_CHECKING:\n    import logging\n\n    from ...adapters.proxy import BackendProxy\n    from ...aioops import TaskManager\n    from ...models import ClientInfo\n    from . import Extension\n    from .lookup import ScratchDB\n    from .objects import Scratch\n\n__all__ = [\"EventsMixin\"]\n\nAFTER_SHOW_INHIBITION = 0.3  # 300ms of ignorance after a show\n\n\nclass EventsMixin:\n    \"\"\"Mixin for scratchpad event handling.\n\n    Handles Hyprland events related to windows and workspaces.\n    \"\"\"\n\n    # Type hints for attributes provided by the composed class\n    log: logging.Logger\n    backend: BackendProxy\n    scratches: ScratchDB\n    _tasks: TaskManager\n    workspace: str\n    previously_focused_window: str\n    last_focused: Scratch | None\n\n    # Methods provided by the composed class\n    def cancel_task(self, uid: str) -> bool:\n        \"\"\"Cancel a running task for a scratchpad.\"\"\"\n        _ = uid\n        return False  # stub, overridden by composed class\n\n    async def run_hide(self, uid: str, flavor: HideFlavors = HideFlavors.NONE) -> None:\n        \"\"\"Hide a scratchpad.\"\"\"\n        _ = uid, flavor  # stub, overridden by composed class\n\n    async def update_scratch_info(self, orig_scratch: Scratch | None = None) -> None:\n        \"\"\"Update scratchpad information.\"\"\"\n        _ = orig_scratch  # stub, overridden by composed class\n\n    async def _handle_multiwindow(self, scratch: Scratch, clients: list[ClientInfo]) -> bool:\n        \"\"\"Handle multi-window scratchpads.\"\"\"\n        _ = scratch, clients\n        return False  # stub, overridden by composed class\n\n    async def event_changefloatingmode(self, args: str) -> None:\n        \"\"\"Update the floating mode of scratchpads.\n\n        Args:\n            args: The arguments passed to the event\n        \"\"\"\n        addr, _onoff = args.split(\",\")\n        onoff = int(_onoff)\n        for scratch in self.scratches.values():\n            if scratch.address == addr and scratch.client_info is not None:\n                scratch.client_info[\"floating\"] = bool(onoff)\n\n    async def event_workspace(self, name: str) -> None:\n        \"\"\"Workspace hook.\n\n        Args:\n            name: The workspace name\n        \"\"\"\n        for scratch in self.scratches.values():\n            scratch.event_workspace(name)\n\n        self.workspace = name\n\n    async def event_closewindow(self, addr: str) -> None:\n        \"\"\"Close window hook.\n\n        Args:\n            addr: The window address\n        \"\"\"\n        # Removes this address from the extra_addr\n        addr = \"0x\" + addr\n        for scratch in self.scratches.values():\n            if addr in scratch.extra_addr:\n                scratch.extra_addr.remove(addr)\n            if addr in scratch.meta.extra_positions:\n                del scratch.meta.extra_positions[addr]\n            # Reset state when the primary window is closed\n            if scratch.full_address == addr:\n                scratch.visible = False\n                scratch.client_info = None\n                scratch.meta.initialized = False\n\n    async def event_monitorremoved(self, monitor_name: str) -> None:\n        \"\"\"Hides scratchpads on the removed screen.\n\n        Uses a cancellable keyed task so that if the monitor is re-added\n        before the delay expires, the hide is cancelled (event_monitoradded\n        will handle cleanup instead).\n\n        Args:\n            monitor_name: The monitor name\n        \"\"\"\n\n        async def _delayed_hide(monitor: str) -> None:\n            await asyncio.sleep(1)\n            for scratch in self.scratches.values():\n                if scratch.monitor == monitor:\n                    try:\n                        await self.run_hide(scratch.uid, flavor=HideFlavors.TRIGGERED_BY_AUTOHIDE)\n                    except (RuntimeError, OSError, ConnectionError, KeyError) as e:\n                        self.log.exception(\"Failed to hide %s\", scratch.uid)\n                        await self.backend.notify_info(f\"Failed to hide {scratch.uid}: {e}\")\n\n        self._tasks.create(_delayed_hide(monitor_name), key=f\"monhide:{monitor_name}\")\n\n    async def event_monitoradded(self, monitor_name: str) -> None:\n        \"\"\"Re-hides scratchpads displaced from their special workspaces during monitor changes.\n\n        When a monitor is removed and re-added (e.g. display power cycle),\n        Hyprland may move scratchpad windows out of their special workspaces\n        and pin them onto regular workspaces. This handler cancels any pending\n        delayed hide from event_monitorremoved (which would race with the\n        re-addition), then checks all hidden scratchpads and moves any\n        displaced ones back to their special workspaces.\n\n        Args:\n            monitor_name: The monitor name\n        \"\"\"\n        # Cancel any pending delayed hide for this monitor — it would race\n        # with the monitor re-addition and produce incorrect state.\n        if self.cancel_task(f\"monhide:{monitor_name}\"):\n            self.log.info(\"Cancelled pending monitor-hide for %s\", monitor_name)\n\n        async def _fixup_displaced() -> None:\n            # Wait for Hyprland to finish all workspace migrations\n            # (FALLBACK removal, workspace recreation, etc.)\n            await asyncio.sleep(2)\n            clients = cast(\"list[dict]\", await self.backend.execute_json(\"clients\"))\n            for scratch in self.scratches.values():\n                if scratch.visible or scratch.client_info is None:\n                    continue\n                expected_workspace = mk_scratch_name(scratch.uid)\n                for client in clients:\n                    if client[\"address\"] != scratch.full_address:\n                        continue\n                    actual_workspace = client[\"workspace\"][\"name\"]\n                    if actual_workspace != expected_workspace:\n                        self.log.warning(\n                            \"Scratchpad %s displaced from %s to %s after monitor change, re-hiding\",\n                            scratch.uid,\n                            expected_workspace,\n                            actual_workspace,\n                        )\n                        # Unpin if Hyprland pinned it during the transition\n                        if client[\"pinned\"]:\n                            await self.backend.pin_window(scratch.full_address)\n                        await self.backend.move_window_to_workspace(\n                            scratch.full_address,\n                            expected_workspace,\n                            silent=True,\n                        )\n                    break\n\n        self._tasks.create(_fixup_displaced(), key=f\"monadd:{monitor_name}\")\n\n    async def event_activewindowv2(self, addr: str) -> None:\n        \"\"\"Active windows hook.\n\n        Args:\n            addr: The window address\n        \"\"\"\n        full_address = \"\" if not addr or len(addr) < MINIMUM_ADDR_LEN else \"0x\" + addr\n        for uid, scratch in self.scratches.items():\n            if scratch.client_info is None:\n                continue\n            if scratch.have_address(full_address):\n                if scratch.full_address == full_address:\n                    self.last_focused = scratch\n                self.cancel_task(uid)\n            elif scratch.visible and scratch.conf.get(\"unfocus\") == \"hide\":\n                last_shown = scratch.meta.last_shown\n                if last_shown + AFTER_SHOW_INHIBITION > time.time():\n                    self.log.debug(\n                        \"(SKIPPED) hide %s because another client is active\",\n                        uid,\n                    )\n                    continue\n\n                await self._hysteresis_handling(scratch)\n        self.previously_focused_window = full_address\n\n    async def _hysteresis_handling(self, scratch: Scratch) -> None:\n        \"\"\"Hysteresis handling.\n\n        Args:\n            scratch: The scratchpad object\n        \"\"\"\n        hysteresis = scratch.conf.get_float(\"hysteresis\")\n        if hysteresis:\n            self.cancel_task(scratch.uid)\n\n            async def _task(scratch: Scratch, delay: float) -> None:\n                await asyncio.sleep(delay)\n                self.log.debug(\"hide %s because another client is active\", scratch.uid)\n                await self.run_hide(scratch.uid, flavor=HideFlavors.TRIGGERED_BY_AUTOHIDE)\n\n            self._tasks.create(_task(scratch, hysteresis), key=scratch.uid)\n        else:\n            self.log.debug(\"hide %s because another client is active\", scratch.uid)\n            await self.run_hide(scratch.uid, flavor=HideFlavors.TRIGGERED_BY_AUTOHIDE)\n\n    async def _alternative_lookup(self) -> bool:\n        \"\"\"If not matching by pid, use specific matching and return True.\"\"\"\n        class_lookup_hack = [s for s in self.scratches.get_by_state(\"respawned\") if s.conf.get(\"match_by\") != \"pid\"]\n        if not class_lookup_hack:\n            return False\n        self.log.debug(\"Lookup hack triggered\")\n        clients = cast(\"list[ClientInfo]\", await self.backend.execute_json(\"clients\"))\n        for pending_scratch in class_lookup_hack:\n            match_by, match_value = pending_scratch.get_match_props()\n            match_fn = get_match_fn(match_by, match_value)\n            for client in clients:\n                if match_fn(client[match_by], match_value):  # type: ignore[literal-required]\n                    self.scratches.register(pending_scratch, addr=client[\"address\"][2:])\n                    self.log.debug(\"client class found: %s\", client)\n                    await pending_scratch.update_client_info(client)\n        return True\n\n    async def event_openwindow(self, params: str) -> None:\n        \"\"\"Open windows hook.\n\n        Args:\n            params: The arguments passed to the event\n        \"\"\"\n        addr, _wrkspc, _kls, _title = params.split(\",\", 3)\n        item = self.scratches.get(addr=addr)\n        respawned = list(self.scratches.get_by_state(\"respawned\"))\n\n        if item:\n            # Ensure initialized (no-op if already initialized)\n            await item.initialize(cast(\"Extension\", self))\n        elif respawned:\n            # NOTE: for windows which aren't related to the process (see #8)\n            if not await self._alternative_lookup():\n                self.log.info(\"Updating Scratch info\")\n                await self.update_scratch_info()\n            if item and item.meta.should_hide:\n                await self.run_hide(item.uid, flavor=HideFlavors.FORCED)\n        else:\n            clients = await self.backend.execute_json(\"clients\")\n            for item in self.scratches.values():\n                if await self._handle_multiwindow(item, clients):\n                    return\n"
  },
  {
    "path": "pyprland/plugins/scratchpads/helpers.py",
    "content": "\"\"\"Helper functions for the scratchpads plugin.\"\"\"\n\n__all__ = [\n    \"DynMonitorConfig\",\n    \"apply_offset\",\n    \"compute_offset\",\n    \"get_active_space_identifier\",\n    \"get_all_space_identifiers\",\n    \"get_match_fn\",\n    \"get_size\",\n]\n\nimport logging\nimport re\nfrom collections.abc import Callable\nfrom typing import Any\n\nfrom ...common import SharedState, is_rotated\nfrom ...config import ConfigValueType, SchemaAwareMixin\nfrom ...models import MonitorInfo\nfrom ...validation import ConfigItems\n\n\ndef mk_scratch_name(uid: str) -> str:\n    \"\"\"Return scratchpad name as register in Hyprland.\n\n    Args:\n        uid: Unique identifier for the scratchpad\n    \"\"\"\n    escaped = uid.replace(\":\", \"_\").replace(\"/\", \"_\").replace(\" \", \"_\")\n    return f\"special:S-{escaped}\"\n\n\ndef compute_offset(pos1: tuple[int, int] | None, pos2: tuple[int, int] | None) -> tuple[int, int]:\n    \"\"\"Compute the offset between two positions.\n\n    Args:\n        pos1: First position (x, y)\n        pos2: Second position (x, y)\n    \"\"\"\n    if pos1 is None or pos2 is None:\n        return (0, 0)\n    return pos1[0] - pos2[0], pos1[1] - pos2[1]\n\n\ndef apply_offset(pos: tuple[int, int], offset: tuple[int, int]) -> tuple[int, int]:\n    \"\"\"Apply the offset to the position.\n\n    Args:\n        pos: Base position (x, y)\n        offset: Offset to apply (dx, dy)\n    \"\"\"\n    return pos[0] + offset[0], pos[1] + offset[1]\n\n\ndef get_size(monitor: MonitorInfo) -> tuple[int, int]:\n    \"\"\"Get the (width, height) size of the monitor.\n\n    Args:\n        monitor: Monitor information\n    \"\"\"\n    s = monitor[\"scale\"]\n    h, w = int(monitor[\"height\"] / s), int(monitor[\"width\"] / s)\n    if is_rotated(monitor):\n        return (h, w)\n    return (w, h)\n\n\ndef get_active_space_identifier(state: SharedState) -> tuple[str, str]:\n    \"\"\"Return a unique object for the workspace + monitor combination.\n\n    Args:\n        state: Shared state containing active workspace and monitor\n    \"\"\"\n    return (state.active_workspace, state.active_monitor)\n\n\nasync def get_all_space_identifiers(monitors: list[MonitorInfo]) -> list[tuple[str, str]]:\n    \"\"\"Return a list of every space identifiers (workspace + monitor) on active screens.\n\n    Args:\n        monitors: List of active monitors\n    \"\"\"\n    return [(monitor[\"activeWorkspace\"][\"name\"], monitor[\"name\"]) for monitor in monitors]\n\n\n_match_fn_re_cache: dict[str, Callable[[Any, Any], bool]] = {}\n\n\ndef get_match_fn(prop_name: str, prop_value: float | bool | str | list) -> Callable[[Any, Any], bool]:\n    \"\"\"Return a function to match a client based on a property.\n\n    Args:\n        prop_name: Name of the property to match\n        prop_value: Value to match against (can be regex starting with \"re:\")\n    \"\"\"\n    assert prop_name  # may be used for more specific matching\n    if isinstance(prop_value, str) and prop_value.startswith(\"re:\"):\n        # get regex from cache if possible:\n        if prop_value not in _match_fn_re_cache:\n            regex = re.compile(prop_value[3:])\n\n            def _comp_function(value1: str, _value2: str) -> bool:\n                return bool(regex.match(value1))\n\n            _match_fn_re_cache[prop_value] = _comp_function\n        return _match_fn_re_cache[prop_value]\n    return lambda value1, value2: value1 == value2\n\n\nclass DynMonitorConfig(SchemaAwareMixin):\n    \"\"\"A `dict`-like object allowing per-monitor overrides with schema-aware defaults.\"\"\"\n\n    def __init__(\n        self,\n        ref: dict[str, ConfigValueType],\n        monitor_override: dict[str, dict[str, ConfigValueType]],\n        state: SharedState,\n        *,\n        log: logging.Logger,\n        schema: ConfigItems | None = None,\n    ) -> None:\n        \"\"\"Initialize dynamic configuration.\n\n        Args:\n            ref: Reference configuration\n            monitor_override: Monitor-specific overrides\n            state: Shared state\n            log: Logger instance\n            schema: Optional schema for default value lookups\n        \"\"\"\n        self.__init_schema__()\n        self.ref = ref\n        self.mon_override = monitor_override\n        self.state = state\n        self.log = log\n        if schema:\n            self.set_schema(schema)\n\n    def __setitem__(self, name: str, value: ConfigValueType) -> None:\n        self.ref[name] = value\n\n    def update(self, other: dict[str, ConfigValueType]) -> None:\n        \"\"\"Update the configuration with another dictionary.\n\n        Args:\n            other: Dictionary to update from\n        \"\"\"\n        self.ref.update(other)\n\n    def _get_raw(self, name: str) -> ConfigValueType:\n        \"\"\"Get raw value from ref or monitor override. Raises KeyError if not found.\"\"\"\n        override = self.mon_override.get(self.state.active_monitor, {})\n        if name in override:\n            return override[name]\n        if name in self.ref:\n            return self.ref[name]\n        raise KeyError(name)\n\n    def __getitem__(self, name: str) -> ConfigValueType:\n        \"\"\"Get value, checking monitor override first, then ref. Raises KeyError if not found.\"\"\"\n        return self._get_raw(name)\n\n    def __contains__(self, name: object) -> bool:\n        \"\"\"Check if name is explicitly set (not from schema defaults).\"\"\"\n        assert isinstance(name, str)\n        return self.has_explicit(name)\n\n    def __str__(self) -> str:\n        return f\"{self.ref} {self.mon_override}\"\n"
  },
  {
    "path": "pyprland/plugins/scratchpads/lifecycle.py",
    "content": "\"\"\"Scratchpad lifecycle management mixin.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport contextlib\nfrom typing import TYPE_CHECKING, cast\n\nfrom ...common import apply_variables\nfrom ...process import create_subprocess\n\nif TYPE_CHECKING:\n    import logging\n\n    from ...adapters.proxy import BackendProxy\n    from ...common import SharedState\n    from ...models import ClientInfo\n    from .lookup import ScratchDB\n    from .objects import Scratch\n\n__all__ = [\"LifecycleMixin\"]\n\n\nclass LifecycleMixin:\n    \"\"\"Mixin for scratchpad process lifecycle management.\n\n    Handles starting, stopping, and monitoring scratchpad processes.\n    \"\"\"\n\n    # Type hints for attributes provided by the composed class\n    log: logging.Logger\n    backend: BackendProxy\n    scratches: ScratchDB\n    procs: dict[str, asyncio.subprocess.Process]\n    state: SharedState\n\n    # Methods provided by the composed class\n    async def _configure_windowrules(self, scratch: Scratch) -> None:\n        \"\"\"Configure window rules for a scratchpad.\"\"\"\n        _ = scratch  # stub, overridden by composed class\n\n    async def _unset_windowrules(self, scratch: Scratch) -> None:\n        \"\"\"Unset window rules for a scratchpad.\"\"\"\n        _ = scratch  # stub, overridden by composed class\n\n    async def __wait_for_client(self, scratch: Scratch, use_proc: bool = True) -> bool:\n        \"\"\"Wait for a client to be up and running.\n\n        if `match_by=` is used, will use the match criteria, else the process's PID will be used.\n\n        Args:\n            scratch: The scratchpad object\n            use_proc: whether to use the process object\n        \"\"\"\n        self.log.info(\"==> Wait for %s spawning\", scratch.uid)\n        interval_range = [0.1] * 10 + [0.2] * 20 + [0.5] * 15\n        for interval in interval_range:\n            await asyncio.sleep(interval)\n            is_alive = await scratch.is_alive()\n\n            # skips the checks if the process isn't started (just wait)\n            if is_alive or not use_proc:\n                info = await scratch.fetch_matching_client()\n                if info:\n                    await scratch.update_client_info(info)\n                    self.log.info(\n                        \"=> %s client (proc:%s, addr:%s) detected on time\",\n                        scratch.uid,\n                        scratch.pid,\n                        scratch.full_address,\n                    )\n                    self.scratches.register(scratch)\n                    self.scratches.clear_state(scratch, \"respawned\")\n                    return True\n            if use_proc and not is_alive:\n                return False\n        return False\n\n    async def _start_scratch_nopid(self, scratch: Scratch) -> bool:\n        \"\"\"Ensure alive, PWA version.\n\n        Args:\n            scratch: The scratchpad object\n        \"\"\"\n        uid = scratch.uid\n        started = scratch.meta.no_pid\n        if not await scratch.is_alive():\n            started = False\n        if not started:\n            self.scratches.reset(scratch)\n            await self.start_scratch_command(uid)\n            r = await self.__wait_for_client(scratch, use_proc=False)\n            scratch.meta.no_pid = r\n            return r\n        return True\n\n    async def _start_scratch(self, scratch: Scratch) -> bool:\n        \"\"\"Ensure alive, standard version.\n\n        Args:\n            scratch: The scratchpad object\n        \"\"\"\n        uid = scratch.uid\n        if uid in self.procs:\n            with contextlib.suppress(ProcessLookupError):\n                self.procs[uid].kill()\n            del self.procs[uid]  # ensure the old process is removed from the dict\n        self.scratches.reset(scratch)\n        await self.start_scratch_command(uid)\n        self.log.info(\"starting %s\", uid)\n        if not await self.__wait_for_client(scratch):\n            self.log.error(\"⚠ Failed spawning %s as proc %s\", uid, scratch.pid)\n            if await scratch.is_alive():\n                error = \"The command didn't open a window\"\n            else:\n                await self.procs[uid].communicate()\n                code = self.procs[uid].returncode\n                error = f\"The command failed with code {code}\" if code else \"The command terminated successfully, is it already running?\"\n            self.log.error('\"%s\": %s', scratch.conf[\"command\"], error)\n            await self.backend.notify_error(error)\n            return False\n        return True\n\n    async def ensure_alive(self, uid: str) -> bool:\n        \"\"\"Ensure the scratchpad is started.\n\n        Returns true if started\n\n        Args:\n            uid: The scratchpad name\n        \"\"\"\n        item = self.scratches.get(name=uid)\n        assert item\n\n        if not item.have_command:\n            return True\n\n        if item.conf.get_bool(\"process_tracking\"):\n            if not await item.is_alive():\n                await self._configure_windowrules(item)\n                self.log.info(\"%s is not running, starting...\", uid)\n                if not await self._start_scratch(item):\n                    await self.backend.notify_error(f'Failed to show scratch \"{item.uid}\"')\n                    return False\n            await self._unset_windowrules(item)\n            return True\n\n        return await self._start_scratch_nopid(item)\n\n    async def start_scratch_command(self, name: str) -> None:\n        \"\"\"Spawn a given scratchpad's process.\n\n        Args:\n            name: The scratchpad name\n        \"\"\"\n        scratch = self.scratches.get(name)\n        assert scratch\n        self.scratches.set_state(scratch, \"respawned\")\n        old_pid = self.procs[name].pid if name in self.procs else 0\n        command = apply_variables(scratch.conf.get_str(\"command\"), self.state.variables)\n        proc = await create_subprocess(command)\n        self.procs[name] = proc\n        pid = proc.pid\n        scratch.reset(pid)\n        self.scratches.register(scratch, pid=pid)\n        self.log.info(\"scratch %s (%s) has pid %s\", scratch.uid, scratch.conf.get(\"command\"), pid)\n        if old_pid:\n            self.scratches.clear(pid=old_pid)\n\n    async def update_scratch_info(self, orig_scratch: Scratch | None = None) -> None:\n        \"\"\"Update Scratchpad information.\n\n        If `scratch` is given, update only this scratchpad.\n        Else, update every scratchpad.\n\n        Args:\n            orig_scratch: The scratchpad object\n        \"\"\"\n        pid = orig_scratch.pid if orig_scratch else None\n        for client in await self.backend.execute_json(\"clients\"):\n            if pid and pid != client[\"pid\"]:\n                continue\n            # if no address registered, register it\n            # + update client info in any case\n            scratch = self.scratches.get(addr=client[\"address\"][2:])\n            if not scratch and client[\"pid\"]:\n                scratch = self.scratches.get(pid=client[\"pid\"])\n            if scratch:\n                self.scratches.register(scratch, addr=client[\"address\"][2:])\n                await scratch.update_client_info(cast(\"ClientInfo\", client))\n                break\n        else:\n            self.log.info(\"Didn't update scratch info %s\", self)\n"
  },
  {
    "path": "pyprland/plugins/scratchpads/lookup.py",
    "content": "\"\"\"Lookup & update API for Scratch objects.\"\"\"\n\nfrom collections import defaultdict\nfrom collections.abc import Iterable, Iterator\nfrom typing import Any, cast, overload\n\nfrom .objects import Scratch\n\n\nclass ScratchDB:  # {{{\n    \"\"\"Single storage for every Scratch allowing a boring lookup & update API.\"\"\"\n\n    _by_addr: dict[str, Scratch]\n    _by_pid: dict[int, Scratch]\n    _by_name: dict[str, Scratch]\n    _states: defaultdict[str, set[Scratch]]\n\n    def __init__(self) -> None:\n        self._by_addr = {}\n        self._by_pid = {}\n        self._by_name = {}\n        self._states = defaultdict(set)\n\n    # State management {{{\n    def get_by_state(self, status: str) -> set[Scratch]:\n        \"\"\"Get a set of `Scratch` being in `status`.\n\n        Args:\n            status: The state to query\n        \"\"\"\n        return self._states[status]\n\n    def has_state(self, scratch: Scratch, status: str) -> bool:\n        \"\"\"Return true if `scratch` has state `status`.\n\n        Args:\n            scratch: The scratch object\n            status: The state to query\n        \"\"\"\n        return scratch in self._states[status]\n\n    def set_state(self, scratch: Scratch, status: str) -> None:\n        \"\"\"Set `scratch` in the provided status.\n\n        Args:\n            scratch: The scratch object\n            status: The state to set\n        \"\"\"\n        self._states[status].add(scratch)\n\n    def clear_state(self, scratch: Scratch, status: str) -> None:\n        \"\"\"Unset the the provided status from the scratch.\n\n        Args:\n            scratch: The scratch object\n            status: The state to clear\n        \"\"\"\n        self._states[status].remove(scratch)\n\n    # }}}\n\n    # dict-like {{{\n    def __iter__(self) -> Iterator[str]:\n        \"\"\"Return all Scratch name.\"\"\"\n        return iter(self._by_name.keys())\n\n    def values(self) -> Iterable[Scratch]:\n        \"\"\"Return every Scratch.\"\"\"\n        return self._by_name.values()\n\n    def items(self) -> Iterable[tuple[str, Scratch]]:\n        \"\"\"Return an iterable list of (name, Scratch).\"\"\"\n        return self._by_name.items()\n\n    # }}}\n\n    def reset(self, scratch: Scratch) -> None:\n        \"\"\"Clear registered address & pid.\n\n        Args:\n            scratch: The scratch object\n        \"\"\"\n        if scratch.address in self._by_addr:\n            del self._by_addr[scratch.address]\n        if scratch.pid in self._by_pid:\n            del self._by_pid[scratch.pid]\n\n    def clear(self, name: str | None = None, pid: int | None = None, addr: str | None = None) -> None:\n        \"\"\"Clear the index by name, pid or address.\n\n        Args:\n            name: The scratchpad name\n            pid: The process ID\n            addr: The window address\n        \"\"\"\n        # {{{\n\n        assert any((name, pid, addr))\n        if name is not None and name in self._by_name:\n            del self._by_name[name]\n        if pid is not None and pid in self._by_pid:\n            del self._by_pid[pid]\n        if addr is not None and addr in self._by_addr:\n            del self._by_addr[addr]\n        # }}}\n\n    @overload\n    def register(self, scratch: Scratch) -> None: ...\n\n    @overload\n    def register(self, scratch: Scratch, name: str) -> None: ...\n\n    @overload\n    def register(self, scratch: Scratch, *, pid: int) -> None: ...\n\n    @overload\n    def register(self, scratch: Scratch, *, addr: str) -> None: ...\n\n    def register(self, scratch: Scratch, name: str | None = None, pid: int | None = None, addr: str | None = None) -> None:\n        \"\"\"Set the Scratch index by name, pid or address, or update every index if only `scratch` is provided.\n\n        Args:\n            scratch: The scratch object\n            name: The scratchpad name\n            pid: The process ID\n            addr: The window address\n        \"\"\"\n        # {{{\n        v: str | int\n        if not any((name, pid, addr)):\n            self._by_name[scratch.uid] = scratch\n            self._by_pid[scratch.pid] = scratch\n            self._by_addr[scratch.address] = scratch\n        else:\n            if name is not None:\n                d: dict[Any, Scratch] = cast(\"dict[str, Scratch]\", self._by_name)\n                v = name\n            elif pid is not None:\n                d = self._by_pid\n                v = pid\n            elif addr is not None:\n                d = self._by_addr\n                v = addr\n            else:\n                msg = \"name, pid or addr must be provided\"\n                raise ValueError(msg)\n            d[v] = scratch\n        # }}}\n\n    @overload\n    def get(self, name: str) -> Scratch | None: ...\n\n    @overload\n    def get(self, *, pid: int) -> Scratch | None: ...\n\n    @overload\n    def get(self, *, addr: str) -> Scratch | None: ...\n\n    def get(self, name: str | None = None, pid: int | None = None, addr: str | None = None) -> Scratch | None:\n        \"\"\"Return the Scratch matching given name, pid or address.\n\n        Args:\n            name: The scratchpad name\n            pid: The process ID\n            addr: The window address\n        \"\"\"\n        # {{{\n        v: str | int\n        assert len(list(filter(bool, (name, pid, addr)))) == 1, (\n            name,\n            pid,\n            addr,\n        )\n        if name is not None:\n            d: dict[Any, Scratch] = self._by_name\n            v = name\n        elif pid is not None:\n            d = self._by_pid\n            v = pid\n        elif addr is not None:\n            d = self._by_addr\n            v = addr\n        else:\n            msg = \"name, pid or addr must be provided\"\n            raise ValueError(msg)\n        return d.get(v)\n        # }}}\n\n\n# }}}\n"
  },
  {
    "path": "pyprland/plugins/scratchpads/objects.py",
    "content": "\"\"\"Scratchpad object definition.\"\"\"\n\n__all__ = [\"Scratch\"]\n\nimport asyncio\nfrom dataclasses import dataclass, field\nfrom itertools import count\nfrom typing import TYPE_CHECKING, Any, Protocol\n\nfrom ...aioops import aiexists, aiopen\nfrom ...models import ClientInfo, MonitorInfo, VersionInfo\nfrom ..interface import PluginContext\nfrom .helpers import DynMonitorConfig, get_match_fn, mk_scratch_name\nfrom .schema import SCRATCHPAD_SCHEMA\n\nif TYPE_CHECKING:\n    from collections.abc import Callable\n\n    import pyprland.plugins.scratchpads as _scratchpads_extension_m\n    from pyprland.plugins.scratchpads import Extension\n\n    class ClientPropGetter(Protocol):\n        \"\"\"type for the get_client_props function.\"\"\"\n\n        async def __call__(\n            self,\n            match_fn: Callable = ...,\n            clients: list[ClientInfo] | None = None,\n            **kw: Any,\n        ) -> ClientInfo | None:\n            pass\n\n\nCLIENT_INFO_RETRY_COUNT = 5\n\n\n@dataclass\nclass MetaInfo:\n    \"\"\"Meta properties.\"\"\"\n\n    initialized: bool = False\n    should_hide: bool = False\n    no_pid: bool = False\n    last_shown: float | int = 0\n    space_identifier: tuple[str, str] = (\"\", \"\")\n    monitor_info: MonitorInfo | None = None\n    extra_positions: dict[str, tuple[int, int]] = field(default_factory=dict)\n    saved_size: dict[str, tuple[int, int]] = field(default_factory=dict)\n\n\nclass Scratch:  # {{{\n    \"\"\"A scratchpad state including configuration & client state.\"\"\"\n\n    # get_client_props: \"ClientPropGetter\"\n    client_info: ClientInfo | None\n    visible = False\n    uid = \"\"\n    monitor = \"\"\n    pid = -1\n    excluded_scratches: list[str]\n\n    def __init__(self, uid: str, full_config: dict[str, Any], plugin: \"Extension\") -> None:\n        \"\"\"Initialize a scratchpad.\n\n        Args:\n            uid: Unique identifier for the scratchpad\n            full_config: The full scratchpads configuration dictionary\n            plugin: The scratchpad extension instance\n        \"\"\"\n        self.ctx = PluginContext(plugin.log, plugin.state, plugin.backend)\n        self.uid = uid\n        self.set_config(full_config)\n        self.client_info: ClientInfo | None = None\n        self.meta = MetaInfo()\n        self.extra_addr: set[str] = set()  # additional client addresses\n        self.excluded_scratches: list[str] = []\n\n    @property\n    def forced_monitor(self) -> str | None:\n        \"\"\"Returns forced monitor if available, else None.\"\"\"\n        forced_monitor = self.conf.get_str(\"force_monitor\")\n        if forced_monitor and forced_monitor in self.ctx.state.active_monitors:\n            return forced_monitor\n        return None\n\n    @property\n    def animation_type(self) -> str:\n        \"\"\"Returns the configured animation (forced lowercase).\"\"\"\n        return self.conf.get_str(\"animation\").lower()\n\n    def _make_initial_config(self, config: dict) -> dict:\n        \"\"\"Return configuration for the scratchpad.\n\n        Args:\n            config: The full configuration dictionary\n        \"\"\"\n        opts = {}\n        scratch_config = config[self.uid]\n        if \"use\" in scratch_config:\n            inheritance = scratch_config[\"use\"]\n            if isinstance(inheritance, str):\n                inheritance = [inheritance]\n\n            for source in inheritance:\n                if source in config:\n                    opts.update(config[source])\n                else:\n                    text = f\"Scratchpad {self.uid} tried to use {source}, but it doesn't exist\"\n                    self.ctx.log.exception(text)\n        opts.update(scratch_config)\n        return opts\n\n    def set_config(self, full_config: dict[str, Any]) -> None:\n        \"\"\"Apply constraints to the configuration.\n\n        Args:\n            full_config: The full configuration dictionary\n        \"\"\"\n        opts = self._make_initial_config(full_config)\n\n        # Create schema-aware config\n        self.conf = DynMonitorConfig(opts, opts.get(\"monitor\", {}), self.ctx.state, log=self.ctx.log, schema=SCRATCHPAD_SCHEMA)\n\n        # Apply constraints using self.conf for reads, writes go to underlying ref\n        if self.conf.get_bool(\"preserve_aspect\"):\n            self.conf[\"lazy\"] = True\n        if not self.have_command:\n            self.conf[\"match_by\"] = \"class\"\n        if not self.conf.get_bool(\"process_tracking\"):\n            self.conf[\"lazy\"] = True\n            if not self.conf.has_explicit(\"match_by\"):\n                self.conf[\"match_by\"] = \"class\"\n        if self.conf.get_bool(\"close_on_hide\"):\n            self.conf[\"lazy\"] = True\n        if self.ctx.state.hyprland_version < VersionInfo(0, 39, 0):\n            self.conf[\"allow_special_workspaces\"] = False\n\n    def have_address(self, addr: str) -> bool:\n        \"\"\"Check if the address is the same as the client.\n\n        Args:\n            addr: The address to check\n        \"\"\"\n        return addr == self.full_address or addr in self.extra_addr\n\n    @property\n    def have_command(self) -> bool:\n        \"\"\"Check if the command is provided.\"\"\"\n        return bool(self.conf.get(\"command\"))\n\n    async def initialize(self, ex: \"_scratchpads_extension_m.Extension\") -> None:\n        \"\"\"Initialize the scratchpad.\n\n        Args:\n            ex: The scratchpad extension instance\n        \"\"\"\n        if self.meta.initialized:\n            return\n        if self.have_command:\n            await self.update_client_info()\n        else:\n            m_client = await self.fetch_matching_client()\n            if not m_client:\n                match_by, match_val = self.get_match_props()\n                msg = f\"No window found matching {match_by}='{match_val}' - is the application running?\"\n                raise RuntimeError(msg)\n            self.client_info = m_client\n        await ex.backend.execute(f\"movetoworkspacesilent {mk_scratch_name(self.uid)},address:{self.full_address}\")\n        self.meta.initialized = True\n\n    async def is_alive(self) -> bool:\n        \"\"\"Is the process running ?.\"\"\"\n        if not self.have_command:\n            return True\n        if self.conf.get_bool(\"process_tracking\"):\n            path = f\"/proc/{self.pid}\"\n            if await aiexists(path):\n                async with aiopen(f\"{path}/status\", mode=\"r\", encoding=\"utf-8\") as f:\n                    for line in await f.readlines():\n                        if line.startswith(\"State\"):\n                            proc_state = line.split()[1]\n                            return proc_state not in \"ZX\"  # not \"Z (zombie)\"or \"X (dead)\"\n        else:\n            if self.meta.no_pid:\n                return bool(await self.fetch_matching_client())\n            return False\n\n        return False\n\n    async def fetch_matching_client(self, clients: list[ClientInfo] | None = None) -> ClientInfo | None:\n        \"\"\"Fetch the first matching client properties.\n\n        Args:\n            clients: The list of clients\n        \"\"\"\n        match_by, match_val = self.get_match_props()\n        return await self.ctx.backend.get_client_props(\n            match_fn=get_match_fn(match_by, match_val),\n            clients=clients,\n            **{match_by: match_val},\n        )\n\n    def get_match_props(self) -> tuple[str, str | float]:\n        \"\"\"Return the match properties for the scratchpad.\"\"\"\n        match_by = self.conf.get_str(\"match_by\")\n        if match_by == \"pid\":\n            return match_by, self.pid\n        # Dynamic key access - the match_by value (e.g., \"class\", \"title\") is used as a key\n        match_value = self.conf.get(match_by)\n        if match_value is None:\n            return match_by, \"\"\n        return match_by, str(match_value) if not isinstance(match_value, (int, float)) else match_value\n\n    def reset(self, pid: int) -> None:\n        \"\"\"Clear the object.\n\n        Args:\n            pid: The process ID\n        \"\"\"\n        self.pid = pid\n        self.visible = False\n        self.client_info = None\n        self.meta.initialized = False\n        self.meta.extra_positions.clear()\n        self.meta.saved_size.clear()\n\n    @property\n    def client_ready(self) -> bool:\n        \"\"\"Check if the client info is available and contains size information.\"\"\"\n        return bool(self.client_info is not None and \"size\" in self.client_info)\n\n    @property\n    def address(self) -> str:\n        \"\"\"Return the client address (without 0x prefix).\"\"\"\n        if self.client_info is None:\n            return \"\"\n        return self.client_info.get(\"address\", \"\")[2:]\n\n    @property\n    def full_address(self) -> str:\n        \"\"\"Return the client address.\"\"\"\n        if self.client_info is None:\n            return \"\"\n        return self.client_info.get(\"address\", \"\")\n\n    async def update_client_info(\n        self,\n        client_info: ClientInfo | None = None,\n        clients: list[ClientInfo] | None = None,\n    ) -> None:\n        \"\"\"Update the internal client info property, if not provided, refresh based on the current address.\n\n        Args:\n            client_info: The client info\n            clients: The list of clients\n        \"\"\"\n        if client_info is None:\n            counter = count(0)\n            while next(counter) < CLIENT_INFO_RETRY_COUNT:\n                if self.have_command:\n                    client_info = await self.ctx.backend.get_client_props(addr=self.full_address, clients=clients)\n                else:\n                    client_info = await self.fetch_matching_client(clients=clients)\n                if client_info and client_info[\"mapped\"]:\n                    break\n                await asyncio.sleep(0.1)  # wait a bit before retrying\n\n        if client_info is None:\n            self.ctx.log.error(\"The client window %s vanished\", self.full_address)\n            msg = f\"Client window {self.full_address} not found\"\n            raise KeyError(msg)\n\n        self.client_info = client_info\n\n    def event_workspace(self, name: str) -> None:\n        \"\"\"Check if the workspace changed.\n\n        Args:\n            name: The workspace name\n        \"\"\"\n        if self.conf.get(\"pinned\"):\n            self.meta.space_identifier = name, self.meta.space_identifier[1]\n\n    def __str__(self) -> str:\n        return f\"{self.uid} {self.address} : {self.client_info} / {self.conf}\"\n\n\n# }}}\n"
  },
  {
    "path": "pyprland/plugins/scratchpads/schema.py",
    "content": "\"\"\"Configuration schema for scratchpads plugin.\"\"\"\n\nimport logging\n\nfrom pyprland.validation import ConfigField, ConfigItems, ConfigValidator\n\n# Null logger for validation (warnings become part of error list)\n_null_logger = logging.getLogger(\"scratchpads.schema\")\n_null_logger.addHandler(logging.NullHandler())\n\n\ndef _validate_against_schema(config: dict, prefix: str, schema: ConfigItems) -> list[str]:\n    \"\"\"Validate config against schema and return errors.\n\n    Args:\n        config: Configuration dictionary to validate\n        prefix: Error message prefix (e.g., \"scratchpads.myterm\")\n        schema: Schema to validate against\n\n    Returns:\n        List of error messages (empty if valid)\n    \"\"\"\n    validator = ConfigValidator(config, prefix, _null_logger)\n    errors = validator.validate(schema)\n    errors.extend(validator.warn_unknown_keys(schema))\n    return errors\n\n\ndef _validate_animation(value: str) -> list[str]:\n    \"\"\"Case-insensitive animation validation.\"\"\"\n    valid = {\"\", \"fromtop\", \"frombottom\", \"fromleft\", \"fromright\"}\n    if not isinstance(value, str) or value.lower() not in valid:\n        return [f\"invalid value '{value}' -> Valid: '', 'fromTop', 'fromBottom', 'fromLeft', 'fromRight'\"]\n    return []\n\n\n# Schema for individual scratchpad configuration\nSCRATCHPAD_SCHEMA = ConfigItems(\n    # Required\n    ConfigField(\"command\", str, required=True, description=\"Command to run (omit for unmanaged scratchpads)\", category=\"basic\"),\n    # Basic\n    ConfigField(\"class\", str, default=\"\", recommended=True, description=\"Window class for matching\", category=\"basic\"),\n    ConfigField(\n        \"animation\",\n        str,\n        default=\"fromTop\",\n        description=\"Animation type\",\n        choices=[\"\", \"fromTop\", \"fromBottom\", \"fromLeft\", \"fromRight\"],\n        validator=_validate_animation,\n        category=\"basic\",\n    ),\n    ConfigField(\"size\", str, default=\"80% 80%\", recommended=True, description=\"Window size (e.g. '80% 80%')\", category=\"basic\"),\n    # Positioning\n    ConfigField(\"position\", str, default=\"\", description=\"Explicit position override\", category=\"positioning\"),\n    ConfigField(\"margin\", int, default=60, description=\"Pixels from screen edge\", category=\"positioning\"),\n    ConfigField(\"offset\", str, default=\"100%\", description=\"Hide animation distance\", category=\"positioning\"),\n    ConfigField(\"max_size\", str, default=\"\", description=\"Maximum window size\", category=\"positioning\"),\n    # Behavior\n    ConfigField(\"lazy\", bool, default=True, description=\"Start on first use\", category=\"behavior\"),\n    ConfigField(\"pinned\", bool, default=True, description=\"Sticky to monitor\", category=\"behavior\"),\n    ConfigField(\"multi\", bool, default=True, description=\"Allow multiple windows\", category=\"behavior\"),\n    ConfigField(\"unfocus\", str, default=\"\", description=\"Action on unfocus ('hide' or empty)\", category=\"behavior\"),\n    ConfigField(\"hysteresis\", float, default=0.4, description=\"Delay before unfocus hide\", category=\"behavior\"),\n    ConfigField(\"excludes\", (list, str), default=[], description='Scratches to hide when shown (use \"*\" for all)', category=\"behavior\"),\n    ConfigField(\"restore_excluded\", bool, default=False, description=\"Restore excluded on hide\", category=\"behavior\"),\n    ConfigField(\"preserve_aspect\", bool, default=False, description=\"Keep size/position across shows\", category=\"behavior\"),\n    ConfigField(\"hide_delay\", float, default=0.2, description=\"Delay before hide animation\", category=\"behavior\"),\n    ConfigField(\"force_monitor\", str, default=\"\", description=\"Always show on specific monitor\", category=\"behavior\"),\n    ConfigField(\"alt_toggle\", bool, default=False, description=\"Alternative toggle for multi-monitor\", category=\"behavior\"),\n    ConfigField(\n        \"allow_special_workspaces\",\n        bool,\n        default=True,\n        description=\"Allow over special workspaces\",\n        category=\"behavior\",\n    ),\n    ConfigField(\"smart_focus\", bool, default=False, description=\"Restore focus on hide\", category=\"behavior\"),\n    ConfigField(\"close_on_hide\", bool, default=False, description=\"Close instead of hide\", category=\"behavior\"),\n    # Non-standard/troubleshooting\n    ConfigField(\n        \"match_by\",\n        str,\n        default=\"pid\",\n        description=\"Match method: pid, class, initialClass, title, initialTitle\",\n        category=\"advanced\",\n    ),\n    ConfigField(\n        \"initialClass\",\n        str,\n        default=\"\",\n        description=\"Match value when match_by='initialClass'\",\n        category=\"advanced\",\n    ),\n    ConfigField(\n        \"initialTitle\",\n        str,\n        default=\"\",\n        description=\"Match value when match_by='initialTitle'\",\n        category=\"advanced\",\n    ),\n    ConfigField(\"title\", str, default=\"\", description=\"Match value when match_by='title'\", category=\"advanced\"),\n    ConfigField(\"process_tracking\", bool, default=True, description=\"Enable process management\", category=\"advanced\"),\n    ConfigField(\n        \"skip_windowrules\",\n        list,\n        default=[],\n        description=\"Rules to skip: aspect, float, workspace\",\n        category=\"advanced\",\n    ),\n    # Template/inheritance\n    ConfigField(\"use\", str, default=\"\", description=\"Inherit from another scratchpad definition\", category=\"advanced\"),\n    ConfigField(\"monitor\", dict, default={}, description=\"Per-monitor config overrides\", category=\"overrides\"),\n)\n\n# Schema for template sections (like [scratchpads.common]) - same fields but 'command' is not required\n_TEMPLATE_SCHEMA = ConfigItems(\n    *(f for f in SCRATCHPAD_SCHEMA if f.name != \"command\"),\n    ConfigField(\"command\", str, default=\"\", description=\"Command to run\", category=\"basic\"),\n)\n\n# Schema for monitor overrides (excludes non-overridable fields)\n_MONITOR_OVERRIDE_SCHEMA = ConfigItems(*(f for f in SCRATCHPAD_SCHEMA if f.name not in {\"command\", \"use\", \"monitor\"}))\n\n\ndef _validate_monitor_overrides(name: str, scratch_config: dict, errors: list[str]) -> None:\n    \"\"\"Validate monitor sub-subsections (per-monitor overrides).\"\"\"\n    monitor_overrides = scratch_config.get(\"monitor\")\n    if not monitor_overrides or not isinstance(monitor_overrides, dict):\n        return\n\n    for monitor_name, override_config in monitor_overrides.items():\n        prefix = f\"scratchpads.{name}.monitor.{monitor_name}\"\n        if not isinstance(override_config, dict):\n            errors.append(f\"[{prefix}] expected dict, got {type(override_config).__name__}\")\n            continue\n\n        errors.extend(_validate_against_schema(override_config, prefix, _MONITOR_OVERRIDE_SCHEMA))\n\n\ndef get_template_names(config: dict) -> set[str]:\n    \"\"\"Collect names of sections referenced via ``use`` by other sections.\n\n    Works with both raw ``dict`` (static / GUI validation) and\n    :class:`Configuration` objects (runtime validation).\n\n    Args:\n        config: The full scratchpads configuration dictionary.\n\n    Returns:\n        Set of section names that are referenced as templates.\n    \"\"\"\n    template_names: set[str] = set()\n    for name, section in config.items():\n        if not isinstance(section, dict) or \".\" in name:\n            continue\n        use = section.get(\"use\")\n        if use:\n            if isinstance(use, str):\n                template_names.add(use)\n            elif isinstance(use, list):\n                template_names.update(use)\n    return template_names\n\n\ndef is_pure_template(name: str, config: dict, template_names: set[str]) -> bool:\n    \"\"\"Return ``True`` if *name* is a pure template (not a real scratchpad).\n\n    A section is considered a pure template when it is referenced by at least\n    one other section via ``use`` **and** it does not define a ``command``\n    of its own.\n\n    Args:\n        name: Section name to check.\n        config: The full scratchpads configuration dictionary.\n        template_names: Pre-computed set from :func:`get_template_names`.\n    \"\"\"\n    section = config.get(name)\n    if not isinstance(section, dict):\n        return False\n    return name in template_names and not section.get(\"command\")\n\n\ndef validate_scratchpad_config(name: str, scratch_config: dict, *, is_template: bool = False) -> list[str]:\n    \"\"\"Validate a single scratchpad's configuration.\n\n    Args:\n        name: The scratchpad name (for error messages)\n        scratch_config: The scratchpad's config dict\n        is_template: If True, this is a template section (referenced via 'use'),\n            so 'command' is not required and cross-field checks are relaxed.\n\n    Returns:\n        List of error messages (empty if valid)\n    \"\"\"\n    errors: list[str] = []\n    prefix = f\"scratchpads.{name}\"\n\n    # Use relaxed schema for templates (command not required)\n    schema = _TEMPLATE_SCHEMA if is_template else SCRATCHPAD_SCHEMA\n\n    # Standard schema validation via ConfigValidator\n    errors.extend(_validate_against_schema(scratch_config, prefix, schema))\n\n    # Cross-field validations (scratchpad-specific, skip for templates)\n    if not is_template:\n        # Note: Using inline default because we're validating raw user config before schema is applied\n        match_by = scratch_config.get(\"match_by\", \"pid\")\n        if match_by != \"pid\" and match_by not in scratch_config:\n            errors.append(f\"[{prefix}] match_by='{match_by}' requires '{match_by}' to be defined\")\n\n        # Validate unmanaged scratchpads (no command) require class for matching\n        if not scratch_config.get(\"command\") and not scratch_config.get(\"class\"):\n            errors.append(f\"[{prefix}] unmanaged scratchpads (no command) require 'class' to be defined\")\n\n    _validate_monitor_overrides(name, scratch_config, errors)\n\n    return errors\n"
  },
  {
    "path": "pyprland/plugins/scratchpads/transitions.py",
    "content": "\"\"\"Scratchpad transitions mixin.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport time\nfrom typing import TYPE_CHECKING\n\nfrom ...adapters.units import convert_coords, convert_monitor_dimension\nfrom ...common import is_rotated\nfrom .animations import AnimationTarget, Placement\nfrom .common import ONE_FRAME, FocusTracker\nfrom .helpers import apply_offset, mk_scratch_name\n\nif TYPE_CHECKING:\n    import logging\n\n    from ...adapters.proxy import BackendProxy\n    from ...common import SharedState\n    from ...models import ClientInfo, MonitorInfo\n    from .objects import Scratch\n\n__all__ = [\"TransitionsMixin\"]\n\n\nclass TransitionsMixin:\n    \"\"\"Mixin for scratchpad show/hide transitions.\n\n    Handles animations and positioning during visibility changes.\n    \"\"\"\n\n    # Type hints for attributes provided by the composed class\n    log: logging.Logger\n    backend: BackendProxy\n    state: SharedState\n    previously_focused_window: str\n    focused_window_tracking: dict[str, FocusTracker]\n\n    # Methods provided by the composed class\n    async def _handle_multiwindow(self, scratch: Scratch, clients: list[ClientInfo]) -> bool:\n        \"\"\"Handle multi-window scratchpads.\"\"\"\n        _ = scratch, clients\n        return False  # stub, overridden by composed class\n\n    async def update_scratch_info(self, orig_scratch: Scratch | None = None) -> None:\n        \"\"\"Update scratchpad information.\"\"\"\n        _ = orig_scratch  # stub, overridden by composed class\n\n    async def get_offsets(self, scratch: Scratch, monitor: MonitorInfo | None = None) -> tuple[int, int]:\n        \"\"\"Return offset from config or use margin as a ref.\n\n        Args:\n            scratch: The scratchpad object\n            monitor: The monitor info\n        \"\"\"\n        if not scratch.client_ready:\n            return (0, 0)\n        assert scratch.client_info\n        offset = scratch.conf.get(\"offset\")\n        if monitor is None:\n            monitor = await self.backend.get_monitor_props(name=scratch.forced_monitor)\n        rotated = is_rotated(monitor)\n        aspect = reversed(scratch.client_info[\"size\"]) if rotated else scratch.client_info[\"size\"]\n\n        margin = scratch.conf.get_int(\"margin\")\n\n        if offset:\n            offset_str = str(offset)\n            aspect_tuple = tuple(aspect)\n            return (\n                convert_monitor_dimension(offset_str, aspect_tuple[0], monitor) + margin,\n                convert_monitor_dimension(offset_str, aspect_tuple[1], monitor) + margin,\n            )\n\n        mon_size = (monitor[\"height\"], monitor[\"width\"]) if rotated else (monitor[\"width\"], monitor[\"height\"])\n\n        return (\n            convert_monitor_dimension(\"100%\", mon_size[0], monitor) + margin,\n            convert_monitor_dimension(\"100%\", mon_size[1], monitor) + margin,\n        )\n\n    async def _hide_transition(self, scratch: Scratch, monitor: MonitorInfo) -> bool:\n        \"\"\"Animate hiding a scratchpad.\n\n        Args:\n            scratch: The scratchpad object\n            monitor: The monitor info\n        \"\"\"\n        animation_type: str = scratch.animation_type\n\n        if not animation_type:\n            return False\n\n        await self._slide_animation(animation_type, scratch, await self.get_offsets(scratch, monitor))\n        delay: float = scratch.conf.get_float(\"hide_delay\")\n        if delay:\n            await asyncio.sleep(delay)  # await for animation to finish\n        return True\n\n    async def _slide_animation(\n        self,\n        animation_type: str,\n        scratch: Scratch,\n        offset: tuple[int, int],\n        target: AnimationTarget = AnimationTarget.ALL,\n    ) -> None:\n        \"\"\"Slides the window `offset` pixels respecting `animation_type`.\n\n        Args:\n            animation_type: The animation type\n            scratch: The scratchpad object\n            offset: The offset to slide\n            target: The target of the animation\n        \"\"\"\n        addresses: list[str] = []\n        if target != AnimationTarget.MAIN:\n            addresses.extend(scratch.extra_addr)\n        if target != AnimationTarget.EXTRA:\n            addresses.append(scratch.full_address)\n        off_x, off_y = offset\n\n        if scratch.client_info:\n            x, y = scratch.client_info.get(\"at\", (0, 0))\n        else:\n            x, y = 0, 0\n\n        animation_actions = {\n            \"fromright\": f\"movewindowpixel exact {x + off_x} {y}\",\n            \"fromleft\": f\"movewindowpixel exact {x - off_x} {y}\",\n            \"frombottom\": f\"movewindowpixel exact {x} {y + off_y}\",\n            \"fromtop\": f\"movewindowpixel exact {x} {y - off_y}\",\n        }\n        await self.backend.execute(\n            [f\"{animation_actions[animation_type]},address:{addr}\" for addr in addresses if animation_type in animation_actions]\n        )\n\n    async def _show_transition(self, scratch: Scratch, monitor: MonitorInfo, was_alive: bool) -> None:\n        \"\"\"Performs the transition to visible state.\n\n        Args:\n            scratch: The scratchpad object\n            monitor: The monitor info\n            was_alive: Whether the scratchpad was already alive\n        \"\"\"\n        forbid_special = not scratch.conf.get_bool(\"allow_special_workspaces\")\n        wrkspc = (\n            monitor[\"activeWorkspace\"][\"name\"]\n            if forbid_special or not monitor[\"specialWorkspace\"][\"name\"] or monitor[\"specialWorkspace\"][\"name\"].startswith(\"special:S-\")\n            else monitor[\"specialWorkspace\"][\"name\"]\n        )\n        if self.previously_focused_window:\n            self.focused_window_tracking[scratch.uid] = FocusTracker(self.previously_focused_window, wrkspc)\n\n        scratch.meta.last_shown = time.time()\n        # Start the transition\n        preserve_aspect = scratch.conf.get_bool(\"preserve_aspect\")\n        should_set_aspect = not (preserve_aspect and was_alive)\n        # Not aspect preserving or it's newly spawned\n        animation_type = scratch.animation_type\n\n        if should_set_aspect:\n            await self._fix_size(scratch, monitor)\n        elif scratch.address in scratch.meta.saved_size:\n            # Restore the saved pixel size (preserve_aspect enabled)\n            saved_w, saved_h = scratch.meta.saved_size[scratch.address]\n            await self.backend.resize_window(scratch.full_address, saved_w, saved_h)\n\n        clients = await self.backend.execute_json(\"clients\")\n        await self._handle_multiwindow(scratch, clients)\n\n        # FIX: initial position\n        # Tag the window with pypr_noanim to disable Hyprland's animation\n        # during the offscreen pre-positioning move, then untag so the real\n        # slide-in animation plays normally.\n        if animation_type and scratch.client_ready:\n            assert scratch.client_info is not None\n            off_x, off_y = Placement.get_offscreen(\n                animation_type,\n                monitor,\n                scratch.client_info,\n                scratch.conf.get_int(\"margin\"),\n            )\n            await self.backend.execute(\n                [\n                    f\"tagwindow +pypr_noanim address:{scratch.full_address}\",\n                    f\"movewindowpixel exact {off_x} {off_y},address:{scratch.full_address}\",\n                ]\n            )\n            await asyncio.sleep(ONE_FRAME)  # NOTE: let some time to process\n            await self.backend.execute(f\"tagwindow -pypr_noanim address:{scratch.full_address}\")\n\n        # move\n        move_commands: list[str] = []\n\n        # Only move workspace to monitor if scratchpad was already alive\n        # (newly spawned windows are already on current monitor via windowrules)\n        if was_alive:\n            move_commands.append(f\"moveworkspacetomonitor {mk_scratch_name(scratch.uid)} {monitor['name']}\")\n\n        move_commands.extend(\n            [\n                f\"movetoworkspacesilent {wrkspc},address:{scratch.full_address}\",\n                f\"alterzorder top,address:{scratch.full_address}\",\n            ]\n        )\n        for addr in scratch.extra_addr:\n            move_commands.extend(\n                [\n                    f\"movetoworkspacesilent {wrkspc},address:{addr}\",\n                    f\"alterzorder top,address:{addr}\",\n                ]\n            )\n\n        await self.backend.execute(move_commands, weak=True)\n        await self._update_infos(scratch, clients)\n\n        # Always apply explicit position config, regardless of should_set_aspect.\n        # The position config is an explicit user intent that should always be honored.\n        position_fixed = await self._fix_position(scratch, monitor)\n\n        if not position_fixed:\n            monitor_changed = scratch.monitor != self.state.active_monitor\n            relative_animation = preserve_aspect and was_alive and not monitor_changed\n            await self._animate_show(scratch, monitor, relative_animation)\n        await self.backend.focus_window(scratch.full_address)\n\n        # Re-apply position after a settle delay to handle apps that resize\n        # themselves after being shown (which causes Hyprland to shift the\n        # window position).\n        if position_fixed:\n            await asyncio.sleep(ONE_FRAME)\n            await self._fix_position(scratch, monitor)\n\n        if scratch.client_info is None or not scratch.client_info[\"pinned\"]:\n            await self._pin_scratch(scratch)\n\n        scratch.meta.last_shown = time.time()\n        scratch.meta.monitor_info = monitor\n\n    async def _pin_scratch(self, scratch: Scratch) -> None:\n        \"\"\"Pin the scratchpad.\n\n        Args:\n            scratch: The scratchpad object\n        \"\"\"\n        if not scratch.conf.get(\"pinned\"):\n            return\n        await self.backend.pin_window(scratch.full_address)\n        for addr in scratch.extra_addr:\n            await self.backend.pin_window(addr)\n\n    async def _update_infos(self, scratch: Scratch, clients: list[ClientInfo]) -> None:\n        \"\"\"Update the client info.\n\n        Args:\n            scratch: The scratchpad object\n            clients: The list of clients\n        \"\"\"\n        try:\n            # Update position, size & workspace information (workspace properties have been created)\n            await scratch.update_client_info(clients=clients)\n        except KeyError:\n            for alt_addr in scratch.extra_addr:\n                # Get the client info for the extra addresses\n                try:\n                    client_info = await self.backend.get_client_props(addr=\"0x\" + alt_addr, clients=clients)\n                    if not client_info:\n                        continue\n                    await scratch.update_client_info(clients=clients, client_info=client_info)\n                except KeyError:\n                    self.log.debug(\"Client info not found for address 0x%s\", alt_addr)\n                else:\n                    break\n            else:\n                self.log.exception(\"Lost the client info for %s\", scratch.uid)\n\n    async def _animate_show(self, scratch: Scratch, monitor: MonitorInfo, relative_animation: bool) -> None:\n        \"\"\"Animate the show transition.\n\n        Args:\n            scratch: The scratchpad object\n            monitor: The monitor info\n            relative_animation: Whether to use relative animation\n        \"\"\"\n        animation_type = scratch.animation_type\n        multiwin_enabled = scratch.conf.get_bool(\"multi\")\n        if animation_type:\n            animation_commands = []\n\n            if scratch.client_info is None or \"size\" not in scratch.client_info:\n                await self.update_scratch_info(scratch)\n\n            if scratch.client_info is None:\n                self.log.error(\"Cannot animate show: client_info is None for %s\", scratch.uid)\n                return\n\n            if relative_animation and scratch.address in scratch.meta.extra_positions:\n                main_win_position = apply_offset((monitor[\"x\"], monitor[\"y\"]), scratch.meta.extra_positions[scratch.address])\n            else:\n                main_win_position = Placement.get(\n                    animation_type,\n                    monitor,\n                    scratch.client_info,\n                    scratch.conf.get_int(\"margin\"),\n                )\n            animation_commands.append([*main_win_position, scratch.full_address])\n\n            if multiwin_enabled:\n                for address in scratch.extra_addr:\n                    off = scratch.meta.extra_positions.get(address)\n                    if off:\n                        pos = apply_offset(main_win_position, off)\n                        animation_commands.append([*pos, address])\n\n            await self.backend.execute([f\"movewindowpixel exact {a[0]} {a[1]},address:{a[2]}\" for a in animation_commands])\n\n    async def _fix_size(self, scratch: Scratch, monitor: MonitorInfo) -> None:\n        \"\"\"Apply the size from config.\n\n        Args:\n            scratch: The scratchpad object\n            monitor: The monitor info\n        \"\"\"\n        size = scratch.conf.get_str(\"size\")\n        if size:\n            width, height = convert_coords(size, monitor)\n            max_size = scratch.conf.get_str(\"max_size\")\n            if max_size:\n                max_width, max_height = convert_coords(max_size, monitor)\n                width = min(max_width, width)\n                height = min(max_height, height)\n            await self.backend.resize_window(scratch.full_address, width, height)\n\n    async def _fix_position(self, scratch: Scratch, monitor: MonitorInfo) -> bool:\n        \"\"\"Apply the `position` config parameter.\n\n        Args:\n            scratch: The scratchpad object\n            monitor: The monitor info\n        \"\"\"\n        position = scratch.conf.get_str(\"position\")\n        if position:\n            x_pos, y_pos = convert_coords(position, monitor)\n            x_pos_abs, y_pos_abs = x_pos + monitor[\"x\"], y_pos + monitor[\"y\"]\n            await self.backend.move_window(scratch.full_address, x_pos_abs, y_pos_abs)\n            return True\n        return False\n"
  },
  {
    "path": "pyprland/plugins/scratchpads/windowruleset.py",
    "content": "\"\"\"WindowRuleSet builder for Hyprland windowrules.\"\"\"\n\n__all__ = [\"WindowRuleSet\"]\n\nfrom collections.abc import Iterable\n\nfrom ...common import SharedState\nfrom ...models import VersionInfo\n\n\nclass WindowRuleSet:\n    \"\"\"Windowrule set builder.\"\"\"\n\n    def __init__(self, state: SharedState) -> None:\n        self.state = state\n        self._params: list[tuple[str, str]] = []\n        self._class = \"\"\n        self._name = \"PyprScratchR\"\n\n    def set_class(self, value: str) -> None:\n        \"\"\"Set the windowrule matching class.\n\n        Args:\n            value: The class name\n        \"\"\"\n        self._class = value\n\n    def set_name(self, value: str) -> None:\n        \"\"\"Set the windowrule name.\n\n        Args:\n            value: The name\n        \"\"\"\n        self._name = value\n\n    def set(self, param: str, value: str) -> None:\n        \"\"\"Set a windowrule property.\n\n        Args:\n            param: The property name\n            value: The property value\n        \"\"\"\n        self._params.append((param, value))\n\n    def _get_content(self) -> Iterable[str]:\n        \"\"\"Get the windowrule content.\"\"\"\n        if self.state.hyprland_version > VersionInfo(0, 47, 2):\n            if self.state.hyprland_version < VersionInfo(0, 53, 0):\n                for p in self._params:\n                    yield f\"windowrule {p[0]} {p[1]}, class: {self._class}\"\n            elif self._name:\n                yield f\"windowrule[{self._name}]:enable true\"\n                yield f\"windowrule[{self._name}]:match:class {self._class}\"\n                for p in self._params:\n                    yield f\"windowrule[{self._name}]:{p[0]} {p[1]}\"\n            else:\n                for p in self._params:\n                    yield f\"windowrule {p[0]} {p[1]}, match:class {self._class}\"\n        else:\n            for p in self._params:\n                yield f\"windowrule {p[0]} {p[1]}, ^({self._class})$\"\n\n    def get_content(self) -> list[str]:\n        \"\"\"Get the windowrule content.\"\"\"\n        return list(self._get_content())\n"
  },
  {
    "path": "pyprland/plugins/shift_monitors.py",
    "content": "\"\"\"Shift workspaces across monitors.\"\"\"\n\nfrom ..models import Environment\nfrom .interface import Plugin\nfrom .mixins import MonitorTrackingMixin\n\nMIN_MONITORS_FOR_SHIFT = 2  # Need at least 2 monitors to shift workspaces\n\n\nclass Extension(MonitorTrackingMixin, Plugin, environments=[Environment.HYPRLAND]):\n    \"\"\"Moves workspaces from monitor to monitor (carousel).\"\"\"\n\n    monitors: list[str]\n\n    async def init(self) -> None:\n        \"\"\"Initialize the plugin.\"\"\"\n        self.monitors = []\n        if self.state.environment == Environment.NIRI:\n            await self.niri_outputschanged({})\n        else:\n            self.monitors = [mon[\"name\"] for mon in await self.backend.get_monitors()]\n\n    async def niri_outputschanged(self, _data: dict) -> None:\n        \"\"\"Track monitors on Niri.\n\n        Args:\n            _data: The event data (unused)\n        \"\"\"\n        try:\n            outputs = await self.backend.execute_json(\"outputs\")\n            self.monitors = list(outputs.keys())\n        except (OSError, RuntimeError) as e:\n            self.log.warning(\"Failed to update monitors from Niri event: %s\", e)\n\n    async def run_shift_monitors(self, arg: str) -> None:\n        \"\"\"<direction> Swaps monitors' workspaces in the given direction.\n\n        Args:\n            arg: Integer direction (+1 or -1) to rotate workspaces across monitors\n        \"\"\"\n        if self.state.environment == Environment.NIRI:\n            # Niri doesn't support swapping workspaces between monitors easily.\n            # We'll implement a \"move workspace to monitor\" shift instead for the active workspace.\n            direction_int = int(arg)\n            if direction_int > 0:\n                await self.backend.execute([\"action\", \"move-workspace-to-monitor-right\"])\n            else:\n                await self.backend.execute([\"action\", \"move-workspace-to-monitor-left\"])\n            return\n\n        if not self.monitors:\n            return\n\n        direction: int = int(arg)\n        # Using modulo arithmetic to simplify logic\n        # If direction is +1: swap 0-1, then 1-2, then 2-3...\n        # If direction is -1: swap 0-(-1), ... wait, logic check\n\n        # Original logic:\n        # mon_list = self.monitors[:-1] if direction > 0 else list(reversed(self.monitors[1:]))\n        # for i, mon in enumerate(mon_list):\n        #    await self.hyprctl(f\"swapactiveworkspaces {mon} {self.monitors[i + direction]}\")\n\n        # New logic with modulo for cyclic shift feeling or just simple swap\n        # The original code swaps active workspaces.\n        # If we have [A, B, C] and direction +1:\n        # i=0 (A): swap A B -> B has old A, A has old B. List effectively [B, A, C] relative to content?\n        # No, Hyprland command \"swapactiveworkspaces MON1 MON2\" swaps the workspaces ON the monitors.\n        # So if Mon1 has WS1, Mon2 has WS2. swap Mon1 Mon2 -> Mon1 has WS2, Mon2 has WS1.\n\n        # Goal: Shift workspaces \"Right\" (+1) means Mon2 gets Mon1's WS, Mon3 gets Mon2's WS, Mon1 gets Mon3's WS.\n        # This is a cyclic shift of workspaces.\n        # A simple series of swaps can achieve this.\n        # [A, B, C] -> want [C, A, B] (workspaces on monitors)\n\n        # Swap A B: [B, A, C]\n        # Swap B C: [B, C, A] -> Result Mon1=B, Mon2=C, Mon3=A.\n        # This matches \"Shift Left\" if we consider monitors ordered 1,2,3.\n        # WS from 1 goes to 3? No.\n        # Mon1 had A, now has B. Mon2 had B, now has C. Mon3 had C, now has A.\n        # So A went to 3. B went to 1. C went to 2.\n        # This is -1 shift.\n\n        # Let's verify standard behavior.\n        # We need to swap safely.\n\n        n = len(self.monitors)\n        if n < MIN_MONITORS_FOR_SHIFT:\n            return\n\n        if direction > 0:\n            # Shift +1: Mon1->Mon2, Mon2->Mon3, Mon3->Mon1\n            # Swap M1 M2 -> [M2, M1, M3] (M1 holds M2's old, M2 holds M1's old)\n            # Swap M2 M3 -> [M2, M3, M1] (M2 holds M3's old, M3 holds M1's old)\n            # Result: M1 has M2's old? No wait.\n\n            # Let's trace values (Workspaces)\n            # Start: M1=W1, M2=W2, M3=W3\n            # swap M1 M2: M1=W2, M2=W1, M3=W3\n            # swap M2 M3: M1=W2, M2=W3, M3=W1\n            # Final: M1=W2, M2=W3, M3=W1.\n            # W1 went to M3. W2 went to M1. W3 went to M2.\n            # This looks like direction -1 (Left shift).\n\n            # If we want W1 -> M2, W2 -> M3, W3 -> M1 (Right shift/+1)\n            # Start: M1=W1, M2=W2, M3=W3\n            # swap M3 M2: M1=W1, M2=W3, M3=W2\n            # swap M2 M1: M1=W3, M2=W1, M3=W2\n            # Final: M1=W3 (from M3), M2=W1 (from M1), M3=W2 (from M2).\n            # Correct!\n\n            # So for +1: Iterate backwards: swap(i, i-1)\n            # For -1: Iterate forwards: swap(i, i+1)\n\n            for i in range(n - 1, 0, -1):\n                await self.backend.execute(f\"swapactiveworkspaces {self.monitors[i]} {self.monitors[i - 1]}\")\n        else:\n            for i in range(n - 1):\n                await self.backend.execute(f\"swapactiveworkspaces {self.monitors[i]} {self.monitors[i + 1]}\")\n"
  },
  {
    "path": "pyprland/plugins/shortcuts_menu.py",
    "content": "\"\"\"Shortcuts menu.\"\"\"\n\nimport asyncio\nfrom typing import cast\n\nfrom ..adapters.menus import MenuMixin\nfrom ..common import apply_filter, apply_variables\nfrom ..process import create_subprocess\nfrom ..validation import ConfigField, ConfigItems\nfrom .interface import Plugin\n\n\nclass Extension(MenuMixin, Plugin):\n    \"\"\"A flexible way to make your own shortcuts menus & launchers.\"\"\"\n\n    config_schema = ConfigItems(\n        ConfigField(\"entries\", dict, required=True, description=\"Menu entries structure (nested dict of commands)\", category=\"basic\"),\n        *MenuMixin.menu_config_schema,\n        ConfigField(\"separator\", str, default=\" | \", description=\"Separator for menu display\", category=\"appearance\"),\n        ConfigField(\"command_start\", str, default=\"\", description=\"Prefix for command entries\", category=\"appearance\"),\n        ConfigField(\"command_end\", str, default=\"\", description=\"Suffix for command entries\", category=\"appearance\"),\n        ConfigField(\"submenu_start\", str, default=\"\", description=\"Prefix for submenu entries\", category=\"appearance\"),\n        ConfigField(\"submenu_end\", str, default=\"➜\", description=\"Suffix for submenu entries\", category=\"appearance\"),\n        ConfigField(\"skip_single\", bool, default=True, description=\"Auto-select when only one option available\", category=\"behavior\"),\n    )\n\n    # Commands\n\n    async def run_menu(self, name: str = \"\") -> None:\n        \"\"\"[name] Shows the menu, if \"name\" is provided, will only show this sub-menu.\n\n        Args:\n            name: The menu name\n        \"\"\"\n        await self.ensure_menu_configured()\n        options: dict | list | str = self.get_config_dict(\"entries\")\n        if name:\n            for elt in name.split(\".\"):\n                assert isinstance(options, dict), f\"Cannot navigate into non-dict at '{elt}'\"\n                options = options[elt]\n\n        def _format_title(label: str, obj: str | list) -> str:\n            if isinstance(obj, str):\n                suffix = self.get_config_str(\"command_end\")\n                prefix = self.get_config_str(\"command_start\")\n            else:\n                suffix = self.get_config_str(\"submenu_end\")\n                prefix = self.get_config_str(\"submenu_start\")\n\n            return f\"{prefix} {label} {suffix}\".strip()\n\n        while True:\n            selection = name\n            if isinstance(options, str):\n                self.log.info(\"running %s\", options)\n                await self._run_command(options.strip(), self.state.variables)\n                break\n            if isinstance(options, list):\n                self.log.info(\"interpreting %s\", options)\n                await self._handle_chain(options)\n                break\n            try:\n                formatted_options = {_format_title(k, v): v for k, v in options.items()}\n                if self.get_config_bool(\"skip_single\") and len(formatted_options) == 1:\n                    selection = next(iter(formatted_options.keys()))\n                else:\n                    selection = await self.menu.run(formatted_options, selection)\n                options = formatted_options[selection]\n            except KeyError:\n                self.log.info(\"menu command canceled\")\n                break\n\n    # Utils\n\n    async def _handle_chain(self, options: list[str | dict]) -> None:\n        \"\"\"Handle a chain of special objects + final command string.\n\n        Args:\n            options: The chain of options\n        \"\"\"\n        variables: dict[str, str] = self.state.variables.copy()\n        autovalidate = self.get_config_bool(\"skip_single\")\n        for option in options:\n            if isinstance(option, str):\n                await self._run_command(option, variables)\n            else:\n                choices = []\n                var_name = option[\"name\"]\n                if option.get(\"command\"):  # use the option to select some variable\n                    proc = await create_subprocess(option[\"command\"], stdout=asyncio.subprocess.PIPE)\n                    assert proc.stdout\n                    await proc.wait()\n                    option_array = (await proc.stdout.read()).decode().split(\"\\n\")\n                    choices.extend([apply_variables(line, variables).strip() for line in option_array if line.strip()])\n                elif option.get(\"options\"):\n                    choices.extend(apply_variables(txt, variables) for txt in option[\"options\"])\n                if not choices:\n                    await self.backend.notify_info(\"command didn't return anything\")\n                    return\n\n                if autovalidate and len(choices) == 1:\n                    variables[var_name] = choices[0]\n                else:\n                    selection = await self.menu.run(choices, var_name)\n                    variables[var_name] = apply_filter(selection, cast(\"str\", option.get(\"filter\", \"\")))\n                    self.log.debug(\"set %s = %s\", var_name, variables[var_name])\n\n    async def _run_command(self, command: str, variables: dict[str, str]) -> None:\n        \"\"\"Run a shell `command`, optionally replacing `variables`.\n\n        The command is run in a shell, and the variables are replaced using the `apply_variables` function.\n\n        Args:\n            command: The command to run.\n            variables: The variables to replace in the command.\n        \"\"\"\n        final_command = apply_variables(command, variables)\n        self.log.info(\"Executing %s\", final_command)\n        await create_subprocess(final_command)\n"
  },
  {
    "path": "pyprland/plugins/stash.py",
    "content": "\"\"\"stash allows stashing and showing windows in named groups.\"\"\"\n\nimport asyncio\nfrom typing import cast\n\nfrom ..models import Environment, ReloadReason\nfrom ..validation import ConfigField, ConfigItems\nfrom .interface import Plugin\n\nSTASH_PREFIX = \"st-\"\nSTASH_TAG = \"stashed\"\n\n\nclass Extension(Plugin, environments=[Environment.HYPRLAND]):\n    \"\"\"Stash and show windows in named groups using special workspaces.\"\"\"\n\n    config_schema = ConfigItems(\n        ConfigField(\"style\", list, default=[], description=\"Window rules to apply to shown stash windows\", category=\"basic\"),\n    )\n\n    def __init__(self, name: str) -> None:\n        super().__init__(name)\n        self._visible: dict[str, bool] = {}\n        self._shown_addresses: dict[str, list[str]] = {}\n        self._was_floating: dict[str, bool] = {}\n\n    async def on_reload(self, reason: ReloadReason = ReloadReason.RELOAD) -> None:  # noqa: ARG002\n        \"\"\"Clear old tag rules and re-register window rules for stash styling.\"\"\"\n        await self.backend.execute(\n            f\"windowrule tag -{STASH_TAG}\",\n            base_command=\"keyword\",\n        )\n        style = self.get_config_list(\"style\")\n        if style:\n            commands = [f\"windowrule {rule}, match:tag {STASH_TAG}\" for rule in style]\n            await self.backend.execute(commands, base_command=\"keyword\")\n\n    async def run_stash(self, name: str = \"default\") -> None:\n        \"\"\"[name] Toggle stashing the focused window (default stash: \"default\").\n\n        Args:\n            name: The stash group name\n        \"\"\"\n        aw = cast(\"dict\", await self.backend.execute_json(\"activewindow\"))\n        addr = aw.get(\"address\", \"\")\n        if not addr:\n            return\n\n        # If the window was shown via stash_toggle, just remove it from tracking\n        for group, addresses in self._shown_addresses.items():\n            if addr in addresses:\n                addresses.remove(addr)\n                await self._restore_floating(addr)\n                if not addresses:\n                    self._shown_addresses.pop(group)\n                    self._visible[group] = False\n                return\n\n        ws_name = aw[\"workspace\"][\"name\"]\n        await asyncio.sleep(0.1)\n\n        if ws_name.startswith(f\"special:{STASH_PREFIX}\"):\n            # Window is stashed → unstash it to current workspace\n            await self.backend.move_window_to_workspace(addr, self.state.active_workspace, silent=True)\n            await self.backend.focus_window(addr)\n\n            await asyncio.sleep(0.1)\n            await self._restore_floating(addr)\n        else:\n            # Window is not stashed → stash it\n            was_floating = aw.get(\"floating\", False)\n            self._was_floating[addr] = was_floating\n            await self.backend.move_window_to_workspace(addr, f\"special:{STASH_PREFIX}{name}\", silent=True)\n            await asyncio.sleep(0.1)\n            if not was_floating:\n                await self.backend.toggle_floating(addr)\n            await asyncio.sleep(0.1)\n            if self.get_config_list(\"style\"):\n                await self.backend.execute(f\"tagwindow +{STASH_TAG} address:{addr}\")\n\n    async def _restore_floating(self, addr: str) -> None:\n        \"\"\"Restore a window's original floating state and remove stash tag.\"\"\"\n        if not self._was_floating.pop(addr, True):\n            await self.backend.toggle_floating(addr)\n            await asyncio.sleep(0.1)\n        if self.get_config_list(\"style\"):\n            await self.backend.execute(f\"tagwindow -{STASH_TAG} address:{addr}\")\n\n    async def event_closewindow(self, addr: str) -> None:\n        \"\"\"Remove a closed window from stash tracking.\n\n        Args:\n            addr: Window address as hex string (without 0x prefix)\n        \"\"\"\n        addr = \"0x\" + addr\n        self._was_floating.pop(addr, None)\n        for group in list(self._shown_addresses):\n            addresses = self._shown_addresses[group]\n            if addr in addresses:\n                addresses.remove(addr)\n                if not addresses:\n                    del self._shown_addresses[group]\n                    self._visible[group] = False\n\n    async def run_stash_toggle(self, name: str = \"default\") -> None:\n        \"\"\"[name] Show or hide stash \"name\" as floating windows on the active workspace (default: \"default\").\n\n        When showing, windows are moved from the hidden stash workspace to the\n        active workspace and made floating.  When hiding, they are moved back.\n\n        Args:\n            name: The stash group name\n        \"\"\"\n        if self._visible.get(name, False):\n            await self._hide_stash(name)\n        else:\n            await self._show_stash(name)\n\n    async def _show_stash(self, name: str) -> None:\n        \"\"\"Move stashed windows to the active workspace.\"\"\"\n        stash_ws = f\"special:{STASH_PREFIX}{name}\"\n        clients = await self.get_clients(workspace=stash_ws)\n        if not clients:\n            return\n\n        addresses: list[str] = []\n        for client in clients:\n            addr = client[\"address\"]\n            addresses.append(addr)\n            await self.backend.move_window_to_workspace(addr, self.state.active_workspace, silent=True)\n\n        self._shown_addresses[name] = addresses\n        self._visible[name] = True\n\n    async def _hide_stash(self, name: str) -> None:\n        \"\"\"Move previously shown stash windows back to the hidden workspace.\"\"\"\n        stash_ws = f\"special:{STASH_PREFIX}{name}\"\n        addresses = self._shown_addresses.get(name, [])\n\n        for addr in addresses:\n            await self.backend.move_window_to_workspace(addr, stash_ws)\n\n        self._shown_addresses.pop(name, None)\n        self._visible[name] = False\n"
  },
  {
    "path": "pyprland/plugins/system_notifier.py",
    "content": "\"\"\"Add system notifications based on journal logs.\"\"\"\n\nimport asyncio\nimport re\nfrom copy import deepcopy\nfrom typing import Any, cast\n\nfrom ..adapters.colors import convert_color\nfrom ..aioops import TaskManager\nfrom ..common import apply_filter, notify_send\nfrom ..models import ReloadReason\nfrom ..process import ManagedProcess\nfrom ..validation import ConfigField, ConfigItems\nfrom .interface import Plugin\n\nbuiltin_parsers = {\n    \"journal\": [\n        {\n            \"pattern\": r\"([a-z0-9]+): Link UP$\",\n            \"filter\": r\"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is active/\",\n            \"color\": \"#00aa00\",\n        },\n        {\n            \"pattern\": r\"([a-z0-9]+): Link DOWN$\",\n            \"filter\": r\"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is inactive/\",\n            \"color\": \"#ff8800\",\n        },\n        {\n            \"pattern\": r\"Process \\d+ \\(.*\\) of .* dumped core.$\",\n            \"filter\": r\"s/.*Process \\d+ \\((.*)\\) of .* dumped core./\\1 dumped core/\",\n            \"color\": \"#aa0000\",\n        },\n        {\n            \"pattern\": r\"usb \\d+-[0-9.]+: Product: \",\n            \"filter\": r\"s/.*usb \\d+-[0-9.]+: Product: (.*)/USB plugged: \\1/\",\n        },\n    ]\n}\n\n\nclass Extension(Plugin):\n    \"\"\"Opens streams (eg: journal logs) and triggers notifications.\"\"\"\n\n    config_schema = ConfigItems(\n        ConfigField(\n            \"command\",\n            dict,\n            default={},\n            description=\"\"\"This is the long-running command (eg: `tail -f <filename>`) returning the stream of text that will be updated.\n            A common option is the system journal output (eg: `journalctl -u nginx`)\"\"\",\n            recommended=True,\n            category=\"basic\",\n        ),\n        ConfigField(\n            \"parser\",\n            dict,\n            default={},\n            description=\"\"\"Sets the list of rules / parser to be used to extract lines of interest\n            Must match a list of rules defined as `system_notifier.parsers.<parser_name>`.\"\"\",\n            category=\"basic\",\n        ),\n        ConfigField(\n            \"parsers\",\n            dict,\n            default={},\n            description=\"\"\"Custom parser definitions (name -> list of rules).\n            Each rule has: pattern (required), filter, color (defaults to default_color), duration (defaults to 3 seconds)\"\"\",\n            recommended=True,\n            category=\"parsers\",\n        ),\n        ConfigField(\n            \"sources\", list, default=[], description=\"Source definitions with command and parser\", recommended=True, category=\"basic\"\n        ),\n        ConfigField(\n            \"pattern\",\n            str,\n            default=\"\",\n            description=\"The pattern is any regular expression that should trigger a match.\",\n            recommended=True,\n            category=\"basic\",\n        ),\n        ConfigField(\"default_color\", str, default=\"#5555AA\", description=\"Default notification color\", category=\"appearance\"),\n        ConfigField(\n            \"use_notify_send\", bool, default=False, description=\"Use notify-send instead of Hyprland notifications\", category=\"behavior\"\n        ),\n    )\n\n    _tasks: TaskManager\n    sources: dict[str, ManagedProcess]\n    parsers: dict[str, asyncio.Queue[Any]]\n\n    def __init__(self, name: str) -> None:\n        \"\"\"Initialize the class.\"\"\"\n        super().__init__(name)\n        self._tasks = TaskManager()\n        self.sources = {}\n        self.parsers = {}\n\n    async def on_reload(self, reason: ReloadReason = ReloadReason.RELOAD) -> None:\n        \"\"\"Reload the plugin.\"\"\"\n        _ = reason  # unused\n        await self.exit()\n        self._tasks.start()\n        parsers = deepcopy(builtin_parsers)\n        parsers.update(self.get_config_dict(\"parsers\"))\n        for name, pprops in parsers.items():\n            self._tasks.create(self.start_parser(name, pprops))\n            self.parsers[name] = asyncio.Queue()\n            self.log.debug(\"Loaded parser %s\", name)\n\n        for props in self.get_config_list(\"sources\"):\n            assert props[\"parser\"] in self.parsers, f\"{props['parser']} was not found in {self.parsers}\"\n            self.log.debug(\"Loaded source %s => %s\", props[\"command\"], props[\"parser\"])\n            self._tasks.create(self.start_source(props))\n\n    async def exit(self) -> None:\n        \"\"\"Exit function.\"\"\"\n        await self._tasks.stop()\n        for source in self.sources.values():\n            await source.stop()\n        self.sources.clear()\n\n    async def start_source(self, props: dict[str, str]) -> None:\n        \"\"\"Start a source loop.\n\n        A source is a command that will be executed and its stdout will be read line by line.\n\n        Args:\n            props: A dictionary with the following keys:\n                - command: The command to execute.\n                - parser: The name of the parser to use.\n        \"\"\"\n        parsers = [props[\"parser\"]] if isinstance(props[\"parser\"], str) else props[\"parser\"]\n        queues = [self.parsers[p] for p in parsers]\n        proc = ManagedProcess()\n        await proc.start(props[\"command\"], stdout=asyncio.subprocess.PIPE)\n\n        self.sources[props[\"command\"]] = proc\n        await asyncio.sleep(1)\n        # Read stdout line by line and push to parser\n        async for line in proc.iter_lines():\n            if not self._tasks.running:\n                break\n            if line:\n                for q in queues:\n                    await q.put(line)\n\n    async def start_parser(self, name: str, props: list) -> None:\n        \"\"\"Start a parser loop.\n\n        Args:\n            name: The name of the parser.\n            props: A list of dictionaries with the following keys:\n                - pattern: A regex pattern to match.\n                - filter: A filter to apply to the matched line.\n                - color: The color to use in the notification.\n        \"\"\"\n        q = self.parsers[name]\n        default_color = self.get_config_str(\"default_color\")\n        rules = [\n            {\n                \"pattern\": re.compile(prop[\"pattern\"]),\n                \"filter\": prop.get(\"filter\"),\n                \"color\": convert_color(prop.get(\"color\", default_color)),\n                \"duration\": prop.get(\"duration\", 3),\n            }\n            for prop in props\n        ]\n        while self._tasks.running:\n            content = await q.get()\n            for rule in rules:\n                if rule[\"pattern\"].search(content):\n                    text = apply_filter(content, cast(\"str\", rule[\"filter\"])) if rule[\"filter\"] else content\n                    if self.get_config_bool(\"use_notify_send\"):\n                        await notify_send(text, duration=rule[\"duration\"] * 1000)\n                    else:\n                        await self.backend.notify(text, color=rule[\"color\"], duration=rule[\"duration\"])\n\n                    await asyncio.sleep(0.01)\n"
  },
  {
    "path": "pyprland/plugins/toggle_dpms.py",
    "content": "\"\"\"Toggle monitors on or off.\"\"\"\n\nfrom ..models import Environment\nfrom .interface import Plugin\n\n\nclass Extension(Plugin, environments=[Environment.HYPRLAND]):\n    \"\"\"Toggles the DPMS status of every plugged monitor.\"\"\"\n\n    async def run_toggle_dpms(self) -> None:\n        \"\"\"Toggle dpms on/off for every monitor.\"\"\"\n        monitors = await self.backend.get_monitors()\n        powered_off = any(m[\"dpmsStatus\"] for m in monitors)\n        if not powered_off:\n            await self.backend.execute(\"dpms on\")\n        else:\n            await self.backend.execute(\"dpms off\")\n"
  },
  {
    "path": "pyprland/plugins/toggle_special.py",
    "content": "\"\"\"toggle_special allows having an \"expose\" like selection of windows in a special group.\"\"\"\n\nfrom typing import cast\n\nfrom ..models import Environment\nfrom ..validation import ConfigField, ConfigItems\nfrom .interface import Plugin\n\n\nclass Extension(Plugin, environments=[Environment.HYPRLAND]):\n    \"\"\"Toggle switching the focused window to a special workspace.\"\"\"\n\n    config_schema = ConfigItems(\n        ConfigField(\"name\", str, default=\"minimized\", description=\"Default special workspace name\", category=\"basic\"),\n    )\n\n    async def run_toggle_special(self, special_workspace: str = \"minimized\") -> None:\n        \"\"\"[name] Toggles switching the focused window to the special workspace \"name\" (default: minimized).\n\n        Args:\n            special_workspace: The special workspace name\n        \"\"\"\n        aw = cast(\"dict\", await self.backend.execute_json(\"activewindow\"))\n        wid = aw[\"workspace\"][\"id\"]\n        if wid < 1:  # special workspace\n            await self.backend.execute(\n                [\n                    f\"movetoworkspacesilent {self.state.active_workspace},address:{aw['address']}\",\n                    f\"togglespecialworkspace {special_workspace}\",\n                    f\"focuswindow address:{aw['address']}\",\n                ]\n            )\n        else:\n            await self.backend.move_window_to_workspace(aw[\"address\"], f\"special:{special_workspace}\")\n"
  },
  {
    "path": "pyprland/plugins/wallpapers/__init__.py",
    "content": "\"\"\"Plugin template.\"\"\"\n\nimport asyncio\nimport colorsys\nimport contextlib\nimport json\nimport random\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Any\n\nfrom ...aioops import TaskManager, aiexists, aiisfile, airmdir, airmtree, aiunlink\nfrom ...common import apply_variables\nfrom ...constants import (\n    DEFAULT_PALETTE_COLOR_RGB,\n    DEFAULT_WALLPAPER_HEIGHT,\n    DEFAULT_WALLPAPER_WIDTH,\n    PREFETCH_MAX_RETRIES,\n    PREFETCH_RETRY_BASE_SECONDS,\n    PREFETCH_RETRY_MAX_SECONDS,\n    SECONDS_PER_DAY,\n)\nfrom ...models import Environment, ReloadReason\nfrom ...process import ManagedProcess, create_subprocess\nfrom ...validation import ConfigField, ConfigItems\nfrom ..interface import Plugin\nfrom .cache import ImageCache\nfrom .colorutils import can_edit_image, get_dominant_colors, nicify_oklab\nfrom .hyprpaper import HyprpaperManager\nfrom .imageutils import (\n    MonitorInfo,\n    RoundedImageManager,\n    expand_path,\n    get_effective_dimensions,\n    get_files_with_ext,\n)\nfrom .models import ColorScheme\nfrom .online import NoBackendAvailableError, OnlineFetcher\nfrom .palette import generate_sample_palette, hex_to_rgb, palette_to_json, palette_to_terminal\nfrom .templates import TemplateEngine\nfrom .theme import detect_theme, generate_palette, get_color_scheme_props\n\n# Length of a hex color without '#' prefix\nHEX_COLOR_LENGTH = 6\n\n# Default backends that support size filtering (excludes bing which returns fixed 1920x1080)\nDEFAULT_ONLINE_BACKENDS = [\"unsplash\", \"picsum\", \"wallhaven\", \"reddit\"]\n\n\n@dataclass\nclass OnlineState:\n    \"\"\"State for online wallpaper fetching.\"\"\"\n\n    fetcher: OnlineFetcher | None = None\n    folder_path: Path | None = None\n    cache: ImageCache | None = None\n    rounded_cache: ImageCache | None = None\n    prefetched_path: str | None = None\n\n\nasync def fetch_monitors(extension: \"Extension\") -> list[MonitorInfo]:\n    \"\"\"Fetch monitor information from the backend.\n\n    Works with any backend that implements get_monitors().\n    \"\"\"\n    monitors = await extension.backend.get_monitors()\n    return [\n        MonitorInfo(\n            name=m[\"name\"],\n            width=int(m[\"width\"]),\n            height=int(m[\"height\"]),\n            transform=m[\"transform\"],\n            scale=m[\"scale\"],\n        )\n        for m in monitors\n    ]\n\n\nclass Extension(Plugin):\n    \"\"\"Handles random wallpapers at regular intervals, with support for rounded corners and color scheme generation.\"\"\"\n\n    config_schema = ConfigItems(\n        ConfigField(\"path\", (Path, list), required=True, description=\"Path(s) to wallpaper images or directories\", category=\"basic\"),\n        ConfigField(\"interval\", int, default=10, description=\"Minutes between wallpaper changes\", category=\"basic\"),\n        ConfigField(\n            \"extensions\",\n            list,\n            description=\"File extensions to include (e.g., ['png', 'jpg'])\",\n            default=[\"png\", \"jpeg\", \"jpg\"],\n            category=\"basic\",\n        ),\n        ConfigField(\"recurse\", bool, default=False, description=\"Recursively search subdirectories\", category=\"basic\"),\n        ConfigField(\"unique\", bool, default=False, description=\"Use different wallpaper per monitor\", category=\"basic\"),\n        ConfigField(\"radius\", int, default=0, description=\"Corner radius for rounded corners\", category=\"appearance\"),\n        ConfigField(\n            \"command\", str, description=\"Custom command to set wallpaper ([file] and [output] variables)\", category=\"external_commands\"\n        ),\n        ConfigField(\"post_command\", str, description=\"Command to run after setting wallpaper\", category=\"external_commands\"),\n        ConfigField(\"clear_command\", str, description=\"Command to run when clearing wallpaper\", category=\"external_commands\"),\n        ConfigField(\n            \"color_scheme\",\n            str,\n            default=\"\",\n            description=\"Color scheme for palette generation\",\n            choices=[c.value for c in ColorScheme] + [\"fluorescent\"],\n            category=\"templating\",\n        ),\n        ConfigField(\"variant\", str, description=\"Color variant type for palette\", category=\"templating\"),\n        ConfigField(\"templates\", dict, description=\"Template files for color palette generation\", category=\"templating\"),\n        # Online wallpaper fetching options\n        ConfigField(\"online_ratio\", float, default=0.0, description=\"Probability of fetching online (0.0-1.0)\", category=\"online\"),\n        ConfigField(\n            \"online_backends\",\n            list,\n            default=DEFAULT_ONLINE_BACKENDS,\n            description=\"Enabled online backends\",\n            category=\"online\",\n        ),\n        ConfigField(\"online_keywords\", list, default=[], description=\"Keywords to filter online images\", category=\"online\"),\n        ConfigField(\"online_folder\", str, default=\"online\", description=\"Subfolder for downloaded online images\", category=\"online\"),\n        # Cache options\n        ConfigField(\"cache_days\", int, default=0, description=\"Days to keep cached images (0 = forever)\", category=\"cache\"),\n        ConfigField(\"cache_max_mb\", int, default=100, description=\"Maximum cache size in MB (0 = unlimited)\", category=\"cache\"),\n        ConfigField(\"cache_max_images\", int, default=0, description=\"Maximum number of cached images (0 = unlimited)\", category=\"cache\"),\n    )\n\n    image_list: list[str]\n    _tasks: TaskManager\n    _loop_started = False\n    proc: list[ManagedProcess]\n\n    next_background_event = asyncio.Event()\n    cur_image = \"\"\n    cur_display_image = \"\"  # The actual path sent to hyprpaper (may be rounded)\n    _paused = False\n\n    rounded_manager: RoundedImageManager | None\n    template_engine: TemplateEngine\n\n    # Online fetching state\n    _online: OnlineState | None = None\n    _online_folders: set[str]\n\n    # Hyprpaper manager (only when using hyprpaper backend)\n    _hyprpaper: HyprpaperManager | None = None\n\n    def __init__(self, name: str) -> None:\n        \"\"\"Initialize the plugin.\"\"\"\n        super().__init__(name)\n        self._tasks = TaskManager()\n        self._online_folders = set()\n\n    async def on_reload(self, reason: ReloadReason = ReloadReason.RELOAD) -> None:\n        \"\"\"Re-build the image list.\"\"\"\n        _ = reason  # unused\n        # Clean up legacy cache folder if it exists\n        legacy_cache = Path.home() / \".cache\" / \"pyprland\" / \"wallpapers\"\n        if await aiexists(legacy_cache):\n            await airmtree(str(legacy_cache))\n            self.log.info(\"Removed legacy cache folder: %s\", legacy_cache)\n            # Also remove parent if empty\n            parent = legacy_cache.parent\n            if await aiexists(parent) and not any(parent.iterdir()):\n                await airmdir(str(parent))\n\n        self.image_list = []\n        # Require 'command' when not on Hyprland (hyprpaper default only works there)\n        if not self.get_config(\"command\") and self.state.environment != \"hyprland\":\n            self.log.error(\n                \"'command' config is required for environment '%s' (hyprpaper default only works on Hyprland)\",\n                self.state.environment,\n            )\n            return\n\n        # Initialize hyprpaper manager if using default hyprpaper backend\n        if self.state.environment == Environment.HYPRLAND and not self.get_config(\"command\"):\n            self._hyprpaper = HyprpaperManager(self.log)\n        else:\n            self._hyprpaper = None\n\n        cfg_path: str | list[str] = self.get_config(\"path\")  # type: ignore[assignment]\n        paths = [expand_path(cfg_path)] if isinstance(cfg_path, str) else [expand_path(p) for p in cfg_path]\n        extensions = self.get_config_list(\"extensions\")\n        radius = self.get_config_int(\"radius\")\n        online_ratio = self.get_config_float(\"online_ratio\")\n\n        # Build set of online folder paths (for wall rm command)\n        online_folder_name = self.get_config_str(\"online_folder\") or \"online\"\n        self._online_folders = {str(Path(p) / online_folder_name) for p in paths}\n\n        # Build local image list, excluding cache folders (rounded, online)\n        exclude_dirs = {\"rounded\", online_folder_name}\n        self.image_list = [\n            full_path\n            for path in paths\n            async for full_path in get_files_with_ext(path, extensions, recurse=self.get_config_bool(\"recurse\"), exclude_dirs=exclude_dirs)\n        ]\n\n        # Set up online fetching and get rounded cache\n        self._online = await self._setup_online_fetching(paths, extensions, online_ratio)\n\n        # Warn if no local images but online_ratio < 1\n        if not self.image_list and online_ratio < 1.0:\n            await self._warn_no_images()\n\n        # Set up rounded corners manager with appropriate cache location\n        rounded_cache = self._online.rounded_cache if self._online else None\n        if radius > 0 and can_edit_image:\n            if not rounded_cache:\n                # Create local rounded cache when online is disabled\n                first_path = paths[0] if paths else expand_path(\"~/Pictures/Wallpapers\")\n                rounded_cache_dir = Path(first_path) / \"rounded\"\n                rounded_cache_dir.mkdir(parents=True, exist_ok=True)\n                rounded_cache = self._create_cache(rounded_cache_dir)\n            self.rounded_manager = RoundedImageManager(radius, cache=rounded_cache)\n        else:\n            self.rounded_manager = None\n\n        self.template_engine = TemplateEngine(self.log)\n\n        # Clean up expired cache entries asynchronously\n        await self._cleanup_caches()\n\n        # Start the main loop if it's the first load of the config\n        if not self._loop_started:\n            self._tasks.start()\n            self._tasks.create(self.main_loop())\n            self._loop_started = True\n\n    def _create_cache(self, cache_dir: Path) -> ImageCache:\n        \"\"\"Create an ImageCache with the configured TTL and size limits.\"\"\"\n        cache_days = self.get_config_int(\"cache_days\")\n        cache_max_mb = self.get_config_int(\"cache_max_mb\")\n        return ImageCache(\n            cache_dir=cache_dir,\n            ttl=cache_days * SECONDS_PER_DAY if cache_days else None,\n            max_size=cache_max_mb * 1024 * 1024 if cache_max_mb else None,\n            max_count=self.get_config_int(\"cache_max_images\") or None,\n        )\n\n    async def _setup_online_fetching(\n        self,\n        paths: list[str],\n        extensions: list[str],\n        online_ratio: float,\n    ) -> OnlineState | None:\n        \"\"\"Set up online fetching if enabled.\n\n        Args:\n            paths: List of wallpaper paths.\n            extensions: List of file extensions.\n            online_ratio: Probability of fetching online.\n\n        Returns:\n            OnlineState with fetcher and caches, or None if online disabled.\n        \"\"\"\n        # Close existing fetcher if any\n        if self._online and self._online.fetcher:\n            await self._online.fetcher.close()\n\n        if online_ratio <= 0:\n            return None\n\n        # Set up online folder\n        first_path = paths[0] if paths else expand_path(\"~/Pictures/Wallpapers\")\n        online_folder_name = self.get_config_str(\"online_folder\") or \"online\"\n        folder_path = Path(first_path) / online_folder_name\n        folder_path.mkdir(parents=True, exist_ok=True)\n        self.log.debug(\"Online cache folder: %s\", folder_path)\n\n        # Create online cache\n        online_cache = self._create_cache(folder_path)\n\n        # Create rounded cache subfolder and cache\n        rounded_cache_dir = folder_path / \"rounded\"\n        rounded_cache_dir.mkdir(parents=True, exist_ok=True)\n        rounded_cache = self._create_cache(rounded_cache_dir)\n\n        # Initialize OnlineFetcher with the online cache\n        backends = self.get_config_list(\"online_backends\")\n        fetcher: OnlineFetcher | None = None\n        try:\n            fetcher = OnlineFetcher(\n                backends=backends or None,\n                cache=online_cache,\n                log=self.log,\n            )\n            self.log.info(\"Online fetching enabled with backends: %s\", fetcher.backends)\n        except ValueError:\n            self.log.exception(\"Failed to initialize online fetcher\")\n\n        # Always scan online folder for existing images (regardless of recurse setting)\n        async for full_path in get_files_with_ext(str(folder_path), extensions, recurse=False):\n            if full_path not in self.image_list:\n                self.image_list.append(full_path)\n\n        return OnlineState(\n            fetcher=fetcher,\n            folder_path=folder_path,\n            cache=online_cache,\n            rounded_cache=rounded_cache,\n        )\n\n    async def _cleanup_caches(self) -> None:\n        \"\"\"Clean up expired cache entries asynchronously.\"\"\"\n        cache_days = self.get_config_int(\"cache_days\")\n        if not cache_days or not self._online:\n            return  # No TTL configured or online disabled, skip cleanup\n\n        cleanup_tasks = []\n        if self._online.cache:\n            cleanup_tasks.append(asyncio.to_thread(self._online.cache.cleanup))\n        if self._online.rounded_cache:\n            cleanup_tasks.append(asyncio.to_thread(self._online.rounded_cache.cleanup))\n\n        if cleanup_tasks:\n            results = await asyncio.gather(*cleanup_tasks, return_exceptions=True)\n            total_removed = sum(r for r in results if isinstance(r, int))\n            if total_removed > 0:\n                self.log.info(\"Cache cleanup: removed %d expired files\", total_removed)\n\n    def _get_local_paths(self) -> list[Path]:\n        \"\"\"Get configured local wallpaper paths.\n\n        Returns:\n            List of Path objects for configured wallpaper directories.\n        \"\"\"\n        cfg_path: str | list[str] = self.get_config(\"path\")  # type: ignore[assignment]\n        if isinstance(cfg_path, str):\n            return [Path(expand_path(cfg_path))]\n        return [Path(expand_path(p)) for p in cfg_path]\n\n    async def _warn_no_images(self) -> None:\n        \"\"\"Warn user when no local images are available.\"\"\"\n        if self._online and self._online.fetcher:\n            self.log.warning(\"No local images found, will use online-only mode\")\n            await self.backend.notify_info(\"No local wallpapers found, using online only\")\n        else:\n            self.log.error(\"No images available: no local images and online fetching disabled\")\n            await self.backend.notify_error(\"No wallpapers available\")\n\n    async def exit(self) -> None:\n        \"\"\"Terminates gracefully.\"\"\"\n        await self._tasks.stop()\n        self._loop_started = False\n        await self.terminate()\n\n        # Close online fetcher session\n        if self._online and self._online.fetcher:\n            await self._online.fetcher.close()\n\n    async def event_monitoradded(self, _: str) -> None:\n        \"\"\"When a new monitor is added, set the background.\"\"\"\n        self.next_background_event.set()\n\n    async def niri_outputschanged(self, _: dict) -> None:\n        \"\"\"When the monitor configuration changes (Niri), set the background.\"\"\"\n        self.next_background_event.set()\n\n    async def select_next_image(self) -> str:\n        \"\"\"Return the next image - randomly selects online or local based on ratio.\"\"\"\n        online_ratio = self.get_config_float(\"online_ratio\")\n        use_online = random.random() < online_ratio\n        has_online_fetcher = self._online is not None and self._online.fetcher is not None\n\n        # Fallback logic\n        if use_online and not has_online_fetcher:\n            use_online = False\n        if not use_online and not self.image_list:\n            if has_online_fetcher:\n                use_online = True\n            else:\n                self.log.error(\"No images available (local or online)\")\n                return self.cur_image  # Return current or empty\n\n        if use_online:\n            choice = await self._fetch_online_image()\n        else:\n            choice = random.choice(self.image_list)\n            if choice == self.cur_image and len(self.image_list) > 1:\n                choice = random.choice(self.image_list)\n\n        self.cur_image = choice\n        self.log.debug(\"Selected image: %s (online=%s)\", choice, use_online)\n        return choice\n\n    async def _fetch_online_image(self) -> str:\n        \"\"\"Fetch a new image from online backends.\n\n        Uses prefetched image if available, otherwise fetches synchronously.\n\n        Returns:\n            Path to the downloaded image.\n\n        Raises:\n            NoBackendAvailableError: If all backends fail and no local fallback.\n        \"\"\"\n        # Use prefetched image if available\n        if self._online and self._online.prefetched_path:\n            path = self._online.prefetched_path\n            self._online.prefetched_path = None\n            if await aiexists(path):\n                self.log.debug(\"Using prefetched image: %s\", path)\n                return path\n            self.log.debug(\"Prefetched image no longer exists, fetching new\")\n\n        if not self._online or not self._online.fetcher:\n            msg = \"Online fetcher not initialized\"\n            raise RuntimeError(msg)\n\n        fetcher = self._online.fetcher\n\n        # Get monitor dimensions for size hint (accounting for rotation)\n        monitors = await fetch_monitors(self)\n        dimensions = [get_effective_dimensions(m) for m in monitors]\n        max_width = max((w for w, _ in dimensions), default=DEFAULT_WALLPAPER_WIDTH)\n        max_height = max((h for _, h in dimensions), default=DEFAULT_WALLPAPER_HEIGHT)\n\n        keywords = self.get_config_list(\"online_keywords\") or None\n\n        try:\n            path = str(\n                await fetcher.get_image(\n                    min_width=max_width,\n                    min_height=max_height,\n                    keywords=keywords,\n                )\n            )\n        except NoBackendAvailableError:\n            self.log.exception(\"Failed to fetch online image\")\n            await self.backend.notify_error(\"Online wallpaper fetch failed\")\n\n            # Fallback to local if available\n            if self.image_list:\n                return random.choice(self.image_list)\n            raise\n\n        # Add to local pool for future selection\n        if path not in self.image_list:\n            self.image_list.append(path)\n\n        return path\n\n    async def _prefetch_online_image(self) -> None:\n        \"\"\"Prefetch next online image in background with exponential backoff retry.\"\"\"\n        if not self._online or not self._online.fetcher:\n            return\n\n        # Get monitor dimensions for size hint (accounting for rotation)\n        monitors = await fetch_monitors(self)\n        dimensions = [get_effective_dimensions(m) for m in monitors]\n        max_width = max((w for w, _ in dimensions), default=DEFAULT_WALLPAPER_WIDTH)\n        max_height = max((h for _, h in dimensions), default=DEFAULT_WALLPAPER_HEIGHT)\n        keywords = self.get_config_list(\"online_keywords\") or None\n\n        for attempt in range(PREFETCH_MAX_RETRIES):\n            try:\n                path = await self._online.fetcher.get_image(min_width=max_width, min_height=max_height, keywords=keywords)\n                self._online.prefetched_path = str(path)\n                if str(path) not in self.image_list:\n                    self.image_list.append(str(path))\n                self.log.debug(\"Prefetched: %s\", path)\n            except Exception:  # noqa: BLE001  # pylint: disable=broad-exception-caught\n                # Catch all errors (network, parsing, etc.) to retry with different backend\n                if attempt < PREFETCH_MAX_RETRIES - 1:\n                    delay = min(PREFETCH_RETRY_BASE_SECONDS * (2**attempt), PREFETCH_RETRY_MAX_SECONDS)\n                    self.log.debug(\"Prefetch attempt %d failed, retry in %ds\", attempt + 1, delay)\n                    await asyncio.sleep(delay)\n            else:\n                return\n\n        self.log.warning(\"Prefetch failed after %d retries\", PREFETCH_MAX_RETRIES)\n\n    async def run_wall_rm(self) -> None:\n        \"\"\"Remove the current online wallpaper and show next.\n\n        Only removes online wallpapers (files in the online folder).\n        Shows an error notification for local wallpapers.\n        \"\"\"\n        if not self.cur_image:\n            self.log.warning(\"No current wallpaper to remove\")\n            return\n\n        cur_path = Path(self.cur_image)\n        online_folder = cur_path.parent\n\n        # Handle images in \"rounded\" subfolder\n        if online_folder.name == \"rounded\":\n            online_folder = online_folder.parent\n\n        # Check if image is in an online folder\n        if str(online_folder) not in self._online_folders:\n            await self.backend.notify_error(\"Cannot remove local wallpapers\")\n            return\n\n        # Remove from image_list\n        if self.cur_image in self.image_list:\n            self.image_list.remove(self.cur_image)\n\n        # Delete the file\n        try:\n            await aiunlink(cur_path)\n            self.log.info(\"Removed wallpaper: %s\", cur_path)\n        except OSError as e:\n            self.log.exception(\"Failed to remove wallpaper %s\", cur_path)\n            await self.backend.notify_error(f\"Failed to remove: {e}\")\n            return\n\n        # Also remove rounded versions for all monitors\n        if self.rounded_manager:\n            monitors = await fetch_monitors(self)\n            for monitor in monitors:\n                key = self.rounded_manager.build_key(monitor, str(cur_path))\n                rounded_path = self.rounded_manager.cache.get_path(key, \"jpg\")\n                if await aiexists(rounded_path):\n                    try:\n                        await aiunlink(rounded_path)\n                        self.log.debug(\"Removed rounded version: %s\", rounded_path)\n                    except OSError:\n                        pass  # Non-critical\n\n        # Trigger next wallpaper\n        self._paused = False\n        self.next_background_event.set()\n\n    async def run_wall_cleanup(self, arg: str = \"\") -> str:\n        \"\"\"[all] Clean up rounded images cache.\n\n        Without arguments: removes orphaned files (source no longer exists).\n        With 'all': removes ALL rounded cache files.\n\n        Example:\n            pypr wall cleanup\n            pypr wall cleanup all\n        \"\"\"\n        if not self.rounded_manager:\n            return \"Rounded corners not enabled, nothing to clean\"\n\n        if arg.strip().lower() == \"all\":\n            removed = await self._clear_rounded_cache()\n            return f\"Cleared {removed} rounded images\"\n\n        # Orphan cleanup: find rounded files whose sources no longer exist\n        removed_orphans, removed_old = await self._cleanup_orphaned_rounded()\n\n        parts = []\n        if removed_orphans:\n            parts.append(f\"{removed_orphans} orphaned\")\n        if removed_old:\n            parts.append(f\"{removed_old} old-format\")\n\n        if parts:\n            return f\"Removed {' + '.join(parts)} rounded images\"\n        return \"No orphaned rounded images found\"\n\n    async def _clear_rounded_cache(self) -> int:\n        \"\"\"Clear all files from rounded cache with throttled deletion.\n\n        Returns:\n            Number of files removed.\n        \"\"\"\n        if not self.rounded_manager:\n            return 0\n\n        removed = 0\n        for cached_file in self.rounded_manager.cache.cache_dir.iterdir():\n            if await aiisfile(cached_file):\n                await aiunlink(cached_file)\n                removed += 1\n                await asyncio.sleep(0.01)  # 10ms throttle to avoid IO saturation\n        return removed\n\n    async def _cleanup_orphaned_rounded(self) -> tuple[int, int]:\n        \"\"\"Remove rounded files whose source images no longer exist.\n\n        Returns:\n            Tuple of (orphaned_removed, old_format_removed).\n        \"\"\"\n        if not self.rounded_manager:\n            return (0, 0)\n\n        cache_dir = self.rounded_manager.cache.cache_dir\n\n        # Build set of source hashes for current image pool\n        valid_source_hashes = set()\n        for image_path in self.image_list:\n            source_hash = self.rounded_manager.hash_source(image_path)\n            valid_source_hashes.add(source_hash)\n\n        # Find and remove orphaned/old-format files\n        removed_orphans = 0\n        removed_old = 0\n\n        for cached_file in cache_dir.iterdir():\n            if not await aiisfile(cached_file):\n                continue\n\n            if \"_\" not in cached_file.stem:\n                # Old format file (single hash), remove it\n                await aiunlink(cached_file)\n                removed_old += 1\n                await asyncio.sleep(0.01)  # 10ms throttle\n                continue\n\n            source_hash = cached_file.stem.split(\"_\")[0]\n            if source_hash not in valid_source_hashes:\n                await aiunlink(cached_file)\n                removed_orphans += 1\n                await asyncio.sleep(0.01)  # 10ms throttle\n\n        return (removed_orphans, removed_old)\n\n    async def run_wall_info(self, arg: str = \"\") -> str:\n        \"\"\"[json] Show current wallpaper information.\n\n        Args:\n            arg: Optional \"json\" flag for JSON output\n\n        Example:\n            pypr wall info\n            pypr wall info json\n        \"\"\"\n        output_json = arg.strip().lower() == \"json\"\n\n        # Gather information\n        source_image = self.cur_image or \"\"\n        display_image = self.cur_display_image or source_image\n        source_exists = await aiexists(source_image) if source_image else False\n        display_exists = await aiexists(display_image) if display_image else False\n\n        # Determine location\n        location = \"unknown\"\n        if source_image:\n            cur_path = Path(source_image)\n            parent_str = str(cur_path.parent)\n            if self._online and self._online.folder_path and str(self._online.folder_path) == parent_str:\n                location = \"online\"\n            else:\n                for local_path in self._get_local_paths():\n                    if parent_str == str(local_path) or str(local_path) in parent_str:\n                        location = \"local\"\n                        break\n\n        is_rounded = display_image != source_image\n        online_enabled = self._online is not None and self._online.fetcher is not None\n        online_ratio = self.get_config_float(\"online_ratio\")\n        online_folder = str(self._online.folder_path) if self._online and self._online.folder_path else None\n        image_count = len(self.image_list)\n\n        if output_json:\n            data = {\n                \"source_image\": source_image or None,\n                \"display_image\": display_image or None,\n                \"source_exists\": source_exists,\n                \"display_exists\": display_exists,\n                \"location\": location,\n                \"is_rounded\": is_rounded,\n                \"is_paused\": self._paused,\n                \"online_enabled\": online_enabled,\n                \"online_ratio\": online_ratio,\n                \"online_folder\": online_folder,\n                \"image_count\": image_count,\n            }\n            return json.dumps(data, indent=2)\n\n        # Human-readable output\n        lines = [\n            f\"Source: {source_image or '(none)'}\",\n            f\"  exists: {'yes' if source_exists else 'no'}\",\n        ]\n        if is_rounded:\n            lines.extend(\n                [\n                    f\"Display: {display_image}\",\n                    f\"  exists: {'yes' if display_exists else 'no'}\",\n                ]\n            )\n        lines.extend(\n            [\n                f\"Location: {location}\",\n                f\"Paused: {'yes' if self._paused else 'no'}\",\n                f\"Online: {'enabled' if online_enabled else 'disabled'} (ratio: {online_ratio})\",\n            ]\n        )\n        if online_folder:\n            lines.append(f\"Online folder: {online_folder}\")\n        lines.append(f\"Image pool: {image_count} images\")\n\n        return \"\\n\".join(lines)\n\n    async def _prepare_wallpaper(self, monitor: MonitorInfo, img_path: str) -> str:\n        \"\"\"Prepare the wallpaper image for the given monitor.\"\"\"\n        if not self.rounded_manager:\n            return img_path\n        return self.rounded_manager.scale_and_round(img_path, monitor)\n\n    async def _run_one(self, template: str, values: dict[str, str]) -> None:\n        \"\"\"Run one command.\"\"\"\n        cmd = apply_variables(template, values)\n        self.log.info(\"Running %s\", cmd)\n        proc = ManagedProcess()\n        await proc.start(cmd)\n        self.proc.append(proc)\n\n    async def _generate_templates(self, img_path: str, color: str | None = None) -> None:\n        \"\"\"Generate templates from the image.\"\"\"\n        templates = self.get_config_dict(\"templates\") if \"templates\" in self.config else None\n        if not templates:\n            return\n\n        if not can_edit_image:\n            self.log.warning(\"PIL not installed, cannot generate color palette\")\n            return\n\n        if color:\n            if color.startswith(\"#\"):\n                c_rgb = (int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16))\n            else:\n                c_rgb = (int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16))\n            dominant_colors = [c_rgb] * 3\n        else:\n            dominant_colors = await asyncio.to_thread(get_dominant_colors, img_path=img_path)\n        theme = await detect_theme(self.log)\n\n        def process_color(rgb: tuple[int, int, int]) -> tuple[float, float, float]:\n            # reduce blue level for earth\n            color_scheme = self.get_config_str(\"color_scheme\")\n            if color_scheme == ColorScheme.EARTH:\n                rgb = (rgb[0], rgb[1], int(rgb[2] * 0.7))\n\n            r, g, b = nicify_oklab(rgb, **get_color_scheme_props(color_scheme))\n            return colorsys.rgb_to_hls(r / 255.0, g / 255.0, b / 255.0)\n\n        variant = self.get_config_str(\"variant\") or None\n        replacements = generate_palette(\n            dominant_colors,\n            theme=theme,\n            process_color=process_color,\n            variant_type=variant,\n        )\n        replacements[\"image\"] = img_path\n\n        for name, template_config in templates.items():\n            self.log.debug(\"processing %s\", name)\n            await self.template_engine.process_single_template(name, template_config, replacements)\n\n    async def update_vars(self, variables: dict[str, Any], monitor: MonitorInfo, img_path: str) -> dict[str, Any]:\n        \"\"\"Get fresh variables for the given monitor.\"\"\"\n        if self.get_config_bool(\"unique\"):\n            img_path = await self.select_next_image()\n        filename = await self._prepare_wallpaper(monitor, img_path)\n        variables.update({\"file\": filename, \"output\": monitor.name})\n        return variables\n\n    async def _iter_one(self, variables: dict[str, Any]) -> None:\n        \"\"\"Run one iteration of the wallpaper loop.\"\"\"\n        cmd_template = self.get_config(\"command\")\n        assert isinstance(cmd_template, str) or cmd_template is None\n        img_path = await self.select_next_image()\n        monitors: list[MonitorInfo] = await fetch_monitors(self)\n\n        if cmd_template:\n            filtered_monitors = monitors if \"[output]\" in cmd_template else [monitors[0]]\n            for monitor in filtered_monitors:\n                variables = await self.update_vars(variables, monitor, img_path)\n                await self._run_one(cmd_template, variables)\n        else:\n            # use hyprpaper\n            assert self._hyprpaper is not None  # Guaranteed by on_reload logic\n            command_collector = []\n            for monitor in monitors:\n                variables = await self.update_vars(variables, monitor, img_path)\n                self.log.debug(\"Setting wallpaper %s for monitor %s\", variables[\"file\"], variables.get(\"output\"))\n                command_collector.append(apply_variables(\"wallpaper [output], [file]\", variables))\n\n            if not await self._hyprpaper.set_wallpaper(command_collector, self.backend):\n                await self.backend.notify_error(\"Could not start hyprpaper\")\n                return\n\n        # Track the display path (may be rounded version of source)\n        self.cur_display_image = variables.get(\"file\", self.cur_image)\n\n        # Generate templates after wallpaper is selected\n        await self._generate_templates(img_path)\n\n        # check if the command failed\n        for proc in self.proc:\n            if proc.returncode:\n                await self.backend.notify_error(\"wallpaper command failed\")\n                break\n\n        post_command = self.get_config_str(\"post_command\")\n        if post_command:\n            command = apply_variables(post_command, variables)\n            post_proc = await create_subprocess(command)\n            if await post_proc.wait() != 0:\n                await self.backend.notify_error(\"wallpaper post_command failed\")\n\n        # Prefetch next online image if enabled and previous was consumed\n        if self._online and self._online.fetcher and not self._online.prefetched_path:\n            self._tasks.create(self._prefetch_online_image())\n\n    async def main_loop(self) -> None:\n        \"\"\"Run the main plugin loop in the 'background'.\"\"\"\n        self.proc = []\n\n        while self._tasks.running:\n            if not self._paused:\n                self.next_background_event.clear()\n                await self.terminate()\n                variables = self.state.variables.copy()\n                await self._iter_one(variables)\n\n            interval_minutes = self.get_config_float(\"interval\")\n            sleep_task = asyncio.create_task(asyncio.sleep(60 * interval_minutes))\n            event_task = asyncio.create_task(self.next_background_event.wait())\n            _, pending = await asyncio.wait(\n                [sleep_task, event_task],\n                return_when=asyncio.FIRST_COMPLETED,\n            )\n            # Cancel pending tasks to avoid leaks\n            for task in pending:\n                task.cancel()\n                with contextlib.suppress(asyncio.CancelledError):\n                    await task\n\n    async def terminate(self) -> None:\n        \"\"\"Exit existing process if any.\"\"\"\n        for proc in self.proc:\n            await proc.stop()\n        self.proc.clear()\n\n    async def run_wall_next(self) -> None:\n        \"\"\"Switch to the next wallpaper immediately.\"\"\"\n        self._paused = False\n        self.next_background_event.set()\n\n    async def run_wall_pause(self) -> None:\n        \"\"\"Pause automatic wallpaper cycling.\"\"\"\n        self._paused = True\n\n    async def run_wall_clear(self) -> None:\n        \"\"\"Stop cycling and clear the current wallpaper.\"\"\"\n        self._paused = True\n        await self.terminate()\n        if self._hyprpaper:\n            await self._hyprpaper.stop()\n        clear_command = self.get_config_str(\"clear_command\")\n        if clear_command:\n            clear_proc = await create_subprocess(clear_command)\n            await clear_proc.wait()\n\n    async def run_color(self, arg: str) -> None:\n        \"\"\"<#RRGGBB> [scheme] Generate color palette from hex color.\n\n        Args:\n            arg: Hex color and optional scheme name\n\n        Schemes: pastel, fluo, vibrant, mellow, neutral, earth\n\n        Example:\n            pypr color #ff5500 vibrant\n        \"\"\"\n        args = arg.split()\n        color = args[0]\n        with contextlib.suppress(IndexError):\n            self.config[\"color_scheme\"] = args[1]\n\n        await self._generate_templates(\"color-\" + color, color)\n\n    async def run_palette(self, arg: str = \"\") -> str:\n        \"\"\"[color] [json] Show available color template variables.\n\n        Args:\n            arg: Optional hex color and/or \"json\" flag\n                - color: Hex color (#RRGGBB) to use for palette\n                - json: Output in JSON format instead of human-readable\n\n        Example:\n            pypr palette\n            pypr palette #ff5500\n            pypr palette json\n        \"\"\"\n        args = arg.split()\n        color: str | None = None\n        output_json = False\n\n        # Parse arguments: [color] [json]\n        for a in args:\n            if a.lower() == \"json\":\n                output_json = True\n            elif a.startswith(\"#\") or (len(a) == HEX_COLOR_LENGTH and all(c in \"0123456789abcdefABCDEF\" for c in a)):\n                color = a\n\n        # Determine base RGB color\n        if color:\n            base_rgb = hex_to_rgb(color)\n        elif self.cur_image and can_edit_image:\n            # Use colors from current wallpaper\n            dominant_colors = await asyncio.to_thread(get_dominant_colors, img_path=self.cur_image)\n            base_rgb = dominant_colors[0]\n        else:\n            # Default: Google blue #4285F4\n            base_rgb = DEFAULT_PALETTE_COLOR_RGB\n\n        theme = await detect_theme(self.log)\n        palette = generate_sample_palette(base_rgb, theme)\n\n        if output_json:\n            return palette_to_json(palette)\n        return palette_to_terminal(palette)\n"
  },
  {
    "path": "pyprland/plugins/wallpapers/cache.py",
    "content": "\"\"\"File-based image cache with TTL support.\"\"\"\n\nimport hashlib\nimport time\nfrom pathlib import Path\n\nfrom ...aioops import aiopen\n\n# Dual-hash key format: {16-char source hash}_{16-char settings hash}\n# Total length: 16 + 1 (underscore) + 16 = 33\nDUAL_HASH_KEY_LENGTH = 33\nDUAL_HASH_SEPARATOR_POS = 16\n\n\nclass ImageCache:\n    \"\"\"File-based image cache with configurable TTL and cleanup.\n\n    Attributes:\n        cache_dir: Directory where cached files are stored.\n        ttl: Time-to-live in seconds for cached files. None means forever.\n        max_size: Maximum cache size in bytes. None means unlimited.\n        max_count: Maximum number of cached files. None means unlimited.\n    \"\"\"\n\n    def __init__(\n        self,\n        cache_dir: Path,\n        ttl: int | None = None,\n        max_size: int | None = None,\n        max_count: int | None = None,\n    ) -> None:\n        \"\"\"Initialize the image cache.\n\n        Args:\n            cache_dir: Directory for cached files.\n            ttl: Time-to-live in seconds. None means cached files never expire.\n            max_size: Maximum total cache size in bytes. None means unlimited.\n            max_count: Maximum number of cached files. None means unlimited.\n        \"\"\"\n        self.cache_dir = cache_dir\n        self.ttl = ttl\n        self.max_size = max_size\n        self.max_count = max_count\n        self.cache_dir.mkdir(parents=True, exist_ok=True)\n\n    def _hash_key(self, key: str) -> str:\n        \"\"\"Generate a hash from the cache key.\n\n        If the key is already in dual-hash format ({16chars}_{16chars}),\n        returns it as-is to preserve the source hash for orphan detection.\n\n        Args:\n            key: The cache key to hash.\n\n        Returns:\n            A hex digest of the key, or the key itself if already hashed.\n        \"\"\"\n        # Check if key is already in dual-hash format: exactly 33 chars with underscore at position 16\n        if len(key) == DUAL_HASH_KEY_LENGTH and key[DUAL_HASH_SEPARATOR_POS] == \"_\":\n            return key\n        return hashlib.sha256(key.encode()).hexdigest()[:32]\n\n    def get_path(self, key: str, extension: str = \"jpg\") -> Path:\n        \"\"\"Get the cache file path for a given key.\n\n        Args:\n            key: Unique identifier for the cached item.\n            extension: File extension for the cached file.\n\n        Returns:\n            Path to the cached file (may or may not exist).\n        \"\"\"\n        filename = f\"{self._hash_key(key)}.{extension}\"\n        return self.cache_dir / filename\n\n    def is_valid(self, path: Path) -> bool:\n        \"\"\"Check if a cached file exists and is not expired.\n\n        Args:\n            path: Path to the cached file.\n\n        Returns:\n            True if the file exists and is within TTL, False otherwise.\n        \"\"\"\n        if not path.exists():\n            return False\n\n        if self.ttl is None:\n            return True\n\n        mtime = path.stat().st_mtime\n        age = time.time() - mtime\n        return age < self.ttl\n\n    def get(self, key: str, extension: str = \"jpg\") -> Path | None:\n        \"\"\"Get a cached file if it exists and is valid.\n\n        Args:\n            key: Unique identifier for the cached item.\n            extension: File extension for the cached file.\n\n        Returns:\n            Path to the cached file if valid, None otherwise.\n        \"\"\"\n        path = self.get_path(key, extension)\n        if self.is_valid(path):\n            return path\n        return None\n\n    async def store(self, key: str, data: bytes, extension: str = \"jpg\") -> Path:\n        \"\"\"Store data in the cache.\n\n        Args:\n            key: Unique identifier for the cached item.\n            data: Binary data to cache.\n            extension: File extension for the cached file.\n\n        Returns:\n            Path to the cached file.\n        \"\"\"\n        path = self.get_path(key, extension)\n        async with aiopen(path, \"wb\") as f:\n            await f.write(data)\n\n        # Auto-cleanup if any limit is set\n        if self.max_size is not None or self.max_count is not None:\n            self._auto_cleanup()\n\n        return path\n\n    def _get_cache_size(self) -> int:\n        \"\"\"Calculate total size of cached files.\n\n        Returns:\n            Total size in bytes.\n        \"\"\"\n        total = 0\n        for file in self.cache_dir.iterdir():\n            if file.is_file():\n                total += file.stat().st_size\n        return total\n\n    def _get_cache_count(self) -> int:\n        \"\"\"Count cached files.\n\n        Returns:\n            Number of cached files.\n        \"\"\"\n        return sum(1 for f in self.cache_dir.iterdir() if f.is_file())\n\n    def _is_under_limits(self, current_size: int, current_count: int) -> bool:\n        \"\"\"Check if cache is under all configured limits.\n\n        Args:\n            current_size: Current total size in bytes.\n            current_count: Current number of files.\n\n        Returns:\n            True if under all limits, False otherwise.\n        \"\"\"\n        size_ok = self.max_size is None or current_size <= self.max_size\n        count_ok = self.max_count is None or current_count <= self.max_count\n        return size_ok and count_ok\n\n    def _auto_cleanup(self) -> None:\n        \"\"\"Automatically clean up old files if cache exceeds any limit.\"\"\"\n        if self.max_size is None and self.max_count is None:\n            return\n\n        current_size = self._get_cache_size()\n        current_count = self._get_cache_count()\n\n        if self._is_under_limits(current_size, current_count):\n            return\n\n        # Sort files by mtime (oldest first)\n        files = sorted(\n            (f for f in self.cache_dir.iterdir() if f.is_file()),\n            key=lambda f: f.stat().st_mtime,\n        )\n\n        # Remove oldest files until under all limits\n        for file in files:\n            if self._is_under_limits(current_size, current_count):\n                break\n            size = file.stat().st_size\n            file.unlink()\n            current_size -= size\n            current_count -= 1\n\n    def cleanup(self, max_age: int | None = None) -> int:\n        \"\"\"Manually clean up old cached files.\n\n        Args:\n            max_age: Maximum age in seconds. Files older than this are removed.\n                     If None, uses the cache's TTL setting.\n\n        Returns:\n            Number of files removed.\n        \"\"\"\n        age_limit = max_age if max_age is not None else self.ttl\n        if age_limit is None:\n            return 0\n\n        removed = 0\n        now = time.time()\n\n        for file in self.cache_dir.iterdir():\n            if not file.is_file():\n                continue\n            mtime = file.stat().st_mtime\n            if now - mtime > age_limit:\n                file.unlink()\n                removed += 1\n\n        return removed\n\n    def clear(self) -> int:\n        \"\"\"Remove all cached files.\n\n        Returns:\n            Number of files removed.\n        \"\"\"\n        removed = 0\n        for file in self.cache_dir.iterdir():\n            if file.is_file():\n                file.unlink()\n                removed += 1\n        return removed\n"
  },
  {
    "path": "pyprland/plugins/wallpapers/colorutils.py",
    "content": "\"\"\"Utils for the wallpaper plugin.\"\"\"\n\nimport math\n\ntry:\n    # pylint: disable=unused-import\n    from PIL import Image, ImageDraw, ImageOps\nexcept ImportError:\n    can_edit_image = False  # pylint: disable=invalid-name\n    Image = None  # type: ignore # pylint: disable=invalid-name\n    ImageDraw = None  # type: ignore # pylint: disable=invalid-name\n    ImageOps = None  # type: ignore # pylint: disable=invalid-name\nelse:\n    can_edit_image = True  # pylint: disable=invalid-name\n\nSRGB_LINEAR_CUTOFF = 0.04045\nSRGB_R_CUTOFF = 0.0031308\n\nMIN_SATURATION = 20\nMIN_BRIGHTNESS = 20\nMIN_HUE_DIST = 21\nHUE_DIFF_THRESHOLD = 128\nHUE_MAX = 256\nKERNEL_SUM = 16.0\nTARGET_COLOR_COUNT = 3  # Number of dominant colors to extract from image\n\n\ndef _build_hue_histogram(hsv_pixels: list[tuple[int, int, int]]) -> tuple[list[float], list[list[int]]]:\n    \"\"\"Build a weighted hue histogram from HSV pixels.\n\n    Args:\n        hsv_pixels: List of (Hue, Saturation, Value) tuples\n    \"\"\"\n    hue_weights = [0.0] * HUE_MAX\n    hue_pixel_indices: list[list[int]] = [[] for _ in range(HUE_MAX)]\n\n    for idx, (h, s, v) in enumerate(hsv_pixels):\n        if s < MIN_SATURATION or v < MIN_BRIGHTNESS:\n            continue\n\n        weight = (s * v) / (255.0 * 255.0)\n        hue_weights[h] += weight\n        hue_pixel_indices[h].append(idx)\n\n    return hue_weights, hue_pixel_indices\n\n\ndef _smooth_histogram(hue_weights: list[float]) -> list[float]:\n    \"\"\"Smooth the histogram using a Gaussian-like kernel.\n\n    Args:\n        hue_weights: List of weights for each hue\n    \"\"\"\n    smoothed_weights = [0.0] * HUE_MAX\n    kernel = [1, 4, 6, 4, 1]\n\n    for i in range(HUE_MAX):\n        w_sum = 0.0\n        for k_idx, offset in enumerate(range(-2, 3)):\n            idx = (i + offset) % HUE_MAX\n            w_sum += hue_weights[idx] * kernel[k_idx]\n        smoothed_weights[i] = w_sum / KERNEL_SUM\n\n    return smoothed_weights\n\n\ndef _find_peaks(smoothed_weights: list[float]) -> list[tuple[float, int]]:\n    \"\"\"Find peaks in the smoothed histogram.\n\n    Args:\n        smoothed_weights: List of smoothed weights\n    \"\"\"\n    peaks: list[tuple[float, int]] = []\n    for i in range(HUE_MAX):\n        left = smoothed_weights[(i - 1) % HUE_MAX]\n        right = smoothed_weights[(i + 1) % HUE_MAX]\n        val = smoothed_weights[i]\n        if val >= left and val >= right and val > 0:\n            peaks.append((val, i))\n\n    peaks.sort(reverse=True)\n    return peaks\n\n\ndef _get_best_pixel_for_hue(\n    target_hue: int,\n    hue_pixel_indices: list[list[int]],\n    hsv_pixels: list[tuple[int, int, int]],\n    rgb_pixels: list[tuple[int, int, int]],\n) -> tuple[int, int, int]:\n    \"\"\"Find the most representative pixel for a given hue bin.\n\n    Args:\n        target_hue: The target hue value\n        hue_pixel_indices: Mapping of hue to pixel indices\n        hsv_pixels: List of HSV pixels\n        rgb_pixels: List of RGB pixels\n    \"\"\"\n    best_pixel_idx = -1\n    max_sv = -1.0\n    for offset in range(-2, 3):\n        check_h = (target_hue + offset) % HUE_MAX\n        for px_idx in hue_pixel_indices[check_h]:\n            _, s, v = hsv_pixels[px_idx]\n            weight = s * v\n            if weight > max_sv:\n                max_sv = weight\n                best_pixel_idx = px_idx\n\n    if best_pixel_idx != -1:\n        return rgb_pixels[best_pixel_idx]\n\n    if hue_pixel_indices[target_hue]:\n        return rgb_pixels[hue_pixel_indices[target_hue][0]]\n\n    return (0, 0, 0)\n\n\ndef _calculate_hue_diff(hue1: int, hue2: int) -> int:\n    \"\"\"Calculate the shortest distance between two hues on the circle.\n\n    Args:\n        hue1: First hue value\n        hue2: Second hue value\n    \"\"\"\n    diff = abs(hue1 - hue2)\n    if diff > HUE_DIFF_THRESHOLD:\n        diff = HUE_MAX - diff\n    return diff\n\n\ndef _select_colors_from_peaks(\n    peaks: list[tuple[float, int]],\n    hue_pixel_indices: list[list[int]],\n    hsv_pixels: list[tuple[int, int, int]],\n    rgb_pixels: list[tuple[int, int, int]],\n) -> list[tuple[int, int, int]]:\n    \"\"\"Select distinct colors from the identified peaks.\n\n    Args:\n        peaks: List of (weight, hue) tuples\n        hue_pixel_indices: Mapping of hue to pixel indices\n        hsv_pixels: List of HSV pixels\n        rgb_pixels: List of RGB pixels\n    \"\"\"\n    final_colors: list[tuple[int, int, int]] = []\n    final_hues: list[int] = []\n\n    if not peaks:\n        return final_colors\n\n    # 1. First Color: The dominant one\n    _, p_hue = peaks.pop(0)\n    final_hues.append(p_hue)\n    final_colors.append(_get_best_pixel_for_hue(p_hue, hue_pixel_indices, hsv_pixels, rgb_pixels))\n\n    # 2. Second Color: The next dominant distinct one\n    second_peak_idx = -1\n    for i, (_, p_hue) in enumerate(peaks):\n        diff = _calculate_hue_diff(p_hue, final_hues[0])\n        if diff > MIN_HUE_DIST:\n            second_peak_idx = i\n            break\n\n    if second_peak_idx != -1:\n        _, p_hue = peaks.pop(second_peak_idx)\n        final_hues.append(p_hue)\n        final_colors.append(_get_best_pixel_for_hue(p_hue, hue_pixel_indices, hsv_pixels, rgb_pixels))\n\n    # 3. Third Color: The most distinct hue\n    if len(final_colors) >= TARGET_COLOR_COUNT - 1 and peaks:\n        best_dist = -1.0\n        best_peak_idx = -1\n\n        for i, (_, p_hue) in enumerate(peaks):\n            dists = [_calculate_hue_diff(p_hue, h) for h in final_hues]\n            min_d = min(dists)\n            if min_d > best_dist:\n                best_dist = min_d\n                best_peak_idx = i\n\n        if best_peak_idx != -1 and best_dist > MIN_HUE_DIST:\n            _, p_hue = peaks.pop(best_peak_idx)\n            final_hues.append(p_hue)\n            final_colors.append(_get_best_pixel_for_hue(p_hue, hue_pixel_indices, hsv_pixels, rgb_pixels))\n\n    return final_colors\n\n\ndef get_dominant_colors(img_path: str) -> list[tuple[int, int, int]]:\n    \"\"\"Pick representative pixels using a weighted Hue Histogram approach.\n\n    Args:\n        img_path: Path to the image file\n    \"\"\"\n    if not Image:\n        return [(0, 0, 0)] * 3\n\n    try:\n        with Image.open(img_path) as initial_img:\n            img = initial_img.convert(\"RGB\")\n            resample = getattr(Image, \"Resampling\", Image).LANCZOS\n            img.thumbnail((200, 200), resample)\n\n            hsv_img = img.convert(\"HSV\")\n            hsv_pixels: list[tuple[int, int, int]] = list(hsv_img.getdata())\n            rgb_pixels: list[tuple[int, int, int]] = list(img.getdata())\n\n            if not hsv_pixels:\n                return [(0, 0, 0)] * 3\n\n            hue_weights, hue_pixel_indices = _build_hue_histogram(hsv_pixels)\n            smoothed_weights = _smooth_histogram(hue_weights)\n            peaks = _find_peaks(smoothed_weights)\n\n            final_colors = _select_colors_from_peaks(peaks, hue_pixel_indices, hsv_pixels, rgb_pixels)\n\n            while len(final_colors) < TARGET_COLOR_COUNT:\n                final_colors.append(final_colors[0] if final_colors else (0, 0, 0))\n\n            return final_colors\n\n    except (OSError, ValueError) as e:  # PIL can raise various exceptions\n        # Log would require passing logger; return default silently\n        _ = e  # Acknowledge the exception was captured\n        return [(0, 0, 0)] * TARGET_COLOR_COUNT\n\n\ndef nicify_oklab(\n    rgb: tuple[int, int, int],\n    min_sat: float = 0.3,\n    max_sat: float = 0.7,\n    min_light: float = 0.2,\n    max_light: float = 0.8,\n) -> tuple[int, int, int]:\n    \"\"\"Transform RGB color using perceptually-uniform OkLab color space.\n\n    Produces more consistent and natural-looking results across all hues.\n\n    Args:\n        rgb: Tuple of (R, G, B) with values 0-255\n        min_sat: Minimum saturation (0.0-1.0, default 0.3)\n        max_sat: Maximum saturation (0.0-1.0, default 0.7)\n        min_light: Minimum lightness (0.0-1.0, default 0.2)\n        max_light: Maximum lightness (0.0-1.0, default 0.8)\n\n    Returns:\n        Tuple of (R, G, B) with values 0-255\n    \"\"\"\n    # pylint: disable=too-many-locals\n\n    # Convert sRGB to linear RGB\n    def to_linear(val: float) -> float:\n        val = val / 255.0\n        return val / 12.92 if val <= SRGB_LINEAR_CUTOFF else pow((val + 0.055) / 1.055, 2.4)\n\n    r_lin = to_linear(rgb[0])\n    g_lin = to_linear(rgb[1])\n    b_lin = to_linear(rgb[2])\n\n    # Convert to OkLab\n    l_val = 0.4122214708 * r_lin + 0.5363325363 * g_lin + 0.0514459929 * b_lin\n    m_val = 0.2119034982 * r_lin + 0.6806995451 * g_lin + 0.1073969566 * b_lin\n    s_val = 0.0883024619 * r_lin + 0.0853627803 * g_lin + 0.8301696993 * b_lin\n\n    l_ = pow(l_val, 1 / 3)\n    m_ = pow(m_val, 1 / 3)\n    s_ = pow(s_val, 1 / 3)\n\n    l_cap = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_\n    a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_\n    b_val = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_\n\n    # Extract chroma and hue\n    chroma = math.sqrt(a * a + b_val * b_val)\n    hue = math.atan2(b_val, a)\n\n    # Scale chroma based on saturation constraints\n    # Map min_sat/max_sat (0-1 conceptual) to OkLab chroma range (~0-0.4)\n    max_oklab_chroma = 0.4\n    chroma_low = min_sat * max_oklab_chroma\n    chroma_high = max_sat * max_oklab_chroma\n    normalized = min(chroma / max_oklab_chroma, 1.0)\n    clamped_chroma = chroma_low + normalized * (chroma_high - chroma_low)\n\n    # Clamp lightness\n    clamped_l = max(min_light, min(max_light, l_cap))\n\n    # Reconstruct with new chroma\n    a_new = clamped_chroma * math.cos(hue)\n    b_new = clamped_chroma * math.sin(hue)\n\n    # Convert back from OkLab\n    l_new = clamped_l + 0.3963377774 * a_new + 0.2158037573 * b_new\n    m_new = clamped_l - 0.1055613458 * a_new - 0.0638541728 * b_new\n    s_new = clamped_l - 0.0894841775 * a_new - 1.2914855480 * b_new\n\n    l_lin = pow(l_new, 3)\n    m_lin = pow(m_new, 3)\n    s_lin = pow(s_new, 3)\n\n    r_out = 4.0767416621 * l_lin - 3.3077363322 * m_lin + 0.2309101289 * s_lin\n    g_out = -1.2684380046 * l_lin + 2.6097574011 * m_lin - 0.3413193761 * s_lin\n    b_out = -0.0041960771 * l_lin - 0.7034186147 * m_lin + 1.7076147010 * s_lin\n\n    # Convert back to sRGB\n    def to_srgb(val: float) -> float:\n        return 12.92 * val if val <= SRGB_R_CUTOFF else 1.055 * pow(val, 1 / 2.4) - 0.055\n\n    return (\n        round(max(0, min(255, to_srgb(r_out) * 255))),\n        round(max(0, min(255, to_srgb(g_out) * 255))),\n        round(max(0, min(255, to_srgb(b_out) * 255))),\n    )\n"
  },
  {
    "path": "pyprland/plugins/wallpapers/hyprpaper.py",
    "content": "\"\"\"Hyprpaper integration for the wallpapers plugin.\"\"\"\n\nimport asyncio\nfrom typing import TYPE_CHECKING\n\nfrom ...aioops import is_process_running\nfrom ...process import create_subprocess\n\nif TYPE_CHECKING:\n    import logging\n\n    from ...adapters.proxy import BackendProxy\n\n__all__ = [\"HyprpaperManager\"]\n\nHYPRPAPER_PROCESS_NAME = \"hyprpaper\"\n\n\nclass HyprpaperManager:\n    \"\"\"Manages hyprpaper lifecycle and command execution.\"\"\"\n\n    def __init__(self, log: \"logging.Logger\") -> None:\n        \"\"\"Initialize the manager.\n\n        Args:\n            log: Logger instance for logging messages.\n        \"\"\"\n        self.log = log\n\n    async def ensure_running(self) -> bool:\n        \"\"\"Ensure hyprpaper is running, starting it if necessary.\n\n        Returns:\n            True if hyprpaper is available, False if it couldn't be started.\n        \"\"\"\n        if await is_process_running(HYPRPAPER_PROCESS_NAME):\n            return True\n\n        self.log.info(\"Hyprpaper not running, starting it...\")\n        await create_subprocess(\"hyprpaper\", shell=False)\n\n        # Wait for hyprpaper to start (up to 3 seconds)\n        for _ in range(30):\n            await asyncio.sleep(0.1)\n            if await is_process_running(HYPRPAPER_PROCESS_NAME):\n                self.log.info(\"Hyprpaper started successfully\")\n                return True\n\n        self.log.warning(\"Hyprpaper failed to start\")\n        return False\n\n    async def set_wallpaper(self, commands: list[str], backend: \"BackendProxy\") -> bool:\n        \"\"\"Send wallpaper commands to hyprpaper via Hyprland.\n\n        Args:\n            commands: List of hyprpaper commands (e.g., [\"wallpaper DP-1, /path/to/img\"])\n            backend: The environment backend for executing commands.\n\n        Returns:\n            True if successful, False otherwise.\n        \"\"\"\n        if not await self.ensure_running():\n            return False\n\n        for cmd in commands:\n            await backend.execute([\"execr hyprctl hyprpaper \" + cmd])\n\n        return True\n\n    async def stop(self) -> None:\n        \"\"\"Stop hyprpaper process.\"\"\"\n        proc = await create_subprocess(\"pkill hyprpaper\")\n        await proc.wait()\n"
  },
  {
    "path": "pyprland/plugins/wallpapers/imageutils.py",
    "content": "\"\"\"Image utilities for the wallpapers plugin.\"\"\"\n\nfrom __future__ import annotations\n\nimport colorsys\nimport hashlib\nimport os\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING\n\nfrom ...aioops import aiisdir, ailistdir\nfrom .colorutils import Image, ImageDraw, ImageOps\n\nif TYPE_CHECKING:\n    from collections.abc import AsyncIterator\n\n    from .cache import ImageCache\n\nIMAGE_FORMAT = \"jpg\"\n\n\ndef expand_path(path: str) -> str:\n    \"\"\"Expand the path.\n\n    Args:\n        path: The path to expand (handles ~ and environment variables)\n    \"\"\"\n    return str(Path(os.path.expandvars(path)).expanduser())\n\n\nasync def get_files_with_ext(\n    path: str,\n    extensions: list[str],\n    recurse: bool = True,\n    exclude_dirs: set[str] | None = None,\n) -> AsyncIterator[str]:\n    \"\"\"Return files matching `extension` in given `path`. Can optionally `recurse` subfolders..\n\n    Args:\n        path: Directory to search in\n        extensions: List of file extensions to include\n        recurse: Whether to search recursively in subdirectories\n        exclude_dirs: Set of directory names to skip when recursing\n    \"\"\"\n    exclude = exclude_dirs or set()\n    for fname in await ailistdir(path):\n        ext = fname.rsplit(\".\", 1)[-1]\n        full_path = f\"{path}/{fname}\"\n        if ext.lower() in extensions:\n            yield full_path\n        elif recurse and await aiisdir(full_path) and fname not in exclude:\n            async for v in get_files_with_ext(full_path, extensions, True, exclude_dirs):\n                yield v\n\n\n@dataclass(slots=True)\nclass MonitorInfo:\n    \"\"\"Monitor information.\"\"\"\n\n    name: str\n    width: int\n    height: int\n    transform: int\n    scale: float\n\n\ndef get_effective_dimensions(monitor: MonitorInfo) -> tuple[int, int]:\n    \"\"\"Get effective dimensions accounting for rotation.\n\n    Hyprland reports physical panel dimensions regardless of rotation.\n    Transforms 1, 3, 5, 7 are 90°/270° rotations that swap width/height.\n\n    Args:\n        monitor: Monitor info with width, height, and transform.\n\n    Returns:\n        Tuple of (effective_width, effective_height) after applying transform.\n    \"\"\"\n    w, h = monitor.width, monitor.height\n    if monitor.transform in {1, 3, 5, 7}:\n        return h, w\n    return w, h\n\n\nclass RoundedImageManager:\n    \"\"\"Manages rounded and scaled images for monitors.\"\"\"\n\n    def __init__(self, radius: int, cache: ImageCache) -> None:\n        \"\"\"Initialize the manager.\n\n        Args:\n            radius: Corner radius for rounding\n            cache: ImageCache instance for caching rounded images\n        \"\"\"\n        self.radius = radius\n        self.cache = cache\n\n    def hash_source(self, image_path: str) -> str:\n        \"\"\"Generate a hash of the source image path.\n\n        Args:\n            image_path: Path to the source image.\n\n        Returns:\n            First 16 characters of SHA256 hash.\n        \"\"\"\n        return hashlib.sha256(image_path.encode()).hexdigest()[:16]\n\n    def _hash_settings(self, monitor: MonitorInfo) -> str:\n        \"\"\"Generate a hash of the monitor/radius settings.\n\n        Args:\n            monitor: Monitor information.\n\n        Returns:\n            First 16 characters of SHA256 hash.\n        \"\"\"\n        settings = f\"{self.radius}:{monitor.transform}:{monitor.scale}x{monitor.width}x{monitor.height}\"\n        return hashlib.sha256(settings.encode()).hexdigest()[:16]\n\n    def build_key(self, monitor: MonitorInfo, image_path: str) -> str:\n        \"\"\"Build the cache key for the image.\n\n        Uses dual-hash format: {source_hash}_{settings_hash}\n        This allows identifying orphaned cache files by source hash.\n\n        Args:\n            monitor: Monitor information\n            image_path: Path to the source image\n\n        Returns:\n            Cache key in format: {source_hash}_{settings_hash}\n        \"\"\"\n        source_hash = self.hash_source(image_path)\n        settings_hash = self._hash_settings(monitor)\n        return f\"{source_hash}_{settings_hash}\"\n\n    def scale_and_round(self, src: str, monitor: MonitorInfo) -> str:\n        \"\"\"Scale and round the image for the given monitor.\n\n        Args:\n            src: Source image path\n            monitor: Monitor information\n\n        Returns:\n            Path to the cached rounded image.\n        \"\"\"\n        key = self.build_key(monitor, src)\n\n        # Check cache for valid entry\n        cached = self.cache.get(key, IMAGE_FORMAT)\n        if cached:\n            return str(cached)\n\n        # Get path for new cache entry\n        dest = self.cache.get_path(key, IMAGE_FORMAT)\n\n        with Image.open(src) as img:\n            is_rotated = monitor.transform % 2\n            width, height = (monitor.width, monitor.height) if not is_rotated else (monitor.height, monitor.width)\n            width = int(width / monitor.scale)\n            height = int(height / monitor.scale)\n            resample = Image.Resampling.LANCZOS\n            resized = ImageOps.fit(img, (width, height), method=resample)\n\n            scale = 4\n            mask = self._create_rounded_mask(resized.width, resized.height, scale, resample)\n\n            result = Image.new(\"RGB\", resized.size, \"black\")\n            result.paste(resized.convert(\"RGB\"), mask=mask)\n            result.convert(\"RGB\").save(str(dest))\n\n        return str(dest)\n\n    def _create_rounded_mask(self, width: int, height: int, scale: int, resample: Image.Resampling) -> Image.Image:\n        \"\"\"Create a rounded mask.\n\n        Args:\n            width: Target width\n            height: Target height\n            scale: Scaling factor for quality\n            resample: Resampling method\n        \"\"\"\n        image_width, image_height = width * scale, height * scale\n        rounded_mask = Image.new(\"L\", (image_width, image_height), 0)\n        corner_draw = ImageDraw.Draw(rounded_mask)\n        corner_draw.rounded_rectangle((0, 0, image_width - 1, image_height - 1), radius=self.radius * scale, fill=255)\n        return rounded_mask.resize((width, height), resample=resample)\n\n\ndef to_hex(red: int, green: int, blue: int) -> str:\n    \"\"\"Convert integer rgb to hex.\n\n    Args:\n        red: Red component (0-255)\n        green: Green component (0-255)\n        blue: Blue component (0-255)\n    \"\"\"\n    return f\"#{red:02x}{green:02x}{blue:02x}\"\n\n\ndef to_rgb(red: int, green: int, blue: int) -> str:\n    \"\"\"Convert integer rgb to rgb string.\n\n    Args:\n        red: Red component (0-255)\n        green: Green component (0-255)\n        blue: Blue component (0-255)\n    \"\"\"\n    return f\"rgb({red}, {green}, {blue})\"\n\n\ndef to_rgba(red: int, green: int, blue: int) -> str:\n    \"\"\"Convert integer rgb to rgba string.\n\n    Args:\n        red: Red component (0-255)\n        green: Green component (0-255)\n        blue: Blue component (0-255)\n    \"\"\"\n    return f\"rgba({red}, {green}, {blue}, 1.0)\"\n\n\ndef get_variant_color(hue: float, saturation: float, lightness: float) -> tuple[int, int, int]:\n    \"\"\"Get variant color.\n\n    Args:\n        hue: Hue value (0.0-1.0)\n        saturation: Saturation value (0.0-1.0)\n        lightness: Lightness value (0.0-1.0)\n    \"\"\"\n    r, g, b = colorsys.hls_to_rgb(hue, max(0.0, min(1.0, lightness)), saturation)\n    return int(r * 255), int(g * 255), int(b * 255)\n"
  },
  {
    "path": "pyprland/plugins/wallpapers/models.py",
    "content": "\"\"\"Models for color variants and configurations.\"\"\"\n\nfrom dataclasses import dataclass\nfrom enum import StrEnum\n\nHEX_LEN = 6\nHEX_LEN_HASH = 7\n\n\nclass Theme(StrEnum):\n    \"\"\"Light/dark theme for color variant selection.\n\n    Used to determine which color variant (dark or light) to use\n    as the default when generating color palettes from wallpapers.\n    \"\"\"\n\n    DARK = \"dark\"\n    LIGHT = \"light\"\n\n\nclass ColorScheme(StrEnum):\n    \"\"\"Color palette schemes for wallpaper theming.\n\n    Controls saturation and lightness ranges applied to extracted colors.\n    Each scheme produces a distinct mood/aesthetic.\n    \"\"\"\n\n    DEFAULT = \"\"  # No color adjustment\n    PASTEL = \"pastel\"  # Soft, muted colors (high lightness, low saturation)\n    FLUORESCENT = \"fluo\"  # Bright, vivid colors (high saturation)\n    VIBRANT = \"vibrant\"  # Rich, saturated colors\n    MELLOW = \"mellow\"  # Subdued, gentle colors\n    NEUTRAL = \"neutral\"  # Minimal saturation (near grayscale)\n    EARTH = \"earth\"  # Natural, earthy tones (lower lightness)\n\n\n@dataclass\nclass MaterialColors:\n    \"\"\"Holds material design color bases.\"\"\"\n\n    primary: tuple[float, float]\n    secondary: tuple[float, float]\n    tertiary: tuple[float, float]\n\n\n@dataclass\nclass ColorVariant:\n    \"\"\"Holds dark and light variants of a color.\"\"\"\n\n    dark: tuple[int, int, int]\n    light: tuple[int, int, int]\n\n\n@dataclass\nclass VariantConfig:\n    \"\"\"Configuration for processing a variant.\"\"\"\n\n    name: str\n    props: tuple[float | str, float, float | str, float | str]\n    mat_colors: MaterialColors\n    source_hls: tuple[float, float, float]\n    theme: Theme\n    colors: dict[str, str]\n\n\n# (hue_offset, saturation_mult, light_dark_mode, light_light_mode)\nMATERIAL_VARIATIONS = {\n    \"source\": (0.0, 1.0, \"source\", \"source\"),\n    \"primary\": (0.0, 1.0, 0.80, 0.40),\n    \"on_primary\": (0.0, 0.2, 0.20, 1.00),\n    \"primary_container\": (0.0, 1.0, 0.30, 0.90),\n    \"on_primary_container\": (0.0, 1.0, 0.90, 0.10),\n    \"primary_fixed\": (0.0, 1.0, 0.90, 0.90),\n    \"primary_fixed_dim\": (0.0, 1.0, 0.80, 0.80),\n    \"on_primary_fixed\": (0.0, 1.0, 0.10, 0.10),\n    \"on_primary_fixed_variant\": (0.0, 1.0, 0.30, 0.30),\n    \"secondary\": (-0.15, 0.8, 0.80, 0.40),\n    \"on_secondary\": (-0.15, 0.2, 0.20, 1.00),\n    \"secondary_container\": (-0.15, 0.8, 0.30, 0.90),\n    \"on_secondary_container\": (-0.15, 0.8, 0.90, 0.10),\n    \"secondary_fixed\": (0.5, 0.8, 0.90, 0.90),\n    \"secondary_fixed_dim\": (0.5, 0.8, 0.80, 0.80),\n    \"on_secondary_fixed\": (0.5, 0.8, 0.10, 0.10),\n    \"on_secondary_fixed_variant\": (0.5, 0.8, 0.30, 0.30),\n    \"tertiary\": (0.15, 0.8, 0.80, 0.40),\n    \"on_tertiary\": (0.15, 0.2, 0.20, 1.00),\n    \"tertiary_container\": (0.15, 0.8, 0.30, 0.90),\n    \"on_tertiary_container\": (0.15, 0.8, 0.90, 0.10),\n    \"tertiary_fixed\": (0.25, 0.8, 0.90, 0.90),\n    \"tertiary_fixed_dim\": (0.25, 0.8, 0.80, 0.80),\n    \"on_tertiary_fixed\": (0.25, 0.8, 0.10, 0.10),\n    \"on_tertiary_fixed_variant\": (0.25, 0.8, 0.30, 0.30),\n    \"error\": (\"=0.0\", 1.0, 0.80, 0.40),\n    \"on_error\": (\"=0.0\", 1.0, 0.20, 1.00),\n    \"error_container\": (\"=0.0\", 1.0, 0.30, 0.90),\n    \"on_error_container\": (\"=0.0\", 1.0, 0.90, 0.10),\n    \"surface\": (0.0, 0.1, 0.10, 0.98),\n    \"surface_bright\": (0.0, 0.1, 0.12, 0.96),\n    \"surface_dim\": (0.0, 0.1, 0.06, 0.87),\n    \"surface_container_lowest\": (0.0, 0.1, 0.04, 1.00),\n    \"surface_container_low\": (0.0, 0.1, 0.10, 0.96),\n    \"surface_container\": (0.0, 0.1, 0.12, 0.94),\n    \"surface_container_high\": (0.0, 0.1, 0.17, 0.92),\n    \"surface_container_highest\": (0.0, 0.1, 0.22, 0.90),\n    \"on_surface\": (0.0, 0.1, 0.90, 0.10),\n    \"surface_variant\": (0.0, 0.1, 0.30, 0.90),\n    \"on_surface_variant\": (0.0, 0.1, 0.80, 0.30),\n    \"background\": (0.0, 0.1, 0.05, 0.99),\n    \"on_background\": (0.0, 0.1, 0.90, 0.10),\n    \"outline\": (0.0, 0.1, 0.60, 0.50),\n    \"outline_variant\": (0.0, 0.1, 0.30, 0.80),\n    \"inverse_primary\": (0.0, 1.0, 0.40, 0.80),\n    \"inverse_surface\": (0.0, 0.1, 0.90, 0.20),\n    \"inverse_on_surface\": (0.0, 0.1, 0.20, 0.95),\n    \"surface_tint\": (0.0, 1.0, 0.80, 0.40),\n    \"scrim\": (0.0, 0.0, 0.0, 0.0),\n    \"shadow\": (0.0, 0.0, 0.0, 0.0),\n    \"white\": (0.0, 0.0, 0.99, 0.99),\n    \"red\": (\"=0.0\", 1.0, 0.80, 0.40),\n    \"green\": (\"=0.333\", 1.0, 0.80, 0.40),\n    \"yellow\": (\"=0.166\", 1.0, 0.80, 0.40),\n    \"blue\": (\"=0.666\", 1.0, 0.80, 0.40),\n    \"magenta\": (\"=0.833\", 1.0, 0.80, 0.40),\n    \"cyan\": (\"=0.5\", 1.0, 0.80, 0.40),\n}\n"
  },
  {
    "path": "pyprland/plugins/wallpapers/online/__init__.py",
    "content": "\"\"\"Online wallpaper fetcher with multiple backend support.\n\nThis module provides an async interface to fetch wallpaper images from\nvarious free online sources without requiring API keys or subscriptions.\n\nExample usage:\n    from pyprland.plugins.wallpapers.online import OnlineFetcher\n\n    async def main():\n        fetcher = OnlineFetcher(backends=[\"unsplash\", \"wallhaven\"])\n        image_path = await fetcher.get_image(min_width=1920, min_height=1080)\n        print(f\"Downloaded: {image_path}\")\n\nAvailable backends:\n    - unsplash: Unsplash Source (keywords supported)\n    - picsum: Picsum Photos (no keywords)\n    - wallhaven: Wallhaven API (keywords supported)\n    - reddit: Reddit JSON API (keywords mapped to subreddits)\n    - bing: Bing Daily Wallpaper (no keywords)\n\"\"\"\n\nimport logging\nimport random\nfrom pathlib import Path\nfrom typing import Any, Self\n\nfrom pyprland.constants import DEFAULT_WALLPAPER_HEIGHT, DEFAULT_WALLPAPER_WIDTH\nfrom pyprland.httpclient import ClientError, ClientSession, ClientTimeout\n\nfrom ..cache import ImageCache\nfrom .backends import (\n    Backend,\n    BackendError,\n    ImageInfo,\n    get_available_backends,\n    get_backend,\n)\n\n__all__ = [\n    \"BackendError\",\n    \"ImageInfo\",\n    \"NoBackendAvailableError\",\n    \"OnlineFetcher\",\n    \"get_available_backends\",\n]\n\n# Default logger\n_log = logging.getLogger(__name__)\n\n\nclass NoBackendAvailableError(Exception):\n    \"\"\"Raised when no backends are available or all backends failed.\"\"\"\n\n    def __init__(self, message: str = \"No backends available\", tried: list[str] | None = None) -> None:\n        \"\"\"Initialize the error.\n\n        Args:\n            message: Error description.\n            tried: List of backends that were tried.\n        \"\"\"\n        self.tried = tried or []\n        super().__init__(message)\n\n\nclass OnlineFetcher:\n    \"\"\"Async wallpaper fetcher supporting multiple online backends.\n\n    Fetches random wallpaper images from various free online sources,\n    caches them locally, and returns the file path.\n\n    Attributes:\n        backends: List of enabled backend names.\n        cache: ImageCache instance for caching downloaded images.\n    \"\"\"\n\n    def __init__(\n        self,\n        backends: list[str] | None = None,\n        *,\n        cache: ImageCache,\n        log: logging.Logger | None = None,\n    ) -> None:\n        \"\"\"Initialize the online fetcher.\n\n        Args:\n            backends: List of backend names to enable. None means all available.\n            cache: ImageCache instance for caching downloaded images.\n            log: Logger instance. Defaults to module logger.\n        \"\"\"\n        self._log = log or _log\n\n        # Validate and set up backends\n        available = get_available_backends()\n        if backends is None:\n            self._backend_names = available\n        else:\n            invalid = [b for b in backends if b not in available]\n            if invalid:\n                msg = f\"Unknown backends: {invalid}. Available: {available}\"\n                raise ValueError(msg)\n            self._backend_names = backends\n\n        if not self._backend_names:\n            msg = \"At least one backend must be enabled\"\n            raise ValueError(msg)\n\n        # Initialize backends\n        self._backends: dict[str, Backend] = {name: get_backend(name) for name in self._backend_names}\n\n        # Use provided cache\n        self.cache = cache\n\n        # Track session for connection reuse\n        self._session: Any = None\n\n    @property\n    def backends(self) -> list[str]:\n        \"\"\"List of enabled backend names.\"\"\"\n        return list(self._backend_names)\n\n    @property\n    def available_backends(self) -> list[str]:\n        \"\"\"List of all available backend names.\"\"\"\n        return get_available_backends()\n\n    async def _get_session(self) -> Any:\n        \"\"\"Get or create an HTTP session.\n\n        Returns:\n            HTTP ClientSession.\n        \"\"\"\n        if self._session is None or self._session.closed:\n            self._session = ClientSession(\n                timeout=ClientTimeout(total=30),\n                headers={\"User-Agent\": \"pyprland-wallpaper-fetcher/1.0\"},\n            )\n        return self._session\n\n    async def close(self) -> None:\n        \"\"\"Close the HTTP session.\"\"\"\n        if self._session and not self._session.closed:\n            await self._session.close()\n            self._session = None\n\n    async def __aenter__(self) -> Self:\n        \"\"\"Async context manager entry.\"\"\"\n        return self\n\n    async def __aexit__(self, *args: object) -> None:\n        \"\"\"Async context manager exit.\"\"\"\n        await self.close()\n\n    def _select_backends(self, backend: str | None) -> list[str]:\n        \"\"\"Select which backends to try.\n\n        Args:\n            backend: Specific backend name, or None for all backends.\n\n        Returns:\n            List of backend names to try in order.\n\n        Raises:\n            ValueError: If specified backend is not enabled.\n        \"\"\"\n        if backend is not None:\n            if backend not in self._backends:\n                msg = f\"Backend '{backend}' not enabled. Enabled: {self.backends}\"\n                raise ValueError(msg)\n            return [backend]\n        # Randomize order for load distribution\n        backends_to_try = list(self._backend_names)\n        random.shuffle(backends_to_try)\n        return backends_to_try\n\n    async def _try_backend(\n        self,\n        session: Any,\n        backend_name: str,\n        *,\n        min_width: int,\n        min_height: int,\n        keywords: list[str] | None,\n    ) -> Path | None:\n        \"\"\"Try fetching an image from a single backend.\n\n        Args:\n            session: HTTP session.\n            backend_name: Name of the backend to try.\n            min_width: Minimum image width.\n            min_height: Minimum image height.\n            keywords: Optional search keywords.\n\n        Returns:\n            Path to cached image if successful, None if failed.\n        \"\"\"\n        backend_instance = self._backends[backend_name]\n        self._log.debug(\"Trying backend: %s\", backend_name)\n\n        info = await backend_instance.fetch_image_info(\n            session=session,\n            min_width=min_width,\n            min_height=min_height,\n            keywords=keywords,\n        )\n\n        # Check cache first\n        cache_key = f\"{info.source}:{info.image_id}:{info.url}\"\n        cached_path = self.cache.get(cache_key, info.extension)\n        if cached_path:\n            self._log.debug(\"Cache hit: %s\", cached_path)\n            return cached_path\n\n        # Download image\n        image_data = await self._download_image(session, info.url)\n\n        # Store in cache\n        cached_path = await self.cache.store(cache_key, image_data, info.extension)\n        self._log.info(\"Downloaded from %s: %s\", backend_name, cached_path)\n        self._log.debug(\"Cache directory: %s\", self.cache.cache_dir)\n        return cached_path\n\n    async def get_image(\n        self,\n        min_width: int = DEFAULT_WALLPAPER_WIDTH,\n        min_height: int = DEFAULT_WALLPAPER_HEIGHT,\n        keywords: list[str] | None = None,\n        backend: str | None = None,\n    ) -> Path:\n        \"\"\"Fetch and cache a random wallpaper image.\n\n        Tries backends in random order until one succeeds. Downloaded images\n        are cached locally and the file path is returned.\n\n        Args:\n            min_width: Minimum image width in pixels.\n            min_height: Minimum image height in pixels.\n            keywords: Optional keywords to filter images (backend-dependent).\n            backend: Force a specific backend. None picks randomly.\n\n        Returns:\n            Path to the cached image file.\n\n        Raises:\n            NoBackendAvailableError: If all backends fail.\n            ValueError: If specified backend is not enabled.\n        \"\"\"\n        session = await self._get_session()\n        backends_to_try = self._select_backends(backend)\n\n        tried: list[str] = []\n        last_error: Exception | None = None\n\n        for backend_name in backends_to_try:\n            tried.append(backend_name)\n            try:\n                result = await self._try_backend(\n                    session,\n                    backend_name,\n                    min_width=min_width,\n                    min_height=min_height,\n                    keywords=keywords,\n                )\n                if result:\n                    return result\n            except BackendError as e:\n                self._log.warning(\"Backend %s failed: %s\", backend_name, e.message)\n                last_error = e\n            except ClientError as e:\n                self._log.warning(\"Network error with %s: %s\", backend_name, e)\n                last_error = e\n\n        # All backends failed\n        msg = f\"All backends failed. Tried: {tried}\"\n        raise NoBackendAvailableError(msg, tried=tried) from last_error\n\n    async def _download_image(self, session: Any, url: str) -> bytes:\n        \"\"\"Download an image from a URL.\n\n        Args:\n            session: HTTP session.\n            url: Image URL.\n\n        Returns:\n            Image data as bytes.\n\n        Raises:\n            ClientError: On network errors.\n        \"\"\"\n        async with session.get(url) as response:\n            response.raise_for_status()\n            data: bytes = await response.read()\n            return data\n"
  },
  {
    "path": "pyprland/plugins/wallpapers/online/backends/__init__.py",
    "content": "\"\"\"Backend registry for online wallpaper sources.\n\nThis module provides the registry for wallpaper backends and re-exports\nthe base types for convenience.\n\"\"\"\n\n# Re-export base types for backward compatibility\nfrom .base import (\n    HTTP_OK,\n    Backend,\n    BackendError,\n    ImageInfo,\n    fetch_redirect_image,\n)\n\n__all__ = [\n    \"HTTP_OK\",\n    \"Backend\",\n    \"BackendError\",\n    \"ImageInfo\",\n    \"fetch_redirect_image\",\n    \"get_available_backends\",\n    \"get_backend\",\n    \"register_backend\",\n]\n\n# Backend registry - populated by imports below\nBACKENDS: dict[str, type[Backend]] = {}\n\n\ndef register_backend(cls: type[Backend]) -> type[Backend]:\n    \"\"\"Decorator to register a backend class.\n\n    Args:\n        cls: Backend class to register.\n\n    Returns:\n        The same class, unmodified.\n    \"\"\"\n    BACKENDS[cls.name] = cls\n    return cls\n\n\ndef get_backend(name: str) -> Backend:\n    \"\"\"Get a backend instance by name.\n\n    Args:\n        name: Backend identifier.\n\n    Returns:\n        An instance of the requested backend.\n\n    Raises:\n        KeyError: If the backend is not registered.\n    \"\"\"\n    if name not in BACKENDS:\n        available = \", \".join(BACKENDS.keys())\n        msg = f\"Unknown backend '{name}'. Available: {available}\"\n        raise KeyError(msg)\n    return BACKENDS[name]()\n\n\ndef get_available_backends() -> list[str]:\n    \"\"\"Get list of all registered backend names.\n\n    Returns:\n        List of backend names.\n    \"\"\"\n    return list(BACKENDS.keys())\n\n\n# Import backends to register them\n# Cyclic import is intentional: backends import register_backend from here\n# pylint: disable=wrong-import-position,cyclic-import\nfrom . import bing, picsum, reddit, unsplash, wallhaven  # noqa: E402, F401\n"
  },
  {
    "path": "pyprland/plugins/wallpapers/online/backends/base.py",
    "content": "\"\"\"Base classes and types for wallpaper backends.\n\nThis module contains the abstract base class, data types, and helper functions\nused by all wallpaper backend implementations. It is separate from __init__.py\nto avoid cyclic imports when backends import these types.\n\"\"\"\n\nfrom abc import ABC, abstractmethod\nfrom collections.abc import Callable\nfrom dataclasses import dataclass, field\nfrom http import HTTPStatus\nfrom typing import TYPE_CHECKING, ClassVar\n\nfrom pyprland.httpclient import ClientError\n\nif TYPE_CHECKING:\n    from pyprland.httpclient import FallbackClientSession\n\n# HTTP status code for successful responses\nHTTP_OK = HTTPStatus.OK\n\n\n@dataclass(slots=True)\nclass ImageInfo:\n    \"\"\"Metadata about a fetched image.\n\n    Attributes:\n        url: Direct URL to download the image.\n        width: Image width in pixels, if known.\n        height: Image height in pixels, if known.\n        source: Name of the backend that provided this image.\n        image_id: Unique identifier for the image from the source.\n        extension: File extension (e.g., \"jpg\", \"png\").\n        extra: Additional metadata from the source.\n    \"\"\"\n\n    url: str\n    width: int | None = None\n    height: int | None = None\n    source: str = \"\"\n    image_id: str = \"\"\n    extension: str = \"jpg\"\n    extra: dict[str, str] = field(default_factory=dict)\n\n\nclass Backend(ABC):\n    \"\"\"Abstract base class for wallpaper backends.\n\n    Each backend must implement fetch_image_info() to retrieve metadata\n    about a random image from its source.\n\n    Class Attributes:\n        name: Unique identifier for the backend.\n        supports_keywords: Whether this backend supports keyword filtering.\n        base_url: Base URL for the API (for documentation).\n    \"\"\"\n\n    name: ClassVar[str]\n    supports_keywords: ClassVar[bool] = False\n    base_url: ClassVar[str] = \"\"\n\n    @abstractmethod\n    async def fetch_image_info(\n        self,\n        session: \"FallbackClientSession\",\n        min_width: int = 1920,\n        min_height: int = 1080,\n        keywords: list[str] | None = None,\n    ) -> ImageInfo:\n        \"\"\"Fetch metadata for a random image meeting size requirements.\n\n        Args:\n            session: HTTP session for making requests.\n            min_width: Minimum image width in pixels.\n            min_height: Minimum image height in pixels.\n            keywords: Optional list of keywords to filter images.\n\n        Returns:\n            ImageInfo with the image URL and metadata.\n\n        Raises:\n            BackendError: If the backend fails to fetch an image.\n        \"\"\"\n        ...\n\n\nclass BackendError(Exception):\n    \"\"\"Exception raised when a backend fails to fetch an image.\"\"\"\n\n    def __init__(self, backend: str, message: str) -> None:\n        \"\"\"Initialize the error.\n\n        Args:\n            backend: Name of the backend that failed.\n            message: Error description.\n        \"\"\"\n        self.backend = backend\n        self.message = message\n        super().__init__(f\"{backend}: {message}\")\n\n\nasync def fetch_redirect_image(\n    session: \"FallbackClientSession\",\n    url: str,\n    backend_name: str,\n    dimensions: tuple[int, int],\n    id_extractor: Callable[[str], str] | None = None,\n) -> ImageInfo:\n    \"\"\"Fetch image info from a URL that redirects to the final image.\n\n    Common pattern for backends like Unsplash and Picsum which redirect\n    to actual image URLs. This helper handles the redirect and extracts\n    the final URL.\n\n    Args:\n        session: HTTP session for making requests.\n        url: Initial URL that will redirect to the image.\n        backend_name: Name of the backend (for ImageInfo.source).\n        dimensions: Tuple of (width, height) for the requested image.\n        id_extractor: Optional function to extract image ID from final URL.\n\n    Returns:\n        ImageInfo with the resolved image URL.\n\n    Raises:\n        BackendError: On HTTP errors or network failures.\n    \"\"\"\n    try:\n        async with session.get(url, allow_redirects=True) as response:\n            if response.status != HTTP_OK:\n                raise BackendError(backend_name, f\"HTTP {response.status}\")\n\n            final_url = str(response.url)\n            image_id = id_extractor(final_url) if id_extractor else \"\"\n\n            return ImageInfo(\n                url=final_url,\n                width=dimensions[0],\n                height=dimensions[1],\n                source=backend_name,\n                image_id=image_id,\n                extension=\"jpg\",\n            )\n    except ClientError as e:\n        raise BackendError(backend_name, str(e)) from e\n"
  },
  {
    "path": "pyprland/plugins/wallpapers/online/backends/bing.py",
    "content": "\"\"\"Bing Daily Wallpaper backend.\"\"\"\n\nimport random\nfrom typing import TYPE_CHECKING, ClassVar\n\nfrom pyprland.httpclient import ClientError\n\nfrom . import register_backend\nfrom .base import HTTP_OK, Backend, BackendError, ImageInfo\n\nif TYPE_CHECKING:\n    from pyprland.httpclient import FallbackClientSession\n\n# Bing's standard wallpaper resolution\nBING_WIDTH = 1920\nBING_HEIGHT = 1080\n\n\n@register_backend\nclass BingBackend(Backend):\n    \"\"\"Backend for Bing Daily Wallpaper.\n\n    Bing provides beautiful daily wallpapers, typically landscapes and nature.\n    No API key required. Returns images from the last 8 days.\n\n    Note: Images are typically 1920x1080. Size parameters are used only\n    for validation, not filtering (Bing doesn't support size filtering).\n    \"\"\"\n\n    name: ClassVar[str] = \"bing\"\n    supports_keywords: ClassVar[bool] = False\n    base_url: ClassVar[str] = \"https://www.bing.com\"\n\n    async def fetch_image_info(\n        self,\n        session: \"FallbackClientSession\",\n        min_width: int = 1920,\n        min_height: int = 1080,\n        keywords: list[str] | None = None,  # noqa: ARG002\n    ) -> ImageInfo:\n        \"\"\"Fetch a random daily wallpaper from Bing.\n\n        Args:\n            session: HTTP session for making requests.\n            min_width: Minimum image width (Bing images are typically 1920x1080).\n            min_height: Minimum image height.\n            keywords: Ignored - Bing doesn't support keyword filtering.\n\n        Returns:\n            ImageInfo with the wallpaper URL.\n\n        Raises:\n            BackendError: If Bing fails to return an image.\n        \"\"\"\n        # Warn if requesting larger than Bing's typical resolution\n        if min_width > BING_WIDTH or min_height > BING_HEIGHT:\n            # Bing images might not meet the size requirement, but we'll try\n            pass\n\n        # Fetch up to 8 recent images (Bing's limit)\n        params = {\n            \"format\": \"js\",\n            \"idx\": \"0\",  # Start from today\n            \"n\": \"8\",  # Number of images\n            \"mkt\": \"en-US\",\n        }\n\n        try:\n            async with session.get(f\"{self.base_url}/HPImageArchive.aspx\", params=params) as response:\n                if response.status != HTTP_OK:\n                    raise BackendError(self.name, f\"HTTP {response.status}\")\n\n                data = await response.json()\n\n                images = data.get(\"images\", [])\n                if not images:\n                    raise BackendError(self.name, \"No images available\")\n\n                # Pick a random image from available\n                image = random.choice(images)\n\n                # Build full URL (Bing returns relative paths)\n                url_path = image.get(\"url\", \"\")\n                if not url_path:\n                    raise BackendError(self.name, \"No URL in image data\")\n\n                # Request UHD resolution if available\n                # Replace resolution in URL for higher quality\n                url_path = url_path.replace(\"1920x1080\", \"UHD\")\n                full_url = f\"{self.base_url}{url_path}\"\n\n                # Extract ID from urlbase\n                urlbase = image.get(\"urlbase\", \"\")\n                extracted_id = urlbase.split(\".\")[-1] if \".\" in urlbase else urlbase\n\n                return ImageInfo(\n                    url=full_url,\n                    width=BING_WIDTH,\n                    height=BING_HEIGHT,\n                    source=self.name,\n                    image_id=extracted_id,\n                    extension=\"jpg\",\n                    extra={\n                        \"title\": image.get(\"title\", \"\"),\n                        \"copyright\": image.get(\"copyright\", \"\"),\n                        \"date\": image.get(\"startdate\", \"\"),\n                    },\n                )\n\n        except ClientError as e:\n            raise BackendError(self.name, str(e)) from e\n        except (KeyError, ValueError) as e:\n            raise BackendError(self.name, f\"Invalid response: {e}\") from e\n"
  },
  {
    "path": "pyprland/plugins/wallpapers/online/backends/picsum.py",
    "content": "\"\"\"Picsum Photos backend for random images.\"\"\"\n\n# pylint: disable=duplicate-code  # Uses shared fetch_redirect_image pattern\n\nimport random\nfrom typing import TYPE_CHECKING, ClassVar\n\nfrom . import register_backend\nfrom .base import Backend, ImageInfo, fetch_redirect_image\n\nif TYPE_CHECKING:\n    from pyprland.httpclient import FallbackClientSession\n\n\n@register_backend\nclass PicsumBackend(Backend):\n    \"\"\"Backend for Picsum Photos - Lorem Ipsum for photos.\n\n    Picsum provides random placeholder images at requested dimensions.\n    No API key required. Does not support keyword filtering.\n\n    See: https://picsum.photos/\n    \"\"\"\n\n    name: ClassVar[str] = \"picsum\"\n    supports_keywords: ClassVar[bool] = False\n    base_url: ClassVar[str] = \"https://picsum.photos\"\n\n    @staticmethod\n    def _extract_id(url: str) -> str:\n        \"\"\"Extract image ID from picsum URL.\n\n        Args:\n            url: Final URL after redirect (e.g., https://i.picsum.photos/id/123/...).\n\n        Returns:\n            Image ID or empty string if not found.\n        \"\"\"\n        if \"/id/\" in url:\n            parts = url.split(\"/id/\")\n            if len(parts) > 1:\n                return parts[1].split(\"/\")[0]\n        return \"\"\n\n    async def fetch_image_info(\n        self,\n        session: \"FallbackClientSession\",\n        min_width: int = 1920,\n        min_height: int = 1080,\n        keywords: list[str] | None = None,  # noqa: ARG002\n    ) -> ImageInfo:\n        \"\"\"Fetch a random image from Picsum.\n\n        Args:\n            session: HTTP session for making requests.\n            min_width: Minimum image width in pixels.\n            min_height: Minimum image height in pixels.\n            keywords: Ignored - Picsum doesn't support keywords.\n\n        Returns:\n            ImageInfo with the image URL.\n\n        Raises:\n            BackendError: If Picsum fails to return an image.\n        \"\"\"\n        # Add random seed to get different images\n        seed = random.randint(1, 1000000)\n        url = f\"{self.base_url}/seed/{seed}/{min_width}/{min_height}\"\n\n        return await fetch_redirect_image(\n            session=session,\n            url=url,\n            backend_name=self.name,\n            dimensions=(min_width, min_height),\n            id_extractor=self._extract_id,\n        )\n"
  },
  {
    "path": "pyprland/plugins/wallpapers/online/backends/reddit.py",
    "content": "\"\"\"Reddit JSON API backend for wallpaper images.\"\"\"\n\nimport random\nfrom typing import TYPE_CHECKING, ClassVar\n\nfrom pyprland.httpclient import ClientError\n\nfrom . import register_backend\nfrom .base import HTTP_OK, Backend, BackendError, ImageInfo\n\nif TYPE_CHECKING:\n    from pyprland.httpclient import FallbackClientSession\n\n# Subreddits with high-quality wallpapers\nDEFAULT_SUBREDDITS = [\n    \"wallpapers\",\n    \"wallpaper\",\n    \"MinimalWallpaper\",\n]\n\n# Mapping of keywords to relevant subreddits\nKEYWORD_SUBREDDITS: dict[str, list[str]] = {\n    \"nature\": [\"EarthPorn\", \"natureporn\", \"SkyPorn\"],\n    \"landscape\": [\"EarthPorn\", \"LandscapePhotography\"],\n    \"city\": [\"CityPorn\", \"cityphotos\"],\n    \"space\": [\"spaceporn\", \"astrophotography\"],\n    \"minimal\": [\"MinimalWallpaper\", \"minimalism\"],\n    \"dark\": [\"Amoledbackgrounds\", \"darkwallpapers\"],\n    \"anime\": [\"Animewallpaper\", \"AnimeWallpapersSFW\"],\n    \"art\": [\"ArtPorn\", \"ImaginaryLandscapes\"],\n    \"car\": [\"carporn\", \"Autos\"],\n    \"architecture\": [\"ArchitecturePorn\", \"architecture\"],\n}\n\n\n@register_backend\nclass RedditBackend(Backend):\n    \"\"\"Backend for Reddit - community-curated wallpapers from subreddits.\n\n    Uses Reddit's public JSON API to fetch images from wallpaper subreddits.\n    No authentication required for public subreddits.\n\n    Keywords are mapped to relevant subreddits (e.g., \"nature\" -> r/EarthPorn).\n    \"\"\"\n\n    name: ClassVar[str] = \"reddit\"\n    supports_keywords: ClassVar[bool] = True\n    base_url: ClassVar[str] = \"https://www.reddit.com\"\n\n    async def fetch_image_info(\n        self,\n        session: \"FallbackClientSession\",\n        min_width: int = 1920,\n        min_height: int = 1080,\n        keywords: list[str] | None = None,\n    ) -> ImageInfo:\n        \"\"\"Fetch a random wallpaper from Reddit.\n\n        Args:\n            session: HTTP session for making requests.\n            min_width: Minimum image width in pixels.\n            min_height: Minimum image height in pixels.\n            keywords: Optional keywords mapped to subreddits.\n\n        Returns:\n            ImageInfo with the image URL.\n\n        Raises:\n            BackendError: If no suitable image is found.\n        \"\"\"\n        # Select subreddits based on keywords\n        subreddits = self._get_subreddits(keywords)\n        subreddit = random.choice(subreddits)\n\n        # Fetch posts from the subreddit\n        url = f\"{self.base_url}/r/{subreddit}/hot.json\"\n        params = {\"limit\": \"50\"}\n\n        headers = {\n            \"User-Agent\": \"pyprland-wallpaper-fetcher/1.0\",\n        }\n\n        try:\n            async with session.get(url, params=params, headers=headers) as response:\n                if response.status != HTTP_OK:\n                    raise BackendError(self.name, f\"HTTP {response.status} from r/{subreddit}\")\n\n                data = await response.json()\n\n                # Filter for suitable images\n                candidates = self._filter_posts(data, min_width, min_height)\n\n                if not candidates:\n                    raise BackendError(self.name, f\"No images found in r/{subreddit} matching size\")\n\n                # Pick a random post\n                post = random.choice(candidates)\n\n                return self._post_to_image_info(post)\n\n        except ClientError as e:\n            raise BackendError(self.name, str(e)) from e\n        except (KeyError, ValueError) as e:\n            raise BackendError(self.name, f\"Invalid response: {e}\") from e\n\n    def _get_subreddits(self, keywords: list[str] | None) -> list[str]:\n        \"\"\"Get relevant subreddits based on keywords.\n\n        Args:\n            keywords: Optional list of keywords.\n\n        Returns:\n            List of subreddit names.\n        \"\"\"\n        if not keywords:\n            return DEFAULT_SUBREDDITS\n\n        subreddits: list[str] = []\n        for keyword in keywords:\n            keyword_lower = keyword.lower()\n            if keyword_lower in KEYWORD_SUBREDDITS:\n                subreddits.extend(KEYWORD_SUBREDDITS[keyword_lower])\n            else:\n                # Try the keyword as a subreddit name\n                subreddits.append(keyword)\n\n        return subreddits or DEFAULT_SUBREDDITS\n\n    def _filter_posts(\n        self,\n        data: dict,\n        min_width: int,\n        min_height: int,\n    ) -> list[dict]:\n        \"\"\"Filter posts for suitable images.\n\n        Args:\n            data: Reddit API response data.\n            min_width: Minimum width.\n            min_height: Minimum height.\n\n        Returns:\n            List of suitable post data dictionaries.\n        \"\"\"\n        candidates: list[dict] = []\n\n        for child in data.get(\"data\", {}).get(\"children\", []):\n            post = child.get(\"data\", {})\n\n            # Skip non-image posts\n            if post.get(\"is_self\"):\n                continue\n\n            url = post.get(\"url\", \"\")\n            if not self._is_image_url(url):\n                continue\n\n            # Check dimensions if available\n            preview = post.get(\"preview\", {})\n            images = preview.get(\"images\", [])\n            if images:\n                source = images[0].get(\"source\", {})\n                width = source.get(\"width\", 0)\n                height = source.get(\"height\", 0)\n                if width < min_width or height < min_height:\n                    continue\n\n            candidates.append(post)\n\n        return candidates\n\n    def _is_image_url(self, url: str) -> bool:\n        \"\"\"Check if URL points to an image.\n\n        Args:\n            url: URL to check.\n\n        Returns:\n            True if URL appears to be an image.\n        \"\"\"\n        image_extensions = (\".jpg\", \".jpeg\", \".png\", \".webp\")\n        image_hosts = (\"i.redd.it\", \"i.imgur.com\")\n\n        url_lower = url.lower()\n        return any(url_lower.endswith(ext) for ext in image_extensions) or any(host in url_lower for host in image_hosts)\n\n    def _post_to_image_info(self, post: dict) -> ImageInfo:\n        \"\"\"Convert a Reddit post to ImageInfo.\n\n        Args:\n            post: Reddit post data.\n\n        Returns:\n            ImageInfo for the post's image.\n        \"\"\"\n        url = post.get(\"url\", \"\")\n\n        # Get dimensions from preview if available\n        width = None\n        height = None\n        preview = post.get(\"preview\", {})\n        images = preview.get(\"images\", [])\n        if images:\n            source = images[0].get(\"source\", {})\n            width = source.get(\"width\")\n            height = source.get(\"height\")\n\n        # Determine extension\n        extension = \"jpg\"\n        for ext in (\".png\", \".webp\", \".jpeg\", \".jpg\"):\n            if ext in url.lower():\n                extension = ext.lstrip(\".\")\n                break\n\n        return ImageInfo(\n            url=url,\n            width=width,\n            height=height,\n            source=self.name,\n            image_id=post.get(\"id\", \"\"),\n            extension=extension,\n            extra={\n                \"title\": post.get(\"title\", \"\"),\n                \"subreddit\": post.get(\"subreddit\", \"\"),\n                \"score\": str(post.get(\"score\", 0)),\n            },\n        )\n"
  },
  {
    "path": "pyprland/plugins/wallpapers/online/backends/unsplash.py",
    "content": "\"\"\"Unsplash Source backend for random images.\"\"\"\n\nimport random\nfrom typing import TYPE_CHECKING, ClassVar\n\nfrom . import register_backend\nfrom .base import Backend, ImageInfo, fetch_redirect_image\n\nif TYPE_CHECKING:\n    from pyprland.httpclient import FallbackClientSession\n\n\n@register_backend\nclass UnsplashBackend(Backend):\n    \"\"\"Backend for Unsplash Source - simple URL-based random images.\n\n    Unsplash Source provides direct image URLs without requiring an API key.\n    Images are served at the requested dimensions.\n\n    See: https://source.unsplash.com/\n    \"\"\"\n\n    name: ClassVar[str] = \"unsplash\"\n    supports_keywords: ClassVar[bool] = True\n    base_url: ClassVar[str] = \"https://source.unsplash.com\"\n\n    async def fetch_image_info(\n        self,\n        session: \"FallbackClientSession\",\n        min_width: int = 1920,\n        min_height: int = 1080,\n        keywords: list[str] | None = None,\n    ) -> ImageInfo:\n        \"\"\"Fetch a random image from Unsplash.\n\n        Args:\n            session: HTTP session for making requests.\n            min_width: Minimum image width in pixels.\n            min_height: Minimum image height in pixels.\n            keywords: Optional keywords to filter images (e.g., [\"nature\", \"forest\"]).\n\n        Returns:\n            ImageInfo with the resolved image URL.\n\n        Raises:\n            BackendError: If Unsplash fails to return an image.\n        \"\"\"\n        # Build URL with size and optional keywords\n        url = f\"{self.base_url}/random/{min_width}x{min_height}\"\n        if keywords:\n            query = \",\".join(keywords)\n            url = f\"{url}/?{query}\"\n\n        # Add cache buster to ensure random image\n        cache_buster = random.randint(1, 1000000)\n        separator = \"&\" if \"?\" in url else \"?\"\n        url = f\"{url}{separator}_={cache_buster}\"\n\n        return await fetch_redirect_image(\n            session=session,\n            url=url,\n            backend_name=self.name,\n            dimensions=(min_width, min_height),\n            id_extractor=self._extract_id,\n        )\n\n    @staticmethod\n    def _extract_id(url: str) -> str:\n        \"\"\"Extract image ID from Unsplash URL.\n\n        Args:\n            url: Final URL after redirects.\n\n        Returns:\n            Image ID if found, empty string otherwise.\n        \"\"\"\n        if \"unsplash.com/photos/\" in url:\n            parts = url.split(\"photos/\")\n            if len(parts) > 1:\n                return parts[1].split(\"/\")[0].split(\"?\")[0]\n        return \"\"\n"
  },
  {
    "path": "pyprland/plugins/wallpapers/online/backends/wallhaven.py",
    "content": "\"\"\"Wallhaven API backend for wallpaper images.\"\"\"\n\nimport random\nfrom typing import TYPE_CHECKING, ClassVar\n\nfrom pyprland.httpclient import ClientError\n\nfrom . import register_backend\nfrom .base import HTTP_OK, Backend, BackendError, ImageInfo\n\nif TYPE_CHECKING:\n    from pyprland.httpclient import FallbackClientSession\n\n\n@register_backend\nclass WallhavenBackend(Backend):\n    \"\"\"Backend for Wallhaven - dedicated wallpaper site with extensive filters.\n\n    Wallhaven provides high-quality wallpapers with filtering by resolution,\n    category, and search terms. No API key required for SFW content.\n\n    See: https://wallhaven.cc/help/api\n    \"\"\"\n\n    name: ClassVar[str] = \"wallhaven\"\n    supports_keywords: ClassVar[bool] = True\n    base_url: ClassVar[str] = \"https://wallhaven.cc/api/v1\"\n\n    async def fetch_image_info(\n        self,\n        session: \"FallbackClientSession\",\n        min_width: int = 1920,\n        min_height: int = 1080,\n        keywords: list[str] | None = None,\n    ) -> ImageInfo:\n        \"\"\"Fetch a random wallpaper from Wallhaven.\n\n        Args:\n            session: HTTP session for making requests.\n            min_width: Minimum image width in pixels.\n            min_height: Minimum image height in pixels.\n            keywords: Optional search terms.\n\n        Returns:\n            ImageInfo with the wallpaper URL.\n\n        Raises:\n            BackendError: If Wallhaven fails to return an image.\n        \"\"\"\n        # Build search parameters\n        params: dict[str, str] = {\n            \"categories\": \"100\",  # General only (not anime/people)\n            \"purity\": \"100\",  # SFW only\n            \"sorting\": \"random\",\n            \"atleast\": f\"{min_width}x{min_height}\",\n        }\n\n        if keywords:\n            params[\"q\"] = \" \".join(keywords)\n\n        try:\n            async with session.get(f\"{self.base_url}/search\", params=params) as response:\n                if response.status != HTTP_OK:\n                    raise BackendError(self.name, f\"HTTP {response.status}\")\n\n                data = await response.json()\n\n                if not data.get(\"data\"):\n                    raise BackendError(self.name, \"No images found matching criteria\")\n\n                # Pick a random image from results\n                image = random.choice(data[\"data\"])\n\n                # Determine extension from path\n                path = image.get(\"path\", \"\")\n                extension = path.rsplit(\".\", 1)[-1] if \".\" in path else \"jpg\"\n\n                return ImageInfo(\n                    url=image[\"path\"],\n                    width=image.get(\"dimension_x\"),\n                    height=image.get(\"dimension_y\"),\n                    source=self.name,\n                    image_id=image.get(\"id\", \"\"),\n                    extension=extension,\n                    extra={\n                        \"category\": image.get(\"category\", \"\"),\n                        \"views\": str(image.get(\"views\", \"\")),\n                        \"favorites\": str(image.get(\"favorites\", \"\")),\n                    },\n                )\n        except ClientError as e:\n            raise BackendError(self.name, str(e)) from e\n        except (KeyError, ValueError) as e:\n            raise BackendError(self.name, f\"Invalid response: {e}\") from e\n"
  },
  {
    "path": "pyprland/plugins/wallpapers/palette.py",
    "content": "\"\"\"Palette display utilities for wallpapers plugin.\"\"\"\n\nimport colorsys\nimport json\n\nfrom .models import Theme\nfrom .theme import generate_palette\n\n# Category definitions for organizing palette output\n# Order matters for display - checked in sequence, first match wins\nPALETTE_CATEGORIES = [\n    (\"primary\", lambda k: \"primary\" in k),\n    (\"secondary\", lambda k: \"secondary\" in k),\n    (\"tertiary\", lambda k: \"tertiary\" in k),\n    (\"surface\", lambda k: \"surface\" in k),\n    (\"error\", lambda k: \"error\" in k),\n    (\"ansi\", lambda k: any(c in k for c in [\"red\", \"green\", \"yellow\", \"blue\", \"magenta\", \"cyan\", \"white\"])),\n    (\"utility\", lambda _k: True),  # Fallback for background, outline, inverse, scrim, shadow\n]\n\n# Display names for categories\nCATEGORY_DISPLAY_NAMES = {\n    \"primary\": \"Primary\",\n    \"secondary\": \"Secondary\",\n    \"tertiary\": \"Tertiary\",\n    \"surface\": \"Surface\",\n    \"error\": \"Error\",\n    \"ansi\": \"ANSI Colors\",\n    \"utility\": \"Utility\",\n}\n\n\ndef hex_to_rgb(hex_color: str) -> tuple[int, int, int]:\n    \"\"\"Convert hex color to RGB tuple.\n\n    Args:\n        hex_color: Hex color string with or without '#' prefix\n\n    Returns:\n        Tuple of (red, green, blue) integers 0-255\n    \"\"\"\n    color = hex_color.lstrip(\"#\")\n    return int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16)\n\n\ndef _categorize_palette(palette: dict[str, str]) -> dict[str, list[str]]:\n    \"\"\"Organize palette keys into categories.\n\n    Args:\n        palette: Palette dictionary from generate_palette()\n\n    Returns:\n        Dictionary mapping category names to lists of palette keys\n    \"\"\"\n    categories: dict[str, list[str]] = {name: [] for name, _ in PALETTE_CATEGORIES}\n\n    for key in palette:\n        if key in (\"scheme\", \"image\") or not key.startswith(\"colors.\"):\n            continue\n        for cat_name, matcher in PALETTE_CATEGORIES:\n            if matcher(key):\n                categories[cat_name].append(key)\n                break\n\n    # Sort within each category\n    for cat in categories.values():\n        cat.sort()\n\n    return categories\n\n\ndef palette_to_json(palette: dict[str, str]) -> str:\n    \"\"\"Convert palette to JSON format with categories and filter examples.\n\n    Args:\n        palette: Palette dictionary from generate_palette()\n\n    Returns:\n        JSON string with variables, categories, and filter documentation\n    \"\"\"\n    categories = _categorize_palette(palette)\n\n    output = {\n        \"variables\": {k: v for k, v in palette.items() if k not in (\"scheme\", \"image\")},\n        \"categories\": categories,\n        \"filters\": {\n            \"set_alpha\": {\n                \"description\": \"Add transparency to a color\",\n                \"example\": \"{{ colors.primary.default.hex | set_alpha: 0.5 }}\",\n                \"result\": \"rgba(R, G, B, 0.5)\",\n            },\n            \"set_lightness\": {\n                \"description\": \"Adjust color brightness (percentage, can be negative)\",\n                \"example\": \"{{ colors.primary.default.hex | set_lightness: -20 }}\",\n                \"result\": \"#XXXXXX (darker)\",\n            },\n        },\n        \"theme\": palette.get(\"scheme\", \"dark\"),\n    }\n\n    return json.dumps(output, indent=2)\n\n\ndef palette_to_terminal(palette: dict[str, str]) -> str:  # pylint: disable=too-many-locals\n    \"\"\"Convert palette to terminal-formatted output with ANSI color swatches.\n\n    Args:\n        palette: Palette dictionary from generate_palette()\n\n    Returns:\n        Formatted string with ANSI color codes for terminal display\n    \"\"\"\n    lines = []\n    categories = _categorize_palette(palette)\n\n    # Display in order defined by PALETTE_CATEGORIES\n    lines.append(\"   variable name prefix              |    dark mode  |  light mode\")\n    lines.append(\"-------------------------------------+---------------+--------------\")\n    for cat_name, _ in PALETTE_CATEGORIES:\n        items = categories.get(cat_name, [])\n        # Only show .dark.hex variants as base entries (skip .default, .rgb, .rgba, .hex_stripped)\n        dark_items = [k for k in items if k.endswith(\".dark.hex\")]\n        if not dark_items:\n            continue\n\n        display_name = CATEGORY_DISPLAY_NAMES.get(cat_name, cat_name.title())\n        lines.append(f\"\\n{display_name}:\")\n\n        for dark_key in dark_items:\n            # Get dark color\n            dark_value = palette[dark_key]\n            r_dark, g_dark, b_dark = hex_to_rgb(dark_value)\n            dark_swatch = f\"\\033[48;2;{r_dark};{g_dark};{b_dark}m    \\033[0m\"\n\n            # Derive light key and get light color\n            light_key = dark_key.replace(\".dark.hex\", \".light.hex\")\n            light_value = palette.get(light_key, \"\")\n            if light_value:\n                r_light, g_light, b_light = hex_to_rgb(light_value)\n                light_swatch = f\"\\033[48;2;{r_light};{g_light};{b_light}m    \\033[0m\"\n                light_part = f\"{light_swatch} {light_value}\"\n            else:\n                light_part = \"\"\n\n            # Two-column layout: dark | light\n            lines.append(f\"   {dark_key[:-9]:<35} {dark_swatch} {dark_value}  |  {light_part}\")\n\n    # Add filter examples\n    lines.append(\"\\nFilters:\")\n    lines.append(\"  set_alpha     {{ colors.primary.dark.hex | set_alpha: 0.5 }}\")\n    lines.append(\"  set_lightness {{ colors.primary.dark.hex | set_lightness: -20 }}\")\n\n    return \"\\n\".join(lines)\n\n\ndef generate_sample_palette(\n    base_rgb: tuple[int, int, int],\n    theme: Theme = Theme.DARK,\n) -> dict[str, str]:\n    \"\"\"Generate a sample palette from an RGB color.\n\n    Args:\n        base_rgb: Base color as RGB tuple (0-255 for each component)\n        theme: Theme to use (Theme.DARK or Theme.LIGHT)\n\n    Returns:\n        Palette dictionary with all color variables\n    \"\"\"\n    dominant_colors = [base_rgb] * 3\n\n    def process_color(rgb: tuple[int, int, int]) -> tuple[float, float, float]:\n        return colorsys.rgb_to_hls(rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0)\n\n    return generate_palette(dominant_colors, theme=theme, process_color=process_color)\n"
  },
  {
    "path": "pyprland/plugins/wallpapers/templates.py",
    "content": "\"\"\"Template processing for wallpapers plugin.\"\"\"\n\nimport colorsys\nimport contextlib\nimport logging\nimport re\nfrom typing import cast\n\nfrom ...aioops import aiexists, aiopen\nfrom ...process import create_subprocess\nfrom .imageutils import expand_path\nfrom .models import HEX_LEN, HEX_LEN_HASH\n\n\ndef _set_alpha(color: str, alpha: str) -> str:\n    \"\"\"Set alpha channel for a color.\n\n    Args:\n        color: Input color string\n        alpha: Alpha value to apply\n    \"\"\"\n    if color.startswith(\"rgba(\"):\n        return f\"{color.rsplit(',', 1)[0]}, {alpha})\"\n    if len(color) == HEX_LEN:\n        r, g, b = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16)\n        return f\"rgba({r}, {g}, {b}, {alpha})\"\n    if len(color) == HEX_LEN_HASH and color.startswith(\"#\"):\n        r, g, b = int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16)\n        return f\"rgba({r}, {g}, {b}, {alpha})\"\n    return color\n\n\ndef _set_lightness(hex_color: str, amount: str) -> str:\n    \"\"\"Adjust lightness of a color.\n\n    Args:\n        hex_color: Input color in hex format\n        amount: Lightness adjustment amount\n    \"\"\"\n    # hex_color can be RRGGBB or #RRGGBB\n    color = hex_color.lstrip(\"#\")\n    if len(color) != HEX_LEN:\n        return hex_color\n\n    r, g, b = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16)\n    h, l_val, s = colorsys.rgb_to_hls(r / 255.0, g / 255.0, b / 255.0)\n\n    # amount is percentage change, e.g. 20.0 or -10.0\n    with contextlib.suppress(ValueError):\n        l_val = max(0.0, min(1.0, l_val + (float(amount) / 100.0)))\n\n    nr, ng, nb = colorsys.hls_to_rgb(h, l_val, s)\n    new_hex = f\"{int(nr * 255):02x}{int(ng * 255):02x}{int(nb * 255):02x}\"\n    return f\"#{new_hex}\" if hex_color.startswith(\"#\") else new_hex\n\n\nasync def _apply_filters(content: str, replacements: dict[str, str]) -> str:\n    \"\"\"Apply filters to the content.\n\n    Args:\n        content: The text content to process\n        replacements: Dictionary of variable replacements\n    \"\"\"\n    # Process all template tags {{ ... }}\n    # We find all tags first, then replace them.\n    # This regex matches {{ variable | filter: arg }} or just {{ variable }}\n    # It handles spaces around variable and filter parts.\n    tag_pattern = re.compile(r\"\\{\\{\\s*([\\w\\.]+)(?:\\s*\\|\\s*([\\w_]+)\\s*(?:[:\\s])\\s*([^}]+))?\\s*\\}\\}\")\n\n    def replace_tag(match: re.Match) -> str:\n        key = match.group(1)\n        filter_name = match.group(2)\n        filter_arg = match.group(3)\n\n        value = replacements.get(key)\n        if value is None:\n            return cast(\"str\", match.group(0))\n\n        if filter_name and filter_arg:\n            filter_arg = filter_arg.strip()\n            with contextlib.suppress(ValueError):\n                if filter_name == \"set_alpha\":\n                    return _set_alpha(value, filter_arg)\n                if filter_name == \"set_lightness\":\n                    return _set_lightness(value, filter_arg)\n            # Fallback if filter fails or unknown\n            return str(value)\n\n        return str(value)\n\n    return tag_pattern.sub(replace_tag, content)\n\n\nclass TemplateEngine:\n    \"\"\"Handle template generation.\"\"\"\n\n    def __init__(self, log: logging.Logger) -> None:\n        \"\"\"Initialize the template engine.\n\n        Args:\n            log: Logger instance\n        \"\"\"\n        self.log = log\n\n    async def process_single_template(\n        self,\n        name: str,\n        template_config: dict[str, str],\n        replacements: dict[str, str],\n    ) -> None:\n        \"\"\"Process a single template.\n\n        Args:\n            name: Template name\n            template_config: Template configuration dictionary\n            replacements: Variable replacements dictionary\n        \"\"\"\n        if \"input_path\" not in template_config or \"output_path\" not in template_config:\n            self.log.error(\"Template %s missing input_path or output_path\", name)\n            return\n\n        input_path = expand_path(template_config[\"input_path\"])\n        output_path = expand_path(template_config[\"output_path\"])\n\n        if not await aiexists(input_path):\n            self.log.error(\"Template input file %s not found\", input_path)\n            return\n\n        try:\n            async with aiopen(input_path, \"r\") as f:\n                content = await f.read()\n\n            content = await _apply_filters(content, replacements)\n\n            async with aiopen(output_path, \"w\") as f:\n                await f.write(content)\n            self.log.info(\"Generated %s from %s\", output_path, input_path)\n\n            post_hook = template_config.get(\"post_hook\")\n            if post_hook:\n                self.log.info(\"Running post_hook for %s: %s\", name, post_hook)\n                await create_subprocess(post_hook)\n\n        except OSError:\n            self.log.exception(\"Error processing template %s\", name)\n"
  },
  {
    "path": "pyprland/plugins/wallpapers/theme.py",
    "content": "\"\"\"Theme detection and palette generation logic.\"\"\"\n\nimport asyncio\nimport colorsys\nimport logging\nfrom collections.abc import Callable\nfrom typing import cast\n\nfrom .imageutils import (\n    get_variant_color,\n    to_hex,\n    to_rgb,\n    to_rgba,\n)\nfrom .models import MATERIAL_VARIATIONS, ColorScheme, ColorVariant, MaterialColors, Theme, VariantConfig\n\n\nasync def detect_theme(logger: logging.Logger) -> Theme:\n    \"\"\"Detect the system theme (light/dark).\n\n    Args:\n        logger: Logger instance for debug messages\n    \"\"\"\n    # Try gsettings (GNOME/GTK)\n    try:\n        proc = await asyncio.create_subprocess_shell(\n            \"gsettings get org.gnome.desktop.interface color-scheme\",\n            stdout=asyncio.subprocess.PIPE,\n            stderr=asyncio.subprocess.PIPE,\n        )\n        stdout, _ = await proc.communicate()\n        if proc.returncode == 0:\n            output = stdout.decode().strip().lower()\n            if \"prefer-light\" in output or \"'light'\" in output:\n                return Theme.LIGHT\n            if \"prefer-dark\" in output or \"'dark'\" in output:\n                return Theme.DARK\n    except OSError:\n        logger.debug(\"gsettings not available for theme detection\")\n\n    # Try darkman\n    try:\n        proc = await asyncio.create_subprocess_shell(\n            \"darkman get\",\n            stdout=asyncio.subprocess.PIPE,\n            stderr=asyncio.subprocess.PIPE,\n        )\n        stdout, _ = await proc.communicate()\n        if proc.returncode == 0:\n            output = stdout.decode().strip().lower()\n            if output == \"light\":\n                return Theme.LIGHT\n            if output == \"dark\":\n                return Theme.DARK\n    except OSError:\n        logger.debug(\"darkman not available for theme detection\")\n\n    return Theme.DARK\n\n\ndef get_color_scheme_props(color_scheme: ColorScheme | str) -> dict[str, float]:\n    \"\"\"Return color scheme properties suitable for nicify_oklab.\n\n    Args:\n        color_scheme: The color scheme (ColorScheme enum or string value)\n    \"\"\"\n    # Convert string to ColorScheme if needed\n    if isinstance(color_scheme, ColorScheme):\n        scheme = color_scheme\n    else:\n        scheme_lower = color_scheme.lower()\n        # Handle \"fluorescent\" alias for \"fluo\"\n        if scheme_lower.startswith(\"fluo\"):\n            scheme = ColorScheme.FLUORESCENT\n        else:\n            try:\n                scheme = ColorScheme(scheme_lower)\n            except ValueError:\n                return {}  # Unknown scheme, return empty dict\n\n    # Scheme properties: min_sat, max_sat, min_light, max_light\n    scheme_props: dict[ColorScheme, dict[str, float]] = {\n        ColorScheme.PASTEL: {\n            \"min_sat\": 0.2,\n            \"max_sat\": 0.5,\n            \"min_light\": 0.6,\n            \"max_light\": 0.9,\n        },\n        ColorScheme.FLUORESCENT: {\n            \"min_sat\": 0.7,\n            \"max_sat\": 1.0,\n            \"min_light\": 0.4,\n            \"max_light\": 0.85,\n        },\n        ColorScheme.VIBRANT: {\n            \"min_sat\": 0.5,\n            \"max_sat\": 0.8,\n            \"min_light\": 0.4,\n            \"max_light\": 0.85,\n        },\n        ColorScheme.MELLOW: {\n            \"min_sat\": 0.3,\n            \"max_sat\": 0.5,\n            \"min_light\": 0.4,\n            \"max_light\": 0.85,\n        },\n        ColorScheme.NEUTRAL: {\n            \"min_sat\": 0.05,\n            \"max_sat\": 0.1,\n            \"min_light\": 0.4,\n            \"max_light\": 0.65,\n        },\n        ColorScheme.EARTH: {\n            \"min_sat\": 0.2,\n            \"max_sat\": 0.6,\n            \"min_light\": 0.2,\n            \"max_light\": 0.6,\n        },\n    }\n\n    return scheme_props.get(scheme, {})\n\n\ndef _get_rgb_for_variant(\n    l_val: str | float,\n    cur_h: float,\n    cur_s: float,\n    source_hls: tuple[float, float, float],\n) -> tuple[int, int, int]:\n    \"\"\"Get RGB color for a specific variant (lightness).\n\n    Args:\n        l_val: Lightness value or \"source\" to use source color\n        cur_h: Current hue\n        cur_s: Current saturation\n        source_hls: Source color in HLS format\n    \"\"\"\n    if l_val == \"source\":\n        r, g, b = colorsys.hls_to_rgb(*source_hls)\n        return int(r * 255), int(g * 255), int(b * 255)\n    return get_variant_color(cur_h, cur_s, float(l_val))\n\n\ndef _get_base_hs(\n    name: str,\n    mat_colors: MaterialColors,\n    h_off: float | str,\n    variant_type: str | None = None,\n) -> tuple[float, float, float | str]:\n    \"\"\"Determine base hue, saturation and offset for a color rule.\n\n    Args:\n        name: Name of the color rule\n        mat_colors: Material colors configuration\n        h_off: Hue offset\n        variant_type: Type of variant (e.g. \"islands\")\n    \"\"\"\n    used_h, used_s = mat_colors.primary\n    used_off = h_off\n\n    if variant_type == \"islands\":\n        if \"secondary\" in name and \"fixed\" not in name:\n            used_h, used_s = mat_colors.secondary\n            used_off = 0.0\n        elif \"tertiary\" in name and \"fixed\" not in name:\n            used_h, used_s = mat_colors.tertiary\n            used_off = 0.0\n    return used_h, used_s, used_off\n\n\ndef _populate_colors(\n    colors: dict[str, str],\n    name: str,\n    theme: Theme,\n    variant: ColorVariant,\n) -> None:\n    \"\"\"Populate the colors dict with dark, light and default variants.\n\n    Args:\n        colors: Dictionary to populate with color values\n        name: Name of the color variant\n        theme: Current theme (Theme.DARK or Theme.LIGHT)\n        variant: ColorVariant object containing dark and light RGB values\n    \"\"\"\n    r_dark, g_dark, b_dark = variant.dark\n    r_light, g_light, b_light = variant.light\n\n    # Dark variants\n    colors[f\"colors.{name}.dark\"] = to_hex(r_dark, g_dark, b_dark)\n    colors[f\"colors.{name}.dark.hex\"] = to_hex(r_dark, g_dark, b_dark)\n    colors[f\"colors.{name}.dark.hex_stripped\"] = to_hex(r_dark, g_dark, b_dark)[1:]\n    colors[f\"colors.{name}.dark.rgb\"] = to_rgb(r_dark, g_dark, b_dark)\n    colors[f\"colors.{name}.dark.rgba\"] = to_rgba(r_dark, g_dark, b_dark)\n\n    # Light variants\n    colors[f\"colors.{name}.light\"] = to_hex(r_light, g_light, b_light)\n    colors[f\"colors.{name}.light.hex\"] = to_hex(r_light, g_light, b_light)\n    colors[f\"colors.{name}.light.hex_stripped\"] = to_hex(r_light, g_light, b_light)[1:]\n    colors[f\"colors.{name}.light.rgb\"] = to_rgb(r_light, g_light, b_light)\n    colors[f\"colors.{name}.light.rgba\"] = to_rgba(r_light, g_light, b_light)\n\n    # Default (chosen)\n    if theme == Theme.DARK:\n        r_chosen, g_chosen, b_chosen = r_dark, g_dark, b_dark\n    else:\n        r_chosen, g_chosen, b_chosen = r_light, g_light, b_light\n\n    chosen_hex = to_hex(r_chosen, g_chosen, b_chosen)\n    colors[f\"colors.{name}\"] = chosen_hex\n    colors[f\"colors.{name}.default.hex\"] = chosen_hex\n    colors[f\"colors.{name}.default.hex_stripped\"] = chosen_hex[1:]\n    colors[f\"colors.{name}.default.rgb\"] = to_rgb(r_chosen, g_chosen, b_chosen)\n    colors[f\"colors.{name}.default.rgba\"] = to_rgba(r_chosen, g_chosen, b_chosen)\n\n\ndef _process_material_variant(\n    config: VariantConfig,\n    variant_type: str | None = None,\n) -> None:\n    \"\"\"Process a single material variant and populate colors.\n\n    Args:\n        config: Configuration for the variant\n        variant_type: Type of variant (optional)\n    \"\"\"\n    h_off, s_mult, l_dark, l_light = config.props\n    used_h, used_s, used_off = _get_base_hs(config.name, config.mat_colors, cast(\"float | str\", h_off), variant_type)\n\n    cur_h = float(used_off[1:]) if isinstance(used_off, str) and used_off.startswith(\"=\") else (used_h + float(used_off)) % 1.0\n    cur_s = max(0.0, min(1.0, used_s * s_mult))\n\n    rgb_dark = _get_rgb_for_variant(cast(\"float | str\", l_dark), cur_h, cur_s, config.source_hls)\n    rgb_light = _get_rgb_for_variant(cast(\"float | str\", l_light), cur_h, cur_s, config.source_hls)\n\n    _populate_colors(\n        config.colors,\n        config.name,\n        config.theme,\n        ColorVariant(\n            dark=rgb_dark,\n            light=rgb_light,\n        ),\n    )\n\n\ndef generate_palette(\n    rgb_list: list[tuple[int, int, int]],\n    process_color: Callable[[tuple[int, int, int]], tuple[float, float, float]],\n    theme: Theme = Theme.DARK,\n    variant_type: str | None = None,\n) -> dict[str, str]:\n    \"\"\"Generate a material-like palette from a single color.\n\n    Args:\n        rgb_list: List of RGB colors to use as base\n        process_color: Function to process/nicify colors\n        theme: Target theme (Theme.DARK or Theme.LIGHT)\n        variant_type: Variant type (optional)\n    \"\"\"\n    hue, light, sat = process_color(rgb_list[0])\n\n    if variant_type == \"islands\":\n        h_sec, _, s_sec = process_color(rgb_list[1])\n        h_tert, _, s_tert = process_color(rgb_list[2])\n    else:\n        h_sec, s_sec = hue, sat\n        h_tert, s_tert = hue, sat\n\n    colors: dict[str, str] = {\"scheme\": theme.value}\n    mat_colors = MaterialColors(primary=(hue, sat), secondary=(h_sec, s_sec), tertiary=(h_tert, s_tert))\n\n    for name, props in MATERIAL_VARIATIONS.items():\n        _process_material_variant(\n            VariantConfig(\n                name=name,\n                props=cast(\"tuple[float | str, float, float | str, float | str]\", props),\n                mat_colors=mat_colors,\n                source_hls=(hue, light, sat),\n                theme=theme,\n                colors=colors,\n            ),\n            variant_type,\n        )\n\n    return colors\n"
  },
  {
    "path": "pyprland/plugins/workspaces_follow_focus.py",
    "content": "\"\"\"Force workspaces to follow the focus / mouse.\"\"\"\n\nimport asyncio\nfrom typing import cast\n\nfrom ..models import Environment, ReloadReason\nfrom ..validation import ConfigField, ConfigItems\nfrom .interface import Plugin\n\n\nclass Extension(Plugin, environments=[Environment.HYPRLAND]):\n    \"\"\"Makes non-visible workspaces available on the currently focused screen.\"\"\"\n\n    config_schema = ConfigItems(\n        ConfigField(\"max_workspaces\", int, default=10, description=\"Maximum number of workspaces to manage\", category=\"basic\"),\n    )\n\n    workspace_list: list[int]\n\n    async def on_reload(self, reason: ReloadReason = ReloadReason.RELOAD) -> None:\n        \"\"\"Rebuild workspaces list.\"\"\"\n        _ = reason  # unused\n        self.workspace_list = list(range(1, self.get_config_int(\"max_workspaces\") + 1))\n\n    async def event_focusedmon(self, screenid_name: str) -> None:\n        \"\"\"Reacts to monitor changes.\n\n        Args:\n            screenid_name: The screen ID and name\n        \"\"\"\n        monitor_id, workspace_name = screenid_name.split(\",\")\n        await asyncio.sleep(0.1)\n        # move every free workspace to the currently focused desktop\n        busy_workspaces = {mon[\"activeWorkspace\"][\"name\"] for mon in await self.backend.get_monitors() if mon[\"name\"] != monitor_id}\n        workspaces = [w[\"name\"] for w in cast(\"list[dict]\", await self.backend.execute_json(\"workspaces\")) if w[\"id\"] > 0]\n\n        batch: list[str] = []\n        for n in workspaces:\n            if n in busy_workspaces or n == workspace_name:\n                continue\n            batch.append(f\"moveworkspacetomonitor name:{n} {monitor_id}\")\n        if batch:\n            await self.backend.execute(batch)\n\n    async def run_change_workspace(self, direction: str) -> None:\n        \"\"\"<direction> Switch workspaces of current monitor, avoiding displayed workspaces.\n\n        Args:\n            direction: Integer offset to move (e.g., +1 for next, -1 for previous)\n        \"\"\"\n        increment = int(direction)\n        # get focused screen info\n        monitors = await self.backend.get_monitors()\n        monitor = await self.get_focused_monitor_or_warn()\n        if monitor is None:\n            return\n        busy_workspaces = {m[\"activeWorkspace\"][\"id\"] for m in monitors if m[\"id\"] != monitor[\"id\"]}\n        cur_workspace = monitor[\"activeWorkspace\"][\"id\"]\n        available_workspaces = [i for i in self.workspace_list if i not in busy_workspaces]\n        try:\n            idx = available_workspaces.index(cur_workspace)\n        except ValueError:\n            next_workspace = available_workspaces[0 if increment > 0 else -1]\n        else:\n            next_workspace = available_workspaces[(idx + increment) % len(available_workspaces)]\n\n        await self.backend.execute(\n            [\n                f\"moveworkspacetomonitor name:{next_workspace} {monitor['name']}\",\n                f\"workspace {next_workspace}\",\n            ],\n            weak=True,\n        )\n"
  },
  {
    "path": "pyprland/process.py",
    "content": "\"\"\"Process lifecycle management utilities for spawning subprocesses.\n\nManagedProcess:\n    Manages a subprocess with proper lifecycle (SIGTERM -> wait -> SIGKILL).\n    Provides start/stop/restart and stdout iteration helpers.\n\nSupervisedProcess:\n    Extends ManagedProcess with automatic restart on crash, cooldown\n    periods to prevent restart loops, and crash event callbacks.\n\"\"\"\n\n__all__ = [\"ManagedProcess\", \"SupervisedProcess\", \"create_subprocess\"]\n\nimport asyncio\nimport contextlib\nfrom collections.abc import AsyncIterator, Callable, Coroutine\nfrom typing import Any\n\nfrom .debug import is_debug\n\n\nasync def create_subprocess(\n    *args: str,\n    shell: bool = True,\n    **kwargs: Any,\n) -> asyncio.subprocess.Process:\n    \"\"\"Create a subprocess with default output suppression.\n\n    Unless ``--debug`` is active, stdout and stderr default to\n    :data:`~asyncio.subprocess.DEVNULL`.  Callers can override by\n    passing ``stdout`` / ``stderr`` explicitly (e.g. ``stdout=PIPE``).\n\n    Args:\n        *args: Command string (shell=True) or program + args (shell=False).\n        shell: If True, use create_subprocess_shell (default).\n               If False, use create_subprocess_exec.\n        **kwargs: Forwarded to the underlying asyncio call.\n\n    Returns:\n        The created subprocess.\n    \"\"\"\n    if not is_debug():\n        kwargs.setdefault(\"stdout\", asyncio.subprocess.DEVNULL)\n        kwargs.setdefault(\"stderr\", asyncio.subprocess.DEVNULL)\n\n    if shell:\n        return await asyncio.create_subprocess_shell(args[0], **kwargs)\n    return await asyncio.create_subprocess_exec(*args, **kwargs)\n\n\nclass ManagedProcess:\n    \"\"\"Manages a subprocess with proper lifecycle handling.\n\n    Provides consistent start/stop behavior with graceful shutdown:\n    1. SIGTERM first (graceful)\n    2. Wait with timeout\n    3. SIGKILL if still alive\n    4. Always wait() to reap zombie\n\n    Usage:\n        proc = ManagedProcess()\n        await proc.start(\"sleep 100\")\n\n        # Access underlying process if needed\n        if proc.process and proc.process.stdout:\n            line = await proc.process.stdout.readline()\n\n        # Or use iter_lines() helper\n        async for line in proc.iter_lines():\n            print(line)\n\n        await proc.stop()\n    \"\"\"\n\n    def __init__(self, graceful_timeout: float = 1.0) -> None:\n        \"\"\"Initialize.\n\n        Args:\n            graceful_timeout: Seconds to wait after SIGTERM before SIGKILL\n        \"\"\"\n        self._proc: asyncio.subprocess.Process | None = None\n        self._command: str | None = None\n        self._graceful_timeout = graceful_timeout\n        self._subprocess_kwargs: dict[str, Any] = {}\n\n    @property\n    def pid(self) -> int | None:\n        \"\"\"Return PID if process exists, else None.\"\"\"\n        return self._proc.pid if self._proc else None\n\n    @property\n    def returncode(self) -> int | None:\n        \"\"\"Return exit code if process exited, else None.\"\"\"\n        return self._proc.returncode if self._proc else None\n\n    @property\n    def is_alive(self) -> bool:\n        \"\"\"Check if process is currently running.\"\"\"\n        return self._proc is not None and self._proc.returncode is None\n\n    @property\n    def process(self) -> asyncio.subprocess.Process | None:\n        \"\"\"Access underlying process for advanced use (e.g., stdin/stdout).\"\"\"\n        return self._proc\n\n    async def start(\n        self,\n        command: str,\n        **subprocess_kwargs: Any,\n    ) -> None:\n        \"\"\"Start the process. Stops existing process first if running.\n\n        Args:\n            command: Shell command to run\n            **subprocess_kwargs: Passed to create_subprocess_shell (e.g., stdout=PIPE)\n        \"\"\"\n        if self.is_alive:\n            await self.stop()\n\n        self._command = command\n        self._subprocess_kwargs = subprocess_kwargs\n        self._proc = await create_subprocess(command, **subprocess_kwargs)\n\n    async def stop(self) -> int | None:\n        \"\"\"Stop the process gracefully.\n\n        Shutdown sequence:\n        1. SIGTERM (graceful)\n        2. Wait up to graceful_timeout\n        3. SIGKILL if still alive\n        4. wait() to reap\n\n        Returns:\n            The process return code, or None if not running\n        \"\"\"\n        if self._proc is None:\n            return None\n\n        if self._proc.returncode is not None:\n            # Already exited\n            return self._proc.returncode\n\n        # 1. Try graceful termination\n        with contextlib.suppress(ProcessLookupError):\n            self._proc.terminate()\n\n        # 2. Wait with timeout\n        try:\n            await asyncio.wait_for(self._proc.wait(), timeout=self._graceful_timeout)\n        except TimeoutError:\n            # 3. Force kill if still alive\n            with contextlib.suppress(ProcessLookupError):\n                self._proc.kill()\n            await self._proc.wait()\n\n        return self._proc.returncode\n\n    async def restart(self) -> None:\n        \"\"\"Restart the process with the same command and kwargs.\n\n        Raises:\n            RuntimeError: If no command was previously set via start()\n        \"\"\"\n        if self._command is None:\n            msg = \"Cannot restart: no command was previously started\"\n            raise RuntimeError(msg)\n        await self.start(self._command, **self._subprocess_kwargs)\n\n    async def wait(self) -> int:\n        \"\"\"Wait for process to exit and return exit code.\n\n        Raises:\n            RuntimeError: If no process is running\n        \"\"\"\n        if self._proc is None:\n            msg = \"No process running\"\n            raise RuntimeError(msg)\n        return await self._proc.wait()\n\n    async def iter_lines(self) -> AsyncIterator[str]:\n        \"\"\"Iterate over stdout lines.\n\n        Requires process to be started with stdout=asyncio.subprocess.PIPE.\n\n        Yields:\n            Decoded, stripped lines from stdout\n\n        Raises:\n            RuntimeError: If process has no stdout pipe\n        \"\"\"\n        if self._proc is None or self._proc.stdout is None:\n            msg = \"No process or stdout not piped\"\n            raise RuntimeError(msg)\n\n        while self._proc.returncode is None:\n            line = await self._proc.stdout.readline()\n            if not line:\n                break\n            yield line.decode().strip()\n\n\nclass SupervisedProcess(ManagedProcess):\n    \"\"\"A ManagedProcess with automatic restart on crash.\n\n    Extends ManagedProcess with supervision capabilities:\n    - Automatic restart when process exits\n    - Cooldown period to prevent restart loops\n    - Configurable callbacks for crash events\n\n    Usage:\n        async def on_crash(proc, return_code):\n            log.warning(f\"Process crashed with code {return_code}\")\n\n        proc = SupervisedProcess(\n            cooldown=60.0,\n            on_crash=on_crash,\n        )\n        await proc.start(\"my-daemon\")\n\n        # Process will auto-restart on crash\n        # Call stop() to permanently stop\n        await proc.stop()\n    \"\"\"\n\n    def __init__(\n        self,\n        graceful_timeout: float = 1.0,\n        cooldown: float = 60.0,\n        min_runtime: float = 0.0,\n        on_crash: Callable[[\"SupervisedProcess\", int], Coroutine[Any, Any, None]] | None = None,\n    ) -> None:\n        \"\"\"Initialize.\n\n        Args:\n            graceful_timeout: Seconds to wait after SIGTERM before SIGKILL\n            cooldown: Minimum seconds between restarts (if process runs shorter, delay is added)\n            min_runtime: Process must run at least this long or cooldown is applied.\n                         Defaults to cooldown value if not specified.\n            on_crash: Async callback when process crashes (receives self and return_code)\n        \"\"\"\n        super().__init__(graceful_timeout)\n        self._cooldown = cooldown\n        self._min_runtime = min_runtime or cooldown\n        self._on_crash = on_crash\n        self._supervisor_task: asyncio.Task[None] | None = None\n        self._running = False\n\n    @property\n    def is_supervised(self) -> bool:\n        \"\"\"Check if supervision loop is active.\"\"\"\n        return self._running and self._supervisor_task is not None\n\n    async def start(\n        self,\n        command: str,\n        **subprocess_kwargs: Any,\n    ) -> None:\n        \"\"\"Start the supervised process.\n\n        Starts the process and begins supervision. If the process crashes,\n        it will be restarted automatically (subject to cooldown).\n\n        Args:\n            command: Shell command to run\n            **subprocess_kwargs: Passed to create_subprocess_shell\n        \"\"\"\n        # Stop any existing supervision\n        await self.stop()\n\n        self._command = command\n        self._subprocess_kwargs = subprocess_kwargs\n        self._running = True\n\n        self._supervisor_task = asyncio.create_task(self._supervise())\n\n    async def _supervise(self) -> None:\n        \"\"\"Internal supervision loop.\"\"\"\n        assert self._command is not None, \"_supervise called without command\"\n        while self._running:\n            start_time = asyncio.get_event_loop().time()\n\n            # Start the process\n            self._proc = await create_subprocess(\n                self._command,\n                **self._subprocess_kwargs,\n            )\n\n            # Wait for it to exit\n            await self._proc.wait()\n\n            # Check if we should continue supervision\n            # (self._running may have been set to False during stop())\n            if self._running:\n                # Process crashed - calculate delay\n                elapsed = asyncio.get_event_loop().time() - start_time\n\n                if self._on_crash:\n                    await self._on_crash(self, self._proc.returncode or -1)\n\n                if elapsed < self._min_runtime:\n                    # Crashed too quickly - apply cooldown\n                    delay = max(0.1, (self._cooldown - elapsed) / 2)\n                    await asyncio.sleep(delay)\n                else:\n                    # Ran long enough - restart immediately\n                    await asyncio.sleep(0.1)\n\n    async def stop(self) -> int | None:\n        \"\"\"Stop the supervised process permanently.\n\n        Cancels the supervision loop and stops the process.\n\n        Returns:\n            The process return code, or None if not running\n        \"\"\"\n        self._running = False\n\n        if self._supervisor_task:\n            self._supervisor_task.cancel()\n            with contextlib.suppress(asyncio.CancelledError):\n                await self._supervisor_task\n            self._supervisor_task = None\n\n        return await super().stop()\n"
  },
  {
    "path": "pyprland/pypr_daemon.py",
    "content": "\"\"\"Daemon startup functions for pyprland.\"\"\"\n\nimport asyncio\nimport itertools\nfrom pathlib import Path\n\nfrom pyprland.constants import CONTROL\nfrom pyprland.ipc import get_event_stream\nfrom pyprland.manager import Pyprland\nfrom pyprland.models import PyprError\n\n__all__ = [\"get_event_stream_with_retry\", \"run_daemon\"]\n\n\nasync def get_event_stream_with_retry(\n    max_retry: int = 10,\n) -> tuple[asyncio.StreamReader, asyncio.StreamWriter] | tuple[None, BaseException]:\n    \"\"\"Obtain the event stream, retrying if it fails.\n\n    If retry count is exhausted, returns (None, exception).\n\n    Args:\n        max_retry: Maximum number of retries\n    \"\"\"\n    err_count = itertools.count()\n    while True:\n        attempt = next(err_count)\n        try:\n            return await get_event_stream()\n        except (OSError, PyprError) as e:\n            if attempt > max_retry:\n                return None, e\n            await asyncio.sleep(1)\n\n\nasync def run_daemon() -> None:\n    \"\"\"Run the server / daemon.\"\"\"\n    manager = Pyprland()\n\n    # Ensure IPC folder exists (needed when no environment is running)\n    ipc_folder = Path(CONTROL).parent\n    try:\n        ipc_folder.mkdir(parents=True, exist_ok=True)\n    except OSError as e:\n        manager.log.critical(\"Cannot create IPC folder %s: %s\", ipc_folder, e)\n        return\n\n    result = await get_event_stream_with_retry()\n    if result[0] is None:\n        events_reader, events_writer = None, None\n        manager.log.warning(\"Failed to open hyprland event stream: %s.\", result[1])\n    else:\n        events_reader, events_writer = result\n        manager.event_reader = events_reader\n\n    await manager.initialize()\n\n    # Start server after initialization to avoid race conditions with plugin loading\n    manager.server = await asyncio.start_unix_server(manager.read_command, CONTROL)\n\n    manager.log.debug(\"[ initialized ]\".center(80, \"=\"))\n\n    try:\n        await manager.run()\n    except KeyboardInterrupt:\n        print(\"Interrupted\")\n    except asyncio.CancelledError:\n        manager.log.critical(\"cancelled\")\n    else:\n        await manager.exit_plugins()\n        if events_writer:\n            assert isinstance(events_writer, asyncio.StreamWriter)\n            events_writer.close()\n            await events_writer.wait_closed()\n        manager.server.close()\n        await manager.server.wait_closed()\n"
  },
  {
    "path": "pyprland/quickstart/__init__.py",
    "content": "\"\"\"Pyprland quickstart configuration wizard - standalone script.\"\"\"\n\nfrom __future__ import annotations\n\nimport sys\nfrom dataclasses import dataclass\nfrom pathlib import Path\n\n\n@dataclass\nclass Args:\n    \"\"\"Parsed command line arguments.\"\"\"\n\n    plugins: list[str] | None = None\n    dry_run: bool = False\n    output: Path | None = None\n\n\ndef parse_args(argv: list[str]) -> Args:\n    \"\"\"Parse command line arguments.\n\n    Args:\n        argv: Command line arguments (without program name)\n\n    Returns:\n        Parsed arguments\n    \"\"\"\n    args = Args()\n    i = 0\n    while i < len(argv):\n        arg = argv[i]\n        if arg == \"--plugins\" and i + 1 < len(argv):\n            args.plugins = [p.strip() for p in argv[i + 1].split(\",\")]\n            i += 2\n        elif arg == \"--dry-run\":\n            args.dry_run = True\n            i += 1\n        elif arg == \"--output\" and i + 1 < len(argv):\n            args.output = Path(argv[i + 1])\n            i += 2\n        else:\n            i += 1\n    return args\n\n\ndef print_help() -> None:\n    \"\"\"Print minimal help message.\"\"\"\n    print(\"Usage: pypr-quickstart [OPTIONS]\")\n    print()\n    print(\"Interactive configuration wizard for Pyprland.\")\n    print()\n    print(\"Options:\")\n    print(\"  --plugins PLUGINS   Comma-separated plugins to configure (skip selection)\")\n    print(\"  --dry-run           Preview config without writing\")\n    print(\"  --output PATH       Custom output path\")\n    print(\"  --help              Show this message\")\n\n\ndef main() -> None:\n    \"\"\"Entry point for pypr-quickstart command.\"\"\"\n    # Handle --help early\n    if \"--help\" in sys.argv or \"-h\" in sys.argv:\n        print_help()\n        sys.exit(0)\n\n    # Check questionary dependency\n    try:\n        import questionary  # noqa: F401, PLC0415  # pylint: disable=unused-import,import-outside-toplevel\n    except ImportError:\n        print(\"Error: The quickstart wizard requires additional dependencies.\")\n        print(\"Install with: pip install 'pyprland[quickstart]'\")\n        sys.exit(1)\n\n    from .wizard import run_wizard  # noqa: PLC0415  # pylint: disable=import-outside-toplevel\n\n    # Parse CLI args\n    args = parse_args(sys.argv[1:])\n\n    try:\n        run_wizard(\n            plugins=args.plugins,\n            dry_run=args.dry_run,\n            output=args.output,\n        )\n    except KeyboardInterrupt:\n        print(\"\\n\\nWizard cancelled.\")\n        sys.exit(1)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "pyprland/quickstart/__main__.py",
    "content": "\"\"\"Allow running the quickstart wizard as a module.\"\"\"\n\nfrom pyprland.quickstart import main\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "pyprland/quickstart/discovery.py",
    "content": "\"\"\"Standalone plugin discovery for the quickstart wizard.\"\"\"\n\nfrom __future__ import annotations\n\nimport importlib\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING\n\nif TYPE_CHECKING:\n    from pyprland.validation import ConfigItems\n\nPLUGINS_DIR = Path(__file__).parent.parent / \"plugins\"\nSKIP_PLUGINS = {\"interface\", \"protocols\", \"__init__\", \"experimental\", \"mixins\"}\n\n\n@dataclass\nclass PluginInfo:\n    \"\"\"Information about a discovered plugin.\"\"\"\n\n    name: str\n    description: str\n    config_schema: ConfigItems | None\n    environments: list[str]\n\n\ndef discover_plugins() -> list[PluginInfo]:\n    \"\"\"Discover all available plugins.\n\n    Scans the pyprland/plugins/ directory for plugin modules and packages,\n    loading their metadata without instantiating them.\n\n    Returns:\n        List of PluginInfo for all discovered plugins, sorted by name\n    \"\"\"\n    plugins = []\n\n    for item in PLUGINS_DIR.iterdir():\n        name = None\n        if item.is_file() and item.suffix == \".py\" and item.stem not in SKIP_PLUGINS:\n            name = item.stem\n        elif item.is_dir() and (item / \"__init__.py\").exists() and item.name not in SKIP_PLUGINS:\n            name = item.name\n\n        if name and name != \"pyprland\":\n            try:\n                info = load_plugin_info(name)\n                if info:\n                    plugins.append(info)\n            except Exception:  # noqa: BLE001, S110  # pylint: disable=broad-exception-caught\n                pass  # Skip plugins that fail to load - intentionally silent\n\n    return sorted(plugins, key=lambda p: p.name)\n\n\ndef load_plugin_info(name: str) -> PluginInfo | None:\n    \"\"\"Load plugin metadata without instantiating.\n\n    Args:\n        name: Plugin module name\n\n    Returns:\n        PluginInfo if successful, None if plugin doesn't have Extension class\n    \"\"\"\n    module = importlib.import_module(f\"pyprland.plugins.{name}\")\n    extension_class = getattr(module, \"Extension\", None)\n\n    if not extension_class:\n        return None\n\n    # Extract description from docstring\n    doc = extension_class.__doc__ or \"\"\n    description = doc.split(\"\\n\")[0].strip()\n\n    # Get schema and environments\n    schema = getattr(extension_class, \"config_schema\", None)\n    environments = getattr(extension_class, \"environments\", [])\n\n    return PluginInfo(\n        name=name,\n        description=description,\n        config_schema=schema,\n        environments=list(environments) if environments else [],\n    )\n\n\ndef filter_by_environment(plugins: list[PluginInfo], environment: str) -> list[PluginInfo]:\n    \"\"\"Filter plugins by environment compatibility.\n\n    Args:\n        plugins: List of plugins to filter\n        environment: Target environment (\"hyprland\", \"niri\", or \"other\")\n\n    Returns:\n        Filtered list of compatible plugins\n\n    For \"other\" environment, only returns plugins with empty environments list.\n    For \"hyprland\" or \"niri\", returns plugins that explicitly support that\n    environment OR have an empty environments list (universal plugins).\n    \"\"\"\n    if environment == \"other\":\n        return [p for p in plugins if not p.environments]\n    return [p for p in plugins if not p.environments or environment in p.environments]\n"
  },
  {
    "path": "pyprland/quickstart/generator.py",
    "content": "\"\"\"TOML configuration generator and file handling.\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport shutil\nimport tomllib\nfrom datetime import UTC, datetime\nfrom pathlib import Path\nfrom typing import Any\n\n# Default config paths - use XDG_CONFIG_HOME with fallback to ~/.config\n_xdg_config_home = Path(os.environ.get(\"XDG_CONFIG_HOME\") or Path.home() / \".config\")\nDEFAULT_CONFIG_PATH = _xdg_config_home / \"pypr\" / \"config.toml\"\nLEGACY_CONFIG_PATH = _xdg_config_home / \"hypr\" / \"pyprland.toml\"\n\n# Max items for inline dict rendering\nMAX_INLINE_DICT_ITEMS = 3\n\n\ndef get_config_path() -> Path:\n    \"\"\"Get the config path, checking for legacy location.\n\n    Returns:\n        Path to use for configuration\n    \"\"\"\n    if LEGACY_CONFIG_PATH.exists() and not DEFAULT_CONFIG_PATH.exists():\n        return LEGACY_CONFIG_PATH\n    return DEFAULT_CONFIG_PATH\n\n\ndef backup_config(config_path: Path) -> Path | None:\n    \"\"\"Create a timestamped backup of existing config.\n\n    Args:\n        config_path: Path to the config file\n\n    Returns:\n        Path to backup file, or None if no backup needed\n    \"\"\"\n    if not config_path.exists():\n        return None\n\n    timestamp = datetime.now(tz=UTC).strftime(\"%Y%m%d_%H%M%S\")\n    backup_path = config_path.with_suffix(f\".{timestamp}.bak\")\n\n    shutil.copy2(config_path, backup_path)\n    return backup_path\n\n\ndef format_toml_value(value: Any, indent: int = 0) -> str:  # noqa: PLR0911\n    # pylint: disable=too-many-return-statements\n    \"\"\"Format a Python value as TOML.\n\n    Args:\n        value: Value to format\n        indent: Current indentation level\n\n    Returns:\n        TOML string representation\n    \"\"\"\n    if value is None:\n        return '\"\"'\n\n    if isinstance(value, bool):\n        return \"true\" if value else \"false\"\n\n    if isinstance(value, int | float):\n        return str(value)\n\n    if isinstance(value, str):\n        # Escape special characters and wrap in quotes\n        escaped = value.replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n        if \"\\n\" in escaped:\n            # Use TOML multiline basic string for values containing newlines\n            return f'\"\"\"\\n{escaped}\"\"\"'\n        return f'\"{escaped}\"'\n\n    if isinstance(value, list):\n        if not value:\n            return \"[]\"\n        # Simple inline list for short items\n        if all(isinstance(x, str | int | float | bool) for x in value):\n            items = \", \".join(format_toml_value(x) for x in value)\n            return f\"[{items}]\"\n        # Multi-line list for complex items\n        items = \",\\n\".join(\"  \" * (indent + 1) + format_toml_value(x, indent + 1) for x in value)\n        return f\"[\\n{items}\\n\" + \"  \" * indent + \"]\"\n\n    if isinstance(value, dict):\n        # Inline table for simple dicts\n        items = \", \".join(f\"{k} = {format_toml_value(v)}\" for k, v in value.items())\n        return f\"{{ {items} }}\"\n\n    return f'\"{value!s}\"'\n\n\ndef generate_toml(config: dict) -> str:  # noqa: C901\n    \"\"\"Generate TOML string from configuration dict.\n\n    The config structure expected:\n    {\n        \"plugins\": [\"plugin1\", \"plugin2\"],\n        \"plugin1\": {...},\n        \"plugin2\": {...},\n        \"plugin2.subsection\": {...},  # For things like scratchpads.term\n    }\n\n    Args:\n        config: Configuration dictionary\n\n    Returns:\n        TOML formatted string\n    \"\"\"\n    lines = []\n\n    # Header comment\n    lines.append(\"# Pyprland configuration\")\n    lines.append(\"# Generated by pypr-quickstart\")\n    lines.append(f\"# {datetime.now(tz=UTC).strftime('%Y-%m-%d %H:%M:%S')} UTC\")\n    lines.append(\"\")\n\n    # Process pyprland core config first\n    if \"pyprland\" in config:\n        lines.append(\"[pyprland]\")\n        for key, value in config[\"pyprland\"].items():\n            lines.append(f\"{key} = {format_toml_value(value)}\")\n        lines.append(\"\")\n\n    # Process each plugin section\n    for section_name, section_data in config.items():\n        if section_name == \"pyprland\":\n            continue\n\n        if not isinstance(section_data, dict):\n            continue\n\n        lines.append(f\"[{section_name}]\")\n\n        # Separate simple values from subsections\n        simple_values = {}\n        subsections = {}\n\n        for key, value in section_data.items():\n            if isinstance(value, dict) and not _is_inline_dict(value):\n                subsections[key] = value\n            else:\n                simple_values[key] = value\n\n        # Write simple values\n        for key, value in simple_values.items():\n            lines.append(f\"{key} = {format_toml_value(value)}\")\n\n        lines.append(\"\")\n\n        # Write subsections\n        for sub_name, sub_data in subsections.items():\n            lines.append(f\"[{section_name}.{sub_name}]\")\n            for key, value in sub_data.items():\n                lines.append(f\"{key} = {format_toml_value(value)}\")\n            lines.append(\"\")\n\n    return \"\\n\".join(lines)\n\n\ndef _is_inline_dict(data: dict) -> bool:\n    \"\"\"Check if a dict should be rendered inline.\n\n    Small dicts with simple values are rendered inline.\n\n    Args:\n        data: Dictionary to check\n\n    Returns:\n        True if should be inline\n    \"\"\"\n    if len(data) > MAX_INLINE_DICT_ITEMS:\n        return False\n    return all(isinstance(v, str | int | float | bool) for v in data.values())\n\n\ndef write_config(\n    config: dict,\n    output_path: Path | None = None,\n    dry_run: bool = False,\n) -> tuple[Path, str]:\n    \"\"\"Write configuration to file.\n\n    Args:\n        config: Configuration dictionary\n        output_path: Custom output path (uses default if None)\n        dry_run: If True, don't write file\n\n    Returns:\n        Tuple of (path, toml_content)\n    \"\"\"\n    path = output_path or get_config_path()\n    content = generate_toml(config)\n\n    if not dry_run:\n        # Ensure directory exists\n        path.parent.mkdir(parents=True, exist_ok=True)\n        path.write_text(content, encoding=\"utf-8\")\n\n    return path, content\n\n\ndef merge_config(existing: dict, new: dict) -> dict:\n    \"\"\"Merge new config into existing config.\n\n    Args:\n        existing: Existing configuration\n        new: New configuration to merge\n\n    Returns:\n        Merged configuration\n    \"\"\"\n    result = existing.copy()\n\n    for key, value in new.items():\n        if key in result and isinstance(result[key], dict) and isinstance(value, dict):\n            result[key] = merge_config(result[key], value)\n        else:\n            result[key] = value\n\n    return result\n\n\ndef load_existing_config(path: Path | None = None) -> dict | None:\n    \"\"\"Load existing configuration if present.\n\n    Args:\n        path: Config path to check (uses default if None)\n\n    Returns:\n        Parsed config dict, or None if not found\n    \"\"\"\n    config_path = path or get_config_path()\n\n    if not config_path.exists():\n        return None\n\n    try:\n        with config_path.open(\"rb\") as f:\n            return tomllib.load(f)\n    except Exception:  # noqa: BLE001  # pylint: disable=broad-exception-caught\n        return None\n"
  },
  {
    "path": "pyprland/quickstart/helpers/__init__.py",
    "content": "\"\"\"Shared helper utilities for the quickstart wizard.\"\"\"\n\nfrom __future__ import annotations\n\nimport shutil\nimport subprocess\n\nfrom ...models import Environment\n\nTERMINALS = [\"kitty\", \"alacritty\", \"foot\", \"wezterm\", \"gnome-terminal\", \"konsole\", \"xterm\"]\n\nTERMINAL_COMMANDS: dict[str, str] = {\n    \"kitty\": \"kitty --class {class_name}\",\n    \"alacritty\": \"alacritty --class {class_name}\",\n    \"foot\": \"foot --app-id {class_name}\",\n    \"wezterm\": \"wezterm start --class {class_name}\",\n    \"gnome-terminal\": \"gnome-terminal --class={class_name}\",\n    \"konsole\": \"konsole --name {class_name}\",\n    \"xterm\": \"xterm -class {class_name}\",\n}\n\n\ndef detect_app(candidates: list[str]) -> str | None:\n    \"\"\"Return first installed app from candidates.\n\n    Args:\n        candidates: List of application names to check\n\n    Returns:\n        First found application name, or None if none installed\n    \"\"\"\n    for app in candidates:\n        if shutil.which(app):\n            return app\n    return None\n\n\ndef detect_terminal() -> str | None:\n    \"\"\"Detect installed terminal emulator.\n\n    Returns:\n        Name of first found terminal, or None\n    \"\"\"\n    return detect_app(TERMINALS)\n\n\ndef get_terminal_command(terminal: str, class_name: str) -> str:\n    \"\"\"Get terminal command with class name substituted.\n\n    Args:\n        terminal: Terminal name\n        class_name: Window class name to use\n\n    Returns:\n        Full command string with class name substituted\n    \"\"\"\n    template = TERMINAL_COMMANDS.get(terminal, terminal)\n    return template.format(class_name=class_name)\n\n\ndef detect_running_environment() -> Environment | None:\n    \"\"\"Auto-detect environment from running compositor.\n\n    Checks for running Hyprland or Niri by trying their CLI tools.\n\n    Returns:\n        Environment.HYPRLAND, Environment.NIRI, or None if neither detected\n    \"\"\"\n    # Try hyprctl\n    try:\n        result = subprocess.run(\n            [\"hyprctl\", \"version\"],\n            capture_output=True,\n            timeout=2,\n            check=False,\n        )\n        if result.returncode == 0:\n            return Environment.HYPRLAND\n    except (FileNotFoundError, subprocess.TimeoutExpired):\n        pass\n\n    # Try niri\n    try:\n        result = subprocess.run(\n            [\"niri\", \"msg\", \"version\"],\n            capture_output=True,\n            timeout=2,\n            check=False,\n        )\n        if result.returncode == 0:\n            return Environment.NIRI\n    except (FileNotFoundError, subprocess.TimeoutExpired):\n        pass\n\n    return None\n"
  },
  {
    "path": "pyprland/quickstart/helpers/monitors.py",
    "content": "\"\"\"Monitor detection and layout wizard.\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport subprocess\nfrom dataclasses import dataclass\n\nimport questionary\n\nfrom ...models import Environment\n\n# Minimum monitors needed for layout configuration\nMIN_MONITORS_FOR_LAYOUT = 2\n\n\n@dataclass\nclass DetectedMonitor:\n    \"\"\"Information about a detected monitor.\"\"\"\n\n    name: str\n    width: int\n    height: int\n    scale: float = 1.0\n\n\ndef detect_monitors_hyprland() -> list[DetectedMonitor]:\n    \"\"\"Detect monitors via hyprctl.\n\n    Returns:\n        List of detected monitors, empty if detection fails\n    \"\"\"\n    try:\n        result = subprocess.run(\n            [\"hyprctl\", \"monitors\", \"-j\"],\n            capture_output=True,\n            text=True,\n            timeout=5,\n            check=False,\n        )\n        if result.returncode == 0:\n            data = json.loads(result.stdout)\n            return [\n                DetectedMonitor(\n                    name=m[\"name\"],\n                    width=m[\"width\"],\n                    height=m[\"height\"],\n                    scale=m.get(\"scale\", 1.0),\n                )\n                for m in data\n            ]\n    except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError):\n        pass\n    return []\n\n\ndef detect_monitors_niri() -> list[DetectedMonitor]:\n    \"\"\"Detect monitors via niri msg.\n\n    Returns:\n        List of detected monitors, empty if detection fails\n    \"\"\"\n    try:\n        result = subprocess.run(\n            [\"niri\", \"msg\", \"-j\", \"outputs\"],\n            capture_output=True,\n            text=True,\n            timeout=5,\n            check=False,\n        )\n        if result.returncode == 0:\n            data = json.loads(result.stdout)\n            monitors = []\n            for name, info in data.items():\n                if info.get(\"current_mode\") is not None:\n                    mode_idx = info.get(\"current_mode\", 0)\n                    modes = info.get(\"modes\", [])\n                    mode = modes[mode_idx] if mode_idx < len(modes) else {}\n                    monitors.append(\n                        DetectedMonitor(\n                            name=name,\n                            width=mode.get(\"width\", 0),\n                            height=mode.get(\"height\", 0),\n                            scale=info.get(\"scale\", 1.0),\n                        )\n                    )\n            return monitors\n    except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError):\n        pass\n    return []\n\n\ndef detect_monitors(environment: str) -> list[DetectedMonitor]:\n    \"\"\"Detect monitors based on environment.\n\n    Args:\n        environment: \"hyprland\" or \"niri\"\n\n    Returns:\n        List of detected monitors\n    \"\"\"\n    if environment == Environment.HYPRLAND:\n        return detect_monitors_hyprland()\n    if environment == Environment.NIRI:\n        return detect_monitors_niri()\n    return []\n\n\ndef ask_monitor_layout(monitors: list[DetectedMonitor]) -> dict:\n    \"\"\"Interactive monitor layout wizard.\n\n    Asks the user about monitor arrangement and builds a placement config.\n\n    Args:\n        monitors: List of detected monitors\n\n    Returns:\n        Dict with placement configuration, empty if skipped or single monitor\n    \"\"\"\n    if not monitors:\n        questionary.print(\"No monitors detected. Skipping layout configuration.\", style=\"fg:yellow\")\n        return {}\n\n    # Show detected monitors\n    questionary.print(\"\\nDetected monitors:\", style=\"bold\")\n    for m in monitors:\n        questionary.print(f\"  - {m.name} ({m.width}x{m.height}, scale {m.scale})\")\n    print()\n\n    if len(monitors) < MIN_MONITORS_FOR_LAYOUT:\n        questionary.print(\"Single monitor detected. No layout needed.\", style=\"fg:ansigray\")\n        return {}\n\n    # Ask layout type\n    layout = questionary.select(\n        \"How should your monitors be arranged?\",\n        choices=[\n            \"Side by side (left to right)\",\n            \"Side by side (right to left)\",\n            \"Stacked (top to bottom)\",\n            \"Stacked (bottom to top)\",\n            \"Skip (configure manually)\",\n        ],\n    ).ask()\n\n    if layout is None or \"Skip\" in layout:\n        return {}\n\n    # Ask which monitor should be first\n    monitor_names = [m.name for m in monitors]\n    first = questionary.select(\n        \"Which monitor should be first (leftmost/topmost)?\",\n        choices=monitor_names,\n    ).ask()\n\n    if first is None:\n        return {}\n\n    # Build placement config\n    # Order monitors: first one, then the rest\n    order = [first, *[n for n in monitor_names if n != first]]\n\n    # Determine direction based on layout choice\n    if \"left to right\" in layout:\n        direction = \"rightof\"\n    elif \"right to left\" in layout:\n        direction = \"leftof\"\n    elif \"top to bottom\" in layout:\n        direction = \"bottomof\"\n    else:  # bottom to top\n        direction = \"topof\"\n\n    # Build placement: each monitor (except first) is placed relative to previous\n    placement: dict[str, dict[str, str]] = {}\n    for i, name in enumerate(order[1:], 1):\n        placement[name] = {direction: order[i - 1]}\n\n    return {\"placement\": placement}\n"
  },
  {
    "path": "pyprland/quickstart/helpers/scratchpads.py",
    "content": "\"\"\"Scratchpad presets and configuration wizard.\"\"\"\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\nfrom typing import Any\n\nimport questionary\n\nfrom . import TERMINAL_COMMANDS, detect_app, detect_terminal, get_terminal_command\n\n# Preset scratchpad configurations\n# Each preset includes:\n#   - label: Human-readable name\n#   - detect: List of apps to detect (first found is used)\n#   - needs_terminal: Whether app needs to run in terminal\n#   - class_name: Window class to use for matching\n#   - animation: Default animation direction\n#   - size: Default size as percentage or absolute\n\nSCRATCHPAD_PRESETS: dict[str, dict[str, Any]] = {\n    \"term\": {\n        \"label\": \"Dropdown terminal\",\n        \"detect\": [\"kitty\", \"alacritty\", \"foot\", \"wezterm\", \"gnome-terminal\", \"konsole\"],\n        \"needs_terminal\": False,  # It IS a terminal\n        \"class_name\": \"dropterm\",\n        \"animation\": \"fromtop\",\n        \"size\": \"75% 60%\",\n    },\n    \"files\": {\n        \"label\": \"File manager\",\n        \"detect\": [\"thunar\", \"nautilus\", \"dolphin\", \"pcmanfm\", \"nemo\", \"caja\"],\n        \"needs_terminal\": False,\n        \"class_name\": \"files-scratch\",\n        \"animation\": \"fromright\",\n        \"size\": \"60% 70%\",\n    },\n    \"music\": {\n        \"label\": \"Music player\",\n        \"detect\": [\"spotify\", \"rhythmbox\", \"clementine\", \"strawberry\", \"lollypop\", \"elisa\"],\n        \"needs_terminal\": False,\n        \"class_name\": \"music-scratch\",\n        \"animation\": \"fromright\",\n        \"size\": \"50% 60%\",\n    },\n    \"volume\": {\n        \"label\": \"Volume mixer\",\n        \"detect\": [\"pavucontrol\", \"pavucontrol-qt\", \"easyeffects\"],\n        \"needs_terminal\": False,\n        \"class_name\": \"volume-scratch\",\n        \"animation\": \"fromright\",\n        \"size\": \"40% 50%\",\n    },\n    \"monitor\": {\n        \"label\": \"System monitor\",\n        \"detect\": [\"btop\", \"htop\", \"top\", \"gotop\", \"gtop\"],\n        \"needs_terminal\": True,\n        \"class_name\": \"monitor-scratch\",\n        \"animation\": \"fromtop\",\n        \"size\": \"80% 70%\",\n    },\n    \"calc\": {\n        \"label\": \"Calculator\",\n        \"detect\": [\"qalculate-gtk\", \"gnome-calculator\", \"kcalc\", \"galculator\", \"speedcrunch\"],\n        \"needs_terminal\": False,\n        \"class_name\": \"calc-scratch\",\n        \"animation\": \"fromright\",\n        \"size\": \"30% 40%\",\n    },\n    \"notes\": {\n        \"label\": \"Notes\",\n        \"detect\": [\"obsidian\", \"logseq\", \"joplin\", \"simplenote\", \"standard-notes\"],\n        \"needs_terminal\": False,\n        \"class_name\": \"notes-scratch\",\n        \"animation\": \"fromright\",\n        \"size\": \"50% 70%\",\n    },\n    \"passwords\": {\n        \"label\": \"Password manager\",\n        \"detect\": [\"keepassxc\", \"bitwarden\", \"1password\"],\n        \"needs_terminal\": False,\n        \"class_name\": \"passwords-scratch\",\n        \"animation\": \"fromright\",\n        \"size\": \"40% 50%\",\n    },\n}\n\n\n@dataclass\nclass ScratchpadConfig:\n    \"\"\"Configuration for a single scratchpad.\"\"\"\n\n    name: str\n    command: str\n    class_name: str\n    animation: str = \"fromtop\"\n    size: str = \"75% 60%\"\n    lazy: bool = True\n\n\ndef detect_available_presets() -> list[tuple[str, str, str]]:\n    \"\"\"Detect which preset apps are available on the system.\n\n    Returns:\n        List of tuples: (preset_name, label, detected_app)\n    \"\"\"\n    available = []\n    for name, preset in SCRATCHPAD_PRESETS.items():\n        app = detect_app(preset[\"detect\"])\n        if app:\n            available.append((name, preset[\"label\"], app))\n    return available\n\n\ndef build_command(\n    app: str,\n    class_name: str,\n    needs_terminal: bool,\n    terminal: str | None = None,\n) -> str:\n    \"\"\"Build command string for a scratchpad.\n\n    Args:\n        app: Application to run\n        class_name: Window class name\n        needs_terminal: Whether app needs to run in terminal\n        terminal: Terminal to use (if needs_terminal is True)\n\n    Returns:\n        Full command string\n    \"\"\"\n    if needs_terminal:\n        if not terminal:\n            terminal = detect_terminal()\n        if terminal and terminal in TERMINAL_COMMANDS:\n            base = get_terminal_command(terminal, class_name)\n            return f\"{base} {app}\"\n        # Fallback\n        return f\"{terminal or 'xterm'} -e {app}\"\n\n    # Check if app is a terminal itself (for the \"term\" preset)\n    if app in TERMINAL_COMMANDS:\n        return get_terminal_command(app, class_name)\n\n    # Regular app - just return app name (class detection will be by process)\n    return app\n\n\ndef create_preset_config(\n    preset_name: str,\n    app: str,\n    terminal: str | None = None,\n) -> ScratchpadConfig:\n    \"\"\"Create a ScratchpadConfig from a preset.\n\n    Args:\n        preset_name: Name of the preset (key in SCRATCHPAD_PRESETS)\n        app: Detected or user-specified application\n        terminal: Terminal to use for terminal-based apps\n\n    Returns:\n        Configured ScratchpadConfig\n    \"\"\"\n    preset = SCRATCHPAD_PRESETS[preset_name]\n\n    command = build_command(\n        app,\n        preset[\"class_name\"],\n        preset[\"needs_terminal\"],\n        terminal,\n    )\n\n    return ScratchpadConfig(\n        name=preset_name,\n        command=command,\n        class_name=preset[\"class_name\"],\n        animation=preset[\"animation\"],\n        size=preset[\"size\"],\n        lazy=True,\n    )\n\n\ndef ask_scratchpads() -> list[ScratchpadConfig]:\n    \"\"\"Interactive wizard to configure scratchpads.\n\n    Returns:\n        List of ScratchpadConfig objects for selected scratchpads\n    \"\"\"\n    # Detect available apps\n    available = detect_available_presets()\n\n    if not available:\n        questionary.print(\n            \"No common scratchpad applications detected. You can add scratchpads manually later.\",\n            style=\"fg:yellow\",\n        )\n        return []\n\n    # Show available presets\n    questionary.print(\"\\nDetected applications for scratchpads:\", style=\"bold\")\n    choices = []\n    for name, label, app in available:\n        choices.append(\n            questionary.Choice(\n                title=f\"{label} ({app})\",\n                value=name,\n                checked=name == \"term\",  # Pre-select terminal by default\n            )\n        )\n\n    # Let user select which to configure\n    selected = questionary.checkbox(\n        \"Which scratchpads would you like to set up?\",\n        choices=choices,\n    ).ask()\n\n    if selected is None:  # User cancelled\n        return []\n\n    if not selected:\n        return []\n\n    # Build configs for selected presets\n    terminal = detect_terminal()\n    configs = []\n\n    for preset_name in selected:\n        # Find the detected app for this preset\n        found_app = next((a for n, _, a in available if n == preset_name), None)\n        if found_app is not None:\n            config = create_preset_config(preset_name, found_app, terminal)\n            configs.append(config)\n\n    return configs\n\n\ndef scratchpad_to_dict(config: ScratchpadConfig) -> dict:\n    \"\"\"Convert ScratchpadConfig to dict for TOML generation.\n\n    Args:\n        config: ScratchpadConfig object\n\n    Returns:\n        Dict representation for TOML\n    \"\"\"\n    return {\n        \"command\": config.command,\n        \"class\": config.class_name,\n        \"animation\": config.animation,\n        \"size\": config.size,\n        \"lazy\": config.lazy,\n    }\n"
  },
  {
    "path": "pyprland/quickstart/questions.py",
    "content": "\"\"\"Convert ConfigField schema to questionary questions.\"\"\"\n\nfrom __future__ import annotations\n\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, Any, get_args, get_origin\n\nimport questionary\nfrom questionary import Choice\n\nif TYPE_CHECKING:\n    from pyprland.validation import ConfigField, ConfigItems\n\n\ndef _is_path_type(field_type: type | tuple) -> bool:\n    \"\"\"Check if field_type is Path or includes Path in a union.\"\"\"\n    if field_type is Path:\n        return True\n    if isinstance(field_type, tuple):\n        return Path in field_type\n    return False\n\n\ndef _is_path_list_type(field_type: type | tuple) -> bool:\n    \"\"\"Check if field_type is list[Path].\"\"\"\n    origin = get_origin(field_type)\n    if origin is list:\n        args = get_args(field_type)\n        return bool(args and args[0] is Path)\n    return False\n\n\ndef field_to_question(field: ConfigField, current_value: Any = None) -> Any | None:  # noqa: C901\n    \"\"\"Convert a ConfigField to a questionary question and ask it.\n\n    Args:\n        field: The ConfigField to convert\n        current_value: Current value if editing existing config\n\n    Returns:\n        The user's answer, or None if skipped/cancelled\n    \"\"\"\n    default = current_value if current_value is not None else field.default\n\n    # Build the question text\n    question_text = field.description or f\"Enter {field.name}\"\n    if field.required:\n        question_text = f\"* {question_text}\"\n    elif field.recommended:\n        question_text = f\"[Recommended] {question_text}\"\n\n    # Dispatch based on field type\n    result: Any | None = None\n\n    if field.choices:\n        result = _ask_choice(question_text, field.choices, default)\n    elif field.field_type is bool:\n        result = _ask_bool(question_text, default)\n    elif field.field_type is int:\n        result = _ask_int(question_text, default)\n    elif field.field_type is float:\n        result = _ask_float(question_text, default)\n    elif _is_path_list_type(field.field_type):\n        result = _ask_path_list(question_text, default, only_directories=field.is_directory)\n    elif _is_path_type(field.field_type):\n        result = _ask_path(question_text, default, only_directories=field.is_directory)\n    elif field.field_type is list:\n        result = _ask_list(question_text, default)\n    elif not (field.field_type is dict and field.children):\n        # Default to text input for str and other types\n        # (dict with children is skipped - handled elsewhere)\n        result = _ask_text(question_text, default)\n\n    return result\n\n\ndef _ask_choice(question: str, choices: list, default: Any) -> Any | None:\n    \"\"\"Ask user to select from choices.\"\"\"\n    # Build choice objects\n    q_choices = [Choice(title=str(c), value=c, checked=c == default) for c in choices]\n\n    return questionary.select(\n        question,\n        choices=q_choices,\n        default=default,\n    ).ask()\n\n\ndef _ask_bool(question: str, default: Any) -> bool | None:\n    \"\"\"Ask yes/no question.\"\"\"\n    default_bool = bool(default) if default is not None else False\n    result = questionary.confirm(question, default=default_bool).ask()\n    if result is None:\n        return None\n    return bool(result)\n\n\ndef _ask_int(question: str, default: Any) -> int | None:\n    \"\"\"Ask for integer input.\"\"\"\n    default_str = str(default) if default is not None else \"\"\n\n    while True:\n        result = questionary.text(\n            question,\n            default=default_str,\n        ).ask()\n\n        if result is None:  # Cancelled\n            return None\n\n        if result == \"\":  # Empty = skip\n            return None\n\n        try:\n            return int(result)\n        except ValueError:\n            questionary.print(\"Please enter a valid integer.\", style=\"fg:red\")\n\n\ndef _ask_float(question: str, default: Any) -> float | None:\n    \"\"\"Ask for float input.\"\"\"\n    default_str = str(default) if default is not None else \"\"\n\n    while True:\n        result = questionary.text(\n            question,\n            default=default_str,\n        ).ask()\n\n        if result is None:  # Cancelled\n            return None\n\n        if result == \"\":  # Empty = skip\n            return None\n\n        try:\n            return float(result)\n        except ValueError:\n            questionary.print(\"Please enter a valid number.\", style=\"fg:red\")\n\n\ndef _ask_text(question: str, default: Any) -> str | None:\n    \"\"\"Ask for text input.\"\"\"\n    default_str = str(default) if default is not None else \"\"\n    result = questionary.text(question, default=default_str).ask()\n    return result or None\n\n\ndef _ask_list(question: str, default: Any) -> list | None:\n    \"\"\"Ask for list input (comma-separated).\"\"\"\n    default_str = \", \".join(str(x) for x in default) if isinstance(default, list) else str(default) if default else \"\"\n\n    result = questionary.text(\n        f\"{question} (comma-separated)\",\n        default=default_str,\n    ).ask()\n\n    if result is None:  # Cancelled\n        return None\n\n    if result == \"\":  # Empty = empty list or skip\n        return []\n\n    # Parse comma-separated values\n    return [item.strip() for item in result.split(\",\") if item.strip()]\n\n\ndef _ask_path(question: str, default: Any, only_directories: bool = False) -> str | None:\n    \"\"\"Ask for a single path with filesystem autocompletion.\"\"\"\n    default_str = str(default) if default is not None else \"\"\n    result = questionary.path(\n        question,\n        default=default_str,\n        only_directories=only_directories,\n    ).ask()\n    return result or None\n\n\ndef _ask_path_list(question: str, default: Any, only_directories: bool = False) -> list[str] | None:\n    \"\"\"Ask for multiple paths one at a time with filesystem autocompletion.\"\"\"\n    paths: list[str] = []\n\n    # Display the main question\n    questionary.print(f\"{question}:\", style=\"bold\")\n\n    # Show existing defaults\n    if isinstance(default, list) and default:\n        questionary.print(f\"  Current: {', '.join(str(p) for p in default)}\", style=\"fg:gray\")\n\n    questionary.print(\"  (Enter paths one at a time, empty to finish)\", style=\"fg:gray\")\n\n    count = 1\n    while True:\n        result = questionary.path(\n            f\"  Path {count}:\",\n            default=\"\",\n            only_directories=only_directories,\n        ).ask()\n\n        if result is None:  # Cancelled (Ctrl+C)\n            return None\n\n        if result == \"\":  # Empty = done\n            break\n\n        paths.append(result)\n        count += 1\n\n    # If no paths entered but had defaults, keep defaults\n    if not paths and isinstance(default, list):\n        return [str(p) for p in default]\n\n    return paths or []\n\n\ndef ask_plugin_options(  # noqa: C901\n    plugin_name: str,\n    schema: ConfigItems | None,\n    only_required: bool = False,\n    only_recommended: bool = True,\n) -> dict:\n    \"\"\"Ask questions for all relevant fields in a plugin schema.\n\n    Args:\n        plugin_name: Name of the plugin (for display)\n        schema: The plugin's config_schema\n        only_required: If True, only ask required fields\n        only_recommended: If True, also ask recommended fields (not just required)\n\n    Returns:\n        Dict of field names to values\n    \"\"\"\n    if not schema:\n        return {}\n\n    questionary.print(f\"\\nConfiguring {plugin_name}:\", style=\"bold\")\n\n    result = {}\n\n    # Group fields by category if available\n    categorized: dict[str, list] = {}\n    uncategorized: list = []\n\n    for field in schema:\n        if field.category:\n            if field.category not in categorized:\n                categorized[field.category] = []\n            categorized[field.category].append(field)\n        else:\n            uncategorized.append(field)\n\n    # Process uncategorized first\n    for field in uncategorized:\n        value = _process_field(field, only_required, only_recommended)\n        if value is not None:\n            result[field.name] = value\n\n    # Process each category\n    for fields in categorized.values():\n        category_values = {}\n        for field in fields:\n            value = _process_field(field, only_required, only_recommended)\n            if value is not None:\n                category_values[field.name] = value\n\n        if category_values:\n            result.update(category_values)\n\n    return result\n\n\ndef _process_field(\n    field: ConfigField,\n    only_required: bool,\n    only_recommended: bool,\n) -> Any | None:\n    \"\"\"Process a single field and return its value if applicable.\"\"\"\n    # Determine if we should ask this field\n    if only_required and not field.required:\n        return None\n\n    # Ask required and recommended fields (when only_recommended is True)\n    if not only_required and only_recommended and not field.required and not field.recommended:\n        return None\n\n    # Skip complex dict fields for now (nested configs)\n    if field.field_type is dict and field.children:\n        return None\n\n    return field_to_question(field)\n"
  },
  {
    "path": "pyprland/quickstart/wizard.py",
    "content": "\"\"\"Main wizard flow for pypr-quickstart.\"\"\"\n\nfrom __future__ import annotations\n\nfrom typing import TYPE_CHECKING\n\nimport questionary\nfrom questionary import Choice\n\nfrom ..models import Environment\nfrom .discovery import PluginInfo, discover_plugins, filter_by_environment\nfrom .generator import (\n    backup_config,\n    generate_toml,\n    get_config_path,\n    load_existing_config,\n    write_config,\n)\nfrom .helpers import detect_running_environment\nfrom .helpers.monitors import ask_monitor_layout, detect_monitors\nfrom .helpers.scratchpads import ask_scratchpads, scratchpad_to_dict\nfrom .questions import ask_plugin_options\n\nif TYPE_CHECKING:\n    from pathlib import Path\n\n# Max description length before truncation\nMAX_DESC_LENGTH = 60\n\n\ndef print_banner() -> None:\n    \"\"\"Print the wizard banner.\"\"\"\n    questionary.print(\"\\n╭─────────────────────────────────────╮\", style=\"bold fg:cyan\")\n    questionary.print(\"│     Pyprland Quickstart Wizard      │\", style=\"bold fg:cyan\")\n    questionary.print(\"╰─────────────────────────────────────╯\\n\", style=\"bold fg:cyan\")\n\n\ndef ask_environment() -> str | None:\n    \"\"\"Ask user to select their environment.\n\n    Returns:\n        \"hyprland\", \"niri\", \"other\", or None if cancelled\n    \"\"\"\n    detected = detect_running_environment()\n\n    choices = [\n        Choice(title=\"Hyprland\", value=Environment.HYPRLAND),\n        Choice(title=\"Niri\", value=Environment.NIRI),\n        Choice(title=\"Other / Not running\", value=\"other\"),\n    ]\n\n    # If detected, move it to top and mark as detected\n    if detected:\n        choices = [c for c in choices if c.value != detected]\n        choices.insert(\n            0,\n            Choice(\n                title=f\"{str(detected).capitalize()} (detected)\",\n                value=detected,\n            ),\n        )\n        questionary.print(f\"Detected: {str(detected).capitalize()}\", style=\"fg:green\")\n\n    result = questionary.select(\n        \"Which compositor are you using?\",\n        choices=choices,\n        default=detected or Environment.HYPRLAND,\n    ).ask()\n    if result is None:\n        return None\n    return str(result)\n\n\ndef ask_plugins(plugins: list[PluginInfo]) -> list[PluginInfo]:\n    \"\"\"Ask user to select plugins to configure.\n\n    Args:\n        plugins: Available plugins for the environment\n\n    Returns:\n        List of selected plugins\n    \"\"\"\n    if not plugins:\n        questionary.print(\"No plugins available for your environment.\", style=\"fg:yellow\")\n        return []\n\n    questionary.print(\"\\nAvailable plugins:\", style=\"bold\")\n\n    choices = []\n    for plugin in plugins:\n        desc = plugin.description or \"No description\"\n        # Truncate long descriptions\n        if len(desc) > MAX_DESC_LENGTH:\n            desc = desc[: MAX_DESC_LENGTH - 3] + \"...\"\n        choices.append(\n            Choice(\n                title=f\"{plugin.name}: {desc}\",\n                value=plugin.name,\n            )\n        )\n\n    selected_names = questionary.checkbox(\n        \"Which plugins would you like to enable?\",\n        choices=choices,\n    ).ask()\n\n    if selected_names is None:\n        return []\n\n    return [p for p in plugins if p.name in selected_names]\n\n\ndef configure_scratchpads(plugin: PluginInfo, environment: str) -> dict:\n    \"\"\"Run the scratchpads configuration wizard.\n\n    Args:\n        plugin: Plugin info (unused, for consistent signature)\n        environment: Current environment (unused, for consistent signature)\n\n    Returns:\n        Scratchpads section config dict\n    \"\"\"\n    _ = plugin, environment  # Unused, but required for consistent handler signature\n\n    questionary.print(\"\\n── Scratchpads Configuration ──\", style=\"bold\")\n\n    scratchpads = ask_scratchpads()\n\n    if not scratchpads:\n        return {}\n\n    config = {}\n    for scratch in scratchpads:\n        config[scratch.name] = scratchpad_to_dict(scratch)\n\n    return config\n\n\ndef configure_monitors(plugin: PluginInfo, environment: str) -> dict:\n    \"\"\"Run the monitors configuration wizard.\n\n    Args:\n        plugin: Plugin info (unused, for consistent signature)\n        environment: Current environment\n\n    Returns:\n        Monitors plugin config\n    \"\"\"\n    _ = plugin  # Unused, but required for consistent handler signature\n\n    questionary.print(\"\\n── Monitors Configuration ──\", style=\"bold\")\n\n    monitors = detect_monitors(environment)\n    return ask_monitor_layout(monitors)\n\n\n# Plugin-specific configuration wizards\n# Each handler takes (PluginInfo, environment) and returns config dict\nPLUGIN_WIZARDS = {\n    \"scratchpads\": configure_scratchpads,\n    \"monitors\": configure_monitors,\n}\n\n\ndef configure_plugin(plugin: PluginInfo, environment: str) -> dict:\n    \"\"\"Configure a single plugin.\n\n    Args:\n        plugin: Plugin to configure\n        environment: Current environment\n\n    Returns:\n        Plugin configuration dict\n    \"\"\"\n    # Use special wizard if available\n    if plugin.name in PLUGIN_WIZARDS:\n        return PLUGIN_WIZARDS[plugin.name](plugin, environment)\n\n    # Generic configuration from schema\n    if plugin.config_schema:\n        return ask_plugin_options(plugin.name, plugin.config_schema)\n\n    return {}\n\n\ndef handle_existing_config(config_path: Path) -> bool:\n    \"\"\"Handle existing configuration file.\n\n    Args:\n        config_path: Path to check\n\n    Returns:\n        True if should continue, False to abort\n    \"\"\"\n    existing = load_existing_config(config_path)\n\n    if existing:\n        questionary.print(\n            f\"\\nExisting config found at: {config_path}\",\n            style=\"fg:yellow\",\n        )\n\n        action = questionary.select(\n            \"What would you like to do?\",\n            choices=[\n                Choice(title=\"Create backup and overwrite\", value=\"overwrite\"),\n                Choice(title=\"Merge with existing config\", value=\"merge\"),\n                Choice(title=\"Cancel\", value=\"cancel\"),\n            ],\n        ).ask()\n\n        if action == \"cancel\" or action is None:\n            return False\n\n        if action == \"overwrite\":\n            backup_path = backup_config(config_path)\n            if backup_path:\n                questionary.print(f\"Backup created: {backup_path}\", style=\"fg:green\")\n\n    return True\n\n\ndef build_config(\n    plugins: list[PluginInfo],\n    environment: str,\n) -> dict:\n    \"\"\"Build the full configuration dictionary.\n\n    Args:\n        plugins: Selected plugins\n        environment: Current environment\n\n    Returns:\n        Complete configuration dict\n    \"\"\"\n    config = {\n        \"pyprland\": {\n            \"plugins\": [p.name for p in plugins],\n        },\n    }\n\n    for plugin in plugins:\n        plugin_config = configure_plugin(plugin, environment)\n        if plugin_config:\n            config[plugin.name] = plugin_config\n\n    return config\n\n\ndef run_wizard(\n    plugins: list[str] | None = None,\n    dry_run: bool = False,\n    output: Path | None = None,\n) -> None:\n    \"\"\"Run the configuration wizard.\n\n    Args:\n        plugins: Pre-selected plugin names (skips plugin selection)\n        dry_run: If True, only preview config without writing\n        output: Custom output path\n    \"\"\"\n    print_banner()\n\n    # Step 1: Environment selection\n    environment = ask_environment()\n    if environment is None:\n        return\n\n    # Step 2: Discover and filter plugins\n    all_plugins = discover_plugins()\n    compatible_plugins = filter_by_environment(all_plugins, environment)\n\n    # Step 3: Plugin selection\n    if plugins:\n        # Use pre-selected plugins\n        selected = [p for p in compatible_plugins if p.name in plugins]\n        if not selected:\n            questionary.print(\n                f\"None of the specified plugins ({', '.join(plugins)}) are available.\",\n                style=\"fg:red\",\n            )\n            return\n    else:\n        selected = ask_plugins(compatible_plugins)\n\n    if not selected:\n        questionary.print(\"No plugins selected. Exiting.\", style=\"fg:yellow\")\n        return\n\n    # Step 4: Check existing config\n    config_path = output or get_config_path()\n    if not dry_run and not handle_existing_config(config_path):\n        return\n\n    # Step 5: Configure each plugin\n    config = build_config(selected, environment)\n\n    # Step 6: Preview / write\n    if dry_run:\n        questionary.print(\"\\n── Generated Configuration (dry-run) ──\", style=\"bold\")\n        print(generate_toml(config))\n    else:\n        path, _content = write_config(config, output)\n        questionary.print(f\"\\n✓ Configuration written to: {path}\", style=\"fg:green bold\")\n        questionary.print(\"\\nTo start pyprland, run:\", style=\"bold\")\n        questionary.print(\"  pypr\", style=\"fg:cyan\")\n\n    # Step 7: Show keybind hints for scratchpads\n    if any(p.name == \"scratchpads\" for p in selected):\n        _show_keybind_hints(config.get(\"scratchpads\", {}), environment)\n\n\ndef _show_keybind_hints(scratchpads_config: dict, environment: str) -> None:\n    \"\"\"Show keybind hints for configured scratchpads.\n\n    Args:\n        scratchpads_config: Scratchpads configuration\n        environment: Current environment\n    \"\"\"\n    if not scratchpads_config:\n        return\n\n    questionary.print(\"\\n── Suggested Keybindings ──\", style=\"bold\")\n\n    if environment == Environment.HYPRLAND:\n        questionary.print(\"Add to ~/.config/hypr/hyprland.conf:\", style=\"fg:gray\")\n        for name in scratchpads_config:\n            questionary.print(f\"  bind = $mainMod, KEY, exec, pypr toggle {name}\", style=\"fg:cyan\")\n    elif environment == Environment.NIRI:\n        questionary.print(\"Add to ~/.config/niri/config.kdl:\", style=\"fg:gray\")\n        for name in scratchpads_config:\n            questionary.print(f'  Mod+KEY {{ spawn \"pypr\" \"toggle\" \"{name}\"; }}', style=\"fg:cyan\")\n\n    questionary.print(\"\\nReplace KEY with your preferred key (e.g., grave, F1, etc.)\", style=\"fg:gray\")\n"
  },
  {
    "path": "pyprland/state.py",
    "content": "\"\"\"Shared state management for cross-plugin coordination.\n\nSharedState is a dataclass holding commonly-accessed mutable state:\n- Active workspace, monitor, and window\n- Environment type (hyprland, niri, wayland, xorg)\n- Monitor list with disabled monitor tracking\n- Hyprland version info\n\nPassed to all plugins via plugin.state for coordination.\n\"\"\"\n\nfrom dataclasses import dataclass, field\n\nfrom .models import Environment, VersionInfo\n\n__all__ = [\n    \"SharedState\",\n]\n\n\n@dataclass\nclass SharedState:\n    \"\"\"Stores commonly requested properties.\"\"\"\n\n    active_workspace: str = \"\"  # workspace name\n    active_monitor: str = \"\"  # monitor name\n    active_window: str = \"\"  # window address\n    environment: Environment = Environment.HYPRLAND\n    variables: dict = field(default_factory=dict)\n    monitors: list[str] = field(default_factory=list)  # ALL monitors (source of truth)\n    _disabled_monitors: set[str] = field(default_factory=set)  # Disabled monitor names\n    hyprland_version: VersionInfo = field(default_factory=VersionInfo)\n\n    @property\n    def active_monitors(self) -> list[str]:\n        \"\"\"Return only active/enabled monitors.\"\"\"\n        return [m for m in self.monitors if m not in self._disabled_monitors]\n\n    def set_disabled_monitors(self, disabled: set[str]) -> None:\n        \"\"\"Update the set of disabled monitors.\n\n        Args:\n            disabled: Set of monitor names that are disabled.\n        \"\"\"\n        self._disabled_monitors = disabled\n"
  },
  {
    "path": "pyprland/terminal.py",
    "content": "\"\"\"Terminal handling utilities for interactive programs.\"\"\"\n\nimport fcntl\nimport os\nimport pty\nimport select\nimport struct\nimport subprocess\nimport sys\nimport termios\n\n__all__ = [\n    \"run_interactive_program\",\n    \"set_raw_mode\",\n    \"set_terminal_size\",\n]\n\n\ndef set_terminal_size(descriptor: int, rows: int, cols: int) -> None:\n    \"\"\"Set the terminal size.\n\n    Args:\n        descriptor: File descriptor of the terminal\n        rows: Number of rows\n        cols: Number of columns\n    \"\"\"\n    fcntl.ioctl(descriptor, termios.TIOCSWINSZ, struct.pack(\"HHHH\", rows, cols, 0, 0))\n\n\ndef set_raw_mode(descriptor: int) -> None:\n    \"\"\"Set a file descriptor in raw mode.\n\n    Args:\n        descriptor: File descriptor to set to raw mode\n    \"\"\"\n    # Get the current terminal attributes\n    attrs = termios.tcgetattr(descriptor)\n    # Set the terminal to raw mode\n    attrs[3] &= ~termios.ICANON  # Disable canonical mode (line buffering)\n    attrs[3] &= ~termios.ECHO  # Disable echoing of input characters\n    termios.tcsetattr(descriptor, termios.TCSANOW, attrs)\n\n\ndef run_interactive_program(command: str) -> None:\n    \"\"\"Run an interactive program in a blocking way.\n\n    Args:\n        command: The command to run\n    \"\"\"\n    # Create a pseudo-terminal\n    master, slave = pty.openpty()\n\n    # Start the program in the pseudo-terminal\n    process = subprocess.Popen(  # pylint: disable=consider-using-with\n        command, shell=True, stdin=slave, stdout=slave, stderr=slave\n    )\n\n    # Close the slave end in the parent process\n    os.close(slave)\n\n    # Get the size of the real terminal\n    rows, cols = os.popen(\"stty size\", \"r\").read().split()\n\n    # Set the terminal size for the pseudo-terminal\n    set_terminal_size(master, int(rows), int(cols))\n\n    # Set the terminal to raw mode\n    set_raw_mode(sys.stdin.fileno())\n    set_raw_mode(master)\n\n    # Forward input from the real terminal to the program and vice versa\n    try:\n        while process.poll() is None:\n            r, _, _ = select.select([sys.stdin, master], [], [])\n            for fd in r:\n                if fd == sys.stdin:\n                    # Read input from the real terminal\n                    user_input = os.read(sys.stdin.fileno(), 1024)\n                    # Forward input to the program\n                    os.write(master, user_input)\n                elif fd == master:\n                    # Read output from the program\n                    output = os.read(master, 1024)\n                    # Forward output to the real terminal\n                    os.write(sys.stdout.fileno(), output)\n    except OSError:\n        pass\n    finally:\n        # Restore terminal settings\n        termios.tcsetattr(sys.stdin, termios.TCSANOW, termios.tcgetattr(0))\n"
  },
  {
    "path": "pyprland/utils.py",
    "content": "\"\"\"General utility functions for Pyprland.\n\nProvides:\n- merge(): Deep dict merging with optional replace mode\n- apply_variables(): Template variable substitution [var_name] -> value\n- apply_filter(): Text filtering with vim-like s/find/replace/ syntax\n- is_rotated(): Check if monitor has 90/270 degree rotation\n- notify_send(): Send desktop notifications via notify-send\n\"\"\"\n\nimport contextlib\nimport re\nfrom typing import Any\n\nfrom .models import MonitorInfo\nfrom .process import create_subprocess\n\n__all__ = [\n    \"apply_filter\",\n    \"apply_variables\",\n    \"is_rotated\",\n    \"merge\",\n    \"notify_send\",\n]\n\n\ndef merge(merged: dict[str, Any], obj2: dict[str, Any], replace: bool = False) -> dict[str, Any]:\n    \"\"\"Merge the content of d2 into d1.\n\n    Args:\n        merged (dict): Dictionary to merge into\n        obj2 (dict): Dictionary to merge from\n        replace (bool): If True, replace content of lists and dicts recursively, deleting missing keys in src.\n\n    Returns:\n         dictionary with the merged content\n\n    Eg:\n        merge({\"a\": {\"b\": 1}}, {\"a\": {\"c\": 2}}) == {\"a\": {\"b\": 1, \"c\": 2}}\n\n    \"\"\"\n    if replace:\n        to_remove = [k for k in merged if k not in obj2]\n        for k in to_remove:\n            del merged[k]\n\n    for key, value in obj2.items():\n        if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):\n            # If both values are dictionaries, recursively merge them\n            merge(merged[key], value, replace=replace)\n        elif key in merged and isinstance(merged[key], list) and isinstance(value, list):\n            # If both values are lists, concatenate them\n            if replace:\n                merged[key].clear()\n                merged[key].extend(value)\n            else:\n                merged[key] += value\n        else:\n            # Otherwise, update the value or add the key-value pair\n            merged[key] = value\n    return merged\n\n\ndef apply_variables(template: str, variables: dict[str, str]) -> str:\n    \"\"\"Replace [var_name] with content from supplied variables.\n\n    Args:\n        template: the string template\n        variables: a dict containing the variables to replace\n\n    Returns:\n        The template with variables replaced\n    \"\"\"\n    pattern = r\"\\[([^\\[\\]]+)\\]\"\n\n    def replace(match: re.Match[str]) -> str:\n        var_name = match.group(1)\n        return variables.get(var_name, match.group(0))\n\n    return re.sub(pattern, replace, template)\n\n\ndef apply_filter(text: str, filt_cmd: str) -> str:\n    \"\"\"Apply filters to text.\n\n    Currently supports only \"s\" command fom vim/ed\n\n    Args:\n        text: The text to filter\n        filt_cmd: The filter command (e.g. \"s/foo/bar/g\")\n\n    Returns:\n        The filtered text\n    \"\"\"\n    if not filt_cmd:\n        return text\n    if filt_cmd[0] == \"s\":  # vi-like substitute\n        try:\n            sep = filt_cmd[1]\n            parts = filt_cmd.split(sep)\n            min_substitute_parts = 3  # s/base/replacement/ requires at least 3 parts\n            if len(parts) < min_substitute_parts:\n                return text\n            (_, base, replacement, opts) = parts[:4]\n            return re.sub(base, replacement, text, count=0 if \"g\" in opts else 1)\n        except (IndexError, ValueError):\n            return text\n    return text\n\n\ndef is_rotated(monitor: MonitorInfo) -> bool:\n    \"\"\"Return True if the monitor is rotated.\n\n    Args:\n        monitor: The monitor info dictionary\n\n    Returns:\n        True if the monitor is rotated (transform is 1, 3, 5, or 7)\n    \"\"\"\n    return monitor[\"transform\"] in {1, 3, 5, 7}\n\n\nasync def notify_send(text: str, duration: int = 3000, color: str | None = None, icon: str | None = None) -> None:\n    \"\"\"Send a notification using notify-send.\n\n    Args:\n        text: The text to display\n        duration: The duration in milliseconds\n        color: The color to use (currently unused by notify-send but kept for API compatibility)\n        icon: The icon to use\n    \"\"\"\n    del color  # unused\n    args = [\"notify-send\", text, f\"--expire-time={duration}\", \"--app-name=pyprland\"]\n    if icon:\n        args.append(f\"--icon={icon}\")\n\n    # notify-send doesn't support color directly in standard implementations without custom patches or specific notification daemons\n    # so we ignore color for now to keep it generic, or we could use hints if we knew the daemon supported them.\n\n    with contextlib.suppress(FileNotFoundError):\n        # We don't care about the output\n        await create_subprocess(*args, shell=False)\n"
  },
  {
    "path": "pyprland/validate_cli.py",
    "content": "\"\"\"CLI validation entry point for pyprland configuration.\"\"\"\n\nimport importlib\nimport json\nimport logging\nimport os\nimport sys\nimport tomllib\nfrom pathlib import Path\nfrom typing import cast\n\nfrom .common import get_logger, merge\nfrom .constants import CONFIG_FILE, LEGACY_CONFIG_FILE, OLD_CONFIG_FILE\nfrom .models import ExitCode\nfrom .plugins.interface import Plugin\nfrom .validation import ConfigItems, ConfigValidator\n\n__all__ = [\"run_validate\"]\n\n\ndef _load_plugin_module(name: str) -> type[Plugin] | None:\n    \"\"\"Load a plugin module and return its Extension class.\n\n    Args:\n        name: Plugin name\n\n    Returns:\n        The Extension class or None if not found\n    \"\"\"\n    for module_path in [f\"pyprland.plugins.{name}\", name]:\n        try:\n            module = importlib.import_module(module_path)\n            return cast(\"type\", module.Extension)\n        except (ModuleNotFoundError, AttributeError):\n            continue\n    return None\n\n\ndef _load_validate_config(log: logging.Logger) -> dict:\n    \"\"\"Load config file for validation.\n\n    Args:\n        log: Logger instance\n\n    Returns:\n        Loaded configuration dictionary\n    \"\"\"\n    config_path = CONFIG_FILE\n    legacy_path = LEGACY_CONFIG_FILE\n    old_json_path = OLD_CONFIG_FILE\n\n    if config_path.exists():\n        with config_path.open(\"rb\") as f:\n            config = tomllib.load(f)\n        log.info(\"Loaded config from %s\", config_path)\n        return config\n\n    if legacy_path.exists():\n        with legacy_path.open(\"rb\") as f:\n            config = tomllib.load(f)\n        log.info(\"Loaded config from %s\", legacy_path)\n        log.warning(\"Consider moving config to %s\", config_path)\n        return config\n\n    if old_json_path.exists():\n        with old_json_path.open(encoding=\"utf-8\") as f:\n            config = cast(\"dict\", json.loads(f.read()))\n        log.info(\"Loaded config from %s (consider migrating to TOML)\", old_json_path)\n        return config\n\n    log.error(\"Config file not found at %s\", config_path)\n    sys.exit(ExitCode.ENV_ERROR)\n\n\ndef _validate_plugin(plugin_name: str, config: dict) -> tuple[int, int]:\n    \"\"\"Validate a single plugin's configuration.\n\n    Args:\n        plugin_name: Name of the plugin\n        config: Full configuration dictionary\n\n    Returns:\n        Tuple of (error_count, warning_count)\n    \"\"\"\n    extension_class = _load_plugin_module(plugin_name)\n    if extension_class is None:\n        get_logger(\"validate\").warning(\"Plugin '%s' not found, skipping validation\", plugin_name)\n        return (0, 0)\n\n    plugin_config = config.get(plugin_name, {})\n    schema: ConfigItems = cast(\"ConfigItems\", getattr(extension_class, \"config_schema\", ConfigItems()))\n\n    # Check if plugin has validation capability\n    has_schema = bool(schema)\n    has_custom_validation = \"validate_config_static\" in vars(extension_class)\n\n    if not has_schema and not has_custom_validation:\n        print(f\"∅  [{plugin_name}] skipped\")\n        return (0, 0)\n\n    # Get errors from class-level validation\n    errors = extension_class.validate_config_static(plugin_name, plugin_config)\n\n    # Get warnings for unknown keys (only if schema exists)\n    warnings: list[str] = []\n    if schema:\n        silent_logger = logging.getLogger(f\"pyprland.validate.{plugin_name}\")\n        silent_logger.addHandler(logging.NullHandler())\n        silent_logger.propagate = False\n        validator = ConfigValidator(plugin_config, plugin_name, silent_logger)\n        warnings = validator.warn_unknown_keys(schema)\n\n    if errors or warnings:\n        print(f\"  [{plugin_name}]\")\n        for error in errors:\n            print(f\"  ERROR: {error}\")\n        for warning in warnings:\n            print(f\"  WARNING: {warning}\")\n    else:\n        print(f\"✅ [{plugin_name}]\")\n\n    return (len(errors), len(warnings))\n\n\ndef run_validate() -> None:\n    \"\"\"Validate the configuration file without starting the daemon.\n\n    Checks all plugin configurations against their schemas and reports errors.\n    \"\"\"\n    log = get_logger(\"validate\")\n    config = _load_validate_config(log)\n\n    # Validate pyprland section exists\n    if \"pyprland\" not in config:\n        log.error(\"Config must have a [pyprland] section\")\n        sys.exit(ExitCode.USAGE_ERROR)\n\n    pyprland_config = config[\"pyprland\"]\n    if \"plugins\" not in pyprland_config:\n        log.error(\"Config must have 'plugins' list in [pyprland] section\")\n        sys.exit(ExitCode.USAGE_ERROR)\n\n    extra_include = pyprland_config.get(\"include\", [])\n    for extra_config in extra_include:\n        fname = Path(os.path.expandvars(extra_config)).expanduser()\n        if fname.is_dir():\n            extra_include.extend(str(fname / f.name) for f in fname.iterdir() if f.name.endswith(\".toml\"))\n        else:\n            doc = {}\n            with fname.open(\"rb\") as toml_file:\n                doc.update(tomllib.load(toml_file))\n            if doc:\n                merge(config, doc)\n\n    plugins = sorted(set(config[\"pyprland\"][\"plugins\"]))\n    print(f\"Validating configuration for {len(plugins)} plugin(s)...\\n\")\n\n    total_errors = 0\n    total_warnings = 0\n    for plugin_name in plugins:\n        errors, warnings = _validate_plugin(plugin_name, config)\n        total_errors += errors\n        total_warnings += warnings\n\n    # Summary\n    print()\n    if total_errors == 0 and total_warnings == 0:\n        print(\"Configuration is valid!\")\n        sys.exit(ExitCode.SUCCESS)\n    elif total_errors > 0:\n        print(f\"Found {total_errors} error(s) and {total_warnings} warning(s)\")\n        sys.exit(ExitCode.USAGE_ERROR)\n    else:\n        print(f\"Found {total_warnings} warning(s)\")\n        sys.exit(ExitCode.SUCCESS)\n"
  },
  {
    "path": "pyprland/validation.py",
    "content": "\"\"\"Configuration validation framework with schema definitions.\n\nProvides declarative schema definitions (ConfigField, ConfigItems) for\nvalidating plugin configuration. Supports type checking, required fields,\nchoices, nested dict validation, and fuzzy matching for typo detection.\n\nUsed by:\n- Plugin.validate_config() for runtime validation\n- 'pypr validate' CLI for static configuration checking\n- TUI editor for configuration field metadata\n\"\"\"\n\nimport difflib\nimport logging\nfrom collections.abc import Callable\nfrom dataclasses import dataclass\nfrom typing import Any, cast, get_args, get_origin\n\nfrom .config import BOOL_STRINGS\n\n__all__ = [\n    \"ConfigField\",\n    \"ConfigValidator\",\n    \"format_config_error\",\n]\n\n\n@dataclass\nclass ConfigField:  # pylint: disable=too-many-instance-attributes\n    \"\"\"Describes an expected configuration field for validation.\n\n    Attributes:\n        name: The configuration key name\n        field_type: Expected type (str, int, float, bool, list, dict) or tuple of types for union\n        required: Whether the field is required\n        recommended: Whether the field is recommended (but not required)\n        default: Default value if not provided\n        description: Human-readable description for error messages\n        choices: List of valid values for enum-like fields\n        validator: Custom validator function returning list of error messages\n        children: Schema for validating dict values (when field_type is dict).\n                  Supports arbitrary nesting depth - children schemas can themselves\n                  have children with their own schemas.\n        children_allow_extra: If True, don't warn about unknown keys in children\n        category: UI grouping category for TUI display (e.g., \"basic\", \"positioning\", \"behavior\")\n        is_directory: For Path types, True means directory path, False means file path\n    \"\"\"\n\n    name: str\n    field_type: type | tuple[type, ...] = str\n    required: bool = False\n    recommended: bool = False\n    default: Any = None\n    description: str = \"\"\n    choices: list | None = None\n    validator: Callable[[Any], list[str]] | None = None\n    children: \"ConfigItems | None\" = None\n    children_allow_extra: bool = False\n    category: str = \"\"  # UI grouping category (e.g., \"basic\", \"positioning\", \"behavior\")\n    is_directory: bool = False  # For Path types: True = directory, False = file\n\n    @property\n    def type_name(self) -> str:\n        \"\"\"Return human-readable type name (e.g., 'str', 'list[Path]').\"\"\"\n\n        def _format_type(typ: type) -> str:\n            origin = get_origin(typ)\n            if origin is not None:\n                args = get_args(typ)\n                if args:\n                    args_str = \", \".join(_format_type(a) for a in args)\n                    return f\"{origin.__name__}[{args_str}]\"\n                return str(origin.__name__)\n            return str(typ.__name__)\n\n        if isinstance(self.field_type, tuple):\n            return \" or \".join(_format_type(typ) for typ in self.field_type)\n        return _format_type(self.field_type)\n\n\nclass ConfigItems(list):\n    \"\"\"A list of ConfigField items with cached lookup by name.\"\"\"\n\n    def __init__(self, *args: ConfigField) -> None:\n        super().__init__(args)\n        self._cache: dict[str, ConfigField] = {}\n\n    def get(self, name: str) -> ConfigField | None:\n        \"\"\"Get a ConfigField by name, with caching for repeated lookups.\n\n        Args:\n            name: The field name to look up\n\n        Returns:\n            The ConfigField if found, None otherwise\n        \"\"\"\n        v = self._cache.get(name)\n        if not v:\n            for prop in self:\n                if prop.name == name:\n                    v = prop\n                    self._cache[name] = v\n                    break\n        return v\n\n\ndef _find_similar_key(unknown_key: str, known_keys: list[str]) -> str | None:\n    \"\"\"Find a similar key using fuzzy matching.\n\n    Args:\n        unknown_key: The unknown key to find a match for\n        known_keys: List of valid keys to search\n\n    Returns:\n        The closest matching key, or None if no close match found\n    \"\"\"\n    matches = difflib.get_close_matches(unknown_key, known_keys, n=1)\n    if matches:\n        return matches[0]\n    return None\n\n\ndef format_config_error(plugin: str, field: str, message: str, suggestion: str = \"\") -> str:\n    \"\"\"Format a configuration error message.\n\n    Args:\n        plugin: Plugin name\n        field: Field name that has the error\n        message: Error description\n        suggestion: Optional suggestion for fixing the error\n\n    Returns:\n        Formatted error message\n    \"\"\"\n    msg = f\"[{plugin}] Config error for '{field}': {message}\"\n    if suggestion:\n        msg += f\" -> {suggestion}\"\n    return msg\n\n\nclass ConfigValidator:\n    \"\"\"Validates configuration against a schema.\"\"\"\n\n    def __init__(self, config: dict, plugin_name: str, logger: logging.Logger) -> None:\n        \"\"\"Initialize the validator.\n\n        Args:\n            config: The configuration dictionary to validate\n            plugin_name: Name of the plugin for error messages\n            logger: Logger instance for warnings\n        \"\"\"\n        self.config = config\n        self.plugin_name = plugin_name\n        self.log = logger\n\n    def validate(self, schema: ConfigItems) -> list[str]:\n        \"\"\"Validate configuration against schema.\n\n        Args:\n            schema: List of ConfigField definitions\n\n        Returns:\n            List of error messages (empty if validation passed)\n        \"\"\"\n        errors = []\n\n        for field_def in schema:\n            value = self.config.get(field_def.name)\n\n            # Check required fields\n            if field_def.required and value is None:\n                suggestion = self._get_required_suggestion(field_def)\n                errors.append(\n                    format_config_error(\n                        self.plugin_name,\n                        field_def.name,\n                        \"Missing required field\",\n                        suggestion,\n                    )\n                )\n                continue\n\n            # Skip optional fields that aren't set\n            if value is None:\n                continue\n\n            # Check type\n            type_error = self._check_type(field_def, value)\n            if type_error:\n                errors.append(type_error)\n                continue\n\n            # Check choices (skip if custom validator handles validation)\n            if field_def.choices is not None and field_def.validator is None and value not in field_def.choices:\n                choices_str = \", \".join(repr(c) for c in field_def.choices)\n                errors.append(\n                    format_config_error(\n                        self.plugin_name,\n                        field_def.name,\n                        f\"Invalid value {value!r}\",\n                        f\"Valid options: {choices_str}\",\n                    )\n                )\n            # custom validation\n            if field_def.validator:\n                errors.extend(\n                    format_config_error(\n                        self.plugin_name,\n                        field_def.name,\n                        validation_error,\n                    )\n                    for validation_error in field_def.validator(value)\n                )\n\n        return errors\n\n    def _check_type(self, field_def: ConfigField, value: Any) -> str | None:\n        \"\"\"Check if value matches expected type.\n\n        Args:\n            field_def: Field definition\n            value: Value to check\n\n        Returns:\n            Error message if type mismatch, None otherwise\n        \"\"\"\n        expected_type = field_def.field_type\n\n        # Handle union types (tuple of types)\n        if isinstance(expected_type, tuple):\n            return self._check_union_type(field_def, value, expected_type)\n\n        # Dispatch to type-specific checkers\n        checkers = {\n            bool: self._check_bool,\n            int: self._check_numeric,\n            float: self._check_numeric,\n            str: self._check_str,\n            list: self._check_list,\n            dict: self._check_dict,\n        }\n\n        checker = checkers.get(expected_type)\n        if checker:\n            return checker(field_def, value)\n        return None\n\n    def _check_union_type(\n        self,\n        field_def: ConfigField,\n        value: Any,\n        expected_types: tuple,\n    ) -> str | None:\n        \"\"\"Check if value matches any of the union types.\"\"\"\n        for single_type in expected_types:\n            temp_field = ConfigField(field_def.name, single_type)\n            if self._check_type(temp_field, value) is None:\n                return None\n        return format_config_error(\n            self.plugin_name,\n            field_def.name,\n            f\"Expected {field_def.type_name}, got {type(value).__name__}\",\n        )\n\n    def _check_bool(self, field_def: ConfigField, value: Any) -> str | None:\n        \"\"\"Check bool type (special handling since bool is subclass of int).\"\"\"\n        if isinstance(value, bool):\n            return None\n        if isinstance(value, str) and value.lower() in BOOL_STRINGS:\n            return None\n        return format_config_error(\n            self.plugin_name,\n            field_def.name,\n            f\"Expected bool, got {type(value).__name__}\",\n            \"Use true/false (without quotes)\",\n        )\n\n    def _check_numeric(self, field_def: ConfigField, value: Any) -> str | None:\n        \"\"\"Check int/float type.\"\"\"\n        if isinstance(value, (int, float)) and not isinstance(value, bool):\n            return None\n        expected_type = cast(\"type[int | float]\", field_def.field_type)\n        try:\n            expected_type(value)\n        except (ValueError, TypeError):\n            return format_config_error(\n                self.plugin_name,\n                field_def.name,\n                f\"Expected {expected_type.__name__}, got {type(value).__name__}\",\n                f\"Use {field_def.name} = 42 (without quotes)\",\n            )\n\n        return None\n\n    def _check_str(self, field_def: ConfigField, value: Any) -> str | None:\n        \"\"\"Check str type.\"\"\"\n        if isinstance(value, str):\n            return None\n        return format_config_error(\n            self.plugin_name,\n            field_def.name,\n            f\"Expected str, got {type(value).__name__}\",\n            f'Use {field_def.name} = \"value\"',\n        )\n\n    def _check_list(self, field_def: ConfigField, value: Any) -> str | None:\n        \"\"\"Check list type.\"\"\"\n        if isinstance(value, list):\n            return None\n        return format_config_error(\n            self.plugin_name,\n            field_def.name,\n            f\"Expected list, got {type(value).__name__}\",\n            f'Use {field_def.name} = [\"item1\", \"item2\"]',\n        )\n\n    def _check_dict(self, field_def: ConfigField, value: Any) -> str | None:\n        \"\"\"Check dict type and optionally validate children.\"\"\"\n        if not isinstance(value, dict):\n            return format_config_error(\n                self.plugin_name,\n                field_def.name,\n                f\"Expected dict/section, got {type(value).__name__}\",\n            )\n\n        # Validate children schema if defined\n        if field_def.children is not None:\n            child_errors = self._validate_dict_children(field_def, value)\n            if child_errors:\n                return \"\\n\".join(child_errors)\n\n        return None\n\n    def _validate_dict_children(\n        self,\n        field_def: ConfigField,\n        value: dict,\n    ) -> list[str]:\n        \"\"\"Validate all children in a dict against the children schema.\n\n        Args:\n            field_def: Field definition with children schema\n            value: Dict value to validate\n\n        Returns:\n            List of all validation errors\n        \"\"\"\n        errors: list[str] = []\n        children_schema = cast(\"ConfigItems\", field_def.children)\n        for key, child_value in value.items():\n            if not isinstance(child_value, dict):\n                errors.append(\n                    format_config_error(\n                        f\"{self.plugin_name}.{field_def.name}\",\n                        key,\n                        f\"Expected dict, got {type(child_value).__name__}\",\n                    )\n                )\n                continue\n\n            child_prefix = f\"{self.plugin_name}.{field_def.name}.{key}\"\n            child_validator = ConfigValidator(child_value, child_prefix, self.log)\n            errors.extend(child_validator.validate(children_schema))\n            # Only warn about unknown keys if not allowing extra keys\n            if not field_def.children_allow_extra:\n                errors.extend(child_validator.warn_unknown_keys(children_schema))\n\n        return errors\n\n    def _get_required_suggestion(self, field_def: ConfigField) -> str:\n        \"\"\"Generate suggestion for a missing required field.\n\n        Args:\n            field_def: Field definition\n\n        Returns:\n            Suggestion string\n        \"\"\"\n        field_type = field_def.field_type\n        # For union types, use the first type for suggestion\n        if isinstance(field_type, tuple):\n            field_type = field_type[0]\n\n        if field_type is str:\n            return f'Add {field_def.name} = \"value\" to [{self.plugin_name}]'\n        if field_type is int:\n            example = field_def.default if field_def.default is not None else 0\n            return f\"Add {field_def.name} = {example} to [{self.plugin_name}]\"\n        if field_type is float:\n            example = field_def.default if field_def.default is not None else 0.0\n            return f\"Add {field_def.name} = {example} to [{self.plugin_name}]\"\n        if field_type is bool:\n            return f\"Add {field_def.name} = true/false to [{self.plugin_name}]\"\n        if field_type is list:\n            return f'Add {field_def.name} = [\"item\"] to [{self.plugin_name}]'\n        return f\"Add '{field_def.name}' to [{self.plugin_name}]\"\n\n    def warn_unknown_keys(self, schema: ConfigItems) -> list[str]:\n        \"\"\"Log warnings for unknown configuration keys.\n\n        Args:\n            schema: List of ConfigField definitions\n\n        Returns:\n            List of warning messages\n        \"\"\"\n        warnings = []\n        known_keys = {f.name for f in schema}\n\n        for key in self.config:\n            if key in known_keys:\n                continue\n\n            # Check for similar keys (typos)\n            similar = _find_similar_key(key, list(known_keys))\n            if similar:\n                msg = f\"[{self.plugin_name}] Unknown option '{key}' (did you mean '{similar}'?)\"\n            else:\n                msg = f\"[{self.plugin_name}] Unknown option '{key}' - will be ignored\"\n\n            self.log.warning(msg)\n            warnings.append(msg)\n\n        return warnings\n"
  },
  {
    "path": "pyprland/version.py",
    "content": "\"\"\"Package version.\"\"\"\n\nVERSION = \"3.3.1-1\"\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"pyprland\"\nversion = \"3.3.1\"\ndescription = \"A companion for your desktop UX\"\nauthors = [\n    { name = \"Fabien Devaux\", email = \"fdev31@gmail.com\" },\n]\nrequires-python = \">=3.11\"\nreadme = \"README.md\"\nlicense = \"MIT\"\nclassifiers = []\ndependencies = [\n    \"aiofiles\",\n    \"aiohttp\",\n    \"pillow\",\n]\n\n[project.urls]\nHomepage = \"https://github.com/hyprland-community/pyprland/\"\n\n[project.scripts]\npypr = \"pyprland.command:main\"\npypr-quickstart = \"pyprland.quickstart:main\"\npypr-gui = \"pyprland.gui:main\"\n\n[dependency-groups]\ndev = [\n    \"black\",\n    \"coverage\",\n    \"flake8\",\n    \"mypy\",\n    \"pdoc\",\n    \"pre-commit>=4.5.1\",\n    \"pydantic\",\n    \"pylint\",\n    \"pytest\",\n    \"pytest-asyncio\",\n    \"pytest-mock\",\n    \"ruff\",\n    \"types-aiofiles\",\n    \"vulture\",\n]\nquickstart = [\n    \"questionary>=2.1.1\",\n]\nvreg = [\n    \"pyglet\",\n]\n\n[tool.ruff]\nline-length = 140\npreview = false\nsrc = [\n    \"pyprland\",\n]\nexclude = [\n    \"*_v?.py\",\n    \"tests\",\n    \"scripts\",\n    \"sample_extension\",\n    \".git\",\n]\ntarget-version = \"py311\"\n\n[tool.ruff.lint]\nfixable = [\n    \"ALL\",\n]\nignore = [\n    \"ANN002\",\n    \"ANN003\",\n    \"RUF100\",\n    \"ANN204\",\n    \"ANN401\",\n    \"D105\",\n    \"D107\",\n    \"E203\",\n    \"ISC001\",\n    \"RET503\",\n    \"S101\",\n    \"S311\",\n    \"S404\",\n    \"S602\",\n    \"S603\",\n    \"S605\",\n    \"S607\",\n    \"TID252\",\n]\nselect = [\n    \"A\",\n    \"AIR\",\n    \"ARG\",\n    \"ASYNC\",\n    \"B\",\n    \"BLE\",\n    \"C\",\n    \"C4\",\n    \"C90\",\n    \"D\",\n    \"DTZ\",\n    \"E\",\n    \"EM\",\n    \"EXE\",\n    \"F\",\n    \"FA\",\n    \"FIX\",\n    \"FLY\",\n    \"FURB\",\n    \"G\",\n    \"I\",\n    \"ICN\",\n    \"INP\",\n    \"INT\",\n    \"ISC\",\n    \"LOG\",\n    \"N\",\n    \"PERF\",\n    \"PIE\",\n    \"PL\",\n    \"PT\",\n    \"PTH\",\n    \"PYI\",\n    \"Q\",\n    \"RET\",\n    \"RSE\",\n    \"RUF\",\n    \"S\",\n    \"SIM\",\n    \"SLF\",\n    \"SLOT\",\n    \"T10\",\n    \"TCH\",\n    \"TD\",\n    \"TID\",\n    \"TRY\",\n    \"UP\",\n    \"W\",\n    \"YTT\",\n    \"ANN\",\n    \"ASYNC1\",\n]\n\n[tool.ruff.lint.pydocstyle]\nconvention = \"google\"\n\n[tool.ruff.lint.per-file-ignores]\n\"pyprland/adapters/fallback.py\" = [\n    \"ARG002\",\n]\n\"pyprland/adapters/niri.py\" = [\n    \"ARG002\",\n]\n\"pyprland/adapters/hyprland.py\" = [\n    \"ARG002\",\n]\n\"pyprland/adapters/backend.py\" = [\n    \"ARG002\",\n]\n\n[tool.ruff.format]\nindent-style = \"space\"\nline-ending = \"auto\"\n\n[[tool.mypy.overrides]]\nmodule = \"questionary\"\nignore_missing_imports = true\n\n[tool.pylsp-mypy]\nenabled = true\nlive_mode = true\nstrict = false\nexclude = [\n    \"tests/*\",\n    \"scripts/*\",\n    \"sample_extension/*\",\n]\n\n[build-system]\nrequires = [\n    \"hatchling\",\n]\nbuild-backend = \"hatchling.build\"\n\n[tool.hatch.build.hooks.custom]\n\n[tool.hatch.build.targets.sdist]\nonly-include = [\n    \"pyprland\",\n    \"client/pypr-client.c\",\n    \"hatch_build.py\",\n]\n\n"
  },
  {
    "path": "sample_extension/README.md",
    "content": "# Sample external Pyprland plugin\n\nNeeds to be installed with the same python environment as Pyprland\n\nEg: `pip install -e .`\n"
  },
  {
    "path": "sample_extension/pypr_examples/__init__.py",
    "content": ""
  },
  {
    "path": "sample_extension/pypr_examples/focus_counter.py",
    "content": "\"\"\"Sample plugin demonstrating pyprland plugin development.\n\nExposes a \"counter\" command: `pypr counter` showing focus change statistics.\n- Listens to `activewindowv2` Hyprland event to count focus changes\n- Uses configuration schema with typed accessors\n\"\"\"\n\nfrom pyprland.plugins.interface import Plugin\nfrom pyprland.validation import ConfigField, ConfigItems\n\n\nclass Extension(Plugin):\n    \"\"\"Count and display window focus changes.\"\"\"\n\n    environments = [\"hyprland\"]\n\n    focus_changes = 0\n\n    config_schema = ConfigItems(\n        ConfigField(\"multiplier\", int, default=1, description=\"Multiplier for focus count\", category=\"basic\"),\n    )\n\n    async def run_counter(self, args: str = \"\") -> None:\n        \"\"\"Show the number of focus switches and monitors.\n\n        This command displays a notification with focus change statistics.\n        \"\"\"\n        monitor_list = await self.backend.execute_json(\"monitors\")\n        await self.backend.notify_info(\n            f\"Focus changed {self.focus_changes} times on {len(monitor_list)} monitor(s)\",\n        )\n\n    async def event_activewindowv2(self, _addr: str) -> None:\n        \"\"\"Handle window focus change events.\"\"\"\n        self.focus_changes += self.get_config_int(\"multiplier\")\n        self.log.info(\"Focus changed, count = %d\", self.focus_changes)\n"
  },
  {
    "path": "sample_extension/pyproject.toml",
    "content": "[tool.poetry]\nname = \"pypr_examples\"\nversion = \"0.0.1\"\ndescription = \"Example pyprland plugin counting focus changes\"\nauthors = [\n    \"fdev31 <fdev31@gmail.com>\",\n]\nlicense = \"MIT\"\nreadme = \"README.md\"\n\n[tool.poetry.dependencies]\npython = \"^3.11\"\npyprland = \"^2.4.0\"\n\n[build-system]\nrequires = [\n    \"poetry-core\",\n]\nbuild-backend = \"poetry.core.masonry.api\"\n"
  },
  {
    "path": "scripts/backquote_as_links.py",
    "content": "#!/bin/env python\nimport os\nimport re\n\n\ndef replace_links(match):\n    \"\"\"Substitution handler for regex, replaces backquote items with links when relevant.\"\"\"\n    text = match.group(1)\n    if os.path.exists(f\"site/{text}.md\"):\n        return f\"[{text}](https://hyprland-community.github.io/pyprland/{text})\"\n    return f\"`{text}`\"\n\n\ndef main(filename):\n    \"\"\"Replace `link` with a markdown link if the .md file exists.\"\"\"\n    with open(filename, encoding=\"utf-8\") as file:\n        content = file.read()\n\n    replaced_content = re.sub(r\"`([^`]+)`\", replace_links, content)\n\n    with open(filename, \"w\", encoding=\"utf-8\") as file:\n        file.write(replaced_content)\n\n\nif __name__ == \"__main__\":\n    main(\"RELEASE_NOTES.md\")\n"
  },
  {
    "path": "scripts/check_plugin_docs.py",
    "content": "#!/usr/bin/env python\n\"\"\"Check that all plugin config options and commands are documented.\n\nThis script verifies that:\n1. Each plugin has at least one documentation page (.md file)\n2. All config options appear in a <PluginConfig> table (via filter or unfiltered)\n3. All commands appear in a <PluginCommands> list\n\nExit codes:\n- 0: Success (warnings are allowed)\n- 1: Error (missing plugin pages)\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport re\nimport sys\nfrom dataclasses import dataclass, field\nfrom pathlib import Path\n\n# Paths\nPROJECT_ROOT = Path(__file__).parent.parent\nSITE_DIR = PROJECT_ROOT / \"site\"\nGENERATED_DIR = SITE_DIR / \"generated\"\n\n# ANSI colors for terminal output\nRED = \"\\033[0;31m\"\nYELLOW = \"\\033[0;33m\"\nGREEN = \"\\033[0;32m\"\nCYAN = \"\\033[0;36m\"\nRESET = \"\\033[0m\"\n\n\n@dataclass\nclass PluginCoverage:\n    \"\"\"Coverage information for a single plugin.\"\"\"\n\n    name: str\n    pages: list[str] = field(default_factory=list)\n    config_options: list[str] = field(default_factory=list)\n    commands: list[str] = field(default_factory=list)\n    covered_options: set[str] = field(default_factory=set)\n    has_unfiltered_config: bool = False\n    has_commands_component: bool = False\n    warnings: list[str] = field(default_factory=list)\n\n\ndef load_plugin_json(plugin_name: str) -> dict | None:\n    \"\"\"Load the generated JSON for a plugin.\"\"\"\n    json_path = GENERATED_DIR / f\"{plugin_name}.json\"\n    if not json_path.exists():\n        return None\n    with open(json_path) as f:\n        return json.load(f)\n\n\ndef extract_option_name(full_name: str) -> str:\n    \"\"\"Extract the base option name from a prefixed name.\n\n    E.g., \"[scratchpad].command\" -> \"command\"\n    \"\"\"\n    match = re.match(r\"^\\[.*?\\]\\.(.+)$\", full_name)\n    return match.group(1) if match else full_name\n\n\ndef find_plugin_pages(plugin_name: str) -> list[Path]:\n    \"\"\"Find all markdown pages that document a plugin.\n\n    Scans for <PluginConfig plugin=\"X\"> or <PluginCommands plugin=\"X\">.\n    \"\"\"\n    pages = []\n    pattern = re.compile(\n        rf'<Plugin(?:Config|Commands)\\s+plugin=[\"\\']?{re.escape(plugin_name)}[\"\\']?',\n        re.IGNORECASE,\n    )\n\n    for md_file in SITE_DIR.glob(\"*.md\"):\n        content = md_file.read_text()\n        if pattern.search(content):\n            pages.append(md_file)\n\n    return pages\n\n\ndef parse_filter_from_component(content: str, plugin_name: str) -> tuple[bool, set[str]]:\n    \"\"\"Parse PluginConfig components and extract filter information.\n\n    Returns:\n        Tuple of (has_unfiltered, covered_options)\n        - has_unfiltered: True if any PluginConfig has no filter (covers all options)\n        - covered_options: Set of option names from :filter arrays\n    \"\"\"\n    has_unfiltered = False\n    covered_options: set[str] = set()\n\n    # Pattern to match <PluginConfig plugin=\"X\" ... />\n    # We need to handle both with and without :filter\n    component_pattern = re.compile(rf'<PluginConfig\\s+plugin=[\"\\']?{re.escape(plugin_name)}[\"\\']?([^>]*)/?>', re.IGNORECASE | re.DOTALL)\n\n    for match in component_pattern.finditer(content):\n        attrs = match.group(1)\n\n        # Check if there's a :filter attribute\n        filter_match = re.search(r':filter=\"\\[([^\\]]*)\\]\"', attrs)\n        if filter_match:\n            # Extract option names from the filter array\n            filter_content = filter_match.group(1)\n            # Parse the array items (they're quoted strings)\n            options = re.findall(r\"['\\\"]([^'\\\"]+)['\\\"]\", filter_content)\n            covered_options.update(options)\n        else:\n            # No filter means all options are covered\n            has_unfiltered = True\n\n    return has_unfiltered, covered_options\n\n\ndef check_commands_component(content: str, plugin_name: str) -> bool:\n    \"\"\"Check if the page has a PluginCommands component for this plugin.\"\"\"\n    pattern = re.compile(rf'<PluginCommands\\s+plugin=[\"\\']?{re.escape(plugin_name)}[\"\\']?', re.IGNORECASE)\n    return bool(pattern.search(content))\n\n\ndef analyze_plugin(plugin_name: str) -> PluginCoverage:\n    \"\"\"Analyze documentation coverage for a plugin.\"\"\"\n    coverage = PluginCoverage(name=plugin_name)\n\n    # Load plugin data\n    data = load_plugin_json(plugin_name)\n    if not data:\n        coverage.warnings.append(f\"No generated JSON found for plugin: {plugin_name}\")\n        return coverage\n\n    # Extract config options and commands\n    coverage.config_options = [extract_option_name(opt[\"name\"]) for opt in data.get(\"config\", [])]\n    coverage.commands = [cmd[\"name\"] for cmd in data.get(\"commands\", [])]\n\n    # Find documentation pages\n    pages = find_plugin_pages(plugin_name)\n    coverage.pages = [p.name for p in pages]\n\n    if not pages:\n        coverage.warnings.append(f\"No documentation page found for plugin: {plugin_name}\")\n        return coverage\n\n    # Analyze each page for coverage\n    for page in pages:\n        content = page.read_text()\n\n        # Check config coverage\n        has_unfiltered, options = parse_filter_from_component(content, plugin_name)\n        if has_unfiltered:\n            coverage.has_unfiltered_config = True\n        coverage.covered_options.update(options)\n\n        # Check commands coverage\n        if check_commands_component(content, plugin_name):\n            coverage.has_commands_component = True\n\n    # Generate warnings for missing items\n    if coverage.config_options:\n        if not coverage.has_unfiltered_config:\n            missing_options = set(coverage.config_options) - coverage.covered_options\n            for opt in sorted(missing_options):\n                coverage.warnings.append(f\"Config option '{opt}' not listed in any table\")\n\n    if coverage.commands and not coverage.has_commands_component:\n        coverage.warnings.append(\"Commands not listed (no <PluginCommands> component found)\")\n\n    return coverage\n\n\ndef discover_plugins() -> list[str]:\n    \"\"\"Discover all plugins from generated JSON files.\"\"\"\n    plugins = []\n    for json_file in GENERATED_DIR.glob(\"*.json\"):\n        name = json_file.stem\n        # Skip special files\n        if name in (\"index\", \"menu\", \"builtins\"):\n            continue\n        plugins.append(name)\n    return sorted(plugins)\n\n\ndef main() -> int:\n    \"\"\"Main entry point.\"\"\"\n    print(f\"{CYAN}Checking plugin documentation coverage...{RESET}\\n\")\n\n    plugins = discover_plugins()\n    if not plugins:\n        print(f\"{RED}No plugins found in {GENERATED_DIR}{RESET}\")\n        return 1\n\n    total_warnings = 0\n    missing_pages = 0\n    results: list[PluginCoverage] = []\n\n    for plugin_name in plugins:\n        coverage = analyze_plugin(plugin_name)\n        results.append(coverage)\n\n        # Print results for this plugin\n        print(f\"{CYAN}{plugin_name}:{RESET}\")\n\n        if coverage.pages:\n            print(f\"  Pages: {', '.join(coverage.pages)}\")\n        else:\n            print(f\"  {RED}Pages: NONE{RESET}\")\n            missing_pages += 1\n\n        # Config status\n        if coverage.config_options:\n            if coverage.has_unfiltered_config:\n                print(f\"  {GREEN}Config: {len(coverage.config_options)}/{len(coverage.config_options)} options covered (unfiltered){RESET}\")\n            else:\n                covered_count = len(coverage.covered_options & set(coverage.config_options))\n                total_count = len(coverage.config_options)\n                if covered_count == total_count:\n                    print(f\"  {GREEN}Config: {covered_count}/{total_count} options covered{RESET}\")\n                else:\n                    print(f\"  {YELLOW}Config: {covered_count}/{total_count} options covered{RESET}\")\n        else:\n            print(\"  Config: No config options\")\n\n        # Commands status\n        if coverage.commands:\n            if coverage.has_commands_component:\n                print(f\"  {GREEN}Commands: {len(coverage.commands)}/{len(coverage.commands)} commands covered{RESET}\")\n            else:\n                print(f\"  {YELLOW}Commands: 0/{len(coverage.commands)} commands covered{RESET}\")\n        else:\n            print(\"  Commands: No commands\")\n\n        # Print warnings\n        for warning in coverage.warnings:\n            print(f\"  {YELLOW}[WARN] {warning}{RESET}\")\n            total_warnings += 1\n\n        print()\n\n    # Summary\n    print(f\"{CYAN}Summary:{RESET}\")\n    print(f\"  Plugins checked: {len(plugins)}\")\n    print(f\"  Warnings: {total_warnings}\")\n    print(f\"  Missing pages: {missing_pages}\")\n\n    if missing_pages > 0:\n        print(f\"\\n{RED}FAILED: Some plugins have no documentation page{RESET}\")\n        return 1\n\n    if total_warnings > 0:\n        print(f\"\\n{YELLOW}PASSED with warnings{RESET}\")\n    else:\n        print(f\"\\n{GREEN}PASSED{RESET}\")\n\n    return 0\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n"
  },
  {
    "path": "scripts/completions/README.md",
    "content": "Installs in:\n\n- /usr/share/zsh/site-functions/_pypr for zsh\n- /usr/share/bash-completion/completions/pypr for bash\n"
  },
  {
    "path": "scripts/completions/pypr.bash",
    "content": "# AUTOMATICALLY GENERATED by `shtab`\n\n_shtab_pypr_subparsers=('dumpjson' 'edit' 'exit' 'help' 'version' 'reload' 'attach' 'show' 'hide' 'toggle' 'bar' 'menu' 'toggle_special' 'layout_center' 'attract_lost' 'shift_monitors' 'toggle_dpms' 'zoom' 'expose' 'change_workspace' 'wall' 'fetch_client_menu' 'unfetch_client' 'relayout')\n\n_shtab_pypr_option_strings=('--debug' '--config' '--print-completion')\n_shtab_pypr_dumpjson_option_strings=('-h' '--help')\n_shtab_pypr_edit_option_strings=('-h' '--help')\n_shtab_pypr_exit_option_strings=('-h' '--help')\n_shtab_pypr_help_option_strings=('-h' '--help')\n_shtab_pypr_version_option_strings=('-h' '--help')\n_shtab_pypr_reload_option_strings=('-h' '--help')\n_shtab_pypr_attach_option_strings=('-h' '--help')\n_shtab_pypr_show_option_strings=('-h' '--help')\n_shtab_pypr_hide_option_strings=('-h' '--help')\n_shtab_pypr_toggle_option_strings=('-h' '--help')\n_shtab_pypr_bar_option_strings=('-h' '--help')\n_shtab_pypr_menu_option_strings=('-h' '--help')\n_shtab_pypr_toggle_special_option_strings=('-h' '--help')\n_shtab_pypr_layout_center_option_strings=('-h' '--help')\n_shtab_pypr_attract_lost_option_strings=('-h' '--help')\n_shtab_pypr_shift_monitors_option_strings=('-h' '--help')\n_shtab_pypr_toggle_dpms_option_strings=('-h' '--help')\n_shtab_pypr_zoom_option_strings=('-h' '--help')\n_shtab_pypr_expose_option_strings=('-h' '--help')\n_shtab_pypr_change_workspace_option_strings=('-h' '--help')\n_shtab_pypr_wall_option_strings=('-h' '--help')\n_shtab_pypr_fetch_client_menu_option_strings=('-h' '--help')\n_shtab_pypr_unfetch_client_option_strings=('-h' '--help')\n_shtab_pypr_relayout_option_strings=('-h' '--help')\n\n_shtab_pypr___debug_COMPGEN=_shtab_compgen_files\n_shtab_pypr___config_COMPGEN=_shtab_greeter_compgen_TOMLFiles\n\n_shtab_pypr_pos_0_choices=('dumpjson' 'edit' 'exit' 'help' 'version' 'reload' 'attach' 'show' 'hide' 'toggle' 'bar' 'menu' 'toggle_special' 'layout_center' 'attract_lost' 'shift_monitors' 'toggle_dpms' 'zoom' 'expose' 'change_workspace' 'wall' 'fetch_client_menu' 'unfetch_client' 'relayout')\n_shtab_pypr___print_completion_choices=('bash' 'zsh' 'tcsh')\n_shtab_pypr_bar_pos_0_choices=('restart' 'stop' 'toggle')\n_shtab_pypr_layout_center_pos_0_choices=('toggle' 'next' 'prev' 'next2' 'prev2')\n_shtab_pypr_shift_monitors_pos_0_choices=('+1' '-1')\n_shtab_pypr_zoom_pos_0_choices=('+1' '-1' '++0.5' '--0.5' '1')\n_shtab_pypr_change_workspace_pos_0_choices=('-1' '+1')\n_shtab_pypr_wall_pos_0_choices=('next' 'clear' 'pause' 'color')\n\n_shtab_pypr_pos_0_nargs=A...\n_shtab_pypr_dumpjson__h_nargs=0\n_shtab_pypr_dumpjson___help_nargs=0\n_shtab_pypr_edit__h_nargs=0\n_shtab_pypr_edit___help_nargs=0\n_shtab_pypr_exit__h_nargs=0\n_shtab_pypr_exit___help_nargs=0\n_shtab_pypr_help__h_nargs=0\n_shtab_pypr_help___help_nargs=0\n_shtab_pypr_version__h_nargs=0\n_shtab_pypr_version___help_nargs=0\n_shtab_pypr_reload__h_nargs=0\n_shtab_pypr_reload___help_nargs=0\n_shtab_pypr_attach__h_nargs=0\n_shtab_pypr_attach___help_nargs=0\n_shtab_pypr_show__h_nargs=0\n_shtab_pypr_show___help_nargs=0\n_shtab_pypr_hide__h_nargs=0\n_shtab_pypr_hide___help_nargs=0\n_shtab_pypr_toggle__h_nargs=0\n_shtab_pypr_toggle___help_nargs=0\n_shtab_pypr_bar__h_nargs=0\n_shtab_pypr_bar___help_nargs=0\n_shtab_pypr_menu__h_nargs=0\n_shtab_pypr_menu___help_nargs=0\n_shtab_pypr_toggle_special__h_nargs=0\n_shtab_pypr_toggle_special___help_nargs=0\n_shtab_pypr_layout_center__h_nargs=0\n_shtab_pypr_layout_center___help_nargs=0\n_shtab_pypr_attract_lost__h_nargs=0\n_shtab_pypr_attract_lost___help_nargs=0\n_shtab_pypr_shift_monitors__h_nargs=0\n_shtab_pypr_shift_monitors___help_nargs=0\n_shtab_pypr_toggle_dpms__h_nargs=0\n_shtab_pypr_toggle_dpms___help_nargs=0\n_shtab_pypr_zoom__h_nargs=0\n_shtab_pypr_zoom___help_nargs=0\n_shtab_pypr_expose__h_nargs=0\n_shtab_pypr_expose___help_nargs=0\n_shtab_pypr_change_workspace__h_nargs=0\n_shtab_pypr_change_workspace___help_nargs=0\n_shtab_pypr_wall__h_nargs=0\n_shtab_pypr_wall___help_nargs=0\n_shtab_pypr_fetch_client_menu__h_nargs=0\n_shtab_pypr_fetch_client_menu___help_nargs=0\n_shtab_pypr_unfetch_client__h_nargs=0\n_shtab_pypr_unfetch_client___help_nargs=0\n_shtab_pypr_relayout__h_nargs=0\n_shtab_pypr_relayout___help_nargs=0\n\n\n# $1=COMP_WORDS[1]\n_shtab_compgen_files() {\n    compgen -f -- $1  # files\n}\n\n# $1=COMP_WORDS[1]\n_shtab_compgen_dirs() {\n    compgen -d -- $1  # recurse into subdirs\n}\n\n# $1=COMP_WORDS[1]\n_shtab_replace_nonword() {\n    echo \"${1//[^[:word:]]/_}\"\n}\n\n# set default values (called for the initial parser & any subparsers)\n_set_parser_defaults() {\n    local subparsers_var=\"${prefix}_subparsers[@]\"\n    sub_parsers=${!subparsers_var-}\n\n    local current_option_strings_var=\"${prefix}_option_strings[@]\"\n    current_option_strings=${!current_option_strings_var}\n\n    completed_positional_actions=0\n\n    _set_new_action \"pos_${completed_positional_actions}\" true\n}\n\n# $1=action identifier\n# $2=positional action (bool)\n# set all identifiers for an action's parameters\n_set_new_action() {\n    current_action=\"${prefix}_$(_shtab_replace_nonword $1)\"\n\n    local current_action_compgen_var=${current_action}_COMPGEN\n    current_action_compgen=\"${!current_action_compgen_var-}\"\n\n    local current_action_choices_var=\"${current_action}_choices[@]\"\n    current_action_choices=\"${!current_action_choices_var-}\"\n\n    local current_action_nargs_var=\"${current_action}_nargs\"\n    if [ -n \"${!current_action_nargs_var-}\" ]; then\n        current_action_nargs=\"${!current_action_nargs_var}\"\n    else\n        current_action_nargs=1\n    fi\n\n    current_action_args_start_index=$(( $word_index + 1 - $pos_only ))\n\n    current_action_is_positional=$2\n}\n\n# Notes:\n# `COMPREPLY`: what will be rendered after completion is triggered\n# `completing_word`: currently typed word to generate completions for\n# `${!var}`: evaluates the content of `var` and expand its content as a variable\n#     hello=\"world\"\n#     x=\"hello\"\n#     ${!x} -> ${hello} -> \"world\"\n_shtab_pypr() {\n    local completing_word=\"${COMP_WORDS[COMP_CWORD]}\"\n    local previous_word=\"${COMP_WORDS[COMP_CWORD-1]}\"\n    local completed_positional_actions\n    local current_action\n    local current_action_args_start_index\n    local current_action_choices\n    local current_action_compgen\n    local current_action_is_positional\n    local current_action_nargs\n    local current_option_strings\n    local sub_parsers\n    COMPREPLY=()\n\n    local prefix=_shtab_pypr\n    local word_index=0\n    local pos_only=0 # \"--\" delimeter not encountered yet\n    _set_parser_defaults\n    word_index=1\n\n    # determine what arguments are appropriate for the current state\n    # of the arg parser\n    while [ $word_index -ne $COMP_CWORD ]; do\n        local this_word=\"${COMP_WORDS[$word_index]}\"\n\n        if [[ $pos_only = 1 || \" $this_word \" != \" -- \" ]]; then\n            if [[ -n $sub_parsers && \" ${sub_parsers[@]} \" == *\" ${this_word} \"* ]]; then\n                # valid subcommand: add it to the prefix & reset the current action\n                prefix=\"${prefix}_$(_shtab_replace_nonword $this_word)\"\n                _set_parser_defaults\n            fi\n\n            if [[ \" ${current_option_strings[@]} \" == *\" ${this_word} \"* ]]; then\n                # a new action should be acquired (due to recognised option string or\n                # no more input expected from current action);\n                # the next positional action can fill in here\n                _set_new_action $this_word false\n            fi\n\n            if [[ \"$current_action_nargs\" != \"*\" ]] && \\\n                [[ \"$current_action_nargs\" != \"+\" ]] && \\\n                [[ \"$current_action_nargs\" != \"?\" ]] && \\\n                [[ \"$current_action_nargs\" != *\"...\" ]] && \\\n                (( $word_index + 1 - $current_action_args_start_index - $pos_only >= \\\n                    $current_action_nargs )); then\n                $current_action_is_positional && let \"completed_positional_actions += 1\"\n                _set_new_action \"pos_${completed_positional_actions}\" true\n            fi\n        else\n            pos_only=1 # \"--\" delimeter encountered\n        fi\n\n        let \"word_index+=1\"\n    done\n\n    # Generate the completions\n\n    if [[ $pos_only = 0 && \"${completing_word}\" == -* ]]; then\n        # optional argument started: use option strings\n        COMPREPLY=( $(compgen -W \"${current_option_strings[*]}\" -- \"${completing_word}\") )\n    elif [[ \"${previous_word}\" == \">\" || \"${previous_word}\" == \">>\" || \"${previous_word}\" =~ ^[12]\">\" || \"${previous_word}\" =~ ^[12]\">>\" ]]; then\n        # handle redirection operators\n        COMPREPLY=( $(compgen -f -- \"${completing_word}\") )\n    else\n        # use choices & compgen\n        local IFS=$'\\n' # items may contain spaces, so delimit using newline\n        COMPREPLY=( $([ -n \"${current_action_compgen}\" ] \\\n            && \"${current_action_compgen}\" \"${completing_word}\") )\n        unset IFS\n        COMPREPLY+=( $(compgen -W \"${current_action_choices[*]}\" -- \"${completing_word}\") )\n    fi\n\n    return 0\n}\n\ncomplete -o filenames -F _shtab_pypr pypr\n"
  },
  {
    "path": "scripts/completions/pypr.zsh",
    "content": "#compdef pypr\n\n# AUTOMATICALLY GENERATED by `shtab`\n\n\n_shtab_pypr_commands() {\n    local _commands=(\n        \"attach:\"\n        \"attract_lost:\"\n        \"bar:\"\n        \"change_workspace:\"\n        \"dumpjson:\"\n        \"edit:\"\n        \"exit:\"\n        \"expose:\"\n        \"fetch_client_menu:\"\n        \"help:\"\n        \"hide:\"\n        \"layout_center:\"\n        \"menu:\"\n        \"relayout:\"\n        \"reload:\"\n        \"shift_monitors:\"\n        \"show:\"\n        \"toggle:\"\n        \"toggle_dpms:\"\n        \"toggle_special:\"\n        \"unfetch_client:\"\n        \"version:\"\n        \"wall:\"\n        \"zoom:\"\n    )\n    _describe 'pypr commands' _commands\n}\n\n_shtab_pypr_options=(\n    \"--debug[Enable debug mode and log to a file]:debug:_files\"\n    \"--config[Use a different configuration file]:config:_files -g '(*.toml|*.TOML)'\"\n    \"(- : *)--print-completion[print shell completion script]:print_completion:(bash zsh tcsh)\"\n)\n\n# guard to ensure default positional specs are added only once per session\n_shtab_pypr_defaults_added=0\n\n_shtab_pypr_attach_options=(\n    \"(- : *)\"{-h,--help}\"[show this help message and exit]\"\n)\n\n# guard to ensure default positional specs are added only once per session\n_shtab_pypr_attach_defaults_added=0\n\n_shtab_pypr_attract_lost_options=(\n    \"(- : *)\"{-h,--help}\"[show this help message and exit]\"\n)\n\n# guard to ensure default positional specs are added only once per session\n_shtab_pypr_attract_lost_defaults_added=0\n\n_shtab_pypr_bar_options=(\n    \"(- : *)\"{-h,--help}\"[show this help message and exit]\"\n    \":Starts gBar on the first available monitor:(restart stop toggle)\"\n    \":Starts gBar on the first available monitor:(restart stop toggle)\"\n)\n\n# guard to ensure default positional specs are added only once per session\n_shtab_pypr_bar_defaults_added=0\n\n_shtab_pypr_change_workspace_options=(\n    \"(- : *)\"{-h,--help}\"[show this help message and exit]\"\n    \":direction to switch workspaces:(-1 +1)\"\n    \":direction to switch workspaces:(-1 +1)\"\n)\n\n# guard to ensure default positional specs are added only once per session\n_shtab_pypr_change_workspace_defaults_added=0\n\n_shtab_pypr_dumpjson_options=(\n    \"(- : *)\"{-h,--help}\"[show this help message and exit]\"\n)\n\n# guard to ensure default positional specs are added only once per session\n_shtab_pypr_dumpjson_defaults_added=0\n\n_shtab_pypr_edit_options=(\n    \"(- : *)\"{-h,--help}\"[show this help message and exit]\"\n)\n\n# guard to ensure default positional specs are added only once per session\n_shtab_pypr_edit_defaults_added=0\n\n_shtab_pypr_exit_options=(\n    \"(- : *)\"{-h,--help}\"[show this help message and exit]\"\n)\n\n# guard to ensure default positional specs are added only once per session\n_shtab_pypr_exit_defaults_added=0\n\n_shtab_pypr_expose_options=(\n    \"(- : *)\"{-h,--help}\"[show this help message and exit]\"\n)\n\n# guard to ensure default positional specs are added only once per session\n_shtab_pypr_expose_defaults_added=0\n\n_shtab_pypr_fetch_client_menu_options=(\n    \"(- : *)\"{-h,--help}\"[show this help message and exit]\"\n)\n\n# guard to ensure default positional specs are added only once per session\n_shtab_pypr_fetch_client_menu_defaults_added=0\n\n_shtab_pypr_help_options=(\n    \"(- : *)\"{-h,--help}\"[show this help message and exit]\"\n)\n\n# guard to ensure default positional specs are added only once per session\n_shtab_pypr_help_defaults_added=0\n\n_shtab_pypr_hide_options=(\n    \"(- : *)\"{-h,--help}\"[show this help message and exit]\"\n    \":scratchpad name:\"\n)\n\n# guard to ensure default positional specs are added only once per session\n_shtab_pypr_hide_defaults_added=0\n\n_shtab_pypr_layout_center_options=(\n    \"(- : *)\"{-h,--help}\"[show this help message and exit]\"\n    \":Change the active window:(toggle next prev next2 prev2)\"\n    \":Change the active window:(toggle next prev next2 prev2)\"\n)\n\n# guard to ensure default positional specs are added only once per session\n_shtab_pypr_layout_center_defaults_added=0\n\n_shtab_pypr_menu_options=(\n    \"(- : *)\"{-h,--help}\"[show this help message and exit]\"\n    \":submenu to show:\"\n)\n\n# guard to ensure default positional specs are added only once per session\n_shtab_pypr_menu_defaults_added=0\n\n_shtab_pypr_relayout_options=(\n    \"(- : *)\"{-h,--help}\"[show this help message and exit]\"\n)\n\n# guard to ensure default positional specs are added only once per session\n_shtab_pypr_relayout_defaults_added=0\n\n_shtab_pypr_reload_options=(\n    \"(- : *)\"{-h,--help}\"[show this help message and exit]\"\n)\n\n# guard to ensure default positional specs are added only once per session\n_shtab_pypr_reload_defaults_added=0\n\n_shtab_pypr_shift_monitors_options=(\n    \"(- : *)\"{-h,--help}\"[show this help message and exit]\"\n    \":Swaps monitors\\' workspaces in the given direction:(+1 -1)\"\n    \":Swaps monitors\\' workspaces in the given direction:(+1 -1)\"\n)\n\n# guard to ensure default positional specs are added only once per session\n_shtab_pypr_shift_monitors_defaults_added=0\n\n_shtab_pypr_show_options=(\n    \"(- : *)\"{-h,--help}\"[show this help message and exit]\"\n    \":scratchpad name:\"\n)\n\n# guard to ensure default positional specs are added only once per session\n_shtab_pypr_show_defaults_added=0\n\n_shtab_pypr_toggle_options=(\n    \"(- : *)\"{-h,--help}\"[show this help message and exit]\"\n    \":scratchpad name:\"\n)\n\n# guard to ensure default positional specs are added only once per session\n_shtab_pypr_toggle_defaults_added=0\n\n_shtab_pypr_toggle_dpms_options=(\n    \"(- : *)\"{-h,--help}\"[show this help message and exit]\"\n)\n\n# guard to ensure default positional specs are added only once per session\n_shtab_pypr_toggle_dpms_defaults_added=0\n\n_shtab_pypr_toggle_special_options=(\n    \"(- : *)\"{-h,--help}\"[show this help message and exit]\"\n    \":special workspace name:\"\n)\n\n# guard to ensure default positional specs are added only once per session\n_shtab_pypr_toggle_special_defaults_added=0\n\n_shtab_pypr_unfetch_client_options=(\n    \"(- : *)\"{-h,--help}\"[show this help message and exit]\"\n)\n\n# guard to ensure default positional specs are added only once per session\n_shtab_pypr_unfetch_client_defaults_added=0\n\n_shtab_pypr_version_options=(\n    \"(- : *)\"{-h,--help}\"[show this help message and exit]\"\n)\n\n# guard to ensure default positional specs are added only once per session\n_shtab_pypr_version_defaults_added=0\n\n_shtab_pypr_wall_options=(\n    \"(- : *)\"{-h,--help}\"[show this help message and exit]\"\n    \":Skip the current background image:(next clear pause color)\"\n    \":Optional parameter (e.g. color hex):\"\n    \":Skip the current background image:(next clear pause color)\"\n)\n\n# guard to ensure default positional specs are added only once per session\n_shtab_pypr_wall_defaults_added=0\n\n_shtab_pypr_zoom_options=(\n    \"(- : *)\"{-h,--help}\"[show this help message and exit]\"\n    \":Zoom to the given factor:(+1 -1 ++0.5 --0.5 1)\"\n    \":Zoom to the given factor:(+1 -1 ++0.5 --0.5 1)\"\n)\n\n# guard to ensure default positional specs are added only once per session\n_shtab_pypr_zoom_defaults_added=0\n\n\n_shtab_pypr() {\n    local context state line curcontext=\"$curcontext\" one_or_more='(*)' remainder='(-)*' default='*::: :->pypr'\n\n    # Add default positional/remainder specs only if none exist, and only once per session\n    if (( ! _shtab_pypr_defaults_added )); then\n        if (( ${_shtab_pypr_options[(I)${(q)one_or_more}*]} +          ${_shtab_pypr_options[(I)${(q)remainder}*]} +          ${_shtab_pypr_options[(I)${(q)default}]} == 0 )); then\n            _shtab_pypr_options+=(': :_shtab_pypr_commands' '*::: :->pypr')\n        fi\n        _shtab_pypr_defaults_added=1\n    fi\n    _arguments -C -s $_shtab_pypr_options\n\n    case $state in\n        pypr)\n            words=($line[1] \"${words[@]}\")\n            (( CURRENT += 1 ))\n            curcontext=\"${curcontext%:*:*}:_shtab_pypr-$line[1]:\"\n            case $line[1] in\n                attach) _arguments -C -s $_shtab_pypr_attach_options ;;\n                attract_lost) _arguments -C -s $_shtab_pypr_attract_lost_options ;;\n                bar) _arguments -C -s $_shtab_pypr_bar_options ;;\n                change_workspace) _arguments -C -s $_shtab_pypr_change_workspace_options ;;\n                dumpjson) _arguments -C -s $_shtab_pypr_dumpjson_options ;;\n                edit) _arguments -C -s $_shtab_pypr_edit_options ;;\n                exit) _arguments -C -s $_shtab_pypr_exit_options ;;\n                expose) _arguments -C -s $_shtab_pypr_expose_options ;;\n                fetch_client_menu) _arguments -C -s $_shtab_pypr_fetch_client_menu_options ;;\n                help) _arguments -C -s $_shtab_pypr_help_options ;;\n                hide) _arguments -C -s $_shtab_pypr_hide_options ;;\n                layout_center) _arguments -C -s $_shtab_pypr_layout_center_options ;;\n                menu) _arguments -C -s $_shtab_pypr_menu_options ;;\n                relayout) _arguments -C -s $_shtab_pypr_relayout_options ;;\n                reload) _arguments -C -s $_shtab_pypr_reload_options ;;\n                shift_monitors) _arguments -C -s $_shtab_pypr_shift_monitors_options ;;\n                show) _arguments -C -s $_shtab_pypr_show_options ;;\n                toggle) _arguments -C -s $_shtab_pypr_toggle_options ;;\n                toggle_dpms) _arguments -C -s $_shtab_pypr_toggle_dpms_options ;;\n                toggle_special) _arguments -C -s $_shtab_pypr_toggle_special_options ;;\n                unfetch_client) _arguments -C -s $_shtab_pypr_unfetch_client_options ;;\n                version) _arguments -C -s $_shtab_pypr_version_options ;;\n                wall) _arguments -C -s $_shtab_pypr_wall_options ;;\n                zoom) _arguments -C -s $_shtab_pypr_zoom_options ;;\n            esac\n    esac\n}\n\n\n\ntypeset -A opt_args\n\nif [[ $zsh_eval_context[-1] == eval ]]; then\n    # eval/source/. command, register function for later\n    compdef _shtab_pypr -N pypr\nelse\n    # autoload from fpath, call function directly\n    _shtab_pypr \"$@\"\nfi\n\n"
  },
  {
    "path": "scripts/generate_codebase_overview.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Generate CODEBASE_OVERVIEW.md from module docstrings.\n\nParses all Python files in the pyprland package, extracts module docstrings\nand __all__ exports, and generates a structured markdown document grouped\nby logical functionality.\n\nUsage:\n    python scripts/generate_codebase_overview.py\n    # or via justfile:\n    just overview\n\"\"\"\n\nfrom __future__ import annotations\n\nimport ast\nimport sys\nfrom dataclasses import dataclass, field\nfrom pathlib import Path\n\n# Project root\nPROJECT_ROOT = Path(__file__).parent.parent\nPYPRLAND_DIR = PROJECT_ROOT / \"pyprland\"\nOUTPUT_FILE = PROJECT_ROOT / \"CODEBASE_OVERVIEW.md\"\n\n# Minimum docstring length to be considered \"good\"\nMIN_GOOD_DOCSTRING_LEN = 50\n\n\n@dataclass\nclass ModuleInfo:\n    \"\"\"Information extracted from a Python module.\"\"\"\n\n    path: Path\n    relative_path: str\n    docstring: str | None\n    exports: list[str] = field(default_factory=list)\n    classes: dict[str, str] = field(default_factory=dict)  # name -> docstring\n\n    @property\n    def has_good_docstring(self) -> bool:\n        \"\"\"Check if module has a meaningful docstring (>50 chars).\"\"\"\n        return bool(self.docstring and len(self.docstring.strip()) > MIN_GOOD_DOCSTRING_LEN)\n\n    @property\n    def docstring_status(self) -> str:\n        \"\"\"Return status: good, brief, or missing.\"\"\"\n        if not self.docstring:\n            return \"missing\"\n        return \"good\" if self.has_good_docstring else \"brief\"\n\n\n# Logical groupings for core modules\nCORE_GROUPINGS: dict[str, list[str]] = {\n    \"Entry Points & CLI\": [\n        \"command.py\",\n        \"client.py\",\n        \"pypr_daemon.py\",\n        \"help.py\",\n    ],\n    \"Configuration\": [\n        \"config.py\",\n        \"config_loader.py\",\n        \"validation.py\",\n        \"validate_cli.py\",\n    ],\n    \"IPC & Communication\": [\n        \"ipc.py\",\n        \"ipc_paths.py\",\n        \"httpclient.py\",\n    ],\n    \"Process & Task Management\": [\n        \"manager.py\",\n        \"process.py\",\n        \"aioops.py\",\n        \"state.py\",\n    ],\n    \"Utilities\": [\n        \"utils.py\",\n        \"common.py\",\n        \"terminal.py\",\n        \"ansi.py\",\n        \"debug.py\",\n        \"logging_setup.py\",\n    ],\n    \"Types & Models\": [\n        \"models.py\",\n        \"constants.py\",\n        \"version.py\",\n    ],\n    \"Shell Integration\": [\n        \"completions.py\",\n        \"command_registry.py\",\n    ],\n}\n\nADAPTER_GROUPINGS: dict[str, list[str]] = {\n    \"Core Abstraction\": [\n        \"backend.py\",\n        \"proxy.py\",\n        \"fallback.py\",\n    ],\n    \"Compositor Backends\": [\n        \"hyprland.py\",\n        \"niri.py\",\n        \"wayland.py\",\n        \"xorg.py\",\n    ],\n    \"Utilities\": [\n        \"menus.py\",\n        \"colors.py\",\n        \"units.py\",\n    ],\n}\n\nPLUGIN_GROUPINGS: dict[str, list[str]] = {\n    \"Infrastructure\": [\n        \"interface.py\",\n        \"mixins.py\",\n        \"protocols.py\",\n    ],\n    \"Window Management\": [\n        \"scratchpads/\",\n        \"expose.py\",\n        \"fetch_client_menu.py\",\n        \"layout_center.py\",\n        \"lost_windows.py\",\n        \"toggle_special.py\",\n    ],\n    \"Monitor & Display\": [\n        \"monitors/\",\n        \"shift_monitors.py\",\n        \"magnify.py\",\n        \"toggle_dpms.py\",\n        \"wallpapers/\",\n    ],\n    \"Menus & Launchers\": [\n        \"shortcuts_menu.py\",\n        \"menubar.py\",\n    ],\n    \"System Integration\": [\n        \"system_notifier.py\",\n        \"fcitx5_switcher.py\",\n        \"workspaces_follow_focus.py\",\n    ],\n}\n\n\ndef parse_module(path: Path) -> ModuleInfo:\n    \"\"\"Parse a Python module and extract docstring, exports, and classes.\"\"\"\n    relative = path.relative_to(PYPRLAND_DIR)\n\n    try:\n        source = path.read_text(encoding=\"utf-8\")\n        tree = ast.parse(source)\n    except (SyntaxError, UnicodeDecodeError) as e:\n        return ModuleInfo(\n            path=path,\n            relative_path=str(relative),\n            docstring=f\"# Parse error: {e}\",\n        )\n\n    # Extract module docstring\n    docstring = ast.get_docstring(tree)\n\n    # Extract __all__ exports\n    exports: list[str] = []\n    for node in ast.walk(tree):\n        if isinstance(node, ast.Assign):\n            for target in node.targets:\n                if isinstance(target, ast.Name) and target.id == \"__all__\":\n                    if isinstance(node.value, ast.List):\n                        exports = [elt.value for elt in node.value.elts if isinstance(elt, ast.Constant) and isinstance(elt.value, str)]\n\n    # Extract class docstrings\n    classes: dict[str, str] = {}\n    for node in ast.iter_child_nodes(tree):\n        if isinstance(node, ast.ClassDef):\n            class_doc = ast.get_docstring(node)\n            if class_doc:\n                # Take first line only\n                classes[node.name] = class_doc.split(\"\\n\")[0]\n\n    return ModuleInfo(\n        path=path,\n        relative_path=str(relative),\n        docstring=docstring,\n        exports=exports,\n        classes=classes,\n    )\n\n\ndef collect_modules() -> dict[str, list[ModuleInfo]]:\n    \"\"\"Collect all modules organized by package.\"\"\"\n    modules: dict[str, list[ModuleInfo]] = {\n        \"core\": [],\n        \"adapters\": [],\n        \"plugins\": [],\n    }\n\n    # Core modules\n    for py_file in sorted(PYPRLAND_DIR.glob(\"*.py\")):\n        if py_file.name != \"__pycache__\":\n            modules[\"core\"].append(parse_module(py_file))\n\n    # Adapters\n    adapters_dir = PYPRLAND_DIR / \"adapters\"\n    for py_file in sorted(adapters_dir.glob(\"*.py\")):\n        modules[\"adapters\"].append(parse_module(py_file))\n\n    # Plugins (top-level only, subdirs handled separately)\n    plugins_dir = PYPRLAND_DIR / \"plugins\"\n    for py_file in sorted(plugins_dir.glob(\"*.py\")):\n        modules[\"plugins\"].append(parse_module(py_file))\n\n    return modules\n\n\ndef format_module_row(mod: ModuleInfo) -> str:\n    \"\"\"Format a module as a markdown table row.\"\"\"\n    name = mod.relative_path.replace(\"\\\\\", \"/\")\n    status_icon = {\"good\": \"✓\", \"brief\": \"~\", \"missing\": \"✗\"}[mod.docstring_status]\n\n    # First line of docstring or status message\n    max_desc_len = 80\n    if mod.docstring:\n        desc = mod.docstring.split(\"\\n\")[0][:max_desc_len]\n    else:\n        desc = \"*No docstring*\"\n\n    return f\"| `{name}` | {status_icon} | {desc} |\"\n\n\ndef generate_section(title: str, modules: list[ModuleInfo], groupings: dict[str, list[str]]) -> list[str]:\n    \"\"\"Generate markdown section for a group of modules.\"\"\"\n    lines = [f\"## {title}\", \"\"]\n\n    # Track which modules we've included\n    included: set[str] = set()\n\n    for group_name, patterns in groupings.items():\n        group_modules = []\n        for mod in modules:\n            filename = Path(mod.relative_path).name\n            if filename in patterns or any(filename.startswith(p.rstrip(\"/\")) for p in patterns if p.endswith(\"/\")):\n                group_modules.append(mod)\n                included.add(mod.relative_path)\n\n        if group_modules:\n            lines.append(f\"### {group_name}\")\n            lines.append(\"\")\n            lines.append(\"| Module | Status | Description |\")\n            lines.append(\"|--------|--------|-------------|\")\n            for mod in group_modules:\n                lines.append(format_module_row(mod))\n            lines.append(\"\")\n\n    # Add ungrouped modules\n    ungrouped = [m for m in modules if m.relative_path not in included]\n    if ungrouped:\n        lines.append(\"### Other\")\n        lines.append(\"\")\n        lines.append(\"| Module | Status | Description |\")\n        lines.append(\"|--------|--------|-------------|\")\n        for mod in ungrouped:\n            lines.append(format_module_row(mod))\n        lines.append(\"\")\n\n    return lines\n\n\ndef generate_coverage_report(all_modules: list[ModuleInfo]) -> list[str]:\n    \"\"\"Generate documentation coverage summary.\"\"\"\n    good = sum(1 for m in all_modules if m.docstring_status == \"good\")\n    brief = sum(1 for m in all_modules if m.docstring_status == \"brief\")\n    missing = sum(1 for m in all_modules if m.docstring_status == \"missing\")\n    total = len(all_modules)\n\n    lines = [\n        \"## Documentation Coverage\",\n        \"\",\n        \"| Status | Count | Percentage |\",\n        \"|--------|-------|------------|\",\n        f\"| ✓ Good | {good} | {100 * good / total:.0f}% |\",\n        f\"| ~ Brief | {brief} | {100 * brief / total:.0f}% |\",\n        f\"| ✗ Missing | {missing} | {100 * missing / total:.0f}% |\",\n        f\"| **Total** | **{total}** | |\",\n        \"\",\n    ]\n\n    # List files needing improvement\n    needs_work = [m for m in all_modules if m.docstring_status != \"good\"]\n    if needs_work:\n        lines.append(\"### Files Needing Improvement\")\n        lines.append(\"\")\n        for mod in needs_work:\n            status = \"missing docstring\" if not mod.docstring else \"brief docstring\"\n            lines.append(f\"- `{mod.relative_path}` - {status}\")\n        lines.append(\"\")\n\n    return lines\n\n\ndef main() -> int:\n    \"\"\"Generate the codebase overview.\"\"\"\n    print(\"Collecting modules...\")\n    modules = collect_modules()\n\n    all_modules = modules[\"core\"] + modules[\"adapters\"] + modules[\"plugins\"]\n\n    print(f\"Found {len(all_modules)} modules\")\n\n    # Generate document\n    lines = [\n        \"# Pyprland Codebase Overview\",\n        \"\",\n        \"*Auto-generated from module docstrings. Run `just overview` to regenerate.*\",\n        \"\",\n    ]\n\n    # Coverage report first\n    lines.extend(generate_coverage_report(all_modules))\n\n    # Core modules\n    lines.extend(generate_section(\"Core Modules\", modules[\"core\"], CORE_GROUPINGS))\n\n    # Adapters\n    lines.extend(generate_section(\"Adapters\", modules[\"adapters\"], ADAPTER_GROUPINGS))\n\n    # Plugins\n    lines.extend(generate_section(\"Plugins\", modules[\"plugins\"], PLUGIN_GROUPINGS))\n\n    # Write output\n    OUTPUT_FILE.write_text(\"\\n\".join(lines), encoding=\"utf-8\")\n    print(f\"Generated {OUTPUT_FILE}\")\n\n    return 0\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n"
  },
  {
    "path": "scripts/generate_monitor_diagrams.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Generate SVG diagrams for monitor placement documentation.\"\"\"\n\nfrom dataclasses import dataclass, field\nfrom pathlib import Path\n\n\n@dataclass\nclass Monitor:\n    \"\"\"Monitor configuration.\"\"\"\n\n    label: str\n    width: int = 1920\n    height: int = 1080\n    transform: int = 0  # 0=normal, 1=90°, 2=180°, 3=270°\n    scale: float = 1.0\n    color_class: str = \"monitor-a\"\n\n    def effective_size(self, base_unit: float = 0.1) -> tuple[float, float]:\n        \"\"\"Calculate effective diagram size.\n\n        Args:\n            base_unit: Scale factor for diagram (0.1 = 10px per 100 real pixels)\n\n        Returns:\n            Tuple of (width, height) in diagram pixels\n        \"\"\"\n        w, h = self.width, self.height\n        if self.transform in [1, 3]:  # Portrait (90° or 270°)\n            w, h = h, w\n        # Scale < 1 makes screen appear larger (more real estate)\n        factor = 1 / self.scale\n        return w * base_unit * factor, h * base_unit * factor\n\n\n@dataclass\nclass DiagramConfig:\n    \"\"\"Configuration for a diagram.\"\"\"\n\n    monitors: list[Monitor] = field(default_factory=list)\n    positions: list[tuple[float, float]] = field(default_factory=list)\n    padding: int = 10\n\n\nSVG_TEMPLATE = \"\"\"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {width} {height}\">\n  <style>\n    .monitor {{\n      stroke: var(--vp-c-border, #c2c2c4);\n      stroke-width: 2;\n    }}\n    .monitor-a {{ fill: #60a5fa; }}  /* Pastel Blue */\n    .monitor-b {{ fill: #86efac; }}  /* Pastel Green */\n    .monitor-c {{ fill: #fdba74; }}  /* Pastel Orange */\n    .label {{\n      fill: #1f2937;  /* Dark gray, almost black */\n      font-family: system-ui, -apple-system, sans-serif;\n      font-size: 16px;\n      font-weight: 600;\n      text-anchor: middle;\n      dominant-baseline: central;\n    }}\n  </style>\n{rects}\n</svg>\"\"\"\n\nRECT_TEMPLATE = '  <rect class=\"monitor {color_class}\" x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\" rx=\"4\" />'\nTEXT_TEMPLATE = '  <text class=\"label\" x=\"{x}\" y=\"{y}\">{label}</text>'\n\n\ndef generate_svg(config: DiagramConfig) -> str:\n    \"\"\"Generate SVG content from diagram configuration.\"\"\"\n    rects = []\n    padding = config.padding\n\n    # Calculate bounding box\n    max_x = 0\n    max_y = 0\n\n    for mon, (px, py) in zip(config.monitors, config.positions):\n        w, h = mon.effective_size()\n        max_x = max(max_x, px + w)\n        max_y = max(max_y, py + h)\n\n    svg_width = max_x + padding * 2\n    svg_height = max_y + padding * 2\n\n    # Generate rectangles and labels\n    for mon, (px, py) in zip(config.monitors, config.positions):\n        w, h = mon.effective_size()\n        x = px + padding\n        y = py + padding\n\n        rects.append(RECT_TEMPLATE.format(color_class=mon.color_class, x=x, y=y, w=w, h=h))\n        rects.append(TEXT_TEMPLATE.format(x=x + w / 2, y=y + h / 2, label=mon.label))\n\n    return SVG_TEMPLATE.format(width=int(svg_width), height=int(svg_height), rects=\"\\n\".join(rects))\n\n\n# =============================================================================\n# Diagram Definitions\n# =============================================================================\n\n# Base monitor sizes\nFULL_HD = (1920, 1080)\nSMALLER = (1280, 720)\n\n# Base unit for diagram scaling\nBASE = 0.1\n\n\ndef basic_top_of() -> DiagramConfig:\n    \"\"\"A on top of B.\"\"\"\n    a = Monitor(\"A\", *FULL_HD, color_class=\"monitor-a\")\n    b = Monitor(\"B\", *FULL_HD, color_class=\"monitor-b\")\n    aw, ah = a.effective_size(BASE)\n    return DiagramConfig(monitors=[a, b], positions=[(0, 0), (0, ah)])\n\n\ndef basic_bottom_of() -> DiagramConfig:\n    \"\"\"A below B.\"\"\"\n    a = Monitor(\"A\", *FULL_HD, color_class=\"monitor-a\")\n    b = Monitor(\"B\", *FULL_HD, color_class=\"monitor-b\")\n    bw, bh = b.effective_size(BASE)\n    return DiagramConfig(monitors=[b, a], positions=[(0, 0), (0, bh)])\n\n\ndef basic_left_of() -> DiagramConfig:\n    \"\"\"A left of B.\"\"\"\n    a = Monitor(\"A\", *FULL_HD, color_class=\"monitor-a\")\n    b = Monitor(\"B\", *FULL_HD, color_class=\"monitor-b\")\n    aw, ah = a.effective_size(BASE)\n    return DiagramConfig(monitors=[a, b], positions=[(0, 0), (aw, 0)])\n\n\ndef basic_right_of() -> DiagramConfig:\n    \"\"\"A right of B.\"\"\"\n    a = Monitor(\"A\", *FULL_HD, color_class=\"monitor-a\")\n    b = Monitor(\"B\", *FULL_HD, color_class=\"monitor-b\")\n    bw, bh = b.effective_size(BASE)\n    return DiagramConfig(monitors=[b, a], positions=[(0, 0), (bw, 0)])\n\n\ndef align_left_start() -> DiagramConfig:\n    \"\"\"A left of B, top-aligned (start).\"\"\"\n    a = Monitor(\"A\", *SMALLER, color_class=\"monitor-a\")\n    b = Monitor(\"B\", *FULL_HD, color_class=\"monitor-b\")\n    aw, ah = a.effective_size(BASE)\n    return DiagramConfig(monitors=[a, b], positions=[(0, 0), (aw, 0)])\n\n\ndef align_left_center() -> DiagramConfig:\n    \"\"\"A left of B, center-aligned.\"\"\"\n    a = Monitor(\"A\", *SMALLER, color_class=\"monitor-a\")\n    b = Monitor(\"B\", *FULL_HD, color_class=\"monitor-b\")\n    aw, ah = a.effective_size(BASE)\n    bw, bh = b.effective_size(BASE)\n    a_y = (bh - ah) / 2\n    return DiagramConfig(monitors=[a, b], positions=[(0, a_y), (aw, 0)])\n\n\ndef align_left_end() -> DiagramConfig:\n    \"\"\"A left of B, bottom-aligned (end).\"\"\"\n    a = Monitor(\"A\", *SMALLER, color_class=\"monitor-a\")\n    b = Monitor(\"B\", *FULL_HD, color_class=\"monitor-b\")\n    aw, ah = a.effective_size(BASE)\n    bw, bh = b.effective_size(BASE)\n    a_y = bh - ah\n    return DiagramConfig(monitors=[a, b], positions=[(0, a_y), (aw, 0)])\n\n\ndef align_top_start() -> DiagramConfig:\n    \"\"\"A on top of B, left-aligned (start).\"\"\"\n    a = Monitor(\"A\", *SMALLER, color_class=\"monitor-a\")\n    b = Monitor(\"B\", *FULL_HD, color_class=\"monitor-b\")\n    aw, ah = a.effective_size(BASE)\n    return DiagramConfig(monitors=[a, b], positions=[(0, 0), (0, ah)])\n\n\ndef align_top_center() -> DiagramConfig:\n    \"\"\"A on top of B, center-aligned.\"\"\"\n    a = Monitor(\"A\", *SMALLER, color_class=\"monitor-a\")\n    b = Monitor(\"B\", *FULL_HD, color_class=\"monitor-b\")\n    aw, ah = a.effective_size(BASE)\n    bw, bh = b.effective_size(BASE)\n    a_x = (bw - aw) / 2\n    return DiagramConfig(monitors=[a, b], positions=[(a_x, 0), (0, ah)])\n\n\ndef align_top_end() -> DiagramConfig:\n    \"\"\"A on top of B, right-aligned (end).\"\"\"\n    a = Monitor(\"A\", *SMALLER, color_class=\"monitor-a\")\n    b = Monitor(\"B\", *FULL_HD, color_class=\"monitor-b\")\n    aw, ah = a.effective_size(BASE)\n    bw, bh = b.effective_size(BASE)\n    a_x = bw - aw\n    return DiagramConfig(monitors=[a, b], positions=[(a_x, 0), (0, ah)])\n\n\ndef setup_dual() -> DiagramConfig:\n    \"\"\"A and B side by side.\"\"\"\n    a = Monitor(\"A\", *FULL_HD, color_class=\"monitor-a\")\n    b = Monitor(\"B\", *FULL_HD, color_class=\"monitor-b\")\n    aw, ah = a.effective_size(BASE)\n    return DiagramConfig(monitors=[a, b], positions=[(0, 0), (aw, 0)])\n\n\ndef setup_triple() -> DiagramConfig:\n    \"\"\"A, B, C horizontal.\"\"\"\n    a = Monitor(\"A\", *FULL_HD, color_class=\"monitor-a\")\n    b = Monitor(\"B\", *FULL_HD, color_class=\"monitor-b\")\n    c = Monitor(\"C\", *FULL_HD, color_class=\"monitor-c\")\n    aw, ah = a.effective_size(BASE)\n    bw, bh = b.effective_size(BASE)\n    return DiagramConfig(monitors=[a, b, c], positions=[(0, 0), (aw, 0), (aw + bw, 0)])\n\n\ndef setup_stacked() -> DiagramConfig:\n    \"\"\"A, B, C vertical.\"\"\"\n    a = Monitor(\"A\", *FULL_HD, color_class=\"monitor-a\")\n    b = Monitor(\"B\", *FULL_HD, color_class=\"monitor-b\")\n    c = Monitor(\"C\", *FULL_HD, color_class=\"monitor-c\")\n    aw, ah = a.effective_size(BASE)\n    bw, bh = b.effective_size(BASE)\n    return DiagramConfig(monitors=[a, b, c], positions=[(0, 0), (0, ah), (0, ah + bh)])\n\n\ndef real_world_l_shape() -> DiagramConfig:\n    \"\"\"Real-world L-shape with portrait monitor.\n\n    B (eDP-1): 1920x1080, transform=0, scale=1.0 (anchor, bottom)\n    A (HDMI-A-1): 1920x1080, transform=1, scale=0.83 (portrait, top-left of B)\n    C: 1920x1080, transform=0, scale=1.0 (right-end of A, tucked in corner)\n    \"\"\"\n    # B is the anchor at bottom\n    b = Monitor(\"B\", 1920, 1080, transform=0, scale=1.0, color_class=\"monitor-b\")\n    # A is portrait (transform=1) with scale=0.83 (appears larger)\n    a = Monitor(\"A\", 1920, 1080, transform=1, scale=0.83, color_class=\"monitor-a\")\n    # C is normal, same as B\n    c = Monitor(\"C\", 1920, 1080, transform=0, scale=1.0, color_class=\"monitor-c\")\n\n    aw, ah = a.effective_size(BASE)  # A is portrait and scaled\n    bw, bh = b.effective_size(BASE)\n    cw, ch = c.effective_size(BASE)\n\n    # A is on top of B (left-aligned with B's left edge)\n    a_x, a_y = 0, 0\n\n    # C is right-end of A (bottom edges aligned)\n    c_x = aw  # Right of A\n    c_y = ah - ch  # Bottom of C aligns with bottom of A\n\n    # B is below A (and partially below C)\n    b_x = 0\n    b_y = ah  # Below A\n\n    return DiagramConfig(monitors=[a, c, b], positions=[(a_x, a_y), (c_x, c_y), (b_x, b_y)])\n\n\n# =============================================================================\n# Main\n# =============================================================================\n\nDIAGRAMS = {\n    \"basic-top-of\": basic_top_of,\n    \"basic-bottom-of\": basic_bottom_of,\n    \"basic-left-of\": basic_left_of,\n    \"basic-right-of\": basic_right_of,\n    \"align-left-start\": align_left_start,\n    \"align-left-center\": align_left_center,\n    \"align-left-end\": align_left_end,\n    \"align-top-start\": align_top_start,\n    \"align-top-center\": align_top_center,\n    \"align-top-end\": align_top_end,\n    \"setup-dual\": setup_dual,\n    \"setup-triple\": setup_triple,\n    \"setup-stacked\": setup_stacked,\n    \"real-world-l-shape\": real_world_l_shape,\n}\n\n\ndef main() -> None:\n    \"\"\"Generate all SVG diagrams.\"\"\"\n    output_dir = Path(__file__).parent.parent / \"site\" / \"public\" / \"images\" / \"monitors\"\n    output_dir.mkdir(parents=True, exist_ok=True)\n\n    for name, config_fn in DIAGRAMS.items():\n        config = config_fn()\n        svg = generate_svg(config)\n        output_path = output_dir / f\"{name}.svg\"\n        output_path.write_text(svg)\n        print(f\"Generated: {output_path}\")\n\n    print(f\"\\nGenerated {len(DIAGRAMS)} SVG files in {output_dir}\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "scripts/generate_plugin_docs.py",
    "content": "#!/usr/bin/env python\n\"\"\"Generate JSON documentation for pyprland plugins.\n\nThis script extracts documentation from plugin source code:\n- Configuration schema (from config_schema class attribute)\n- Commands (from run_* methods and their docstrings)\n- Plugin metadata (description, environments)\n\nOutput is written to site/generated/ as JSON files.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport importlib\nimport inspect\nimport json\nimport sys\nimport tomllib\nfrom dataclasses import asdict, dataclass, field\nfrom pathlib import Path\nfrom typing import Any\n\n# Add the project root to the path so we can import pyprland modules\nPROJECT_ROOT = Path(__file__).parent.parent\nsys.path.insert(0, str(PROJECT_ROOT))\n\nfrom pyprland.commands.discovery import extract_commands_from_object, get_client_commands\nfrom pyprland.commands.models import CommandArg\nfrom pyprland.commands.tree import get_display_name, get_parent_prefixes\n\n# Paths\nPLUGINS_DIR = PROJECT_ROOT / \"pyprland\" / \"plugins\"\nOUTPUT_DIR = PROJECT_ROOT / \"site\" / \"generated\"\nMETADATA_FILE = PROJECT_ROOT / \"scripts\" / \"plugin_metadata.toml\"\n\n# Plugins to skip (not real plugins)\nSKIP_PLUGINS = {\"interface\", \"protocols\", \"__init__\", \"experimental\", \"mixins\"}\n\n\n@dataclass\nclass ConfigItem:\n    \"\"\"A configuration field extracted from a plugin.\"\"\"\n\n    name: str\n    type: str\n    required: bool = False\n    recommended: bool = False\n    default: Any = None\n    description: str = \"\"\n    choices: list[str] | None = None\n    children: list[\"ConfigItem\"] | None = None\n    category: str = \"\"\n    is_directory: bool = False  # For Path types: True = directory, False = file\n\n\n@dataclass\nclass CommandItem:\n    \"\"\"A command extracted from a plugin.\"\"\"\n\n    name: str\n    args: list[CommandArg]  # Structured args for rendering\n    short_description: str\n    full_description: str\n\n\n@dataclass\nclass PluginDoc:\n    \"\"\"Complete documentation for a plugin.\"\"\"\n\n    name: str\n    description: str\n    environments: list[str]\n    commands: list[CommandItem] = field(default_factory=list)\n    config: list[ConfigItem] = field(default_factory=list)\n\n\ndef extract_config_from_schema(schema: list) -> list[ConfigItem]:\n    \"\"\"Extract configuration items from a config_schema list.\n\n    Args:\n        schema: List of ConfigField objects\n\n    Returns:\n        List of ConfigItem dataclasses\n    \"\"\"\n    config_items = []\n    for field_def in schema:\n        # Handle default value serialization\n        default = field_def.default\n        if default is not None and not isinstance(default, (str, int, float, bool, list, dict)):\n            default = str(default)\n\n        # Extract children if present\n        children_items = None\n        if getattr(field_def, \"children\", None):\n            children_items = extract_config_from_schema(field_def.children)\n\n        config_items.append(\n            ConfigItem(\n                name=field_def.name,\n                type=field_def.type_name,\n                required=field_def.required,\n                recommended=getattr(field_def, \"recommended\", False),\n                default=default,\n                description=field_def.description,\n                choices=field_def.choices,\n                children=children_items,\n                category=getattr(field_def, \"category\", \"\"),\n                is_directory=getattr(field_def, \"is_directory\", False),\n            )\n        )\n    return config_items\n\n\ndef extract_commands(extension_class: type) -> list[CommandItem]:\n    \"\"\"Extract command documentation from a plugin's Extension class.\n\n    Args:\n        extension_class: The Extension class from a plugin module\n\n    Returns:\n        List of CommandItem dataclasses\n    \"\"\"\n    commands = []\n    for cmd_info in extract_commands_from_object(extension_class, source=\"\"):\n        commands.append(\n            CommandItem(\n                name=cmd_info.name,\n                args=cmd_info.args,\n                short_description=cmd_info.short_description,\n                full_description=cmd_info.full_description,\n            )\n        )\n    return commands\n\n\ndef get_plugin_description(extension_class: type) -> str:\n    \"\"\"Get the description from a plugin's Extension class docstring.\"\"\"\n    doc = inspect.getdoc(extension_class)\n    if doc:\n        # Return first line/sentence as description\n        return doc.split(\"\\n\")[0].strip()\n    return \"\"\n\n\ndef check_menu_mixin(extension_class: type) -> list:\n    \"\"\"Check if the plugin uses MenuMixin and return its schema if so.\n\n    Args:\n        extension_class: The Extension class\n\n    Returns:\n        List of ConfigField from MenuMixin, or empty list\n    \"\"\"\n    # Check class hierarchy for MenuMixin\n    for base in extension_class.__mro__:\n        if base.__name__ == \"MenuMixin\":\n            # Import MenuMixin's schema\n            from pyprland.adapters.menus import MenuMixin\n\n            return list(MenuMixin.menu_config_schema)\n    return []\n\n\ndef load_scratchpads_schema() -> list:\n    \"\"\"Load the scratchpads-specific schema from schema.py.\"\"\"\n    from pyprland.plugins.scratchpads.schema import SCRATCHPAD_SCHEMA\n\n    return list(SCRATCHPAD_SCHEMA)\n\n\ndef discover_plugins() -> list[str]:\n    \"\"\"Discover all available plugins.\n\n    Returns:\n        List of plugin names\n    \"\"\"\n    plugins = []\n\n    for item in PLUGINS_DIR.iterdir():\n        if item.name.startswith(\"_\"):\n            continue\n\n        if item.is_file() and item.suffix == \".py\":\n            name = item.stem\n            if name not in SKIP_PLUGINS:\n                plugins.append(name)\n        elif item.is_dir() and (item / \"__init__.py\").exists():\n            if item.name not in SKIP_PLUGINS:\n                plugins.append(item.name)\n\n    return sorted(plugins)\n\n\ndef load_plugin(plugin_name: str) -> PluginDoc | None:\n    \"\"\"Load a plugin and extract its documentation.\n\n    Args:\n        plugin_name: Name of the plugin to load\n\n    Returns:\n        PluginDoc dataclass or None if loading failed\n    \"\"\"\n    try:\n        module = importlib.import_module(f\"pyprland.plugins.{plugin_name}\")\n    except ImportError as e:\n        print(f\"  Warning: Could not import {plugin_name}: {e}\")\n        return None\n\n    if not hasattr(module, \"Extension\"):\n        print(f\"  Warning: {plugin_name} has no Extension class\")\n        return None\n\n    extension_class = module.Extension\n\n    # Get basic info\n    description = get_plugin_description(extension_class)\n    environments = getattr(extension_class, \"environments\", [])\n\n    # Extract commands\n    commands = extract_commands(extension_class)\n\n    # For pyprland plugin, also include client-only commands (edit, validate)\n    if plugin_name == \"pyprland\":\n        for cmd_info in get_client_commands():\n            commands.append(\n                CommandItem(\n                    name=cmd_info.name,\n                    args=cmd_info.args,\n                    short_description=cmd_info.short_description,\n                    full_description=cmd_info.full_description,\n                )\n            )\n\n    # Extract configuration schema\n    config_items = []\n\n    # Check for config_schema on the class\n    config_schema = getattr(extension_class, \"config_schema\", [])\n    if config_schema:\n        config_items.extend(extract_config_from_schema(config_schema))\n\n    # Check for MenuMixin schema\n    menu_schema = check_menu_mixin(extension_class)\n    if menu_schema:\n        # Add menu config items, avoiding duplicates\n        existing_names = {c.name for c in config_items}\n        for field_def in menu_schema:\n            if field_def.name not in existing_names:\n                config_items.extend(extract_config_from_schema([field_def]))\n\n    # Special case: scratchpads has per-scratchpad schema\n    if plugin_name == \"scratchpads\":\n        # Add a special marker for the scratchpad item schema\n        scratchpad_schema = load_scratchpads_schema()\n        # We'll add these as a nested structure\n        # For now, add them with a prefix indicator\n        for field_def in scratchpad_schema:\n            item = ConfigItem(\n                name=f\"[scratchpad].{field_def.name}\",\n                type=field_def.type_name,\n                required=field_def.required,\n                recommended=getattr(field_def, \"recommended\", False),\n                default=field_def.default,\n                description=field_def.description,\n                choices=field_def.choices,\n                category=getattr(field_def, \"category\", \"\"),\n                is_directory=getattr(field_def, \"is_directory\", False),\n            )\n            config_items.append(item)\n\n    return PluginDoc(\n        name=plugin_name,\n        description=description,\n        environments=environments,\n        commands=commands,\n        config=config_items,\n    )\n\n\ndef load_metadata() -> dict[str, dict]:\n    \"\"\"Load the editorial metadata file.\"\"\"\n    if METADATA_FILE.exists():\n        with open(METADATA_FILE, mode=\"rb\") as f:\n            return tomllib.load(f)\n    return {}\n\n\ndef generate_plugin_json(plugin_doc: PluginDoc) -> dict:\n    \"\"\"Convert a PluginDoc to a JSON-serializable dict.\"\"\"\n    return {\n        \"name\": plugin_doc.name,\n        \"description\": plugin_doc.description,\n        \"environments\": plugin_doc.environments,\n        \"commands\": [asdict(cmd) for cmd in plugin_doc.commands],\n        \"config\": [asdict(cfg) for cfg in plugin_doc.config],\n    }\n\n\ndef generate_menu_json() -> dict:\n    \"\"\"Generate JSON for the Menu capability (shared by menu-based plugins).\n\n    Returns:\n        Dict ready for JSON serialization\n    \"\"\"\n    from pyprland.adapters.menus import MenuMixin, every_menu_engine\n\n    config_items = extract_config_from_schema(MenuMixin.menu_config_schema)\n\n    # Extract engine default parameters from the implementation\n    engine_defaults = {engine.proc_name: engine.proc_extra_parameters for engine in every_menu_engine}\n\n    return {\n        \"name\": \"menu\",\n        \"description\": \"Shared configuration for menu-based plugins.\",\n        \"environments\": [],\n        \"commands\": [],\n        \"config\": [asdict(cfg) for cfg in config_items],\n        \"engine_defaults\": engine_defaults,\n    }\n\n\ndef generate_index_json(plugin_docs: list[PluginDoc], metadata: dict) -> dict:\n    \"\"\"Generate the index.json with all plugins.\n\n    Args:\n        plugin_docs: List of PluginDoc dataclasses\n        metadata: Editorial metadata dict\n\n    Returns:\n        Dict ready for JSON serialization\n    \"\"\"\n    plugins = []\n    for doc in plugin_docs:\n        plugin_meta = metadata.get(doc.name, {})\n        plugins.append(\n            {\n                \"name\": doc.name,\n                \"description\": doc.description,\n                \"environments\": doc.environments,\n                \"stars\": plugin_meta.get(\"stars\", 0),\n                \"demoVideoId\": plugin_meta.get(\"demoVideoId\"),\n                \"multimon\": plugin_meta.get(\"multimon\", False),\n            }\n        )\n\n    # Sort by stars (descending), then name (ascending)\n    plugins.sort(key=lambda p: (-p[\"stars\"], p[\"name\"]))\n\n    return {\"plugins\": plugins}\n\n\ndef main():\n    \"\"\"Main entry point.\"\"\"\n    print(\"Generating plugin documentation...\")\n\n    # Ensure output directory exists\n    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)\n\n    # Discover and load plugins\n    plugin_names = discover_plugins()\n    print(f\"Found {len(plugin_names)} plugins: {', '.join(plugin_names)}\")\n\n    # Load metadata\n    metadata = load_metadata()\n    print(f\"Loaded metadata for {len(metadata)} plugins\")\n\n    # Process each plugin (collect docs first, don't write yet)\n    plugin_docs = []\n    for plugin_name in plugin_names:\n        print(f\"Processing {plugin_name}...\")\n        doc = load_plugin(plugin_name)\n        if doc:\n            plugin_docs.append(doc)\n\n    # Compute parent prefixes from ALL commands across ALL plugins\n    # Only group commands from the SAME plugin (source) into hierarchies\n    # e.g., wall_rm -> \"wall rm\" (same plugin as wall_next, wall_pause)\n    # but toggle_special stays as-is (different plugin than toggle_dpms)\n    all_commands_with_source = {cmd.name: doc.name for doc in plugin_docs for cmd in doc.commands}\n    parent_prefixes = get_parent_prefixes(all_commands_with_source)\n\n    # Transform command names to display format\n    for doc in plugin_docs:\n        for cmd in doc.commands:\n            cmd.name = get_display_name(cmd.name, parent_prefixes)\n\n    # Write individual plugin JSON files\n    for doc in plugin_docs:\n        output_file = OUTPUT_DIR / f\"{doc.name}.json\"\n        with open(output_file, \"w\") as f:\n            json.dump(generate_plugin_json(doc), f, indent=2)\n            f.write(\"\\n\")\n        print(f\"  -> {output_file.relative_to(PROJECT_ROOT)}\")\n\n    # Generate index.json\n    index_file = OUTPUT_DIR / \"index.json\"\n    with open(index_file, \"w\") as f:\n        json.dump(generate_index_json(plugin_docs, metadata), f, indent=2)\n        f.write(\"\\n\")\n    print(f\"Generated index: {index_file.relative_to(PROJECT_ROOT)}\")\n\n    # Generate menu.json for Menu capability\n    menu_file = OUTPUT_DIR / \"menu.json\"\n    with open(menu_file, \"w\") as f:\n        json.dump(generate_menu_json(), f, indent=2)\n        f.write(\"\\n\")\n    print(f\"Generated menu capability: {menu_file.relative_to(PROJECT_ROOT)}\")\n\n    print(f\"\\nDone! Generated documentation for {len(plugin_docs)} plugins.\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "scripts/get-pypr",
    "content": "#!/bin/sh\nset -e\nURL=\"https://files.pythonhosted.org/packages/58/6b/a8c8d8fbfb7ece2046b9a1ce811b53ba00e528fa3e45779acc243263b2b1/pyprland-3.2.1-py3-none-any.whl\"\nSUDO=sudo\nPROGRAM=/usr/local/bin/pypr\nCACHEDIR=/var/cache/pypr\nWHEEL=package.whl\n\nif [ -e \"$PROGRAM\" ]; then\n    echo \"$PROGRAM exists, do you really want to continue [y/n] ?\"\n    read answer\n    if [ \"$answer\" != \"y\" ]; then\n        exit 1\n    fi\nfi\n\n[ ! -d $CACHEDIR ] && ${SUDO} mkdir $CACHEDIR\ncd $CACHEDIR\n${SUDO} curl -o $WHEEL $URL\n\nappend() {\n    echo $@ | ${SUDO} tee -a $PROGRAM\n}\n\n${SUDO} rm -fr ${PROGRAM}\n${SUDO} touch ${PROGRAM}\n${SUDO} chmod 755 ${PROGRAM}\n${SUDO} chmod 755 $CACHEDIR\n${SUDO} chmod 644 $CACHEDIR/$WHEEL\n\nappend \"#!/bin/sh\"\nappend \"export PYTHONPATH='$CACHEDIR/$WHEEL'\"\nappend 'exec python -m pyprland.command $@'\n\necho \"Installed successfully.\"\n"
  },
  {
    "path": "scripts/make_release",
    "content": "#!/bin/bash\n\ncd $(git rev-parse --show-toplevel)\n\n./scripts/backquote_as_links.py\n\nglow RELEASE_NOTES.md || exit \"Can't find the release notes\"\n\nver_line=$(grep version pyproject.toml | head -n 1)\necho $ver_line\necho -n \"New version: \"\nread version\necho -n \"Release name: \"\nread title\n\nif [ -z \"$title\" ]; then\n    title=\"$version\"\nfi\n\nTOKEN=$(gopass show -o websites/api.github.com/fdev31)\nURL=https://api.github.com/repos/hyprland-community/pyprland\n\ntox run -e unit || exit -1\n\n# Bump py project {{{\nsed -i \"s#$ver_line#version = \\\"$version\\\"#\" pyproject.toml\ngit add pyproject.toml\nV=\"$version\" ./scripts/update_version\ngit add pyprland/version.py\ngit commit -m \"Version $version\"  --no-verify\ngit tag $version\ngit push\n\nrm -fr dist\n# Build sdist + pure Python wheel\nuv build\n# Build platform-specific wheel with statically compiled C client\nPYPRLAND_BUILD_NATIVE=1 uv build --wheel\nuv publish -u $PYPI_USERNAME -p $PYPI_PASSWORD\n# }}}\n\n# Make the release\n#\nrel_id=$(curl -s -X POST $URL/releases \\\n        -H \"Accept: application/vnd.github+json\" \\\n        -H \"Authorization: Bearer ${TOKEN}\" \\\n        -H \"X-GitHub-Api-Version: 2022-11-28\" \\\n        -d @- <<EOF | jq .id\n{\n  \"tag_name\": \"${version}\",\n  \"target_commitish\": \"main\",\n  \"name\": \"${title}\",\n  \"body\": $(jq -Rs . < RELEASE_NOTES.md),\n  \"draft\": false,\n  \"prerelease\": false,\n  \"generate_release_notes\": false\n}\nEOF\n)\n\necho \"RELEASE ID: $rel_id\"\n\necho \"Waiting 15s for pypi to update...\"\nfor n in $(seq 15); do\n    echo $n\n    sleep 1\ndone\n\npushd scripts\n./update_get-pypr.sh\ngit commit get-pypr ../pyprland/command.py -m \"Update get-pypr script\" --no-verify\ngit push\npopd\n\npushd ../aurPkgs/pyprland/\necho $version | bumpAurPkg.sh\npopd\n\necho \"\" >RELEASE_NOTES.md\nexit 0\n# Upload the standalone version\n\nhttp -j POST \"https://uploads.github.com/repos/hyprland-community/pyprland/releases/${rel_id}/assets?name=pypr\" \\\n    \"Accept: application/vnd.github+json\" \\\n    \"Authorization: Bearer ${TOKEN}\" \\\n    \"X-GitHub-Api-Version: 2022-11-28\" \\\n    \"Content-Type: application/octet-stream\" < dist/pypr\n"
  },
  {
    "path": "scripts/plugin_metadata.toml",
    "content": "\n\n[scratchpads]\nstars = 3\ndemoVideoId = \"ZOhv59VYqkc\"\n\n[stash]\nstars = 2\n\n[magnify]\nstars = 3\ndemoVideoId = \"yN-mhh9aDuo\"\n\n[toggle_special]\nstars = 3\ndemoVideoId = \"BNZCMqkwTOo\"\n\n[shortcuts_menu]\nstars = 3\ndemoVideoId = \"UCuS417BZK8\"\n\n[fetch_client_menu]\nstars = 3\n\n[layout_center]\nstars = 3\ndemoVideoId = \"vEr9eeSJYDc\"\n\n[system_notifier]\nstars = 3\n\n[wallpapers]\nstars = 3\n\n[menubar]\nstars = 3\n\n[expose]\nstars = 2\ndemoVideoId = \"ce5HQZ3na8M\"\n\n[lost_windows]\nstars = 1\n\n[toggle_dpms]\nstars = 1\n\n[workspaces_follow_focus]\nstars = 3\nmultimon = true\n\n[shift_monitors]\nstars = 3\nmultimon = true\n\n[monitors]\nstars = 3\n\n[fcitx5_switcher]\nstars = 1\n\n[gamemode]\nstars = 2\n\n"
  },
  {
    "path": "scripts/pypr.py",
    "content": "\"\"\"Fake pypr CLI to generate auto-completion scripts.\"\"\"\n\nimport argparse\nimport os\nimport pathlib\n\nimport shtab\n\nTOML_FILE = {\n    \"bash\": \"_shtab_greeter_compgen_TOMLFiles\",\n    \"zsh\": \"_files -g '(*.toml|*.TOML)'\",\n    \"tcsh\": \"f:*.toml\",\n}\n\nPREAMBLE = {\n    \"bash\": \"\"\"\n# $1=COMP_WORDS[1]\n_shtab_greeter_compgen_TOMLFiles() {\n  compgen -d -- $1  # recurse into subdirs\n  compgen -f -X '!*?.toml' -- $1\n  compgen -f -X '!*?.TOML' -- $1\n}\n\"\"\",\n    \"zsh\": \"\",\n    \"tcsh\": \"\",\n}\n\n\ndef get_parser():\n    \"\"\"Parses the command line arguments.\"\"\"\n    parser = argparse.ArgumentParser(prog=\"pypr\", description=\"Pyprland CLI\", add_help=False, allow_abbrev=False)\n    parser.add_argument(\n        \"--debug\",\n        help=\"Enable debug mode, optionally logging to a file\",\n        metavar=\"filename\",\n        nargs=\"?\",\n        const=True,\n        default=None,\n    ).complete = shtab.FILE\n    parser.add_argument(\n        \"--config\",\n        help=\"Use a different configuration file\",\n        metavar=\"filename\",\n        type=pathlib.Path,\n    ).complete = TOML_FILE\n    shtab.add_argument_to(parser, preamble=PREAMBLE)\n\n    # Base commands\n    subparsers = parser.add_subparsers(dest=\"command\")\n    subparsers.add_parser(\"dumpjson\", help=\"Dump the current state in JSON format\")\n    subparsers.add_parser(\"edit\", help=\"Edit the configuration file\")\n    subparsers.add_parser(\"exit\", help=\"Exit the currently running daemon\")\n    subparsers.add_parser(\"help\", help=\"Prints this help message\")\n    subparsers.add_parser(\"validate\", help=\"Validate the configuration file\")\n    subparsers.add_parser(\"version\", help=\"Prints the current version\")\n    subparsers.add_parser(\"reload\", help=\"Reload the configuration file\")\n\n    # Scratchpads\n    subparsers.add_parser(\"attach\", help=\"Attach the focused window to the last focused scratchpad\")\n    show = subparsers.add_parser(\"show\", help=\"Show the given scratchpad\")\n    show.add_argument(\"Scratchpad\", help=\"scratchpad name\", nargs=\"?\")\n    hide = subparsers.add_parser(\"hide\", help=\"Hide the given scratchpad\")\n    hide.add_argument(\"Scratchpad\", help=\"scratchpad name\", nargs=\"?\")\n    toggle = subparsers.add_parser(\"toggle\", help=\"Toggle the given scratchpad\")\n    toggle.add_argument(\"Scratchpad\", help=\"scratchpad name\", nargs=\"?\")\n    # bar\n    bar = subparsers.add_parser(\"bar\", help=\"Starts gBar on the first available monitor\")\n    bar.add_argument(\n        \"command\",\n        help=\"Starts gBar on the first available monitor\",\n        nargs=\"?\",\n        choices=[\"restart\", \"stop\"],\n    )\n    # shortcuts_menu\n    menu = subparsers.add_parser(\"menu\", help=\"Shows the menu\")\n    menu.add_argument(\"name\", help=\"submenu to show\", nargs=\"?\")\n    # toggle_special\n    toggle_special = subparsers.add_parser(\n        \"toggle_special\",\n        help=\"Toggle switching the focused window to the special workspace\",\n    )\n    toggle_special.add_argument(\"name\", help=\"special workspace name\", nargs=\"?\")\n    # layout_center\n    layout_center = subparsers.add_parser(\"layout_center\", help=\"Change the active window\")\n    layout_center.add_argument(\"command\", help=\"Change the active window\", choices=[\"toggle\", \"next\", \"prev\", \"next2\", \"prev2\"])\n    # lost_windows\n    subparsers.add_parser(\"attract_lost\", help=\"Brings lost floating windows to the current workspace\")\n    # shift_monitors\n    shift_monitors = subparsers.add_parser(\"shift_monitors\", help=\"Swaps monitors' workspaces in the given direction\")\n    shift_monitors.add_argument(\"direction\", help=\"Swaps monitors' workspaces in the given direction\", choices=[\"+1\", \"-1\"])\n    # toggle_dpms\n    subparsers.add_parser(\"toggle_dpms\", help=\"Toggles dpms on/off for every monitor\")\n    # magnify\n    zoom = subparsers.add_parser(\"zoom\", help=\"Zoom to the given factor\")\n    zoom.add_argument(\"factor\", help=\"Zoom to the given factor\", nargs=\"?\", choices=[\"+1\", \"-1\", \"++0.5\", \"--0.5\", \"1\"])\n    # Expose\n    subparsers.add_parser(\"expose\", help=\"Expose every client on the active workspace\")\n    # workspaces_follow_focus\n    change_workspace = subparsers.add_parser(\"change_workspace\", help=\"Switch workspaces of current monitor\")\n    change_workspace.add_argument(\"direction\", help=\"direction to switch workspaces\", choices=[\"-1\", \"+1\"], nargs=\"?\")\n    # wallpapers\n    wall = subparsers.add_parser(\"wall\", help=\"Skip the current background image\")\n    wall.add_argument(\"action\", help=\"Skip the current background image\", choices=[\"next\", \"clear\", \"pause\", \"color\"])\n    wall.add_argument(\"param\", help=\"Optional parameter (e.g. color hex)\", nargs=\"?\")\n    # fetch_client_menu\n    subparsers.add_parser(\n        \"fetch_client_menu\",\n        help=\"Select a client window and move it to the active workspace\",\n    )\n    subparsers.add_parser(\"unfetch_client\", help=\"Returns a window back to its origin\")\n    # monitors\n    subparsers.add_parser(\"relayout\", help=\"Recompute & apply every monitors's layout\")\n\n    return parser\n\n\nif \"RUN\" in os.environ:\n    get_parser().parse_args()\n"
  },
  {
    "path": "scripts/pypr.sh",
    "content": "#!/bin/sh\nsocat - \"UNIX-CONNECT:${XDG_RUNTIME_DIR}/hypr/${HYPRLAND_INSTANCE_SIGNATURE}/.pyprland.sock\" <<< $@\n"
  },
  {
    "path": "scripts/title",
    "content": "#!/bin/sh\necho -e \"\\e[1;30;43m                                        \"$@\"                                        \\e[1;0;0m\"\n"
  },
  {
    "path": "scripts/update_get-pypr.sh",
    "content": "#!/bin/sh\nset -e\nurl=$(curl https://pypi.org/pypi/pyprland/json | jq '.urls[] |.url' |grep 'whl\"$')\n\nsed -i \"s#^URL=.*#URL=${url}#\" get-pypr\n"
  },
  {
    "path": "scripts/update_version",
    "content": "#!/bin/sh\n\n# Change directory to the root of the Git repository\ncd \"$(git rev-parse --show-toplevel)\"\n\n# Get the version from Git tags if not provided\nif [ -z \"$V\" ]; then\n    V=$(git describe --tags | sed -E -e 's/-[^-]+$//')\nfi\n\n# Create a temporary file\nT=$(mktemp)\n\necho '\"\"\"Package version.\"\"\"' > ${T}\necho '' >> ${T}\necho \"VERSION = \\\"${V}\\\"\" >> ${T}\n\ncmp \"$T\" pyprland/version.py >/dev/null || {\n    cp \"$T\" pyprland/version.py\n    echo \"Version updated to ${V}\"\n}\n\n# Remove the temporary file\nrm \"$T\"\n"
  },
  {
    "path": "scripts/v_whitelist.py",
    "content": "_.execute_batch  # unused method (pyprland/adapters/backend.py:66)\n_.execute_batch  # unused method (pyprland/adapters/hyprland.py:86)\n_.execute_batch  # unused method (pyprland/adapters/niri.py:179)\nexc_type  # unused variable (pyprland/aioops.py:44)\nexc_val  # unused variable (pyprland/aioops.py:45)\nexc_tb  # unused variable (pyprland/aioops.py:46)\nnotify_method  # unused variable (pyprland/ipc.py:27)\n_.notify_method  # unused attribute (pyprland/ipc.py:87)\nRetensionTimes  # unused class (pyprland/models.py:24)\nSHORT  # unused variable (pyprland/models.py:27)\nLONG  # unused variable (pyprland/models.py:28)\nid  # unused variable (pyprland/models.py:34)\nid  # unused variable (pyprland/models.py:70)\nmake  # unused variable (pyprland/models.py:73)\nmodel  # unused variable (pyprland/models.py:74)\nserial  # unused variable (pyprland/models.py:75)\nrefreshRate  # unused variable (pyprland/models.py:78)\nactiveWorkspace  # unused variable (pyprland/models.py:81)\nspecialWorkspace  # unused variable (pyprland/models.py:82)\nreserved  # unused variable (pyprland/models.py:83)\ndpmsStatus  # unused variable (pyprland/models.py:87)\nvrr  # unused variable (pyprland/models.py:88)\nactivelyTearing  # unused variable (pyprland/models.py:89)\ncurrentFormat  # unused variable (pyprland/models.py:91)\navailableModes  # unused variable (pyprland/models.py:92)\nto_disable  # unused variable (pyprland/models.py:94)\nmajor  # unused variable (pyprland/models.py:101)\nminor  # unused variable (pyprland/models.py:102)\nmicro  # unused variable (pyprland/models.py:103)\nHyprlandEvents  # unused class (pyprland/plugins/protocols.py:22)\nNiriEvents  # unused class (pyprland/plugins/protocols.py:105)\n_.has_state  # unused method (pyprland/plugins/scratchpads/lookup.py:33)\n_scratchpads_extension_m  # unused import (pyprland/plugins/scratchpads/objects.py:19)\nClientPropGetter  # unused class (pyprland/plugins/scratchpads/objects.py:22)\nrecommended  # unused variable (pyprland/validation.py:33)\n_.restart  # unused method (pyprland/process.py:118) - public API for process restart\n_.is_supervised  # unused property (pyprland/process.py:208) - public API to check supervision status\n# httpclient fallback - aiohttp compatibility parameter\nallow_redirects  # unused variable (pyprland/httpclient.py) - aiohttp API compatibility\n# wallpapers/online plugin - public API and dynamically loaded backends\n_.available_backends  # unused property (pyprland/plugins/wallpapers/online/__init__.py) - public API\nextra  # unused variable (pyprland/plugins/wallpapers/online/backends/__init__.py) - backend metadata\nsupports_keywords  # unused variable (pyprland/plugins/wallpapers/online/backends) - backend capability flag\nBingBackend  # unused class (pyprland/plugins/wallpapers/online/backends/bing.py) - dynamically loaded\nPicsumBackend  # unused class (pyprland/plugins/wallpapers/online/backends/picsum.py) - dynamically loaded\nRedditBackend  # unused class (pyprland/plugins/wallpapers/online/backends/reddit.py) - dynamically loaded\nUnsplashBackend  # unused class (pyprland/plugins/wallpapers/online/backends/unsplash.py) - dynamically loaded\nWallhavenBackend  # unused class (pyprland/plugins/wallpapers/online/backends/wallhaven.py) - dynamically loaded\n# wallpapers/models - enum members used dynamically in config/completions\nDEFAULT  # unused variable (pyprland/plugins/wallpapers/models.py) - ColorScheme enum member for no adjustment\n"
  },
  {
    "path": "site/.vitepress/config.mjs",
    "content": "/**\n * VitePress configuration with dynamic version discovery.\n *\n * Sidebar configurations are loaded from sidebar.json files:\n * - site/sidebar.json for current version\n * - site/versions/<version>/sidebar.json for archived versions\n *\n * Versions are automatically discovered by scanning the versions/ folder.\n */\n\nimport { readdirSync, readFileSync, existsSync } from \"fs\";\nimport { join, dirname } from \"path\";\nimport { fileURLToPath } from \"url\";\nimport { defineConfig } from \"vitepress\";\nimport { withMermaid } from \"vitepress-plugin-mermaid\";\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\nconst siteDir = join(__dirname, \"..\");\n\n/**\n * Load sidebar configuration from a sidebar.json file.\n * @param {string} dir - Directory containing sidebar.json\n * @returns {Array|null} - Sidebar items array or null if not found\n */\nfunction loadSidebar(dir) {\n  const sidebarPath = join(dir, \"sidebar.json\");\n  if (!existsSync(sidebarPath)) {\n    console.warn(`Warning: sidebar.json not found in ${dir}`);\n    return null;\n  }\n\n  try {\n    const config = JSON.parse(readFileSync(sidebarPath, \"utf-8\"));\n    return [...config.main, config.plugins];\n  } catch (e) {\n    console.error(`Error loading sidebar from ${sidebarPath}:`, e.message);\n    return null;\n  }\n}\n\n/**\n * Discover all versions and build sidebar + nav configuration.\n * @returns {{ sidebar: Object, nav: Array }}\n */\nfunction buildVersionedConfig() {\n  const sidebar = {};\n  const versionItems = [{ text: \"Current\", link: \"/\" }];\n\n  // Load current version sidebar\n  const currentSidebar = loadSidebar(siteDir);\n  if (currentSidebar) {\n    sidebar[\"/\"] = currentSidebar;\n  }\n\n  // Discover and load archived versions\n  const versionsDir = join(siteDir, \"versions\");\n  if (existsSync(versionsDir)) {\n    const versions = readdirSync(versionsDir, { withFileTypes: true })\n      .filter((d) => d.isDirectory())\n      .map((d) => d.name)\n      // Sort versions: newest first (descending)\n      .sort((a, b) => {\n        // Extract numeric parts for comparison\n        const aParts = a\n          .replace(/[^0-9.]/g, \"\")\n          .split(\".\")\n          .map(Number);\n        const bParts = b\n          .replace(/[^0-9.]/g, \"\")\n          .split(\".\")\n          .map(Number);\n        for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {\n          const aVal = aParts[i] || 0;\n          const bVal = bParts[i] || 0;\n          if (bVal !== aVal) return bVal - aVal;\n        }\n        return 0;\n      });\n\n    for (const version of versions) {\n      const versionDir = join(versionsDir, version);\n      const versionSidebar = loadSidebar(versionDir);\n\n      if (versionSidebar) {\n        sidebar[`/versions/${version}/`] = [\n          ...versionSidebar,\n          { text: \"Return to latest version\", link: \"/\" },\n        ];\n      }\n\n      versionItems.push({ text: version, link: `/versions/${version}/` });\n    }\n  }\n\n  const nav = [{ text: \"Versions\", items: [{ items: versionItems }] }];\n\n  return { sidebar, nav };\n}\n\nconst { sidebar, nav } = buildVersionedConfig();\n\nexport default withMermaid(\n  defineConfig({\n    title: \"Pyprland docs\",\n    base: \"/pyprland/\",\n    description: \"The official Pyprland website\",\n    themeConfig: {\n      nav,\n      logo: \"/icon.png\",\n      search: {\n        provider: \"local\",\n        options: {\n          _render(src, env, md) {\n            const html = md.render(src, env);\n            // Exclude versioned pages from search index\n            if (env.relativePath.startsWith(\"versions/\")) return \"\";\n            // Also respect frontmatter search: false\n            if (env.frontmatter?.search === false) return \"\";\n            return html;\n          },\n        },\n      },\n      outline: { level: [2, 3] },\n      sidebar,\n      socialLinks: [\n        { icon: \"github\", link: \"https://github.com/fdev31/pyprland\" },\n        {\n          icon: \"discord\",\n          link: \"https://discord.com/channels/1055990214411169892/1230972154330218526\",\n        },\n      ],\n    },\n    mermaid: {},\n    mermaidPlugin: { class: \"mermaid\" },\n  }),\n);\n"
  },
  {
    "path": "site/.vitepress/theme/custom.css",
    "content": "summary {\n    cursor: help;\n}\n\nsummary:hover {\n    text-decoration: underline;\n}\n\ndetails {\n    border: solid 1px #777;\n    border-radius: 5px;\n    padding: 0 1ex;\n}\n\n/* Mermaid: Make arrows visible in dark mode */\n.dark .mermaid .edgePath path,\n.dark .mermaid .flowchart-link,\n.dark .mermaid line {\n    stroke: #ffffff !important;\n}\n\n/* Mermaid: Arrow heads in dark mode */\n.dark .mermaid .arrowheadPath,\n.dark .mermaid marker path {\n    fill: #ffffff !important;\n}\n\n/* ===========================================\n   Data Table Styles (generic table styling)\n   Used by EngineDefaults, BuiltinCommands, etc.\n   =========================================== */\n\n.data-table {\n  width: 100%;\n  border-collapse: collapse;\n  margin: 1rem 0;\n}\n\n.data-table th,\n.data-table td {\n  border: 1px solid var(--vp-c-divider);\n  padding: 0.5rem 0.75rem;\n  text-align: left;\n}\n\n.data-table th {\n  background-color: var(--vp-c-bg-soft);\n  font-weight: 600;\n}\n\n.data-table tr:hover {\n  background-color: var(--vp-c-bg-soft);\n}\n\n.data-table code {\n  font-size: 0.875em;\n}\n\n.data-loading,\n.data-error {\n  padding: 1rem;\n  color: var(--vp-c-text-2);\n}\n\n.data-error {\n  color: var(--vp-c-danger-1);\n}\n\n/* ===========================================\n   Config Table Styles (shared by PluginConfig and ConfigTable)\n   =========================================== */\n\n.config-table {\n  width: 100%;\n  border-collapse: collapse;\n  margin: 1rem 0;\n}\n\n.config-table.config-nested {\n  margin: 0 0.5rem 0.5rem 0.5rem;\n  font-size: 0.95em;\n}\n\n.config-table th,\n.config-table td {\n  border: 1px solid var(--vp-c-divider);\n  padding: 0.5rem 0.75rem;\n  text-align: left;\n}\n\n.config-table.config-nested th,\n.config-table.config-nested td {\n  padding: 0.4rem 0.6rem;\n}\n\n.config-table th {\n  background-color: var(--vp-c-bg-soft);\n  font-weight: 600;\n}\n\n.config-table.config-nested th {\n  background-color: var(--vp-c-bg);\n  font-size: 0.9em;\n}\n\n.config-table tr:hover {\n  background-color: var(--vp-c-bg-soft);\n}\n\n.config-table.config-nested tr:hover {\n  background-color: var(--vp-c-bg);\n}\n\n.config-table code {\n  font-size: 0.875em;\n}\n\n.config-none {\n  color: var(--vp-c-text-3);\n}\n\n.config-description {\n  font-size: 0.95em;\n}\n\n.config-table.config-nested .config-description {\n  font-size: 0.9em;\n}\n\n.config-description a {\n  color: var(--vp-c-brand-1);\n  text-decoration: none;\n}\n\n.config-description a:hover {\n  text-decoration: underline;\n}\n\n.config-description code {\n  background-color: var(--vp-c-bg-soft);\n  padding: 0.15em 0.3em;\n  border-radius: 3px;\n}\n\n.config-table.config-nested .config-description code {\n  background-color: var(--vp-c-bg);\n}\n\n.config-loading,\n.config-error,\n.config-empty {\n  padding: 1rem;\n  color: var(--vp-c-text-2);\n}\n\n.config-error {\n  color: var(--vp-c-danger-1);\n}\n\n/* Link with info icon */\n.config-link {\n  text-decoration: none;\n  color: inherit;\n}\n\n.config-link:hover {\n  text-decoration: underline;\n  color: var(--vp-c-brand-1);\n}\n\n.config-link:hover code {\n  color: var(--vp-c-brand-1);\n}\n\n.config-info-icon {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  width: 1.1em;\n  height: 1.1em;\n  margin-left: 0.4em;\n  font-size: 0.75em;\n  font-weight: 600;\n  font-style: italic;\n  font-family: serif;\n  color: var(--vp-c-brand-1);\n  border: 1px solid var(--vp-c-brand-1);\n  border-radius: 50%;\n  opacity: 0.7;\n  transition: opacity 0.2s;\n  vertical-align: middle;\n}\n\n.config-link:hover .config-info-icon {\n  opacity: 1;\n}\n\n/* Children indicator badge */\n.config-has-children {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  width: 1.2em;\n  height: 1.2em;\n  margin-right: 0.3em;\n  font-size: 0.8em;\n  font-weight: 600;\n  color: var(--vp-c-brand-1);\n  border: 1px solid var(--vp-c-brand-1);\n  border-radius: 3px;\n  opacity: 0.7;\n  vertical-align: middle;\n}\n\n/* Children row (contains the collapsible details) */\n.config-children-row {\n  background-color: transparent !important;\n}\n\n.config-children-row:hover {\n  background-color: transparent !important;\n}\n\n.config-children-cell {\n  padding: 0 !important;\n  border: none !important;\n}\n\n/* Children details/summary */\n.config-children-details {\n  margin: 0.5rem 0.75rem 0.75rem 1.5rem;\n  border: 1px solid var(--vp-c-divider);\n  border-radius: 6px;\n  background-color: var(--vp-c-bg-soft);\n}\n\n.config-table.config-nested .config-children-details {\n  margin: 0.5rem 0.5rem 0.5rem 1rem;\n}\n\n.config-children-details summary {\n  padding: 0.5rem 0.75rem;\n  cursor: pointer;\n  font-size: 0.9em;\n  color: var(--vp-c-text-2);\n  list-style: none;\n}\n\n.config-children-details summary::-webkit-details-marker {\n  display: none;\n}\n\n.config-children-details summary::before {\n  content: '';\n  display: inline-block;\n  width: 0;\n  height: 0;\n  margin-right: 0.5em;\n  border-left: 0.4em solid var(--vp-c-text-2);\n  border-top: 0.25em solid transparent;\n  border-bottom: 0.25em solid transparent;\n  transition: transform 0.2s ease;\n  vertical-align: middle;\n}\n\n.config-children-details[open] summary::before {\n  transform: rotate(90deg);\n}\n\n.config-children-details summary:hover {\n  color: var(--vp-c-brand-1);\n}\n\n.config-children-details summary:hover::before {\n  border-left-color: var(--vp-c-brand-1);\n}\n\n.config-children-details summary code {\n  background-color: var(--vp-c-bg);\n  padding: 0.15em 0.4em;\n  border-radius: 3px;\n  font-size: 0.95em;\n}\n\n/* Option cell for 2-column config tables */\n.config-option-cell {\n  white-space: nowrap;\n}\n\n/* ===========================================\n   Command Box Styles (PluginCommands)\n   =========================================== */\n\n.command-box {\n  background-color: var(--vp-c-bg-soft);\n  border: 1px solid var(--vp-c-divider);\n  border-radius: 8px;\n  padding: 0.75rem 1rem;\n  margin: 1rem 0;\n}\n\n.command-list {\n  list-style: none;\n  padding: 0;\n  margin: 0;\n}\n\n.command-item {\n  padding: 0.4rem 0;\n  border-bottom: 1px solid var(--vp-c-divider);\n}\n\n.command-item:last-child {\n  border-bottom: none;\n}\n\n.command-name {\n  background-color: var(--vp-c-brand-soft);\n  color: var(--vp-c-brand-1);\n  padding: 0.15em 0.4em;\n  border-radius: 4px;\n  font-weight: 500;\n}\n\n.command-arg {\n  background-color: var(--vp-c-bg);\n  padding: 0.1em 0.3em;\n  border-radius: 3px;\n  margin-left: 0.25em;\n  font-size: 0.9em;\n}\n\n.command-desc {\n  margin-left: 0.5em;\n  color: var(--vp-c-text-2);\n}\n\n.command-loading,\n.command-error {\n  padding: 1rem;\n  color: var(--vp-c-text-2);\n}\n\n.command-error {\n  color: var(--vp-c-danger-1);\n}\n\n.command-empty {\n  padding: 1rem;\n  color: var(--vp-c-text-3);\n  font-style: italic;\n}\n\n/* Command link with info icon */\n.command-link {\n  text-decoration: none;\n  color: inherit;\n}\n\n.command-link:hover {\n  text-decoration: underline;\n}\n\n.command-link:hover .command-name {\n  color: var(--vp-c-brand-1);\n}\n\n.command-info-icon {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  width: 1.1em;\n  height: 1.1em;\n  margin-left: 0.3em;\n  font-size: 0.7em;\n  font-weight: 600;\n  font-style: italic;\n  font-family: serif;\n  color: var(--vp-c-brand-1);\n  border: 1px solid var(--vp-c-brand-1);\n  border-radius: 50%;\n  opacity: 0.7;\n  transition: opacity 0.2s;\n  vertical-align: middle;\n}\n\n.command-link:hover .command-info-icon {\n  opacity: 1;\n}\n"
  },
  {
    "path": "site/.vitepress/theme/index.js",
    "content": "// .vitepress/theme/index.js\nimport DefaultTheme from 'vitepress/theme'\n\nimport CommandList from \"/components/CommandList.vue\";\nimport ConfigBadges from \"/components/ConfigBadges.vue\";\nimport EngineDefaults from \"/components/EngineDefaults.vue\";\nimport EngineList from \"/components/EngineList.vue\";\nimport PluginCommands from '/components/PluginCommands.vue'\nimport PluginConfig from '/components/PluginConfig.vue'\nimport PluginList from '/components/PluginList.vue'\nimport './custom.css'\n\n/** @type {import('vitepress').Theme} */\nexport default {\n    extends: DefaultTheme,\n    enhanceApp({ app, router }) {\n        // global components\n        app.component('CommandList', CommandList)\n        app.component('PluginCommands', PluginCommands)\n        app.component(\"PluginConfig\", PluginConfig);\n        app.component(\"PluginList\", PluginList);\n        app.component(\"ConfigBadges\", ConfigBadges);\n        app.component(\"EngineDefaults\", EngineDefaults);\n        app.component(\"EngineList\", EngineList);\n\n        // Version switcher: preserve current page when changing versions\n        router.onBeforeRouteChange = (to) => {\n            // Don't intercept if we're leaving a 404 page\n            if (router.route.data.isNotFound) {\n                return\n            }\n\n            // Switching to a specific version\n            const versionRootMatch = to.match(/^\\/pyprland\\/versions\\/([^/]+)\\/$/)\n            if (versionRootMatch) {\n                const currentPage = router.route.path\n                    .replace(/^\\/pyprland\\/versions\\/[^/]+\\//, '')\n                    .replace(/^\\/pyprland\\//, '')\n                if (currentPage && currentPage !== '' && currentPage !== 'index.html') {\n                    router.go(`/pyprland/versions/${versionRootMatch[1]}/${currentPage}`)\n                    return false\n                }\n            }\n\n            // Switching to current version (from a versioned page)\n            if (to === '/pyprland/' || to === '/pyprland/index.html') {\n                const versionedPageMatch = router.route.path.match(/^\\/pyprland\\/versions\\/[^/]+\\/(.+)$/)\n                if (versionedPageMatch && versionedPageMatch[1] !== 'index.html') {\n                    router.go(`/pyprland/${versionedPageMatch[1]}`)\n                    return false\n                }\n            }\n        }\n\n        // Fallback: if page doesn't exist (404), redirect to version root\n        router.onAfterRouteChanged = (to) => {\n            if (router.route.data.isNotFound) {\n                const versionMatch = to.match(/^\\/pyprland\\/versions\\/([^/]+)\\//)\n                if (versionMatch) {\n                    const target = `/pyprland/versions/${versionMatch[1]}/`\n                    // Replace current history entry so back button skips the 404\n                    history.replaceState(null, '', target)\n                    router.go(target)\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "site/Architecture.md",
    "content": "# Architecture\n\nThis section provides a comprehensive overview of Pyprland's internal architecture, designed for developers who want to understand, extend, or contribute to the project.\n\n> [!tip]\n> For a practical guide to writing plugins, see the [Development](./Development) document.\n\n## Sections\n\n| Section | Description |\n|---------|-------------|\n| [Overview](./Architecture_overview) | High-level architecture, executive summary, data flow, directory structure, design patterns |\n| [Core Components](./Architecture_core) | Manager, plugins, adapters, IPC layer, socket protocol, C client, configuration, data models |\n\n## Quick Links\n\n### Overview\n\n- [Executive Summary](./Architecture_overview#executive-summary) - What Pyprland is and how it works\n- [High-Level Architecture](./Architecture_overview#high-level-architecture) - Visual overview of all components\n- [Data Flow](./Architecture_overview#data-flow) - Event processing and command processing sequences\n- [Directory Structure](./Architecture_overview#directory-structure) - Source code organization\n- [Design Patterns](./Architecture_overview#design-patterns) - Patterns used throughout the codebase\n\n### Core Components\n\n- [Entry Points](./Architecture_core#entry-points) - Daemon vs client mode\n- [Manager](./Architecture_core#manager) - The core orchestrator\n- [Plugin System](./Architecture_core#plugin-system) - Base class, lifecycle, built-in plugins\n- [Backend Adapter Layer](./Architecture_core#backend-adapter-layer) - Hyprland and Niri abstractions\n- [IPC Layer](./Architecture_core#ipc-layer) - Window manager communication\n- [Socket Protocol](./Architecture_core#pyprland-socket-protocol) - Client-daemon protocol specification\n- [pypr-client](./Architecture_core#pypr-client) - Lightweight alternative for keybindings\n- [Configuration System](./Architecture_core#configuration-system) - TOML config system\n- [Data Models](./Architecture_core#data-models) - TypedDict definitions\n\n## Further Reading\n\n- [Development Guide](./Development) - How to write plugins\n- [Plugin Documentation](./Plugins) - List of available plugins\n- [Sample Extension](https://github.com/fdev31/pyprland/tree/main/sample_extension) - Example external plugin package\n- [Hyprland IPC](https://wiki.hyprland.org/IPC/) - Hyprland's IPC documentation\n"
  },
  {
    "path": "site/Architecture_core.md",
    "content": "# Core Components\n\nThis document details the core components of Pyprland's architecture.\n\n## Entry Points\n\nThe application can run in two modes: **daemon** (background service) or **client** (send commands to running daemon).\n\n```mermaid\nflowchart LR\n    main([\"🚀 main()\"]) --> detect{\"❓ Args?\"}\n    detect -->|No arguments| daemon[\"🔧 run_daemon()\"]\n    detect -->|Command given| client[\"📤 run_client()\"]\n    daemon --> Pyprland([\"⚙️ Pyprland().run()\"])\n    client --> socket([\"📡 Send via socket\"])\n    Pyprland --> events[\"📨 Listen for events\"]\n    socket --> response([\"✅ Receive response\"])\n\n    style main fill:#7fb3d3,stroke:#5a8fa8,color:#000\n    style detect fill:#d4c875,stroke:#a89a50,color:#000\n    style daemon fill:#8fbc8f,stroke:#6a9a6a,color:#000\n    style client fill:#8fbc8f,stroke:#6a9a6a,color:#000\n    style Pyprland fill:#d4a574,stroke:#a67c50,color:#000\n    style socket fill:#d4a574,stroke:#a67c50,color:#000\n```\n\n| Entry Point | File | Purpose |\n|-------------|------|---------|\n| `pypr` | [`command.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/command.py) | Main CLI entry (daemon or client mode) |\n| Daemon mode | [`pypr_daemon.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/pypr_daemon.py) | Start the background daemon |\n| Client mode | [`client.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/client.py) | Send command to running daemon |\n\n## Manager\n\nThe [`Pyprland`](https://github.com/fdev31/pyprland/blob/main/pyprland/manager.py) class is the core orchestrator, responsible for:\n\n| Responsibility | Method/Attribute |\n|----------------|------------------|\n| Plugin loading | `_load_plugins()` |\n| Event dispatching | `_run_event()` |\n| Command handling | `handle_command()` |\n| Server lifecycle | `run()`, `serve()` |\n| Configuration | `load_config()`, `config` |\n| Shared state | `state: SharedState` |\n\n**Key Design Patterns:**\n\n- **Per-plugin async task queues** (`queues: dict[str, asyncio.Queue]`) - ensures plugin isolation\n- **Deduplication** via `@remove_duplicate` decorator - prevents rapid duplicate events\n- **Plugin isolation** - each plugin processes events independently\n\n## Plugin System\n\n### Base Class\n\nAll plugins inherit from the [`Plugin`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/interface.py) base class:\n\n```python\nclass Plugin:\n    name: str                    # Plugin identifier\n    config: Configuration        # Plugin-specific config section\n    state: SharedState           # Shared application state\n    backend: EnvironmentBackend  # WM abstraction layer\n    log: Logger                  # Plugin-specific logger\n    \n    # Lifecycle hooks\n    async def init() -> None           # Called once at startup\n    async def on_reload() -> None      # Called on init and config reload\n    async def exit() -> None           # Called on shutdown\n    \n    # Config validation\n    config_schema: ClassVar[list[ConfigField]]\n    def validate_config() -> list[str]\n```\n\n### Event Handler Protocol\n\nPlugins implement handlers by naming convention. See [`protocols.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/protocols.py) for the full protocol definitions:\n\n```python\n# Hyprland events: event_<eventname>\nasync def event_openwindow(self, params: str) -> None: ...\nasync def event_closewindow(self, addr: str) -> None: ...\nasync def event_workspace(self, workspace: str) -> None: ...\n\n# Commands: run_<command>\nasync def run_toggle(self, name: str) -> str | None: ...\n\n# Niri events: niri_<eventtype>\nasync def niri_outputschanged(self, data: dict) -> None: ...\n```\n\n### Plugin Lifecycle\n\n```mermaid\nsequenceDiagram\n    autonumber\n    participant M as ⚙️ Manager\n    participant P as 🔌 Plugin\n    participant C as 📄 Config\n\n    rect rgba(143, 188, 143, 0.2)\n        Note over M,C: Initialization Phase\n        M->>P: __init__(name)\n        M->>P: init()\n        M->>C: load TOML config\n        C-->>M: config data\n        M->>P: load_config(config)\n        M->>P: validate_config()\n        P-->>M: validation errors (if any)\n        M->>P: on_reload()\n    end\n    \n    rect rgba(127, 179, 211, 0.2)\n        Note over M,P: Runtime Phase\n        loop Events from WM\n            M->>P: event_*(data)\n            P-->>M: (async processing)\n        end\n        \n        loop Commands from User\n            M->>P: run_*(args)\n            P-->>M: result\n        end\n    end\n    \n    rect rgba(212, 165, 116, 0.2)\n        Note over M,P: Shutdown Phase\n        M->>P: exit()\n        P-->>M: cleanup complete\n    end\n```\n\n### Built-in Plugins\n\n| Plugin | Source | Description |\n|--------|--------|-------------|\n| `pyprland` (core) | [`plugins/pyprland/`](https://github.com/fdev31/pyprland/tree/main/pyprland/plugins/pyprland) | Internal state management |\n| `scratchpads` | [`plugins/scratchpads/`](https://github.com/fdev31/pyprland/tree/main/pyprland/plugins/scratchpads) | Dropdown/scratchpad windows |\n| `monitors` | [`plugins/monitors/`](https://github.com/fdev31/pyprland/tree/main/pyprland/plugins/monitors) | Monitor layout management |\n| `wallpapers` | [`plugins/wallpapers/`](https://github.com/fdev31/pyprland/tree/main/pyprland/plugins/wallpapers) | Wallpaper cycling, color schemes |\n| `expose` | [`plugins/expose.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/expose.py) | Window overview |\n| `magnify` | [`plugins/magnify.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/magnify.py) | Zoom functionality |\n| `layout_center` | [`plugins/layout_center.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/layout_center.py) | Centered layout mode |\n| `fetch_client_menu` | [`plugins/fetch_client_menu.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/fetch_client_menu.py) | Menu-based window switching |\n| `shortcuts_menu` | [`plugins/shortcuts_menu.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/shortcuts_menu.py) | Shortcut launcher |\n| `toggle_dpms` | [`plugins/toggle_dpms.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/toggle_dpms.py) | Screen power toggle |\n| `toggle_special` | [`plugins/toggle_special.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/toggle_special.py) | Special workspace toggle |\n| `system_notifier` | [`plugins/system_notifier.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/system_notifier.py) | System notifications |\n| `lost_windows` | [`plugins/lost_windows.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/lost_windows.py) | Recover lost windows |\n| `shift_monitors` | [`plugins/shift_monitors.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/shift_monitors.py) | Shift windows between monitors |\n| `workspaces_follow_focus` | [`plugins/workspaces_follow_focus.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/workspaces_follow_focus.py) | Workspace follows focus |\n| `fcitx5_switcher` | [`plugins/fcitx5_switcher.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/fcitx5_switcher.py) | Input method switching |\n| `menubar` | [`plugins/menubar.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/menubar.py) | Menu bar integration |\n\n## Backend Adapter Layer\n\nThe adapter layer abstracts differences between window managers. See [`adapters/`](https://github.com/fdev31/pyprland/tree/main/pyprland/adapters) for the full implementation.\n\n```mermaid\nclassDiagram\n    class EnvironmentBackend {\n        <<abstract>>\n        #state: SharedState\n        #log: Logger\n        +get_clients() list~ClientInfo~\n        +get_monitors() list~MonitorInfo~\n        +execute(command) bool\n        +execute_json(command) Any\n        +execute_batch(commands) None\n        +notify(message, duration, color) None\n        +parse_event(raw_data) tuple\n    }\n    \n    class HyprlandBackend {\n        +get_clients()\n        +get_monitors()\n        +execute()\n        +parse_event()\n    }\n    note for HyprlandBackend \"Communicates via<br/>HYPRLAND_INSTANCE_SIGNATURE<br/>socket paths\"\n    \n    class NiriBackend {\n        +get_clients()\n        +get_monitors()\n        +execute()\n        +parse_event()\n    }\n    note for NiriBackend \"Communicates via<br/>NIRI_SOCKET<br/>JSON protocol\"\n    \n    EnvironmentBackend <|-- HyprlandBackend : implements\n    EnvironmentBackend <|-- NiriBackend : implements\n```\n\n| Class | Source |\n|-------|--------|\n| `EnvironmentBackend` | [`adapters/backend.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/backend.py) |\n| `HyprlandBackend` | [`adapters/hyprland.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/hyprland.py) |\n| `NiriBackend` | [`adapters/niri.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/niri.py) |\n\nThe backend is selected automatically based on environment:\n- If `NIRI_SOCKET` is set -> `NiriBackend`\n- Otherwise -> `HyprlandBackend`\n\n## IPC Layer\n\nLow-level socket communication with the window manager is handled in [`ipc.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/ipc.py):\n\n| Function | Purpose |\n|----------|---------|\n| `hyprctl_connection()` | Context manager for Hyprland command socket |\n| `niri_connection()` | Context manager for Niri socket |\n| `get_response()` | Send command, receive JSON response |\n| `get_event_stream()` | Subscribe to WM event stream |\n| `niri_request()` | Send Niri-specific request |\n| `@retry_on_reset` | Decorator for automatic connection retry |\n\n**Socket Paths:**\n\n| Socket | Path |\n|--------|------|\n| Hyprland commands | `$XDG_RUNTIME_DIR/hypr/$SIGNATURE/.socket.sock` |\n| Hyprland events | `$XDG_RUNTIME_DIR/hypr/$SIGNATURE/.socket2.sock` |\n| Niri | `$NIRI_SOCKET` |\n| Pyprland (Hyprland) | `$XDG_RUNTIME_DIR/hypr/$SIGNATURE/.pyprland.sock` |\n| Pyprland (Niri) | `dirname($NIRI_SOCKET)/.pyprland.sock` |\n| Pyprland (standalone) | `$XDG_DATA_HOME/.pyprland.sock` |\n\n## Pyprland Socket Protocol\n\nThe daemon exposes a Unix domain socket for client-daemon communication. This simple text-based protocol allows any language to implement a client.\n\n### Socket Path\n\nThe socket location depends on the environment:\n\n| Environment | Socket Path |\n|-------------|-------------|\n| Hyprland | `$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.pyprland.sock` |\n| Niri | `dirname($NIRI_SOCKET)/.pyprland.sock` |\n| Standalone | `$XDG_DATA_HOME/.pyprland.sock` (defaults to `~/.local/share/.pyprland.sock`) |\n\nIf the Hyprland path exceeds 107 characters, a shortened path is used:\n\n```\n/tmp/.pypr-$HYPRLAND_INSTANCE_SIGNATURE/.pyprland.sock\n```\n\n### Protocol\n\n```mermaid\nsequenceDiagram\n    autonumber\n    participant Client as 💻 Client\n    participant Socket as 📡 Unix Socket\n    participant Daemon as ⚙️ Daemon\n\n    rect rgba(127, 179, 211, 0.2)\n        Note over Client,Socket: Request\n        Client->>Socket: Connect\n        Client->>Socket: \"command args\\n\"\n        Client->>Socket: EOF (shutdown write)\n    end\n\n    rect rgba(143, 188, 143, 0.2)\n        Note over Socket,Daemon: Processing\n        Socket->>Daemon: read_command()\n        Daemon->>Daemon: Execute command\n    end\n\n    rect rgba(212, 165, 116, 0.2)\n        Note over Daemon,Client: Response\n        Daemon->>Socket: \"OK\\n\" or \"ERROR: msg\\n\"\n        Socket->>Client: Read until EOF\n        Client->>Client: Parse & exit\n    end\n```\n\n| Direction | Format |\n|-----------|--------|\n| **Request** | `<command> [args...]\\n` (newline-terminated, then EOF) |\n| **Response** | `OK [output]` or `ERROR: <message>` or raw text (legacy) |\n\n**Response Prefixes:**\n\n| Prefix | Meaning | Exit Code |\n|--------|---------|-----------|\n| `OK` | Command succeeded | 0 |\n| `OK <output>` | Command succeeded with output | 0 |\n| `ERROR: <msg>` | Command failed | 4 |\n| *(raw text)* | Legacy response (help, version, dumpjson) | 0 |\n\n**Exit Codes:**\n\n| Code | Name | Description |\n|------|------|-------------|\n| 0 | SUCCESS | Command completed successfully |\n| 1 | USAGE_ERROR | No command provided or invalid arguments |\n| 2 | ENV_ERROR | Missing environment variables |\n| 3 | CONNECTION_ERROR | Cannot connect to daemon |\n| 4 | COMMAND_ERROR | Command execution failed |\n\nSee [`models.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/models.py) for `ExitCode` and `ResponsePrefix` definitions.\n\n## pypr-client {#pypr-client}\n\nFor performance-critical use cases (e.g., keybindings), `pypr-client` is a lightweight C client available as an alternative to `pypr`. It supports all commands except `validate` and `edit` (which require Python).\n\n| File | Description |\n|------|-------------|\n| [`client/pypr-client.c`](https://github.com/fdev31/pyprland/blob/main/client/pypr-client.c) | C implementation of the pypr client |\n\n**Build:**\n\n```bash\ncd client\ngcc -O2 -o pypr-client pypr-client.c\n```\n\n**Features:**\n\n- Minimal dependencies (libc only)\n- Fast startup (~1ms vs ~50ms for Python)\n- Same protocol as Python client\n- Proper exit codes for scripting\n\n**Comparison:**\n\n| Aspect | `pypr` | `pypr-client` |\n|--------|--------|---------------|\n| Startup time | ~50ms | ~1ms |\n| Dependencies | Python 3.11+ | libc |\n| Daemon mode | Yes | No |\n| Commands | All | All except `validate`, `edit` |\n| Best for | Interactive use, daemon | Keybindings |\n| Source | [`client.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/client.py) | [`pypr-client.c`](https://github.com/fdev31/pyprland/blob/main/client/pypr-client.c) |\n\n## Configuration System\n\nConfiguration is stored in TOML format at `~/.config/pypr/config.toml`:\n\n```toml\n[pyprland]\nplugins = [\"scratchpads\", \"monitors\", \"magnify\"]\n\n[scratchpads.term]\ncommand = \"kitty --class scratchpad\"\nposition = \"50% 50%\"\nsize = \"80% 80%\"\n\n[monitors]\nunknown = \"extend\"\n```\n\n| Component | Source | Description |\n|-----------|--------|-------------|\n| `Configuration` | [`config.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/config.py) | Dict wrapper with typed accessors |\n| `ConfigValidator` | [`validation.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/validation.py) | Schema-based validation |\n| `ConfigField` | [`validation.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/validation.py) | Field definition (name, type, required, default) |\n\n## Shared State\n\nThe [`SharedState`](https://github.com/fdev31/pyprland/blob/main/pyprland/common.py) dataclass maintains commonly needed information:\n\n```python\n@dataclass\nclass SharedState:\n    active_workspace: str    # Current workspace name\n    active_monitor: str      # Current monitor name  \n    active_window: str       # Current window address\n    environment: str         # \"hyprland\" or \"niri\"\n    variables: dict          # User-defined variables\n    monitors: list[str]      # All monitor names\n    hyprland_version: VersionInfo\n```\n\n## Data Models\n\nTypedDict definitions in [`models.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/models.py) ensure type safety:\n\n```python\nclass ClientInfo(TypedDict):\n    address: str\n    mapped: bool\n    hidden: bool\n    workspace: WorkspaceInfo\n    class_: str  # aliased from \"class\"\n    title: str\n    # ... more fields\n\nclass MonitorInfo(TypedDict):\n    name: str\n    width: int\n    height: int\n    x: int\n    y: int\n    focused: bool\n    transform: int\n    # ... more fields\n```\n"
  },
  {
    "path": "site/Architecture_overview.md",
    "content": "# Architecture Overview\n\nThis document provides a high-level overview of Pyprland's architecture, data flow, and design patterns.\n\n> [!tip]\n> For a practical guide to writing plugins, see the [Development](./Development) document.\n\n## Executive Summary\n\n**Pyprland** is a plugin-based companion application for tiling window managers (Hyprland, Niri). It operates as a daemon that extends the window manager's capabilities through a modular plugin system, communicating via Unix domain sockets (IPC).\n\n| Attribute | Value |\n|-----------|-------|\n| Language | Python 3.11+ |\n| License | MIT |\n| Architecture | Daemon/Client, Plugin-based |\n| Async Framework | asyncio |\n\n## High-Level Architecture\n\n```mermaid\nflowchart TB\n    subgraph User[\"👤 User Layer\"]\n        KB([\"⌨️ Keyboard Bindings\"])\n        CLI([\"💻 pypr / pypr-client\"])\n    end\n\n    subgraph Pyprland[\"🔶 Pyprland Daemon\"]\n        direction TB\n        CMD[\"🎯 Command Handler\"]\n        EVT[\"📨 Event Listener\"]\n        \n        subgraph Plugins[\"🔌 Plugin Registry\"]\n            P1[\"scratchpads\"]\n            P2[\"monitors\"]\n            P3[\"wallpapers\"]\n            P4[\"expose\"]\n            P5[\"...\"]\n        end\n        \n        subgraph Adapters[\"🔄 Backend Adapters\"]\n            HB[\"HyprlandBackend\"]\n            NB[\"NiriBackend\"]\n        end\n        \n        MGR[\"⚙️ Manager<br/>Orchestrator\"]\n        STATE[\"📦 SharedState\"]\n    end\n\n    subgraph WM[\"🪟 Window Manager\"]\n        HYPR([\"Hyprland\"])\n        NIRI([\"Niri\"])\n    end\n\n    KB --> CLI\n    CLI -->|Unix Socket| CMD\n    CMD --> MGR\n    MGR --> Plugins\n    Plugins --> Adapters\n    EVT -->|Event Stream| MGR\n    Adapters <-->|IPC Socket| WM\n    WM -->|Events| EVT\n    MGR --> STATE\n    Plugins --> STATE\n\n    style User fill:#7fb3d3,stroke:#5a8fa8,color:#000\n    style Pyprland fill:#d4a574,stroke:#a67c50,color:#000\n    style WM fill:#8fbc8f,stroke:#6a9a6a,color:#000\n    style Plugins fill:#c9a86c,stroke:#9a7a4a,color:#000\n    style Adapters fill:#c9a86c,stroke:#9a7a4a,color:#000\n```\n\n## Data Flow\n\n### Event Processing\n\nWhen the window manager emits an event (window opened, workspace changed, etc.):\n\n```mermaid\nsequenceDiagram\n    autonumber\n    participant WM as 🪟 Window Manager\n    participant IPC as 📡 IPC Layer\n    participant MGR as ⚙️ Manager\n    participant Q1 as 📥 Plugin A Queue\n    participant Q2 as 📥 Plugin B Queue\n    participant P1 as 🔌 Plugin A\n    participant P2 as 🔌 Plugin B\n\n    rect rgba(143, 188, 143, 0.2)\n        Note over WM,IPC: Event Reception\n        WM->>+IPC: Event stream (async)\n        IPC->>-MGR: Parse event (name, params)\n    end\n\n    rect rgba(127, 179, 211, 0.2)\n        Note over MGR,Q2: Event Distribution\n        par Parallel queuing\n            MGR->>Q1: Queue event\n            MGR->>Q2: Queue event\n        end\n    end\n\n    rect rgba(212, 165, 116, 0.2)\n        Note over Q1,WM: Plugin Execution\n        par Parallel processing\n            Q1->>P1: event_openwindow()\n            P1->>WM: Execute commands\n        and\n            Q2->>P2: event_openwindow()\n            P2->>WM: Execute commands\n        end\n    end\n```\n\n### Command Processing\n\nWhen the user runs `pypr <command>`:\n\n```mermaid\nsequenceDiagram\n    autonumber\n    participant User as 👤 User\n    participant CLI as 💻 pypr / pypr-client\n    participant Socket as 📡 Unix Socket\n    participant MGR as ⚙️ Manager\n    participant Plugin as 🔌 Plugin\n    participant Backend as 🔄 Backend\n    participant WM as 🪟 Window Manager\n\n    rect rgba(127, 179, 211, 0.2)\n        Note over User,Socket: Request Phase\n        User->>CLI: pypr toggle term\n        CLI->>Socket: Connect & send command\n        Socket->>MGR: handle_command()\n    end\n\n    rect rgba(212, 165, 116, 0.2)\n        Note over MGR,Plugin: Routing Phase\n        MGR->>MGR: Find plugin with run_toggle\n        MGR->>Plugin: run_toggle(\"term\")\n    end\n\n    rect rgba(143, 188, 143, 0.2)\n        Note over Plugin,WM: Execution Phase\n        Plugin->>Backend: execute(command)\n        Backend->>WM: IPC call\n        WM-->>Backend: Response\n        Backend-->>Plugin: Result\n    end\n\n    rect rgba(150, 120, 160, 0.2)\n        Note over Plugin,User: Response Phase\n        Plugin-->>MGR: Return value\n        MGR-->>Socket: Response\n        Socket-->>CLI: Display result\n    end\n```\n\n## Directory Structure\n\nAll source files are in the [`pyprland/`](https://github.com/fdev31/pyprland/tree/main/pyprland) directory:\n\n```\npyprland/\n├── command.py           # CLI entry point, argument parsing\n├── pypr_daemon.py       # Daemon startup logic\n├── manager.py           # Core Pyprland class (orchestrator)\n├── client.py            # Client mode implementation\n├── ipc.py               # Socket communication with WM\n├── config.py            # Configuration wrapper\n├── validation.py        # Config validation framework\n├── common.py            # Shared utilities, SharedState, logging\n├── constants.py         # Global constants\n├── models.py            # TypedDict definitions\n├── version.py           # Version string\n├── aioops.py            # Async file ops, DebouncedTask\n├── completions.py       # Shell completion generators\n├── help.py              # Help system\n├── ansi.py              # Terminal colors/styling\n├── debug.py             # Debug utilities\n│\n├── adapters/            # Window manager abstraction\n│   ├── backend.py       # Abstract EnvironmentBackend\n│   ├── hyprland.py      # Hyprland implementation\n│   ├── niri.py          # Niri implementation\n│   ├── menus.py         # Menu engine abstraction (rofi, wofi, etc.)\n│   └── units.py         # Unit conversion utilities\n│\n└── plugins/             # Plugin implementations\n    ├── interface.py     # Plugin base class\n    ├── protocols.py     # Event handler protocols\n    │\n    ├── pyprland/        # Core internal plugin\n    ├── scratchpads/     # Scratchpad plugin (complex, multi-file)\n    ├── monitors/        # Monitor management\n    ├── wallpapers/      # Wallpaper management\n    │\n    └── *.py             # Simple single-file plugins\n```\n\n## Design Patterns\n\n| Pattern | Usage |\n|---------|-------|\n| **Plugin Architecture** | Extensibility via [`Plugin`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/interface.py) base class |\n| **Adapter Pattern** | [`EnvironmentBackend`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/backend.py) abstracts WM differences |\n| **Strategy Pattern** | Menu engines in [`menus.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/menus.py) (rofi, wofi, tofi, etc.) |\n| **Observer Pattern** | Event handlers subscribe to WM events |\n| **Async Task Queues** | Per-plugin isolation, prevents blocking |\n| **Decorator Pattern** | `@retry_on_reset` in [`ipc.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/ipc.py), `@remove_duplicate` in [`manager.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/manager.py) |\n| **Template Method** | Plugin lifecycle hooks (`init`, `on_reload`, `exit`) |\n"
  },
  {
    "path": "site/Commands.md",
    "content": "# Commands\n\n<script setup>\nimport PluginCommands from './components/PluginCommands.vue'\n</script>\n\nThis page covers the `pypr` command-line interface and available commands.\n\n## Overview\n\nThe `pypr` command operates in two modes:\n\n| Usage | Mode | Description |\n|-------|------|-------------|\n| `pypr` | Daemon | Starts the Pyprland daemon (foreground) |\n| `pypr <command>` | Client | Sends a command to the running daemon |\n\n\nThere is also an optional `pypr-client` command which is designed for running in keyboard-bindings since it starts faster but doesn't support every built-in command (eg: `validate`, `edit`).\n\n> [!tip]\n> Command names can use dashes or underscores interchangeably.\n> E.g., `pypr shift_monitors` and `pypr shift-monitors` are equivalent.\n\n## Built-in Commands\n\nThese commands are always available, regardless of which plugins are loaded:\n\n<PluginCommands plugin=\"pyprland\" />\n\n## Plugin Commands\n\nEach plugin can add its own commands. Use `pypr help` to see the commands made available by the list of plugins you set in your configuration file.\n\nExamples:\n- `scratchpads` plugin adds: `toggle`, `show`, `hide`\n- `magnify` plugin adds: `zoom`\n- `expose` plugin adds: `expose`\n\nSee individual [plugin documentation](./Plugins) for command details.\n\n## Shell Completions {#command-compgen}\n\nPyprland can generate shell completions dynamically based on your loaded plugins and configuration.\n\n### Generating Completions\n\nWith the daemon running:\n\n```sh\n# Output to stdout (redirect to file)\npypr compgen zsh > ~/.zsh/completions/_pypr\n\n# Install to default user path\npypr compgen bash default\npypr compgen zsh default\npypr compgen fish default\n\n# Install to custom path (absolute or ~/)\npypr compgen bash ~/custom/path/pypr\npypr compgen zsh /etc/zsh/completions/_pypr\n```\n\n> [!warning]\n> Relative paths may not do what you expect. Use `default`, an absolute path, or a `~/` path.\n\n### Default Installation Paths\n\n| Shell | Default Path |\n|-------|--------------|\n| Bash | `~/.local/share/bash-completion/completions/pypr` |\n| Zsh | `~/.zsh/completions/_pypr` |\n| Fish | `~/.config/fish/completions/pypr.fish` |\n\n> [!tip]\n> For Zsh, the default path may not be in your `$fpath`. Pypr will show instructions to add it.\n\n> [!note]\n> Regenerate completions after adding new plugins or scratchpads to keep them up to date.\n\n## pypr-client {#pypr-client}\n\n`pypr-client` is a lightweight, compiled alternative to `pypr` for sending commands to the daemon. It's significantly faster and ideal for key bindings.\n\n### When to Use It\n\n- In `hyprland.conf` key bindings where startup time matters\n- When you need minimal latency (e.g., toggling scratchpads)\n\n### Limitations\n\n- Cannot run the daemon (use `pypr` for that)\n- Does not support `validate` or `edit` commands (these require Python)\n\n### Installation\n\nDepending on your installation method, `pypr-client` may already be available. If not:\n\n1. Download the [source code](https://github.com/hyprland-community/pyprland/tree/main/client/)\n2. Compile it: `gcc -o pypr-client pypr-client.c`\n\nRust and Go versions are also available in the same directory.\n\n### Usage in hyprland.conf\n\n```ini\n# Use pypr-client for faster key bindings\n$pypr = /usr/bin/pypr-client\n\nbind = $mainMod, A, exec, $pypr toggle term\nbind = $mainMod, B, exec, $pypr expose\nbind = $mainMod SHIFT, Z, exec, $pypr zoom\n```\n\n> [!tip]\n> If using [uwsm](https://github.com/Vladimir-csp/uwsm), wrap the command:\n> ```ini\n> $pypr = uwsm-app -- /usr/bin/pypr-client\n> ```\n\nFor technical details about the client-daemon protocol, see [Architecture: Socket Protocol](./Architecture_core#pyprland-socket-protocol).\n\n## pypr-gui {#pypr-gui}\n\n`pypr-gui` is a web-based configuration editor. It starts a local HTTP server and opens a browser interface for viewing and editing your pyprland configuration.\n\n### Features\n\n- **Plugin browser**: enable/disable plugins with checkboxes\n- **Schema-driven forms**: each plugin's options are presented with types, defaults, descriptions, and validation\n- **Validate / Save / Apply**: check your config for errors, save to disk, or save and live-reload the running daemon in one click\n- **Multi-file support**: saves per-plugin files under `conf.d/`, keeping your configuration tidy (see [Multiple Configuration Files](./MultipleConfigurationFiles))\n\n### Usage\n\n```sh\npypr-gui              # Start server and open browser\npypr-gui -s           # Print URL (start server in background if needed)\npypr-gui -w           # Open browser (explicit, same as default)\npypr-gui --no-browser # Start server without opening browser\npypr-gui --port 9000  # Use a specific port (default: 18099)\n```\n\nThe server listens on `127.0.0.1:18099` by default (not exposed to the network).\nIf an instance is already running, `pypr-gui` will reuse it instead of starting a new one.\n\n> [!note]\n> The pyprland daemon does **not** need to be running to edit and validate your configuration. However, the **Apply** action (save + reload) requires a running daemon.\n\n## Debugging\n\nTo run the daemon with debug logging:\n\n```sh\npypr --debug\n```\n\nTo also save logs to a file:\n\n```sh\npypr --debug $HOME/pypr.log\n```\n\nOr in `hyprland.conf`:\n\n```ini\nexec-once = /usr/bin/pypr --debug $HOME/pypr.log\n```\n\nThe log file will contain detailed information useful for troubleshooting.\n"
  },
  {
    "path": "site/Configuration.md",
    "content": "# Configuration\n\nThis page covers the configuration file format and available options.\n\n## File Location\n\nThe default configuration file is:\n\n```\n~/.config/pypr/config.toml\n```\n\nYou can specify a different path using the `--config` flag:\n\n```sh\npypr --config /path/to/config.toml\n```\n\n## Format\n\nPyprland uses the [TOML format](https://toml.io/). The basic structure is:\n\n```toml\n[pyprland]\nplugins = [\"plugin_name\", \"other_plugin\"]\n\n[plugin_name]\noption = \"value\"\n\n[plugin_name.nested_option]\nsuboption = 42\n```\n\n## [pyprland] Section\n\nThe main section configures the Pyprland daemon itself.\n\n<PluginConfig plugin=\"pyprland\" linkPrefix=\"config-\" />\n\n### `include` <ConfigBadges plugin=\"pyprland\" option=\"include\" /> {#config-include}\n\nList of additional configuration files to include. See [Multiple Configuration Files](./MultipleConfigurationFiles) for details.\n\n### `notification_type` <ConfigBadges plugin=\"pyprland\" option=\"notification_type\" /> {#config-notification-type}\n\nControls how notifications are displayed:\n\n| Value | Behavior |\n|-------|----------|\n| `\"auto\"` | Adapts to environment (Niri uses `notify-send`, Hyprland uses `hyprctl notify`) |\n| `\"notify-send\"` | Forces use of `notify-send` command |\n| `\"native\"` | Forces use of compositor's native notification system |\n\n### `variables` <ConfigBadges plugin=\"pyprland\" option=\"variables\" /> {#config-variables}\n\nCustom variables that can be used in plugin configurations. See [Variables](./Variables) for usage details.\n\n## Examples\n\n```toml\n[pyprland]\nplugins = [\n    \"scratchpads\",\n    \"magnify\",\n    \"expose\",\n]\nnotification_type = \"notify-send\"\n```\n\n### Plugin Configuration\n\nEach plugin can have its own configuration section. The format depends on the plugin:\n\n```toml\n# Simple options\n[magnify]\nfactor = 2\n\n# Nested options (e.g., scratchpads)\n[scratchpads.term]\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\n```\n\nSee individual [plugin documentation](./Plugins) for available options.\n\n### Multiple Configuration Files\n\nYou can split your configuration across multiple files using `include`:\n\n```toml\n[pyprland]\ninclude = [\n    \"~/.config/pypr/scratchpads.toml\",\n    \"~/.config/pypr/monitors.toml\",\n]\nplugins = [\"scratchpads\", \"monitors\"]\n```\n\nSee [Multiple Configuration Files](./MultipleConfigurationFiles) for details.\n\n## Hyprland Integration\n\nMost plugins provide commands that you'll want to bind to keys. Add bindings to your `hyprland.conf`:\n\n```ini\n# Define pypr command (adjust path as needed)\n$pypr = /usr/bin/pypr\n\n# Example bindings\nbind = $mainMod, A, exec, $pypr toggle term\nbind = $mainMod, B, exec, $pypr expose\nbind = $mainMod SHIFT, Z, exec, $pypr zoom\n```\n\n> [!tip]\n> For faster key bindings, use `pypr-client` instead of `pypr`. See [Commands](./Commands#pypr-client) for details.\n\n> [!tip]\n> Command names can use dashes or underscores interchangeably.\n> E.g., `pypr shift_monitors` and `pypr shift-monitors` are equivalent.\n\n## Validation\n\nYou can validate your configuration without running the daemon:\n\n```sh\npypr validate\n```\n\nThis checks your config against plugin schemas and reports any errors.\n\n> [!tip]\n> You can also use [`pypr-gui`](./Commands#pypr-gui) to browse available plugins, edit options with schema-aware forms, and validate interactively.\n\n## Tips\n\n- See [Examples](./Examples) for complete configuration samples\n- See [Optimizations](./Optimizations) for performance tips\n- Only enable plugins you actually use in the `plugins` array\n"
  },
  {
    "path": "site/Development.md",
    "content": "# Development\n\nIt's easy to write your own plugin by making a Python package and then indicating its name as the plugin name.\n\n> [!tip]\n> For details on internal architecture, data flows, and design patterns, see the [Architecture](./Architecture) document.\n\n[Contributing guidelines](https://github.com/fdev31/pyprland/blob/main/CONTRIBUTING.md)\n\n## Development Setup\n\n### Prerequisites\n\n- Python 3.11+\n- [uv](https://docs.astral.sh/uv/getting-started/installation/) for dependency management\n- [pre-commit](https://pre-commit.com/) for Git hooks\n\n### Initial Setup\n\n```sh\n# Clone the repository\ngit clone https://github.com/fdev31/pyprland.git\ncd pyprland\n\n# Install dev and lint dependencies\nuv sync --all-groups\n\n# Install pre-commit hooks\nuv run pre-commit install\nuv run pre-commit install --hook-type pre-push\n```\n\n## Quick Start\n\n### Debugging\n\nTo get detailed logs when an error occurs, use:\n\n```sh\npypr --debug\n```\n\nThis displays logs in the console. To also save logs to a file:\n\n```sh\npypr --debug $HOME/pypr.log\n```\n\n### Quick Experimentation\n\n> [!note]\n> To quickly get started, you can directly edit the built-in [`experimental`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/experimental.py) plugin.\n> To distribute your plugin, create your own Python package or submit a pull request.\n\n### Custom Plugin Paths\n\n> [!tip]\n> Set `plugins_paths = [\"/custom/path\"]` in the `[pyprland]` section of your config to add extra plugin search paths during development.\n\n## Writing Plugins\n\n### Plugin Loading\n\nPlugins are loaded by their full Python module path:\n\n```toml\n[pyprland]\nplugins = [\"mypackage.myplugin\"]\n```\n\nThe module must provide an `Extension` class inheriting from [`Plugin`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/interface.py).\n\n> [!note]\n> If your extension is at the root level (not recommended), you can import it using the `external:` prefix:\n> ```toml\n> plugins = [\"external:myplugin\"]\n> ```\n> Prefer namespaced packages like `johns_pyprland.super_feature` instead.\n\n### Plugin Attributes\n\nYour `Extension` class has access to these attributes:\n\n| Attribute | Type | Description |\n|-----------|------|-------------|\n| `self.name` | `str` | Plugin identifier |\n| `self.config` | [`Configuration`](https://github.com/fdev31/pyprland/blob/main/pyprland/config.py) | Plugin's TOML config section |\n| `self.state` | [`SharedState`](https://github.com/fdev31/pyprland/blob/main/pyprland/common.py) | Shared application state (active workspace, monitor, etc.) |\n| `self.backend` | [`EnvironmentBackend`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/backend.py) | WM interaction: commands, queries, notifications |\n| `self.log` | `Logger` | Plugin-specific logger |\n\n### Creating Your First Plugin\n\n```python\nfrom pyprland.plugins.interface import Plugin\n\n\nclass Extension(Plugin):\n    \"\"\"My custom plugin.\"\"\"\n\n    async def init(self) -> None:\n        \"\"\"Called once at startup.\"\"\"\n        self.log.info(\"My plugin initialized\")\n\n    async def on_reload(self) -> None:\n        \"\"\"Called on init and config reload.\"\"\"\n        self.log.info(f\"Config: {self.config}\")\n\n    async def exit(self) -> None:\n        \"\"\"Cleanup on shutdown.\"\"\"\n        pass\n```\n\n### Adding Commands\n\nAdd `run_<commandname>` methods to handle `pypr <commandname>` calls.\n\nThe **first line** of the docstring appears in `pypr help`:\n\n```python\nclass Extension(Plugin):\n    zoomed = False\n\n    async def run_togglezoom(self, args: str) -> str | None:\n        \"\"\"Toggle zoom level.\n\n        This second line won't appear in CLI help.\n        \"\"\"\n        if self.zoomed:\n            await self.backend.execute(\"keyword misc:cursor_zoom_factor 1\")\n        else:\n            await self.backend.execute(\"keyword misc:cursor_zoom_factor 2\")\n        self.zoomed = not self.zoomed\n```\n\n### Reacting to Events\n\nAdd `event_<eventname>` methods to react to [Hyprland events](https://wiki.hyprland.org/IPC/):\n\n```python\nasync def event_openwindow(self, params: str) -> None:\n    \"\"\"React to window open events.\"\"\"\n    addr, workspace, cls, title = params.split(\",\", 3)\n    self.log.debug(f\"Window opened: {title}\")\n\nasync def event_workspace(self, workspace: str) -> None:\n    \"\"\"React to workspace changes.\"\"\"\n    self.log.info(f\"Switched to workspace: {workspace}\")\n```\n\n> [!note]\n> **Code Safety:** Pypr ensures only one handler runs at a time per plugin, so you don't need concurrency handling. Each plugin runs independently in parallel. See [Architecture - Manager](./Architecture#manager) for details.\n\n### Configuration Schema\n\nDefine expected config fields for automatic validation using [`ConfigField`](https://github.com/fdev31/pyprland/blob/main/pyprland/validation.py):\n\n```python\nfrom pyprland.plugins.interface import Plugin\nfrom pyprland.validation import ConfigField\n\n\nclass Extension(Plugin):\n    config_schema = [\n        ConfigField(\"enabled\", bool, required=False, default=True),\n        ConfigField(\"timeout\", int, required=False, default=5000),\n        ConfigField(\"command\", str, required=True),\n    ]\n\n    async def on_reload(self) -> None:\n        # Config is validated before on_reload is called\n        cmd = self.config[\"command\"]  # Guaranteed to exist\n```\n\n### Using Menus\n\nFor plugins that need menu interaction (rofi, wofi, tofi, etc.), use [`MenuMixin`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/menus.py):\n\n```python\nfrom pyprland.adapters.menus import MenuMixin\nfrom pyprland.plugins.interface import Plugin\n\n\nclass Extension(MenuMixin, Plugin):\n    async def run_select(self, args: str) -> None:\n        \"\"\"Show a selection menu.\"\"\"\n        await self.ensure_menu_configured()\n\n        options = [\"Option 1\", \"Option 2\", \"Option 3\"]\n        selected = await self.menu(options, \"Choose an option:\")\n\n        if selected:\n            await self.backend.notify_info(f\"Selected: {selected}\")\n```\n\n## Reusable Code\n\n### Shared State\n\nAccess commonly needed information without fetching it:\n\n```python\n# Current workspace, monitor, window\nworkspace = self.state.active_workspace\nmonitor = self.state.active_monitor\nwindow_addr = self.state.active_window\n\n# Environment detection\nif self.state.environment == \"niri\":\n    # Niri-specific logic\n    pass\n```\n\nSee [Architecture - Shared State](./Architecture#shared-state) for all available fields.\n\n### Mixins\n\nUse mixins for common functionality:\n\n```python\nfrom pyprland.common import CastBoolMixin\nfrom pyprland.plugins.interface import Plugin\n\n\nclass Extension(CastBoolMixin, Plugin):\n    async def on_reload(self) -> None:\n        # Safely cast config values to bool\n        enabled = self.cast_bool(self.config.get(\"enabled\", True))\n```\n\n## Development Workflow\n\nRestart the daemon after making changes:\n\n```sh\npypr exit ; pypr --debug\n```\n\n### API Documentation\n\nGenerate and browse the full API documentation:\n\n```sh\ntox run -e doc\n# Then visit http://localhost:8080\n```\n\n## Testing & Quality Assurance\n\n### Running All Checks\n\nBefore submitting a PR, run the full test suite:\n\n```sh\ntox\n```\n\nThis runs unit tests across Python versions and linting checks.\n\n### Tox Environments\n\n| Environment | Command | Description |\n|-------------|---------|-------------|\n| `py314-unit` | `tox run -e py314-unit` | Unit tests (Python 3.14) |\n| `py311-unit` | `tox run -e py311-unit` | Unit tests (Python 3.11) |\n| `py312-unit` | `tox run -e py312-unit` | Unit tests (Python 3.12) |\n| `py314-linting` | `tox run -e py314-linting` | Full linting suite (mypy, ruff, pylint, flake8) |\n| `py314-wiki` | `tox run -e py314-wiki` | Check plugin documentation coverage |\n| `doc` | `tox run -e doc` | Generate API docs with pdoc |\n| `coverage` | `tox run -e coverage` | Run tests with coverage report |\n| `deadcode` | `tox run -e deadcode` | Detect dead code with vulture |\n\n### Quick Test Commands\n\n```sh\n# Run unit tests only\ntox run -e py314-unit\n\n# Run linting only\ntox run -e py314-linting\n\n# Check documentation coverage\ntox run -e py314-wiki\n\n# Run tests with coverage\ntox run -e coverage\n```\n\n## Pre-commit Hooks\n\nPre-commit hooks ensure code quality before commits and pushes.\n\n### Installation\n\n```sh\npip install pre-commit\npre-commit install\npre-commit install --hook-type pre-push\n```\n\n### What Runs Automatically\n\n**On every commit:**\n\n| Hook | Purpose |\n|------|---------|\n| `versionMgmt` | Auto-increment version number |\n| `wikiDocGen` | Regenerate plugin documentation JSON |\n| `wikiDocCheck` | Verify documentation coverage |\n| `ruff-check` | Lint Python code |\n| `ruff-format` | Format Python code |\n| `flake8` | Additional Python linting |\n| `check-yaml` | Validate YAML files |\n| `check-json` | Validate JSON files |\n| `pretty-format-json` | Auto-format JSON files |\n| `beautysh` | Format shell scripts |\n| `yamllint` | Lint YAML files |\n\n**On push:**\n\n| Hook | Purpose |\n|------|---------|\n| `runtests` | Run full pytest suite |\n\n### Manual Execution\n\nRun all hooks manually:\n\n```sh\npre-commit run --all-files\n```\n\nRun a specific hook:\n\n```sh\npre-commit run ruff-check --all-files\n```\n\n## Packaging & Distribution\n\n### Creating an External Plugin Package\n\nSee the [sample extension](https://github.com/fdev31/pyprland/tree/main/sample_extension) for a complete example with:\n- Proper package structure\n- `pyproject.toml` configuration\n- Example plugin code: [`focus_counter.py`](https://github.com/fdev31/pyprland/blob/main/sample_extension/pypr_examples/focus_counter.py)\n\n### Development Installation\n\nInstall your package in editable mode for testing:\n\n```sh\ncd your-plugin-package/\npip install -e .\n```\n\n### Publishing\n\nWhen ready to distribute:\n\n```sh\nuv publish\n```\n\nDon't forget to update the details in your `pyproject.toml` file first.\n\n### Example Usage\n\nAdd your plugin to the config:\n\n```toml\n[pyprland]\nplugins = [\"pypr_examples.focus_counter\"]\n\n[\"pypr_examples.focus_counter\"]\nmultiplier = 2\n```\n\n> [!important]\n> Contact the maintainer to get your extension listed on the home page.\n\n## Further Reading\n\n- [Architecture](./Architecture) - Internal system design, data flows, and design patterns\n- [Plugins](./Plugins) - List of available built-in plugins\n- [Sample Extension](https://github.com/fdev31/pyprland/tree/main/sample_extension) - Complete example plugin package\n- [Hyprland IPC](https://wiki.hyprland.org/IPC/) - Hyprland's IPC documentation\n"
  },
  {
    "path": "site/Examples.md",
    "content": "# Examples\n\nThis page provides complete configuration examples to help you get started.\n\n## Basic Setup\n\nA minimal configuration with a few popular plugins:\n\n### pyprland.toml\n\n```toml\n[pyprland]\nplugins = [\n    \"scratchpads\",\n    \"magnify\",\n    \"expose\",\n]\n\n[scratchpads.term]\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\nanimation = \"fromTop\"\n\n[scratchpads.volume]\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nsize = \"40% 90%\"\nanimation = \"fromRight\"\nlazy = true\n```\n\n### hyprland.conf\n\n```ini\n$pypr = /usr/bin/pypr\n\nbind = $mainMod, A, exec, $pypr toggle term\nbind = $mainMod, V, exec, $pypr toggle volume\nbind = $mainMod, B, exec, $pypr expose\nbind = $mainMod SHIFT, Z, exec, $pypr zoom\n```\n\n## Full-Featured Setup\n\nA comprehensive configuration demonstrating multiple plugins and features:\n\n### pyprland.toml\n\n```toml\n[pyprland]\nplugins = [\n  \"scratchpads\",\n  \"lost_windows\",\n  \"monitors\",\n  \"toggle_dpms\",\n  \"magnify\",\n  \"expose\",\n  \"shift_monitors\",\n  \"workspaces_follow_focus\",\n]\n\n[monitors.placement]\n\"Acer\".top_center_of = \"Sony\"\n\n[workspaces_follow_focus]\nmax_workspaces = 9\n\n[expose]\ninclude_special = false\n\n[scratchpads.stb]\nanimation = \"fromBottom\"\ncommand = \"kitty --class kitty-stb sstb\"\nclass = \"kitty-stb\"\nlazy = true\nsize = \"75% 45%\"\n\n[scratchpads.stb-logs]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-stb-logs stbLog\"\nclass = \"kitty-stb-logs\"\nlazy = true\nsize = \"75% 40%\"\n\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nlazy = true\nsize = \"40% 90%\"\nunfocus = \"hide\"\n```\n\n### hyprland.conf\n\n```ini\n# Use pypr-client for faster response in key bindings\n$pypr = uwsm-app -- /usr/local/bin/pypr-client\n\nbind = $mainMod SHIFT,  Z, exec, $pypr zoom\nbind = $mainMod ALT,    P, exec, $pypr toggle_dpms\nbind = $mainMod SHIFT,  O, exec, $pypr shift_monitors +1\nbind = $mainMod,        B, exec, $pypr expose\nbind = $mainMod,        K, exec, $pypr change_workspace +1\nbind = $mainMod,        J, exec, $pypr change_workspace -1\nbind = $mainMod,        L, exec, $pypr toggle_dpms\nbind = $mainMod  SHIFT, M, exec, $pypr toggle stb stb-logs\nbind = $mainMod,        A, exec, $pypr toggle term\nbind = $mainMod,        V, exec, $pypr toggle volume\n```\n\n> [!note]\n> This example uses `pypr-client` for faster key binding response. See [Commands: pypr-client](./Commands#pypr-client) for details.\n\n## Advanced Features\n\n### Variables\n\nYou can define reusable variables in your configuration to avoid repetition and make it easier to switch terminals or other tools.\n\nDefine variables in the `[pyprland.variables]` section:\n\n```toml\n[pyprland.variables]\nterm = \"foot\"\nterm_classed = \"foot -a\"  # For kitty, use \"kitty --class\"\n```\n\nThen use them in plugin configurations that support variable substitution:\n\n```toml\n[scratchpads.term]\ncommand = \"[term_classed] scratchterm\"\nclass = \"scratchterm\"\n```\n\nThis way, switching from `foot` to `kitty` only requires changing the variables, not every scratchpad definition.\n\nSee [Variables](./Variables) for more details.\n\n### Text Filters\n\nSome plugins support text filters for transforming output. Filters use a syntax similar to sed's `s` command:\n\n```toml\nfilter = 's/foo/bar/'           # Replace first \"foo\" with \"bar\"\nfilter = 's/foo/bar/g'          # Replace all occurrences\nfilter = 's/.*started (.*)/\\1 has started/'  # Regex with capture groups\nfilter = 's#</?div>##g'         # Use different delimiter\n```\n\nSee [Filters](./filters) for more details.\n\n## Community Examples\n\nBrowse community-contributed configuration files:\n\n- [GitHub examples folder](https://github.com/hyprland-community/pyprland/tree/main/examples)\n\nFeel free to share your own configurations by contributing to the repository.\n\n## Tips\n\n- [Optimizations](./Optimizations) - Performance tuning tips\n- [Troubleshooting](./Troubleshooting) - Common issues and solutions\n- [Multiple Configuration Files](./MultipleConfigurationFiles) - Split your config for better organization\n"
  },
  {
    "path": "site/Getting-started.md",
    "content": "# Getting Started\n\nPypr consists of two things:\n\n- **A tool**: `pypr` which runs the daemon (service) and allows you to interact with it\n- **A config file**: `~/.config/pypr/config.toml` using the [TOML](https://toml.io/en/) format\n\n> [!important]\n> - With no arguments, `pypr` runs the daemon (doesn't fork to background)\n> - With arguments, it sends commands to the running daemon\n\n> [!tip]\n> For keybindings, use `pypr-client` instead of `pypr` for faster response (~1ms vs ~50ms). See [Commands: pypr-client](./Commands#pypr-client) for details.\n\n> [!tip]\n> Prefer a visual editor? Run `pypr-gui` to configure pyprland from your browser. See [Commands: pypr-gui](./Commands#pypr-gui) for details.\n\n## Installation\n\nCheck your OS package manager first:\n\n- **Arch Linux**: Available on AUR, e.g., with [yay](https://github.com/Jguer/yay): `yay pyprland`\n- **NixOS**: See the [Nix](./Nix) page for instructions\n\nOtherwise, you may want using the [uv](https://docs.astral.sh/uv/) way:\n\n```sh\n# Install pyprland\nuv tool install pyprland\n```\n\n\nelse, install via pip (in a [virtual environment](./InstallVirtualEnvironment)):\n\n```sh\npip install pyprland\n```\n\n## Minimal Configuration\n\nCreate `~/.config/pypr/config.toml` with:\n\n```toml\n[pyprland]\nplugins = [\n    \"scratchpads\",\n    \"gamemode\",\n    \"magnify\",\n]\n```\n\nThis enables only few plugins. See the [Plugins](./Plugins) page for the full list.\n\n## Running the Daemon\n\n> [!caution]\n> If you installed pypr outside your OS package manager (e.g., pip, virtual environment), use the full path to the `pypr` command. Get it with `which pypr` in a working terminal.\n\n### Option 1: Hyprland exec-once\n\nAdd to your `hyprland.conf`:\n\n```ini\nexec-once = /usr/bin/pypr\n```\n\nFor debugging, use:\n\n```ini\nexec-once = /usr/bin/pypr --debug\n```\n\nOr to also save logs to a file:\n\n```ini\nexec-once = /usr/bin/pypr --debug $HOME/pypr.log\n```\n\n### Option 2: Systemd User Service\n\nCreate `~/.config/systemd/user/pyprland.service`:\n\n```ini\n[Unit]\nDescription=Starts pyprland daemon\nAfter=graphical-session.target\nWants=graphical-session.target\n# Optional: wait for other services to start first\n# Wants=hyprpaper.service\nStartLimitIntervalSec=600\nStartLimitBurst=5\n\n[Service]\nType=simple\n# Optional: only start on specific compositor\n# For Hyprland:\n# ExecStartPre=/bin/sh -c '[ \"$XDG_CURRENT_DESKTOP\" = \"Hyprland\" ] || exit 0'\n# For Niri:\n# ExecStartPre=/bin/sh -c '[ \"$XDG_CURRENT_DESKTOP\" = \"niri\" ] || exit 0'\nExecStart=pypr\nRestart=always\n\n[Install]\nWantedBy=graphical-session.target\n```\n\nThen enable and start the service:\n\n```sh\nsystemctl enable --user --now pyprland.service\n```\n\n## Verifying It Works\n\nOnce the daemon is running, check available commands:\n\n```sh\npypr help\n```\n\nIf something isn't working, check the [Troubleshooting](./Troubleshooting) page.\n\n## Next Steps\n\n- [Configuration](./Configuration) - Full configuration reference\n- [Commands](./Commands) - CLI commands and shell completions\n- [Plugins](./Plugins) - Browse available plugins\n- [Examples](./Examples) - Complete configuration examples\n"
  },
  {
    "path": "site/InstallVirtualEnvironment.md",
    "content": "# Virtual env\n\nEven though the best way to get Pyprland installed is to use your operating system package manager,\nfor some usages or users it can be convenient to use a virtual environment.\n\nThis is very easy to achieve in a couple of steps:\n\n```shell\npython -m venv ~/pypr-env\n~/pypr-env/bin/pip install pyprland\n```\n\n**That's all folks!**\n\nThe only extra care to take is to use `pypr` from the virtual environment, eg:\n\n- adding the environment's \"bin\" folder to the `PATH` (using `export PATH=\"$PATH:~/pypr-env/bin/\"` in your shell configuration file)\n- always using the full path to the pypr command (in `hyprland.conf`: `exec-once = ~/pypr-env/bin/pypr` or with debug logging: `exec-once = ~/pypr-env/bin/pypr --debug $HOME/pypr.log`)\n\n# Going bleeding edge!\n\nIf you would rather like to use the latest version available (not released yet), then you can clone the git repository and install from it:\n\n```shell\ncd ~/pypr-env\ngit clone git@github.com:hyprland-community/pyprland.git pyprland-sources\ncd pyprland-sources\n../bin/pip install -e .\n```\n\n## Updating\n\n```shell\ncd ~/pypr-env\ngit pull -r\n```\n\n# Troubelshooting\n\nIf things go wrong, try (eg: after a system upgrade where Python got updated):\n\n```shell\npython -m venv --upgrade ~/pypr-env\ncd  ~/pypr-env\n../bin/pip install -e .\n```\n"
  },
  {
    "path": "site/Menu.md",
    "content": "# Menu capability\n\nMenu based plugins have the following configuration options:\n\n<PluginConfig plugin=\"menu\" linkPrefix=\"config-\" />\n\n### `engine` <ConfigBadges plugin=\"menu\" option=\"engine\" /> {#config-engine}\n\nAuto-detects the available menu engine if not set.\n\nSupported engines (tested in order):\n\n<EngineList />\n\n> [!note]\n> If your menu system isn't supported, you can open a [feature request](https://github.com/hyprland-community/pyprland/issues/new?assignees=fdev31&labels=bug&projects=&template=feature_request.md&title=%5BFEAT%5D+Description+of+the+feature)\n>\n> In case the engine isn't recognized, `engine` + `parameters` configuration options will be used to start the process, it requires a dmenu-like behavior.\n\n### `parameters` <ConfigBadges plugin=\"menu\" option=\"parameters\" /> {#config-parameters}\n\nExtra parameters added to the engine command. Setting this will override the engine's default value.\n\n> [!tip]\n> You can use `[prompt]` in the parameters, it will be replaced by the prompt, eg for rofi/wofi:\n> ```sh\n> -dmenu -matching fuzzy -i -p '[prompt]'\n> ```\n\n#### Default parameters per engine\n\n<EngineDefaults />\n"
  },
  {
    "path": "site/MultipleConfigurationFiles.md",
    "content": "### Multiple configuration files\n\nYou can also split your configuration into multiple files that will be loaded in the provided order after the main file:\n```toml\n[pyprland]\ninclude = [\"/shared/pyprland.toml\", \"~/pypr_extra_config.toml\"]\n```\nYou can also load folders, in which case TOML files in the folder will be loaded in alphabetical order:\n```toml\n[pyprland]\ninclude = [\"~/.config/pypr.d/\"]\n```\n\nAnd then add a `~/.config/pypr.d/monitors.toml` file:\n```toml\npyprland.plugins = [ \"monitors\" ]\n\n[monitors.placement]\nBenQ.Top_Center_Of = \"DP-1\" # projo\n\"CJFH277Q3HCB\".top_of = \"eDP-1\" # work\n```\n\n> [!tip]\n> To check the final merged configuration, you can use the `dumpjson` command.\n\n"
  },
  {
    "path": "site/Nix.md",
    "content": "# Nix\n\nYou are recommended to get the latest pyprland package from the [flake.nix](https://github.com/hyprland-community/pyprland/blob/main/flake.nix)\nprovided within this repository. To use it in your system, you may add pyprland\nto your flake inputs.\n\n## Flake\n\n```nix\n## flake.nix\n{\n  inputs = {\n    nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n    pyprland.url = \"github:hyprland-community/pyprland\";\n  };\n\n  outputs = { self, nixpkgs, pyprland, ...}: let\n  in {\n    nixosConfigurations.<yourHostname> = nixpkgs.lib.nixosSystem {\n      #         remember to replace this with your system arch ↓\n      environment.systemPackages = [ pyprland.packages.\"x86_64-linux\".pyprland ];\n      # ...\n    };\n  };\n}\n```\n\nAlternatively, if you are using the Nix package manager but not NixOS as your\nmain distribution, you may use `nix profile` tool to install pyprland from this\nrepository using the following command.\n\n```bash\nnix profile install github:nix-community/pyprland\n```\n\nThe package will now be in your latest profile. You may use `nix profile list`\nto verify your installation.\n\n## Nixpkgs\n\nPyprland is available under nixpkgs, and can be installed by adding\n`pkgs.pyprland` to either `environment.systemPackages` or `home.packages`\ndepending on whether you want it available system-wide or to only a single\nuser using home-manager. If the derivation available in nixpkgs is out-of-date\nthen you may consider using `overrideAttrs` to update the source locally.\n\n```nix\nlet\n  pyprland = pkgs.pyprland.overrideAttrs {\n    version = \"your-version-here\";\n    src = fetchFromGitHub {\n      owner = \"hyprland-community\";\n      repo = \"pyprland\";\n      rev = \"tag-or-revision\";\n      # leave empty for the first time, add the new hash from the error message\n      hash = \"\";\n    };\n  };\nin {\n  # add the overridden package to systemPackages\n  environment.systemPackages = [pyprland];\n}\n```\n"
  },
  {
    "path": "site/Optimizations.md",
    "content": "## Optimizing\n\n### Plugins\n\n- Only enable the plugins you are using in the `plugins` array (in `[pyprland]` section).\n- Leaving the configuration for plugins which are not enabled will have no impact.\n- Using multiple configuration files only have a small impact on the startup time.\n\n### Pypr\n\nYou can run `pypr` using `pypy` (version 3) for a more snappy experience.\nOne way is to use a `pypy3` virtual environment:\n\n```bash\npypy3 -m venv pypr-venv\nsource ./pypr-venv/bin/activate\ncd <pypr source folder>\npip install -e .\n```\n\n### Pypr command\n\nIn case you want to save some time when interacting with the daemon, the simplest is to use `pypr-client`. See [Commands: pypr-client](./Commands#pypr-client) for details. If `pypr-client` isn't available from your OS package and you cannot compile code,\nyou can use `socat` instead (needs to be installed).\n\nExample of a `pypr-cli` command (should be reachable from your environment's `PATH`):\n\n#### Hyprland\n```sh\n#!/bin/sh\nsocat - \"UNIX-CONNECT:${XDG_RUNTIME_DIR}/hypr/${HYPRLAND_INSTANCE_SIGNATURE}/.pyprland.sock\" <<< $@\n```\n\n#### Niri\n```sh\n#!/bin/sh\nsocat - \"UNIX-CONNECT:$(dirname ${NIRI_SOCKET})/.pyprland.sock\" <<< $@\n```\n\n#### Standalone (other window manager)\n```sh\n#!/bin/sh\nsocat - \"UNIX-CONNECT:${XDG_DATA_HOME:-$HOME/.local/share}/.pyprland.sock\" <<< $@\n```\n\nOn slow systems this may make a difference.\nNote that `validate` and `edit` commands require the standard `pypr` command.\n"
  },
  {
    "path": "site/Plugins.md",
    "content": "<script setup>\nimport PluginList from '/components/PluginList.vue'\n</script>\n\nThis page lists every plugin provided by Pyprland out of the box, more can be enabled if you install the matching package.\n\n\"🌟\" indicates some maturity & reliability level of the plugin, considering age, attention paid and complexity - from 0 to 3.\n\nA badge such as <Badge type=\"tip\">multi-monitor</Badge> indicates a requirement.\n\nSome plugins require an external **graphical menu system**, such as *rofi*.\nEach plugin can use a different menu system but the [configuration is unified](Menu). In case no [engine](Menu#engine) is provided some auto-detection of installed applications will happen.\n\n<PluginList/>\n"
  },
  {
    "path": "site/Troubleshooting.md",
    "content": "# Troubleshooting\n\n## Checking Logs\n\nHow you access logs depends on how you run pyprland.\n\n### Systemd Service\n\nIf you run pyprland as a [systemd user service](./Getting-started#option-2-systemd-user-service):\n\n```sh\njournalctl --user -u pyprland -f\n```\n\n### exec-once (Hyprland)\n\nIf you run pyprland via [exec-once](./Getting-started#option-1-hyprland-exec-once), logs go to stderr by default and are typically lost.\n\nTo enable debug logging, add `--debug` to your exec-once command. Optionally specify a file path to also save logs to a file:\n\n```ini\nexec-once = /usr/bin/pypr --debug $HOME/pypr.log\n```\n\nThen check the log file:\n\n```sh\ntail -f ~/pypr.log\n```\n\n> [!tip]\n> Use a path like `$HOME/pypr.log` or `/tmp/pypr.log` to avoid cluttering your home directory.\n\n### Running from Terminal\n\nFor quick debugging, run pypr directly in a terminal:\n\n```sh\npypr --debug\n```\n\nThis shows debug output directly in the terminal. Optionally add a file path to also save logs to a file.\n\n## General Issues\n\nIn case of trouble running a `pypr` command:\n\n1. Kill the existing pypr daemon if running (try `pypr exit` first)\n2. Run from a terminal with `--debug` to see error messages\n\nIf the client says it can't connect, the daemon likely didn't start. Check if it's running:\n\n```sh\nps aux | grep pypr\n```\n\nYou can try starting it manually from a terminal:\n\n```sh\npypr --debug\n```\n\nThis will show any startup errors directly in the terminal.\n\n## Force Hyprland Version\n\nIn case your `hyprctl version -j` command isn't returning an accurate version, you can make Pyprland ignore it and use a provided value instead:\n\n```toml\n[pyprland]\nhyprland_version = \"0.41.0\"\n```\n\n## Unresponsive Scratchpads\n\nScratchpads aren't responding for a few seconds after trying to show one (which didn't show!)\n\nThis may happen if an application is very slow to start.\nIn that case pypr will wait for a window, blocking other scratchpad operations, before giving up after a few seconds.\n\nNote that other plugins shouldn't be blocked by this.\n\nMore scratchpads troubleshooting can be found [here](./scratchpads_nonstandard).\n\n## See Also\n\n- [Getting Started: Running the Daemon](./Getting-started#running-the-daemon) - Setup options\n- [Commands: Debugging](./Commands#debugging) - Debug flag reference\n"
  },
  {
    "path": "site/Variables.md",
    "content": "# Variables & substitutions\n\nSome commands support shared global variables, they must be defined in the *pyprland* section of the configuration:\n```toml\n[pyprland.variables]\nterm = \"foot\"\nterm_classed = \"foot -a\" # kitty uses --class\n```\n\nIf a plugin supports it, you can then use the variables in the attribute that supports it, eg:\n\n```toml\n[myplugin]\nsome_variable = \"the terminal is [term]\"\n```\n"
  },
  {
    "path": "site/components/CommandList.vue",
    "content": "<template>\n    <dl>\n    </dl>\n    <ul v-for=\"command in commands\" :key=\"command.name\">\n        <li><code v-html=\"command.name.replace(/[   ]*$/, '').replace(/ /g, '&ensp;')\" /> <span\n                v-html=\"command.description\" /></li>\n    </ul>\n</template>\n\n<script>\nexport default {\n    props: {\n        commands: {\n            type: Array,\n            required: true\n        }\n    }\n}\n</script>\n"
  },
  {
    "path": "site/components/ConfigBadges.vue",
    "content": "<template>\n    <span v-if=\"loaded && item\" class=\"config-badges\">\n        <Badge type=\"info\">{{ typeIcon }}{{ item.type }}</Badge>\n        <Badge v-if=\"hasDefault\" type=\"tip\">=<code>{{ formattedDefault }}</code></Badge>\n        <Badge v-if=\"item.required\" type=\"danger\">required</Badge>\n        <Badge v-else-if=\"item.recommended\" type=\"warning\">recommended</Badge>\n    </span>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted } from 'vue'\nimport { getPluginData } from './jsonLoader.js'\n\nconst props = defineProps({\n    plugin: {\n        type: String,\n        required: true\n    },\n    option: {\n        type: String,\n        required: true\n    },\n    version: {\n        type: String,\n        default: null\n    }\n})\n\nconst item = ref(null)\nconst loaded = ref(false)\n\nonMounted(() => {\n    try {\n        const data = getPluginData(props.plugin, props.version)\n        if (data) {\n            const config = data.config || []\n            // Find the option - handle both \"option\" and \"[prefix].option\" formats\n            item.value = config.find(c => {\n                const baseName = c.name.replace(/^\\[.*?\\]\\./, '')\n                return baseName === props.option || c.name === props.option\n            })\n        }\n    } catch (e) {\n        console.error(`Failed to load config for plugin: ${props.plugin}`, e)\n    } finally {\n        loaded.value = true\n    }\n})\n\nconst typeIcon = computed(() => {\n    if (!item.value) return ''\n    const type = item.value.type || ''\n    if (type.includes('Path')) {\n        return item.value.is_directory ? '\\u{1F4C1} ' : '\\u{1F4C4} '\n    }\n    return ''\n})\n\nconst hasDefault = computed(() => {\n    if (!item.value) return false\n    const value = item.value.default\n    if (value === null || value === undefined) return false\n    if (value === '') return false\n    if (Array.isArray(value) && value.length === 0) return false\n    if (typeof value === 'object' && Object.keys(value).length === 0) return false\n    return true\n})\n\nconst formattedDefault = computed(() => {\n    if (!item.value) return ''\n    const value = item.value.default\n    if (typeof value === 'boolean') {\n        return value ? 'true' : 'false'\n    }\n    if (typeof value === 'string') {\n        return `\"${value}\"`\n    }\n    if (Array.isArray(value)) {\n        return JSON.stringify(value)\n    }\n    return String(value)\n})\n</script>\n\n<style scoped>\n.config-badges {\n    margin-left: 0.5em;\n}\n\n.config-badges code {\n    background: transparent;\n    font-size: 0.9em;\n}\n</style>\n"
  },
  {
    "path": "site/components/ConfigTable.vue",
    "content": "<template>\n  <!-- Grouped by category (only at top level, when categories exist) -->\n  <div v-if=\"hasCategories && !isNested\" class=\"config-categories\">\n    <details\n      v-for=\"group in groupedItems\"\n      :key=\"group.category\"\n      :open=\"group.category === 'basic'\"\n      class=\"config-category\"\n    >\n      <summary class=\"config-category-header\">\n        {{ getCategoryDisplayName(group.category) }}\n        <span class=\"config-category-count\">({{ group.items.length }})</span>\n        <a v-if=\"group.category === 'menu'\" href=\"./Menu\" class=\"config-category-link\">See full documentation</a>\n      </summary>\n      <table class=\"config-table\">\n        <thead>\n          <tr>\n            <th>Option</th>\n            <th>Description</th>\n          </tr>\n        </thead>\n        <tbody>\n          <template v-for=\"item in group.items\" :key=\"item.name\">\n            <tr>\n              <td class=\"config-option-cell\">\n                <a v-if=\"isDocumented(item.name)\" :href=\"'#' + getAnchor(item.name)\" class=\"config-link\" title=\"More details below\">\n                  <span v-if=\"hasChildren(item)\" class=\"config-has-children\" title=\"Has child options\">+</span>\n                  <code>{{ item.name }}</code>\n                  <span class=\"config-info-icon\">i</span>\n                </a>\n                <template v-else>\n                  <span v-if=\"hasChildren(item)\" class=\"config-has-children\" title=\"Has child options\">+</span>\n                  <code>{{ item.name }}</code>\n                </template>\n                <Badge type=\"info\">{{ getTypeIcon(item) }}{{ item.type }}</Badge>\n                <Badge v-if=\"item.required\" type=\"danger\">required</Badge>\n                <Badge v-else-if=\"item.recommended\" type=\"warning\">recommended</Badge>\n                <div v-if=\"hasDefault(item.default)\" type=\"tip\">=<code>{{ formatDefault(item.default) }}</code></div>\n              </td>\n              <td class=\"config-description\" v-html=\"renderDescription(item.description)\" />\n            </tr>\n            <!-- Children row (recursive) -->\n            <tr v-if=\"hasChildren(item)\" class=\"config-children-row\">\n              <td colspan=\"2\" class=\"config-children-cell\">\n                <details class=\"config-children-details\">\n                  <summary><code>{{ item.name }}</code> options</summary>\n                  <config-table\n                    :items=\"item.children\"\n                    :is-nested=\"true\"\n                    :option-to-anchor=\"optionToAnchor\"\n                    :parent-name=\"getQualifiedName(item.name)\"\n                  />\n                </details>\n              </td>\n            </tr>\n          </template>\n        </tbody>\n      </table>\n    </details>\n  </div>\n\n  <!-- Flat table (for nested tables or when no categories) -->\n  <table v-else :class=\"['config-table', { 'config-nested': isNested }]\">\n    <thead>\n      <tr>\n        <th>Option</th>\n        <th>Description</th>\n      </tr>\n    </thead>\n    <tbody>\n      <template v-for=\"item in items\" :key=\"item.name\">\n        <tr>\n          <td class=\"config-option-cell\">\n            <a v-if=\"isDocumented(item.name)\" :href=\"'#' + getAnchor(item.name)\" class=\"config-link\" title=\"More details below\">\n              <span v-if=\"hasChildren(item)\" class=\"config-has-children\" title=\"Has child options\">+</span>\n              <code>{{ item.name }}</code>\n              <span class=\"config-info-icon\">i</span>\n            </a>\n            <template v-else>\n              <span v-if=\"hasChildren(item)\" class=\"config-has-children\" title=\"Has child options\">+</span>\n              <code>{{ item.name }}</code>\n            </template>\n            <Badge type=\"info\">{{ item.type }}</Badge>\n            <Badge v-if=\"item.required\" type=\"danger\">required</Badge>\n            <Badge v-else-if=\"item.recommended\" type=\"warning\">recommended</Badge>\n            <div v-if=\"hasDefault(item.default)\" type=\"tip\">=<code>{{ formatDefault(item.default) }}</code></div>\n          </td>\n          <td class=\"config-description\" v-html=\"renderDescription(item.description)\" />\n        </tr>\n        <!-- Children row (recursive) -->\n        <tr v-if=\"hasChildren(item)\" class=\"config-children-row\">\n          <td colspan=\"2\" class=\"config-children-cell\">\n            <details class=\"config-children-details\">\n              <summary><code>{{ item.name }}</code> options</summary>\n              <config-table\n                :items=\"item.children\"\n                :is-nested=\"true\"\n                :option-to-anchor=\"optionToAnchor\"\n                :parent-name=\"getQualifiedName(item.name)\"\n              />\n            </details>\n          </td>\n        </tr>\n      </template>\n    </tbody>\n  </table>\n</template>\n\n<script>\nimport { hasChildren, hasDefault, formatDefault, renderDescription } from './configHelpers.js'\n\n// Category display order and names\nconst CATEGORY_ORDER = ['basic', 'menu', 'appearance', 'positioning', 'behavior', 'external_commands', 'templating', 'placement', 'advanced', 'overrides', '']\nconst CATEGORY_NAMES = {\n  'basic': 'Basic',\n  'menu': 'Menu',\n  'appearance': 'Appearance',\n  'positioning': 'Positioning',\n  'behavior': 'Behavior',\n  'external_commands': 'External commands',\n  'templating': 'Templating',\n  'placement': 'Placement',\n  'advanced': 'Advanced',\n  'overrides': 'Overrides',\n  '': 'Other'\n}\n\nexport default {\n  name: 'ConfigTable',\n  props: {\n    items: { type: Array, required: true },\n    isNested: { type: Boolean, default: false },\n    optionToAnchor: { type: Object, default: () => ({}) },\n    parentName: { type: String, default: '' }\n  },\n  computed: {\n    hasCategories() {\n      // Only group if there are multiple distinct categories\n      const categories = new Set(this.items.map(item => item.category || ''))\n      return categories.size > 1\n    },\n    groupedItems() {\n      // Group items by category\n      const groups = {}\n      for (const item of this.items) {\n        const category = item.category || ''\n        if (!groups[category]) {\n          groups[category] = []\n        }\n        groups[category].push(item)\n      }\n\n      // Sort groups by CATEGORY_ORDER\n      const result = []\n      for (const cat of CATEGORY_ORDER) {\n        if (groups[cat]) {\n          result.push({ category: cat, items: groups[cat] })\n          delete groups[cat]\n        }\n      }\n      // Add any remaining categories not in the order list\n      for (const cat of Object.keys(groups).sort()) {\n        result.push({ category: cat, items: groups[cat] })\n      }\n\n      return result\n    }\n  },\n  methods: {\n    hasChildren,\n    hasDefault,\n    formatDefault,\n    renderDescription,\n    getTypeIcon(item) {\n      const type = item.type || ''\n      if (type.includes('Path')) {\n        return item.is_directory ? '\\u{1F4C1} ' : '\\u{1F4C4} '\n      }\n      return ''\n    },\n    getCategoryDisplayName(category) {\n      return CATEGORY_NAMES[category] || category.charAt(0).toUpperCase() + category.slice(1)\n    },\n    getQualifiedName(name) {\n      const baseName = name.replace(/^\\[.*?\\]\\./, '')\n      return this.parentName ? `${this.parentName}.${baseName}` : baseName\n    },\n    isDocumented(name) {\n      if (Object.keys(this.optionToAnchor).length === 0) return false\n      const qualifiedName = this.getQualifiedName(name)\n      const anchorKey = qualifiedName.replace(/\\./g, '-')\n      return anchorKey in this.optionToAnchor || qualifiedName in this.optionToAnchor\n    },\n    getAnchor(name) {\n      const qualifiedName = this.getQualifiedName(name)\n      const anchorKey = qualifiedName.replace(/\\./g, '-')\n      return this.optionToAnchor[anchorKey] || this.optionToAnchor[qualifiedName] || ''\n    }\n  }\n}\n</script>\n\n<style scoped>\n.config-categories {\n  display: flex;\n  flex-direction: column;\n  gap: 0.5rem;\n}\n\n.config-category {\n  border: 1px solid var(--vp-c-divider);\n  border-radius: 8px;\n  overflow: hidden;\n}\n\n.config-category[open] {\n  border-color: var(--vp-c-brand);\n}\n\n.config-category-header {\n  display: flex;\n  align-items: center;\n  gap: 0.5rem;\n  padding: 0.75rem 1rem;\n  background: var(--vp-c-bg-soft);\n  font-weight: 600;\n  cursor: pointer;\n  user-select: none;\n}\n\n.config-category-header:hover {\n  background: var(--vp-c-bg-mute);\n}\n\n.config-category-count {\n  font-weight: 400;\n  color: var(--vp-c-text-2);\n  font-size: 0.875em;\n}\n\n.config-category-link {\n  margin-left: auto;\n  font-weight: 400;\n  font-size: 0.875em;\n  color: var(--vp-c-brand);\n  text-decoration: none;\n}\n\n.config-category-link:hover {\n  text-decoration: underline;\n}\n\n.config-category .config-table {\n  margin: 0;\n  border: none;\n  border-radius: 0;\n}\n\n.config-category .config-table thead {\n  background: var(--vp-c-bg-alt);\n}\n</style>\n"
  },
  {
    "path": "site/components/EngineDefaults.vue",
    "content": "<template>\n  <div v-if=\"error\" class=\"data-error\">{{ error }}</div>\n  <table v-else-if=\"engineDefaults\" class=\"data-table\">\n    <thead>\n      <tr>\n        <th>Engine</th>\n        <th>Default Parameters</th>\n      </tr>\n    </thead>\n    <tbody>\n      <tr v-for=\"(params, engine) in engineDefaults\" :key=\"engine\">\n        <td><code>{{ engine }}</code></td>\n        <td><code>{{ params || '-' }}</code></td>\n      </tr>\n    </tbody>\n  </table>\n</template>\n\n<script setup>\nimport { computed } from 'vue'\nimport { getPluginData } from './jsonLoader.js'\n\nconst props = defineProps({\n  version: {\n    type: String,\n    default: null\n  }\n})\n\nconst data = computed(() => {\n  try {\n    return getPluginData('menu', props.version)\n  } catch (e) {\n    console.error('Failed to load menu data:', e)\n    return null\n  }\n})\n\nconst engineDefaults = computed(() => data.value?.engine_defaults || null)\nconst error = computed(() => data.value === null ? 'Failed to load engine defaults' : null)\n</script>\n"
  },
  {
    "path": "site/components/EngineList.vue",
    "content": "<template>\n  <div v-if=\"error\" class=\"data-error\">{{ error }}</div>\n  <ul v-else class=\"engine-list\">\n    <li></li>\n    <li v-for=\"engine in engines\" :key=\"engine\">\n            <code>{{ engine }}</code>\n    </li>\n  </ul>\n</template>\n\n<script setup>\nimport { computed } from 'vue'\nimport { getPluginData } from './jsonLoader.js'\n\nconst props = defineProps({\n  version: {\n    type: String,\n    default: null\n  }\n})\n\nconst data = computed(() => {\n  try {\n    return getPluginData('menu', props.version)\n  } catch (e) {\n    console.error('Failed to load menu data:', e)\n    return null\n  }\n})\n\nconst engineDefaults = computed(() => data.value?.engine_defaults ?? {})\nconst engines = computed(() => Object.keys(engineDefaults.value))\nconst error = computed(() => (data.value === null ? 'Failed to load engine defaults' : null))\n</script>\n\n<style scoped>\n.engine-list {\n  list-style: none;\n  margin: 0;\n  padding: 0;\n  overflow: hidden;\n}\n\n.engine-list li {\n  float: left;\n  margin-right: 0.75rem;\n}\n</style>\n"
  },
  {
    "path": "site/components/PluginCommands.vue",
    "content": "<template>\n  <div v-if=\"loading\" class=\"command-loading\">Loading commands...</div>\n  <div v-else-if=\"error\" class=\"command-error\">{{ error }}</div>\n  <div v-else-if=\"filteredCommands.length === 0\" class=\"command-empty\">\n    No commands are provided by this plugin.\n  </div>\n  <div v-else class=\"command-box\">\n    <ul class=\"command-list\">\n      <li v-for=\"command in filteredCommands\" :key=\"command.name\" class=\"command-item\">\n        <a v-if=\"isDocumented(command.name)\" :href=\"'#' + getAnchor(command.name)\" class=\"command-link\" title=\"More details below\">\n          <code class=\"command-name\">{{ command.name }}</code>\n          <span class=\"command-info-icon\">i</span>\n        </a>\n        <code v-else class=\"command-name\">{{ command.name }}</code>\n        <template v-for=\"(arg, idx) in command.args\" :key=\"idx\">\n          <code class=\"command-arg\">{{ formatArg(arg) }}</code>\n        </template>\n        <span class=\"command-desc\" v-html=\"renderDescription(command.short_description)\" />\n      </li>\n    </ul>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted } from 'vue'\nimport { renderDescription } from './configHelpers.js'\nimport { usePluginData } from './usePluginData.js'\nimport { getPluginData } from './jsonLoader.js'\n\nconst props = defineProps({\n  plugin: {\n    type: String,\n    required: true\n  },\n  filter: {\n    type: Array,\n    default: null\n  },\n  linkPrefix: {\n    type: String,\n    default: ''\n  },\n  version: {\n    type: String,\n    default: null\n  }\n})\n\nconst commandToAnchor = ref({})\n\nconst { data: commands, loading, error } = usePluginData(async () => {\n  const data = getPluginData(props.plugin, props.version)\n  if (!data) throw new Error(`Plugin data not found: ${props.plugin}`)\n  return data.commands || []\n})\n\nconst filteredCommands = computed(() => {\n  if (!props.filter || props.filter.length === 0) {\n    return commands.value\n  }\n  return commands.value.filter(cmd => props.filter.includes(cmd.name))\n})\n\nonMounted(() => {\n  if (props.linkPrefix) {\n    const anchors = document.querySelectorAll('h3[id], h4[id], h5[id]')\n    const mapping = {}\n    anchors.forEach(heading => {\n      mapping[heading.id] = heading.id\n      // Also extract command names from <code> elements\n      const codes = heading.querySelectorAll('code')\n      codes.forEach(code => {\n        mapping[code.textContent] = heading.id\n      })\n    })\n    commandToAnchor.value = mapping\n  }\n})\n\nfunction isDocumented(name) {\n  if (Object.keys(commandToAnchor.value).length === 0) return false\n  const anchorKey = `${props.linkPrefix}${name}`\n  return anchorKey in commandToAnchor.value || name in commandToAnchor.value\n}\n\nfunction getAnchor(name) {\n  const anchorKey = `${props.linkPrefix}${name}`\n  return commandToAnchor.value[anchorKey] || commandToAnchor.value[name] || ''\n}\n\nfunction formatArg(arg) {\n  return arg.required ? arg.value : `[${arg.value}]`\n}\n</script>\n"
  },
  {
    "path": "site/components/PluginConfig.vue",
    "content": "<template>\n  <div v-if=\"loading\" class=\"config-loading\">Loading configuration...</div>\n  <div v-else-if=\"error\" class=\"config-error\">{{ error }}</div>\n  <div v-else-if=\"filteredConfig.length === 0\" class=\"config-empty\">No configuration options available.</div>\n  <config-table\n    v-else\n    :items=\"filteredConfig\"\n    :option-to-anchor=\"optionToAnchor\"\n  />\n</template>\n\n<script>\nimport ConfigTable from './ConfigTable.vue'\nimport { getPluginData } from './jsonLoader.js'\n\nexport default {\n  components: {\n    ConfigTable\n  },\n  props: {\n    plugin: {\n      type: String,\n      required: true\n    },\n    linkPrefix: {\n      type: String,\n      default: ''\n    },\n    filter: {\n      type: Array,\n      default: null\n    },\n    version: {\n      type: String,\n      default: null\n    }\n  },\n  data() {\n    return {\n      config: [],\n      loading: true,\n      error: null,\n      optionToAnchor: {}\n    }\n  },\n  computed: {\n    filteredConfig() {\n      if (!this.filter || this.filter.length === 0) {\n        return this.config\n      }\n      return this.config.filter(item => {\n        const baseName = item.name.replace(/^\\[.*?\\]\\./, '')\n        return this.filter.includes(baseName)\n      })\n    }\n  },\n  mounted() {\n    try {\n      const data = getPluginData(this.plugin, this.version)\n      if (!data) {\n        this.error = `Plugin data not found: ${this.plugin}`\n      } else {\n        this.config = data.config || []\n      }\n    } catch (e) {\n      this.error = `Failed to load configuration for plugin: ${this.plugin}`\n      console.error(e)\n    } finally {\n      this.loading = false\n    }\n    \n    // Scan page for documented option anchors (h3, h4, h5) and build option->anchor mapping\n    if (this.linkPrefix) {\n      const anchors = document.querySelectorAll('h3[id], h4[id], h5[id]')\n      const mapping = {}\n      anchors.forEach(heading => {\n        // Map by anchor ID directly (e.g., \"placement-scale\" -> \"placement-scale\")\n        // This allows qualified lookups like \"placement.scale\" -> \"placement-scale\"\n        mapping[heading.id] = heading.id\n        // Also extract option names from <code> elements for top-level matching\n        const codes = heading.querySelectorAll('code')\n        codes.forEach(code => {\n          mapping[code.textContent] = heading.id\n        })\n      })\n      this.optionToAnchor = mapping\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "site/components/PluginList.vue",
    "content": "<template>\n    <div>\n        <h1>Built-in plugins</h1>\n        <div v-if=\"loading\">Loading plugins...</div>\n        <div v-else-if=\"error\">{{ error }}</div>\n        <div v-else v-for=\"plugin in sortedPlugins\" :key=\"plugin.name\" class=\"plugin-item\">\n            <div class=\"plugin-info\">\n                <h3>\n                    <a :href=\"plugin.name + '.html'\">{{ plugin.name }}</a>\n                    <span v-html=\"'&nbsp;' + getStars(plugin.stars)\"></span>\n                    <span v-if=\"plugin.multimon\">\n                        <Badge type=\"tip\" text=\"multi-monitor\" />\n                    </span>\n                    <span v-if=\"plugin.environments && plugin.environments.length\">\n                        <Badge v-for=\"env in plugin.environments\" :key=\"env\" type=\"tip\" :text=\"env\" style=\"margin-left: 5px;\" />\n                    </span>\n                </h3>\n                <p v-html=\"plugin.description\" />\n            </div>\n            <a v-if=\"plugin.demoVideoId\" :href=\"'https://www.youtube.com/watch?v=' + plugin.demoVideoId\"\n                class=\"plugin-video\">\n                <img :src=\"'https://img.youtube.com/vi/' + plugin.demoVideoId + '/1.jpg'\" alt=\"Demo video thumbnail\" />\n            </a>\n        </div>\n    </div>\n</template>\n\n<style scoped>\n.plugin-item {\n    display: flex;\n    align-items: flex-start;\n    margin-bottom: 1.5rem;\n}\n\n.plugin-info {\n    flex: 1;\n}\n\n.plugin-video {\n    margin-left: 1rem;\n}\n\n.plugin-video img {\n    max-width: 200px;\n    /* Adjust as needed */\n    height: auto;\n}\n</style>\n\n\n<script setup>\nimport { computed } from 'vue'\nimport { usePluginData } from './usePluginData.js'\nimport { getPluginData } from './jsonLoader.js'\n\nconst props = defineProps({\n    version: {\n        type: String,\n        default: null\n    }\n})\n\nconst { data: plugins, loading, error } = usePluginData(async () => {\n    const data = getPluginData('index', props.version)\n    if (!data) throw new Error('Plugin index not found')\n    // Filter out internal plugins like 'pyprland'\n    return (data.plugins || []).filter(p => p.name !== 'pyprland')\n})\n\nconst sortedPlugins = computed(() => {\n    if (!plugins.value?.length) return []\n    return plugins.value.slice().sort((a, b) => a.name.localeCompare(b.name))\n})\n\nfunction getStars(count) {\n    return count > 0 ? '&#11088;'.repeat(count) : ''\n}\n</script>\n"
  },
  {
    "path": "site/components/configHelpers.js",
    "content": "/**\n * Shared helper functions for config table components.\n */\n\nimport MarkdownIt from 'markdown-it'\n\nconst md = new MarkdownIt({ html: true, linkify: true })\n\n/**\n * Check if a config item has children.\n * @param {Object} item - Config item\n * @returns {boolean}\n */\nexport function hasChildren(item) {\n  return item.children && item.children.length > 0\n}\n\n/**\n * Check if a value represents a meaningful default (not empty/null).\n * @param {*} value - Default value to check\n * @returns {boolean}\n */\nexport function hasDefault(value) {\n  if (value === null || value === undefined) return false\n  if (value === '') return false\n  if (Array.isArray(value) && value.length === 0) return false\n  if (typeof value === 'object' && Object.keys(value).length === 0) return false\n  return true\n}\n\n/**\n * Format a default value for display.\n * @param {*} value - Value to format\n * @returns {string}\n */\nexport function formatDefault(value) {\n  if (typeof value === 'boolean') {\n    return value ? 'true' : 'false'\n  }\n  if (typeof value === 'string') {\n    return `\"${value}\"`\n  }\n  if (Array.isArray(value)) {\n    return JSON.stringify(value)\n  }\n  return String(value)\n}\n\n/**\n * Render description text with markdown support.\n * Transforms <opt1|opt2|...> patterns to styled inline code blocks.\n * @param {string} text - Description text\n * @returns {string} - HTML string\n */\nexport function renderDescription(text) {\n  if (!text) return ''\n  // Transform <opt1|opt2|...> patterns to styled inline code blocks\n  text = text.replace(/<([^>|]+(?:\\|[^>|]+)+)>/g, (match, choices) => {\n    return choices.split('|').map(c => `\\`${c}\\``).join(' | ')\n  })\n  // Use render() to support links, then strip wrapping <p> tags\n  const html = md.render(text)\n  return html.replace(/^<p>/, '').replace(/<\\/p>\\n?$/, '')\n}\n"
  },
  {
    "path": "site/components/jsonLoader.js",
    "content": "/**\n * JSON loader with glob imports for version-aware plugin data.\n *\n * Uses Vite's import.meta.glob to pre-bundle all JSON files at build time,\n * enabling runtime selection based on version.\n */\n\n// Pre-load all JSON files at build time\nconst currentJson = import.meta.glob('../generated/*.json', { eager: true })\nconst versionedJson = import.meta.glob('../versions/*/generated/*.json', { eager: true })\n\n/**\n * Get plugin data from the appropriate JSON file.\n *\n * @param {string} name - JSON filename without extension (e.g., 'scratchpads', 'index', 'menu')\n * @param {string|null} version - Version string (e.g., '3.0.0') or null for current\n * @returns {object|null} - Parsed JSON data or null if not found\n */\nexport function getPluginData(name, version = null) {\n  const filename = `${name}.json`\n\n  if (version) {\n    const key = `../versions/${version}/generated/${filename}`\n    const data = versionedJson[key]\n    return data?.default || data || null\n  }\n\n  const key = `../generated/${filename}`\n  const data = currentJson[key]\n  return data?.default || data || null\n}\n"
  },
  {
    "path": "site/components/usePluginData.js",
    "content": "/**\n * Composable for loading plugin data with loading/error states.\n *\n * Provides a standardized pattern for async data loading in Vue components.\n */\n\nimport { ref, onMounted } from 'vue'\n\n/**\n * Load data asynchronously with loading and error state management.\n *\n * @param {Function} loader - Async function that returns the data\n * @returns {Object} - { data, loading, error } refs\n *\n * @example\n * // Load commands from a plugin JSON file\n * const { data: commands, loading, error } = usePluginData(async () => {\n *   const module = await import(`../generated/${props.plugin}.json`)\n *   return module.commands || []\n * })\n *\n * @example\n * // Load with default value\n * const { data: config, loading, error } = usePluginData(\n *   async () => {\n *     const module = await import('../generated/menu.json')\n *     return module.engine_defaults || {}\n *   }\n * )\n */\nexport function usePluginData(loader) {\n  const data = ref(null)\n  const loading = ref(true)\n  const error = ref(null)\n\n  onMounted(async () => {\n    try {\n      data.value = await loader()\n    } catch (e) {\n      error.value = e.message || 'Failed to load data'\n      console.error(e)\n    } finally {\n      loading.value = false\n    }\n  })\n\n  return { data, loading, error }\n}\n"
  },
  {
    "path": "site/expose.md",
    "content": "---\n---\n\n# expose\n\nImplements the \"expose\" effect, showing every client window on the focused screen.\n\nFor a similar feature using a menu, try the [fetch_client_menu](./fetch_client_menu) plugin (less intrusive).\n\nSample `hyprland.conf`:\n\n```bash\n# Setup the key binding\nbind = $mainMod, B, exec, pypr expose\n\n# Add some style to the \"exposed\" workspace\nworkspace = special:exposed,gapsout:60,gapsin:30,bordersize:5,border:true,shadow:false\n```\n\n`MOD+B` will bring every client to the focused workspace, pressed again it will go to this workspace.\n\nCheck [workspace rules](https://wiki.hyprland.org/Configuring/Workspace-Rules/#rules) for styling options.\n\n> [!note]\n> If you are looking for `toggle_minimized`, check the [toggle_special](./toggle_special) plugin\n\n\n## Commands\n\n<PluginCommands plugin=\"expose\" />\n\n## Configuration\n\n<PluginConfig plugin=\"expose\" linkPrefix=\"config-\" />\n\n"
  },
  {
    "path": "site/fcitx5_switcher.md",
    "content": "---\n---\n\n# fcitx5_switcher\n\nThis is a useful tool for CJK input method users.\n\nIt can automatically switch fcitx5 input method status based on window class and title.\n\n<details>\n<summary>Example</summary>\n\n```toml\n[fcitx5_switcher]\nactive_classes = [\"wechat\", \"QQ\", \"zoom\"]\ninactive_classes = [\n    \"code\",\n    \"kitty\",\n    \"google-chrome\",\n]\nactive_titles = []\ninactive_titles = []\n```\n\nIn this example, if the window class is \"wechat\" or \"QQ\" or \"zoom\", the input method will be activated. If the window class is \"code\" or \"kitty\" or \"google-chrome\", the input method will be inactivated.\n\n</details>\n\n## Commands\n\n<PluginCommands plugin=\"fcitx5_switcher\" />\n\n## Configuration\n\n<PluginConfig plugin=\"fcitx5_switcher\" linkPrefix=\"config-\" />\n\n"
  },
  {
    "path": "site/fetch_client_menu.md",
    "content": "---\n---\n\n# fetch_client_menu\n\nBring any window to the active workspace using a menu.\n\nA bit like the [expose](./expose) plugin but using a menu instead (less intrusive).\n\nIt brings the window to the current workspace, while [expose](./expose) moves the currently focused screen to the application workspace.\n\n## Commands\n\n<PluginCommands plugin=\"fetch_client_menu\" />\n\n## Configuration\n\n<PluginConfig plugin=\"fetch_client_menu\" linkPrefix=\"config-\" />"
  },
  {
    "path": "site/filters.md",
    "content": "# Text filters\n\nAt the moment there is only one filter, close match of [sed's \"s\" command](https://www.gnu.org/software/sed/manual/html_node/The-_0022s_0022-Command.html).\n\nThe only supported flag is \"g\"\n\n## Example\n\n```toml\nfilter = 's/foo/bar/'\nfilter = 's/.*started (.*)/\\1 has started/'\nfilter = 's#</?div>##g'\n```\n"
  },
  {
    "path": "site/gamemode.md",
    "content": "---\n---\n\n# gamemode\n\nToggle game mode (automatically) for improved performance. When enabled, disables animations, blur, shadows, gaps, and rounding. When disabled, reloads the Hyprland config to restore original settings.\n\nThis is useful when gaming or running performance-intensive applications where visual effects may cause frame drops or input lag.\n\n<details>\n    <summary>Example</summary>\n\nSample `hyprland.conf`:\n\n```sh\nbind = $mainMod, G, exec, pypr gamemode\n```\n\n</details>\n\n## Commands\n\n<PluginCommands plugin=\"gamemode\" />\n\n## Configuration\n\n<PluginConfig plugin=\"gamemode\" linkPrefix=\"config-\" />\n\n### `auto` <ConfigBadges plugin=\"gamemode\" option=\"auto\" /> {#config-auto}\n\nEnable automatic game mode detection. When enabled, pyprland monitors window open/close events and automatically enables game mode when a window matching one of the configured patterns is detected. Game mode is disabled when all matching windows are closed.\n\n```toml\n[gamemode]\nauto = true\n```\n\n### `patterns` <ConfigBadges plugin=\"gamemode\" option=\"patterns\" /> {#config-patterns}\n\nList of glob patterns to match window class names for automatic game mode activation. Uses shell-style wildcards (`*`, `?`, `[seq]`, `[!seq]`).\n\nThe default pattern `steam_app_*` matches all Steam games, which have window classes like `steam_app_870780`.\n\n```toml\n[gamemode]\nauto = true\npatterns = [\"steam_app_*\", \"gamescope*\", \"lutris_*\"]\n```\n\nTo find the window class of a specific application, run:\n\n```sh\nhyprctl clients -j | jq '.[].class'\n```\n\n### `border_size` <ConfigBadges plugin=\"gamemode\" option=\"border_size\" /> {#config-border_size}\n\nBorder size to use when game mode is enabled. Since gaps are removed, a visible border helps distinguish window boundaries.\n\n### `notify` <ConfigBadges plugin=\"gamemode\" option=\"notify\" /> {#config-notify}\n\nWhether to show a notification when toggling game mode on or off.\n"
  },
  {
    "path": "site/generated/generted_files.keep_me",
    "content": ""
  },
  {
    "path": "site/index.md",
    "content": "---\n# https://vitepress.dev/reference/default-theme-home-page\nlayout: home\n\nhero:\n  text: \"Extensions for your desktop environment\"\n  tagline: Enhance your desktop experience with Pyprland\n  image:\n    src: /logo.svg\n    alt: logo\n  actions:\n    - theme: brand\n      text: Getting started\n      link: ./Getting-started\n    - theme: alt\n      text: Plugins\n      link: ./Plugins\n    - theme: alt\n      text: Report something\n      link: https://github.com/hyprland-community/pyprland/issues/new/choose\n\nfeatures:\n  - title: TOML format\n    details: Simple and flexible configuration file(s)\n  - title: Customizable\n    details: Create your own Hyprland experience\n  - title: Fast and easy\n    details: Designed for performance and simplicity\n---\n\n# What is Pyprland?\n\nIt's a software that extends the functionality of your desktop environment (Hyprland, Niri, etc...), adding new features and improving the existing ones.\n\nIt also enables a high degree of customization and automation, making it easier to adapt to your workflow.\n\nTo understand the potential of Pyprland, you can check the [plugins](./Plugins) page.\n\n# Major recent changes\n\n- The [Scratchpads](/monitors) got reworked to better satisfy current Hyprland version\n- New [Stash](/stash) plugin, allowing to park windows and show/hide them easily\n- Self documented using cli \"doc\" command\n- Schema validation and \"always in sync\" configurations and commands (doc and code)\n- Major rewrite of the [Monitors plugin](/monitors) delivers improved stability and functionality.\n- The [Wallpapers plugin](/wallpapers) now applies [rounded corners](/wallpapers#radius) per display and derives cohesive [color schemes from the background](/wallpapers#templates) (Matugen/Pywal-inspired).\n"
  },
  {
    "path": "site/layout_center.md",
    "content": "---\n---\n# layout_center\n\nImplements a workspace layout where one window is bigger and centered,\nother windows are tiled as usual in the background.\n\nOn `toggle`, the active window is made floating and centered if the layout wasn't enabled, else reverts the floating status.\n\nWith `next` and `prev` you can cycle the active window, keeping the same layout type.\nIf the layout_center isn't active and `next` or `prev` is used, it will call the [next](#config-next) and [prev](#config-next) configuration options.\n\nTo allow full override of the focus keys, `next2` and `prev2` are provided, they do the same actions as `next` and `prev` but allow different fallback commands.\n\n<details>\n<summary>Configuration sample</summary>\n\n```toml\n[layout_center]\nmargin = 60\noffset = [0, 30]\nnext = \"movefocus r\"\nprev = \"movefocus l\"\nnext2 = \"movefocus d\"\nprev2 = \"movefocus u\"\n```\n\nusing the following in `hyprland.conf`:\n```sh\nbind = $mainMod, M, exec, pypr layout_center toggle # toggle the layout\n## focus change keys\nbind = $mainMod, left, exec, pypr layout_center prev\nbind = $mainMod, right, exec, pypr layout_center next\nbind = $mainMod, up, exec, pypr layout_center prev2\nbind = $mainMod, down, exec, pypr layout_center next2\n```\n\nYou can completely ignore `next2` and `prev2` if you are allowing focus change in a single direction (when the layout is enabled), eg:\n\n```sh\nbind = $mainMod, up, movefocus, u\nbind = $mainMod, down, movefocus, d\n```\n\n</details>\n\n\n## Commands\n\n<PluginCommands plugin=\"layout_center\" />\n\n## Configuration\n\n<PluginConfig plugin=\"layout_center\" linkPrefix=\"config-\" />\n\n### `style` <ConfigBadges plugin=\"layout_center\" option=\"style\" /> {#config-style}\n\nCustom Hyprland style rules applied to the centered window. Requires Hyprland > 0.40.0.\n\n```toml\nstyle = [\"opacity 1\", \"bordercolor rgb(FFFF00)\"]\n```\n\n### `on_new_client` <ConfigBadges plugin=\"layout_center\" option=\"on_new_client\" /> {#config-on-new-client}\n\nBehavior when a new window opens while layout is active:\n\n- `\"focus\"` (or `\"foreground\"`) - make the new window the main window\n- `\"background\"` - make the new window appear in the background  \n- `\"close\"` - stop the centered layout when a new window opens\n\n### `next` / `prev` <ConfigBadges plugin=\"layout_center\" option=\"next\" /> {#config-next}\n\nHyprland dispatcher command to run when layout_center isn't active:\n\n```toml\nnext = \"movefocus r\"\nprev = \"movefocus l\"\n```\n\n### `next2` / `prev2` <ConfigBadges plugin=\"layout_center\" option=\"next2\" /> {#config-next2}\n\nAlternative fallback commands for vertical navigation:\n\n```toml\nnext2 = \"movefocus d\"\nprev2 = \"movefocus u\"\n```\n\n### `offset` <ConfigBadges plugin=\"layout_center\" option=\"offset\" /> {#config-offset}\n\noffset in pixels applied to the main window position\n\nExample shift the main window 20px down:\n```toml\noffset = [0, 20]\n```\n\n\n### `margin` <ConfigBadges plugin=\"layout_center\" option=\"margin\" /> {#config-margin}\n\nmargin (in pixels) used when placing the center window, calculated from the border of the screen.\n\nExample to make the main window be 100px far from the monitor's limits:\n```toml\nmargin = 100\n```\nYou can also set a different margin for width and height by using a list:\n\n```toml\nmargin = [100, 100]\n```\n"
  },
  {
    "path": "site/lost_windows.md",
    "content": "---\n---\n# lost_windows\n\nBring windows which are not reachable in the currently focused workspace.\n*Deprecated:* Was used to work around issues in Hyprland which have been fixed since then.\n\n## Commands\n\n<PluginCommands plugin=\"lost_windows\" />\n\n## Configuration\n\nThis plugin has no configuration options.\n"
  },
  {
    "path": "site/magnify.md",
    "content": "---\n---\n# magnify\n\nZooms in and out with an optional animation.\n\n\n<details>\n    <summary>Example</summary>\n\n```sh\npypr zoom  # sets zoom to `factor`\npypr zoom +1  # will set zoom to 3x\npypr zoom  # will set zoom to 1x\npypr zoom 1 # will (also) set zoom to 1x - effectively doing nothing\n```\n\nSample `hyprland.conf`:\n\n```sh\nbind = $mainMod , Z, exec, pypr zoom ++0.5\nbind = $mainMod SHIFT, Z, exec, pypr zoom\n```\n\n</details>\n\n## Commands\n\n<PluginCommands plugin=\"magnify\" />\n\n### `zoom [factor]`\n\n#### unset / not specified\n\nWill zoom to [factor](#config-factor) if not zoomed, else will set the zoom to 1x.\n\n#### floating or integer value\n\nWill set the zoom to the provided value.\n\n#### +value / -value\n\nUpdate (increment or decrement) the current zoom level by the provided value.\n\n#### ++value / --value\n\nUpdate (increment or decrement) the current zoom level by a non-linear scale.\nIt _looks_ more linear changes than using a single + or -.\n\n> [!NOTE]\n>\n> The non-linear scale is calculated as powers of two, eg:\n>\n> - `zoom ++1` → 2x, 4x, 8x, 16x...\n> - `zoom ++0.7` → 1.6x, 2.6x, 4.3x, 7.0x, 11.3x, 18.4x...\n> - `zoom ++0.5` → 1.4x, 2x, 2.8x, 4x, 5.7x, 8x, 11.3x, 16x...\n\n## Configuration\n\n<PluginConfig plugin=\"magnify\" linkPrefix=\"config-\" />\n\n### `factor` <ConfigBadges plugin=\"magnify\" option=\"factor\" /> {#config-factor}\n\nThe zoom level to use when `pypr zoom` is called without arguments.\n\n### `duration` <ConfigBadges plugin=\"magnify\" option=\"duration\" /> {#config-duration}\n\nAnimation duration in seconds. Not needed with recent Hyprland versions - you can customize the animation in Hyprland config instead:\n\n```C\nanimations {\n    bezier = easeInOut,0.65, 0, 0.35, 1\n    animation = zoomFactor, 1, 4, easeInOut\n}\n```\n"
  },
  {
    "path": "site/make_version.sh",
    "content": "#!/bin/bash\n# Archive documentation for a specific version.\n#\n# Creates a full copy of the site's source files including:\n# - Markdown content\n# - Sidebar configuration (sidebar.json)\n# - Vue components\n# - Generated JSON data\n#\n# The archived version will automatically appear in the version picker\n# since config.mjs dynamically discovers versions.\n\nset -e\n\ncd \"$(dirname \"$0\")\"\n\necho -n \"Current is: \"\npypr version\necho -n \"Available: \"\nls versions\n\nversion=\"${1:-}\"\n[ -z \"$version\" ] && { echo -n \"Archive current version as: \"; read version; }\n\ndest=\"versions/$version\"\necho \"Archiving version $version to $dest...\"\n\n# Regenerate JSON from the tagged version's source code\necho \"Regenerating plugin documentation from tag $version...\"\ntmp=$(mktemp -d)\ntrap 'git -C .. worktree remove --force \"$tmp\" 2>/dev/null; rm -rf \"$tmp\"' EXIT\ngit -C .. worktree add \"$tmp\" \"$version\" --detach\nuv run --directory \"$tmp\" python \"$tmp/scripts/generate_plugin_docs.py\"\ncp \"$tmp/site/generated/\"*.json generated/\ngit -C .. worktree remove --force \"$tmp\"\ntrap - EXIT\n\n# Create destination\nmkdir -p \"$dest\"\n\n# Copy markdown files\ncp *.md \"$dest/\"\n\n# Copy sidebar config\ncp sidebar.json \"$dest/\"\n\n# Copy components (for historical reference)\ncp -r components \"$dest/\"\n\n# Copy generated JSON if present\nif ls generated/*.json >/dev/null 2>&1; then\n    mkdir -p \"$dest/generated\"\n    cp generated/*.json \"$dest/generated/\"\nfi\n\n# Inject version prop into Vue component tags\n# This ensures components load data from the correct version's JSON files\necho \"Injecting version props into Vue components...\"\nfor file in \"$dest\"/*.md; do\n    # Handle tags with existing attributes\n    sed -i -E 's/<(PluginCommands|PluginConfig|PluginList|ConfigBadges|EngineDefaults)([^>]*[^/])\\s*\\/>/<\\1\\2 version=\"'\"$version\"'\" \\/>/g' \"$file\"\n    # Handle tags without attributes\n    sed -i -E 's/<(PluginCommands|PluginConfig|PluginList|ConfigBadges|EngineDefaults)\\s*\\/>/<\\1 version=\"'\"$version\"'\" \\/>/g' \"$file\"\ndone\n\n# Truncate index.md to remove dynamic content\nsed -i '/## What/,$d' \"$dest/index.md\"\necho \"## Version $version archive\" >> \"$dest/index.md\"\n\n# Stage files\ngit add \"$dest/\"\n\n# Commit\ngit commit -m \"Archive documentation for version $version\" --no-verify\n\necho \"\"\necho \"Done! Version $version archived.\"\necho \"The version will automatically appear in the version picker.\"\n"
  },
  {
    "path": "site/menubar.md",
    "content": "---\n---\n\n# menubar\n\nRuns your favorite bar app (gbar, ags / hyprpanel, waybar, ...) with option to pass the \"best\" monitor from a list of monitors.\n\n- Will take care of starting the command on startup (you must not run it from another source like `hyprland.conf`).\n- Automatically restarts the menu bar on crash\n- Checks which monitors are on and take the best one from a provided list\n\n<details>\n<summary>Example</summary>\n\n```toml\n[menubar]\ncommand = \"gBar bar [monitor]\"\nmonitors = [\"DP-1\", \"HDMI-1\", \"HDMI-1-A\"]\n```\n\n</details>\n\n> [!tip]\n> This plugin supports both Hyprland and Niri. It will automatically detect the environment and use the appropriate IPC commands.\n\n## Commands\n\n<PluginCommands plugin=\"menubar\" />\n\n## Configuration\n\n<PluginConfig plugin=\"menubar\" linkPrefix=\"config-\" />\n\n### `command` <ConfigBadges plugin=\"menubar\" option=\"command\" /> {#config-command}\n\nThe command to run the bar. Use `[monitor]` as a placeholder for the monitor name:\n\n```toml\ncommand = \"waybar -o [monitor]\"\n```\n"
  },
  {
    "path": "site/monitors.md",
    "content": "---\n---\n\n# monitors\n\nAllows relative placement of monitors depending on the model (\"description\" returned by `hyprctl monitors`).\nUseful if you have multiple monitors connected to a video signal switch or using a laptop and plugging monitors having different relative positions.\n\n> [!Tip]\n> This plugin also supports Niri. It will automatically detect the environment and use `nirictl` to apply the layout.\n> Note that \"hotplug_commands\" and \"unknown\" commands may need adjustment for Niri (e.g. using `sh -c '...'` or Niri specific tools).\n\nSyntax:\n\n\n```toml\n[monitors.placement]\n\"description match\".placement = \"other description match\"\n```\n\n<details>\n    <summary>Example to set a Sony monitor on top of a BenQ monitor</summary>\n\n```toml\n[monitors.placement]\nSony.topOf = \"BenQ\"\n\n## Character case is ignored, \"_\" can be added\nSony.Top_Of = [\"BenQ\"]\n\n## Thanks to TOML format, complex configurations can use separate \"sections\" for clarity, eg:\n\n[monitors.placement.\"My monitor brand\"]\n## You can also use \"port\" names such as *HDMI-A-1*, *DP-1*, etc...\nleftOf = \"eDP-1\"\n\n## lists are possible on the right part of the assignment:\nrightOf = [\"Sony\", \"BenQ\"]\n\n# When multiple targets are specified, only the first connected monitor\n# matching a pattern is used as the reference.\n\n## > 2.3.2: you can also set scale, transform & rate for a given monitor\n[monitors.placement.Microstep]\nrate = 100\n```\n\nTry to keep the rules as simple as possible, but relatively complex scenarios are supported.\n\n> [!note]\n> Check [wlr layout UI](https://github.com/fdev31/wlr-layout-ui) which is a nice complement to configure your monitor settings.\n\n</details>\n\n## Commands\n\n<PluginCommands plugin=\"monitors\" />\n\n## Configuration\n\n<PluginConfig plugin=\"monitors\" linkPrefix=\"config-\" />\n\n### `placement` <ConfigBadges plugin=\"monitors\" option=\"placement\" /> {#config-placement}\n\nConfigure monitor settings and relative positioning. Each monitor is identified by a [pattern](#monitor-patterns) (port name or description substring) and can have both display settings and positioning rules.\n\n```toml\n[monitors.placement.\"My monitor\"]\n# Display settings\nscale = 1.25\ntransform = 1\nrate = 144\nresolution = \"2560x1440\"\n\n# Positioning\nleftOf = \"eDP-1\"\n```\n\n#### Monitor Settings\n\nThese settings control the display properties of a monitor.\n\n##### `scale` {#placement-scale}\n\nControls UI element size. Higher values make the UI larger (zoomed in), showing less content.\n\n| Scale Value | Content Visible |\n|---------------|-----------------|\n|`0.666667` | More (zoomed out) |\n|`0.833333` | More |\n| `1.0` | Native |\n| `1.25` | Less |\n| `1.6` | Less |\n| `2.0` | 25% (zoomed in) |\n\n> [!tip]\n> For HiDPI displays, use values like `1.5` or `2.0` to make UI elements larger and more readable at the cost of screen real estate.\n\n##### `transform` {#placement-transform}\n\nRotates and optionally flips the monitor.\n\n| Value | Rotation | Description |\n|-------|----------|-------------|\n| 0 | Normal | No rotation (landscape) |\n| 1 | 90° | Portrait (rotated right) |\n| 2 | 180° | Upside down |\n| 3 | 270° | Portrait (rotated left) |\n| 4 | Flipped | Mirrored horizontally |\n| 5 | Flipped 90° | Mirrored + 90° |\n| 6 | Flipped 180° | Mirrored + 180° |\n| 7 | Flipped 270° | Mirrored + 270° |\n\n##### `rate` {#placement-rate}\n\nRefresh rate in Hz.\n\n```toml\nrate = 144\n```\n\n> [!tip]\n> Run `hyprctl monitors` to see available refresh rates for each monitor.\n\n##### `resolution` {#placement-resolution}\n\nDisplay resolution. Can be specified as a string or array.\n\n```toml\nresolution = \"2560x1440\"\n# or\nresolution = [2560, 1440]\n```\n\n> [!tip]\n> Run `hyprctl monitors` to see available resolutions for each monitor.\n\n##### `disables` {#placement-disables}\n\nList of monitors to disable when this monitor is connected. This is useful for automatically turning off a laptop's built-in display when an external monitor is plugged in.\n\n```toml\n[monitors.placement.\"External Monitor\"]\ndisables = [\"eDP-1\"]  # Disable laptop screen when this monitor is connected\n```\n\nYou can disable multiple monitors and combine with positioning rules:\n\n```toml\n[monitors.placement.\"DELL U2722D\"]\nleftOf = \"DP-2\"\ndisables = [\"eDP-1\", \"HDMI-A-2\"]\n```\n\n> [!note]\n> Monitors specified in `disables` are excluded from layout calculations. They will be re-enabled on the next relayout if the disabling monitor is disconnected.\n\n#### Positioning Rules\n\nPosition monitors relative to each other using directional keywords.\n\n**Directions:**\n\n- `leftOf` / `rightOf` — horizontal placement\n- `topOf` / `bottomOf` — vertical placement\n\n**Alignment modifiers** (for different-sized monitors):\n\n- `start` (default) — align at top/left edge\n- `center` / `middle` — center alignment\n- `end` — align at bottom/right edge\n\nCombine direction + alignment: `topCenterOf`, `leftEndOf`, `right_middle_of`, etc.\n\nEverything is case insensitive; use `_` for readability (e.g., `top_center_of`).\n\n> [!important]\n> At least one monitor must have **no placement rule** to serve as the anchor/reference point.\n> Other monitors are positioned relative to this anchor.\n\nSee [Placement Examples](#placement-examples) for visual diagrams.\n\n#### Monitor Patterns {#monitor-patterns}\n\nBoth the monitor being configured and the target monitor can be specified using:\n\n1. **Port name** (exact match) — e.g., `eDP-1`, `HDMI-A-1`, `DP-1`\n2. **Description substring** (partial match) — e.g., `Hisense`, `BenQ`, `DELL P2417H`\n\nThe plugin first checks for an exact port name match, then searches monitor descriptions for a substring match. Descriptions typically contain the manufacturer, model, and serial number.\n\n```toml\n# Target by port name\n[monitors.placement.Sony]\ntopOf = \"eDP-1\"\n\n# Target by brand/model name\n[monitors.placement.Hisense]\ntop_middle_of = \"BenQ\"\n\n# Mix both approaches\n[monitors.placement.\"DELL P2417H\"]\nright_end_of = \"HDMI-A-1\"\n```\n\n> [!tip]\n> Run `hyprctl monitors` (or `nirictl outputs` for Niri) to see the full description of each connected monitor.\n\n### `startup_relayout` <ConfigBadges plugin=\"monitors\" option=\"startup_relayout\" /> {#config-startup-relayout}\n\nWhen set to `false`, do not initialize the monitor layout on startup or when configuration is reloaded.\n\n### `relayout_on_config_change` <ConfigBadges plugin=\"monitors\" option=\"relayout_on_config_change\" /> {#config-relayout-on-config-change}\n\nWhen set to `false`, do not relayout when Hyprland config is reloaded.\n\n### `new_monitor_delay` <ConfigBadges plugin=\"monitors\" option=\"new_monitor_delay\" /> {#config-new-monitor-delay}\n\nThe layout computation happens after this delay when a new monitor is detected, to let time for things to settle.\n\n### `hotplug_command` <ConfigBadges plugin=\"monitors\" option=\"hotplug_command\" /> {#config-hotplug-command}\n\nAllows to run a command when any monitor is plugged.\n\n```toml\n[monitors]\nhotplug_command = \"wlrlui -m\"\n```\n\n### `hotplug_commands` <ConfigBadges plugin=\"monitors\" option=\"hotplug_commands\" /> {#config-hotplug-commands}\n\nAllows to run a command when a specific monitor is plugged.\n\nExample to load a specific profile using [wlr layout ui](https://github.com/fdev31/wlr-layout-ui):\n\n```toml\n[monitors.hotplug_commands]\n\"DELL P2417H CJFH277Q3HCB\" = \"wlrlui rotated\"\n```\n\n### `unknown` <ConfigBadges plugin=\"monitors\" option=\"unknown\" /> {#config-unknown}\n\nAllows to run a command when no monitor layout has been changed (no rule applied).\n\n```toml\n[monitors]\nunknown = \"wlrlui\"\n```\n\n## Placement Examples {#placement-examples}\n\nThis section provides visual diagrams to help understand monitor placement rules.\n\n### Basic Positions\n\nThe four basic placement directions position a monitor relative to another:\n\n#### `topOf` - Monitor above another\n\n<img src=\"/images/monitors/basic-top-of.svg\" alt=\"Monitor A placed on top of Monitor B\" style=\"max-width: 36%\" />\n\n```toml\n[monitors.placement.A]\ntopOf = \"B\"\n```\n\n#### `bottomOf` - Monitor below another\n\n<img src=\"/images/monitors/basic-bottom-of.svg\" alt=\"Monitor A placed below Monitor B\" style=\"max-width: 36%\" />\n\n```toml\n[monitors.placement.A]\nbottomOf = \"B\"\n```\n\n#### `leftOf` - Monitor to the left\n\n<img src=\"/images/monitors/basic-left-of.svg\" alt=\"Monitor A placed to the left of Monitor B\" style=\"max-width: 68%\" />\n\n```toml\n[monitors.placement.A]\nleftOf = \"B\"\n```\n\n#### `rightOf` - Monitor to the right\n\n<img src=\"/images/monitors/basic-right-of.svg\" alt=\"Monitor A placed to the right of Monitor B\" style=\"max-width: 68%\" />\n\n```toml\n[monitors.placement.A]\nrightOf = \"B\"\n```\n\n### Alignment Modifiers\n\nWhen monitors have different sizes, alignment modifiers control where the smaller monitor aligns along the edge.\n\n#### Horizontal placement (`leftOf` / `rightOf`)\n\n**Start (default)** - Top edges align:\n\n<img src=\"/images/monitors/align-left-start.svg\" alt=\"Monitor A to the left of B, top edges aligned\" style=\"max-width: 57%\" />\n\n```toml\n[monitors.placement.A]\nleftOf = \"B\"  # same as leftStartOf\n```\n\n**Center / Middle** - Vertically centered:\n\n<img src=\"/images/monitors/align-left-center.svg\" alt=\"Monitor A to the left of B, vertically centered\" style=\"max-width: 57%\" />\n\n```toml\n[monitors.placement.A]\nleftCenterOf = \"B\"  # or leftMiddleOf\n```\n\n**End** - Bottom edges align:\n\n<img src=\"/images/monitors/align-left-end.svg\" alt=\"Monitor A to the left of B, bottom edges aligned\" style=\"max-width: 57%\" />\n\n```toml\n[monitors.placement.A]\nleftEndOf = \"B\"\n```\n\n#### Vertical placement (`topOf` / `bottomOf`)\n\n**Start (default)** - Left edges align:\n\n<img src=\"/images/monitors/align-top-start.svg\" alt=\"Monitor A on top of B, left edges aligned\" style=\"max-width: 36%\" />\n\n```toml\n[monitors.placement.A]\ntopOf = \"B\"  # same as topStartOf\n```\n\n**Center / Middle** - Horizontally centered:\n\n<img src=\"/images/monitors/align-top-center.svg\" alt=\"Monitor A on top of B, horizontally centered\" style=\"max-width: 36%\" />\n\n```toml\n[monitors.placement.A]\ntopCenterOf = \"B\"  # or topMiddleOf\n```\n\n**End** - Right edges align:\n\n<img src=\"/images/monitors/align-top-end.svg\" alt=\"Monitor A on top of B, right edges aligned\" style=\"max-width: 36%\" />\n\n```toml\n[monitors.placement.A]\ntopEndOf = \"B\"\n```\n\n### Common Setups\n\n#### Dual side-by-side\n\n<img src=\"/images/monitors/setup-dual.svg\" alt=\"Dual monitor setup: A and B side by side\" style=\"max-width: 68%\" />\n\n```toml\n[monitors.placement.A]\nleftOf = \"B\"\n```\n\n#### Triple horizontal\n\n<img src=\"/images/monitors/setup-triple.svg\" alt=\"Triple monitor setup: A, B, C in a row\" style=\"max-width: 100%\" />\n\n```toml\n[monitors.placement.A]\nleftOf = \"B\"\n\n[monitors.placement.C]\nrightOf = \"B\"\n```\n\n#### Stacked (vertical)\n\n<img src=\"/images/monitors/setup-stacked.svg\" alt=\"Stacked monitor setup: A on top, B in middle, C at bottom\" style=\"max-width: 36%\" />\n\n```toml\n[monitors.placement.A]\ntopOf = \"B\"\n\n[monitors.placement.C]\nbottomOf = \"B\"\n```\n\n### Real-World Example: L-Shape with Portrait Monitor\n\nThis example shows a complex 3-monitor setup combining portrait mode, corner alignment, and different-sized displays.\n\n**Layout:**\n\n<img src=\"/images/monitors/real-world-l-shape.svg\" alt=\"L-shape monitor setup with portrait monitor A, anchor B, and landscape C\" style=\"max-width: 57%\" />\n\nWhere:\n\n- **A** (HDMI-A-1) = Portrait monitor (transform=1), directly on top of B (blue)\n- **B** (eDP-1) = Main anchor monitor, landscape (green)\n- **C** = Landscape monitor, positioned at the bottom-right corner of A (orange)\n\n**Configuration:**\n\n```toml\n[monitors.placement.CJFH277Q3HCB]\ntop_of = \"eDP-1\"\ntransform = 1\nscale = 0.83\n\n[monitors.placement.CJFH27888CUB]\nright_end_of = \"HDMI-A-1\"\n```\n\n**Explanation:**\n\n1. **B (eDP-1)** has no placement rule, making it the anchor/reference point\n2. **A (CJFH277Q3HCB)** is placed on top of B with `top_of = \"eDP-1\"`, rotated to portrait with `transform = 1`, and scaled to 83%\n3. **C (CJFH27888CUB)** uses `right_end_of = \"HDMI-A-1\"` to position itself to the right of A with bottom edges aligned, creating the L-shape\n\nThe `right_end_of` placement is key here: it aligns C's bottom edge with A's bottom edge, tucking C into the corner rather than aligning at the top (which `rightOf` would do).\n"
  },
  {
    "path": "site/scratchpads.md",
    "content": "---\n---\n# scratchpads\n\nEasily toggle the visibility of applications you use the most.\n\nConfigurable and flexible, while supporting complex setups it's easy to get started with:\n\n```toml\n[scratchpads.name]\ncommand = \"command to run\"\nclass = \"the window's class\"  # check: hyprctl clients | grep class\nsize = \"[width] [height]\"  # size of the window relative to the screen size\n```\n\n<details>\n<summary>Example</summary>\n\nAs an example, defining two scratchpads:\n\n- _term_ which would be a kitty terminal on upper part of the screen\n- _volume_ which would be a pavucontrol window on the right part of the screen\n\n\n```toml\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\nmax_size = \"1920px 100%\"\nmargin = 50\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nsize = \"40% 90%\"\nunfocus = \"hide\"\nlazy = true\n```\n\nShortcuts are generally needed:\n\n```ini\nbind = $mainMod,V,exec,pypr toggle volume\nbind = $mainMod,A,exec,pypr toggle term\nbind = $mainMod,Y,exec,pypr attach\n```\n</details>\n\n- If you wish to have a more generic space for any application you may run, check [toggle_special](./toggle_special).\n- When you create a scratchpad called \"name\", it will be hidden in `special:scratch_<name>`.\n- Providing `class` allows a glitch free experience, mostly noticeable when using animations\n\n\n## Commands\n\n<PluginCommands plugin=\"scratchpads\" />\n\n> [!tip]\n> You can use `\"*\"` as a _scratchpad name_ to target every scratchpad when using `show` or `hide`.\n> You'll need to quote or escape the `*` character to avoid interpretation from your shell.\n\n## Configuration\n\n<PluginConfig plugin=\"scratchpads\" linkPrefix=\"config-\" :filter=\"['command', 'class', 'animation', 'size', 'position', 'margin', 'max_size', 'multi', 'lazy']\" />\n\n> [!tip]\n> Looking for more options? See:\n> - [Advanced Configuration](./scratchpads_advanced) - unfocus, excludes, monitor overrides, and more\n> - [Troubleshooting](./scratchpads_nonstandard) - PWAs, emacsclient, custom window matching\n\n### `command` <ConfigBadges plugin=\"scratchpads\" option=\"command\" /> {#config-command}\n\nThis is the command you wish to run in the scratchpad. It supports [variables](./Variables).\n\n### `class` <ConfigBadges plugin=\"scratchpads\" option=\"class\" /> {#config-class}\n\nAllows _Pyprland_ prepare the window for a correct animation and initial positioning.\nCheck your window's class with: `hyprctl clients | grep class`\n\n### `animation` <ConfigBadges plugin=\"scratchpads\" option=\"animation\" /> {#config-animation}\n\nType of animation to use:\n\n- `fromTop` (default) stays close to upper screen border\n- `fromBottom` stays close to lower screen border\n- `fromLeft` stays close to left screen border\n- `fromRight` stays close to right screen border\n- `null` / `\"\"` no sliding animation - also disables positioning relative to the border\n\nIt is recommended to set [position](#config-position) when disabling this configuration option.\n\n### `size` <ConfigBadges plugin=\"scratchpads\" option=\"size\" /> {#config-size}\n\nEach time scratchpad is shown, window will be resized according to the provided values.\n\nFor example on monitor of size `800x600` and `size= \"80% 80%\"` in config scratchpad always have size `640x480`,\nregardless of which monitor it was first launched on.\n\n> #### Format\n>\n> String with \"x y\" (or \"width height\") values using some units suffix:\n>\n> - **percents** relative to the focused screen size (`%` suffix), eg: `60% 30%`\n> - **pixels** for absolute values (`px` suffix), eg: `800px 600px`\n> - a mix is possible, eg: `800px 40%`\n\n### `position` <ConfigBadges plugin=\"scratchpads\" option=\"position\" /> {#config-position}\n\nOverrides the automatic margin-based position.\nSets the scratchpad client window position relative to the top-left corner.\n\nSame format as `size` (see above)\n\nExample of scratchpad that always sits on the top-right corner of the screen:\n\n```toml\n[scratchpads.term_quake]\ncommand = \"wezterm start --class term_quake\"\nposition = \"50% 0%\"\nsize = \"50% 50%\"\nclass = \"term_quake\"\n```\n\n> [!note]\n> If `position` is not provided, the window is placed according to `margin` on one axis and centered on the other.\n>\n> Hide animations then slide away from the configured coordinates — not from wherever the window was last manually moved or tiled.\n\n### `margin` <ConfigBadges plugin=\"scratchpads\" option=\"margin\" /> {#config-margin}\n\nPixels from the screen edge when using animations. Used to position the window along the animation axis.\n\n### `max_size` <ConfigBadges plugin=\"scratchpads\" option=\"max_size\" /> {#config-max-size}\n\nMaximum window size. Same format as `size`. Useful to prevent scratchpads from growing too large on big monitors.\n\n### `multi` <ConfigBadges plugin=\"scratchpads\" option=\"multi\" /> {#config-multi}\n\nWhen set to `false`, only one client window is supported for this scratchpad.\nOtherwise other matching windows will be **attach**ed to the scratchpad.\nAllows the `attach` command on the scratchpad.\n\n### `lazy` <ConfigBadges plugin=\"scratchpads\" option=\"lazy\" /> {#config-lazy}\n\nWhen `true`, the scratchpad command is only started on first use instead of at startup.\n\n## Advanced configuration\n\nTo go beyond the basic setup and have a look at every configuration item, you can read the following pages:\n\n- [Advanced](./scratchpads_advanced) contains options for fine-tuners or specific tastes (eg: i3 compatibility)\n- [Non-Standard](./scratchpads_nonstandard) contains options for \"broken\" applications\nlike progressive web apps (PWA) or emacsclient, use only if you can't get it to work otherwise\n"
  },
  {
    "path": "site/scratchpads_advanced.md",
    "content": "---\n---\n# Fine tuning scratchpads\n\n> [!note]\n> For basic setup, see [Scratchpads](./scratchpads).\n\nAdvanced configuration options\n\n<PluginConfig plugin=\"scratchpads\" linkPrefix=\"config-\" :filter=\"['use', 'pinned', 'excludes', 'restore_excluded', 'unfocus', 'hysteresis', 'preserve_aspect', 'offset', 'hide_delay', 'force_monitor', 'alt_toggle', 'allow_special_workspaces', 'smart_focus', 'close_on_hide', 'monitor']\" />\n\n### `use` <ConfigBadges plugin=\"scratchpads\" option=\"use\" /> {#config-use}\n\nList of scratchpads (or single string) that will be used for the default values of this scratchpad.\nThink about *templates*:\n\n```toml\n[scratchpads.terminals]\nanimation = \"fromTop\"\nmargin = 50\nsize = \"75% 60%\"\nmax_size = \"1920px 100%\"\n\n[scratchpads.term]\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nuse = \"terminals\"\n```\n\n### `pinned` <ConfigBadges plugin=\"scratchpads\" option=\"pinned\" /> {#config-pinned}\n\nMakes the scratchpad \"sticky\" to the monitor, following any workspace change.\n\n### `excludes` <ConfigBadges plugin=\"scratchpads\" option=\"excludes\" /> {#config-excludes}\n\nList of scratchpads to hide when this one is displayed, eg: `excludes = [\"term\", \"volume\"]`.\nIf you want to hide every displayed scratch you can set this to the string `\"*\"` instead of a list: `excludes = \"*\"`.\n\n### `restore_excluded` <ConfigBadges plugin=\"scratchpads\" option=\"restore_excluded\" /> {#config-restore-excluded}\n\nWhen enabled, will remember the scratchpads which have been closed due to `excludes` rules, so when the scratchpad is hidden, those previously hidden scratchpads will be shown again.\n\n### `unfocus` <ConfigBadges plugin=\"scratchpads\" option=\"unfocus\" /> {#config-unfocus}\n\nWhen set to `\"hide\"`, allow to hide the window when the focus is lost.\n\nUse `hysteresis` to change the reactivity\n\n### `hysteresis` <ConfigBadges plugin=\"scratchpads\" option=\"hysteresis\" /> {#config-hysteresis}\n\nControls how fast a scratchpad hiding on unfocus will react. Check `unfocus` option.\nSet to `0` to disable.\n\n> [!important]\n> Only relevant when `unfocus=\"hide\"` is used.\n\n### `preserve_aspect` <ConfigBadges plugin=\"scratchpads\" option=\"preserve_aspect\" /> {#config-preserve-aspect}\n\nWhen set to `true`, will preserve the size and position of the scratchpad when called repeatedly from the same monitor and workspace even though an `animation` , `position` or `size` is used (those will be used for the initial setting only).\n\nForces the `lazy` option.\n\n### `offset` <ConfigBadges plugin=\"scratchpads\" option=\"offset\" /> {#config-offset}\n\nNumber of pixels for the **hide** sliding animation (how far the window will go).\n\n> [!tip]\n> - It is also possible to set a string to express percentages of the client window\n> - `margin` is automatically added to the offset\n\n### `hide_delay` <ConfigBadges plugin=\"scratchpads\" option=\"hide_delay\" /> {#config-hide-delay}\n\nDelay (in seconds) after which the hide animation happens, before hiding the scratchpad.\n\nRule of thumb, if you have an animation with speed \"7\", as in:\n```bash\n    animation = windowsOut, 1, 7, easeInOut, popin 80%\n```\nYou can divide the value by two and round to the lowest value, here `3`, then divide by 10, leading to `hide_delay = 0.3`.\n\n### `force_monitor` <ConfigBadges plugin=\"scratchpads\" option=\"force_monitor\" /> {#config-force-monitor}\n\nIf set to some monitor name (eg: `\"DP-1\"`), it will always use this monitor to show the scratchpad.\n\n### `alt_toggle` <ConfigBadges plugin=\"scratchpads\" option=\"alt_toggle\" /> {#config-alt-toggle}\n\nWhen enabled, use an alternative `toggle` command logic for multi-screen setups.\nIt applies when the `toggle` command is triggered and the toggled scratchpad is visible on a screen which is not the focused one.\n\nInstead of moving the scratchpad to the focused screen, it will hide the scratchpad.\n\n### `allow_special_workspaces` <ConfigBadges plugin=\"scratchpads\" option=\"allow_special_workspaces\" /> {#config-allow-special-workspaces}\n\nWhen enabled, you can toggle a scratchpad over a special workspace.\nIt will always use the \"normal\" workspace otherwise.\n\n> [!note]\n> Can't be disabled when using *Hyprland* < 0.39 where this behavior can't be controlled.\n\n### `smart_focus` <ConfigBadges plugin=\"scratchpads\" option=\"smart_focus\" /> {#config-smart-focus}\n\nWhen enabled, the focus will be restored in a best effort way as an attempt to improve the user experience.\nIf you face issues such as spontaneous workspace changes, you can disable this feature.\n\n\n### `close_on_hide` <ConfigBadges plugin=\"scratchpads\" option=\"close_on_hide\" /> {#config-close-on-hide}\n\nWhen enabled, the window in the scratchpad is closed instead of hidden when `pypr hide <name>` is run.\nThis option implies `lazy = true`.\nThis can be useful on laptops where background apps may increase battery power draw.\n\nNote: Currently this option changes the hide animation to use hyprland's close window animation.\n\n### `monitor` <ConfigBadges plugin=\"scratchpads\" option=\"monitor\" /> {#config-monitor}\n\nPer-monitor configuration overrides. Most display-related attributes can be changed (not `command`, `class` or `process_tracking`).\n\nUse the `monitor.<monitor name>` configuration item to override values, eg:\n\n```toml\n[scratchpads.music.monitor.eDP-1]\nposition = \"30% 50%\"\nanimation = \"fromBottom\"\n```\n\nYou may want to inline it for simple cases:\n\n```toml\n[scratchpads.music]\nmonitor = {HDMI-A-1={size = \"30% 50%\"}}\n```\n"
  },
  {
    "path": "site/scratchpads_nonstandard.md",
    "content": "---\n---\n# Troubleshooting scratchpads\n\n> [!note]\n> For basic setup, see [Scratchpads](./scratchpads).\n\nOptions that should only be used for applications that are not behaving in a \"standard\" way, such as `emacsclient` or progressive web apps.\n\n<PluginConfig plugin=\"scratchpads\" linkPrefix=\"config-\" :filter=\"['match_by', 'initialClass', 'initialTitle', 'title', 'process_tracking', 'skip_windowrules']\" />\n\n### `match_by` <ConfigBadges plugin=\"scratchpads\" option=\"match_by\" /> {#config-match-by}\n\nWhen set to a sensitive client property value (eg: `class`, `initialClass`, `title`, `initialTitle`), will match the client window using the provided property instead of the PID of the process.\n\nThis property must be set accordingly, eg:\n\n```toml\nmatch_by = \"class\"\nclass = \"my-web-app\"\n```\n\nor\n\n```toml\nmatch_by = \"initialClass\"\ninitialClass = \"my-web-app\"\n```\n\nYou can add the \"re:\" prefix to use a regular expression, eg:\n\n```toml\nmatch_by = \"title\"\ntitle = \"re:.*some string.*\"\n```\n\n> [!note]\n> Some apps may open the graphical client window in a \"complicated\" way, to work around this, it is possible to disable the process PID matching algorithm and simply rely on window's class.\n>\n> The `match_by` attribute can be used to achieve this, eg. for emacsclient:\n> ```toml\n> [scratchpads.emacs]\n> command = \"/usr/local/bin/emacsStart.sh\"\n> class = \"Emacs\"\n> match_by = \"class\"\n> ```\n\n### `process_tracking` <ConfigBadges plugin=\"scratchpads\" option=\"process_tracking\" /> {#config-process-tracking}\n\nAllows disabling the process management. Use only if running a progressive web app (Chrome based apps) or similar.\n\nThis will automatically force `lazy = true` and set `match_by=\"class\"` if no `match_by` rule is provided, to help with the fuzzy client window matching.\n\nIt requires defining a `class` option (or the option matching your `match_by` value).\n\n```toml\n## Chat GPT on Brave\n[scratchpads.gpt]\nanimation = \"fromTop\"\ncommand = \"brave --profile-directory=Default --app=https://chat.openai.com\"\nclass = \"brave-chat.openai.com__-Default\"\nsize = \"75% 60%\"\nprocess_tracking = false\n\n## Some chrome app\n[scratchpads.music]\ncommand = \"google-chrome --profile-directory=Default --app-id=cinhimbnkkaeohfgghhklpknlkffjgod\"\nclass = \"chrome-cinhimbnkkaeohfgghhklpknlkffjgod-Default\"\nsize = \"50% 50%\"\nprocess_tracking = false\n```\n\n> [!tip]\n> To list windows by class and title you can use:\n> - `hyprctl -j clients | jq '.[]|[.class,.title]'`\n> - or if you prefer a graphical tool: `rofi -show window`\n\n> [!note]\n> Progressive web apps will share a single process for every window.\n> On top of requiring the class based window tracking (using `match_by`),\n> the process can not be managed the same way as usual apps and the correlation\n> between the process and the client window isn't as straightforward and can lead to false matches in extreme cases.\n\n### `skip_windowrules` <ConfigBadges plugin=\"scratchpads\" option=\"skip_windowrules\" /> {#config-skip-windowrules}\n\nAllows you to skip the window rules for a specific scratchpad.\nAvailable rules are:\n\n- \"aspect\" controlling size and position\n- \"float\" controlling the floating state\n- \"workspace\" which moves the window to its own workspace\n\nIf you are using an application which can spawn multiple windows and you can't see them, you can skip rules made to improve the initial display of the window.\n\n```toml\n[scratchpads.filemanager]\nanimation = \"fromBottom\"\ncommand = \"nemo\"\nclass = \"nemo\"\nsize = \"60% 60%\"\nskip_windowrules = [\"aspect\", \"workspace\"]\n```\n"
  },
  {
    "path": "site/shift_monitors.md",
    "content": "---\n---\n\n# shift_monitors\n\nSwaps the workspaces of every screen in the given direction.\n\n> [!Note]\n> the behavior can be hard to predict if you have more than 2 monitors (depending on your layout).\n> If you use this plugin with many monitors and have some ideas about a convenient configuration, you are welcome ;)\n\n> [!Tip]\n> On Niri, this plugin moves the active workspace to the adjacent monitor instead of swapping workspaces, as Niri workspaces are dynamic.\n\nExample usage in `hyprland.conf`:\n\n```sh\nbind = $mainMod, O, exec, pypr shift_monitors +1\nbind = $mainMod SHIFT, O, exec, pypr shift_monitors -1\n```\n\n## Commands\n\n<PluginCommands plugin=\"shift_monitors\" />\n\n## Configuration\n\nThis plugin has no configuration options.\n"
  },
  {
    "path": "site/shortcuts_menu.md",
    "content": "---\n---\n\n# shortcuts_menu\n\nPresents some menu to run shortcut commands. Supports nested menus (aka categories / sub-menus).\n\n<details>\n   <summary>Configuration examples</summary>\n\n```toml\n[shortcuts_menu.entries]\n\n\"Open Jira ticket\" = 'open-jira-ticket \"$(wl-paste)\"'\nRelayout = \"pypr relayout\"\n\"Fetch window\" = \"pypr fetch_client_menu\"\n\"Hyprland socket\" = 'kitty  socat - \"UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock\"'\n\"Hyprland logs\" = 'kitty tail -f $XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/hyprland.log'\n\n\"Serial USB Term\" = [\n    {name=\"device\", command=\"ls -1 /dev/ttyUSB*; ls -1 /dev/ttyACM*\"},\n    {name=\"speed\", options=[\"115200\", \"9600\", \"38400\", \"115200\", \"256000\", \"512000\"]},\n    \"kitty miniterm --raw --eol LF [device] [speed]\"\n]\n\n\"Color picker\" = [\n    {name=\"format\", options=[\"hex\", \"rgb\", \"hsv\", \"hsl\", \"cmyk\"]},\n    \"sleep 0.2; hyprpicker --format [format] | wl-copy\" # sleep to let the menu close before the picker opens\n]\n\nscreenshot = [\n    {name=\"what\", options=[\"output\", \"window\", \"region\", \"active\"]},\n    \"hyprshot -m [what] -o /tmp -f shot_[what].png\"\n]\n\nannotate = [\n    {name=\"fname\", command=\"ls /tmp/shot_*.png\"},\n    \"satty --filename '[fname]' --output-filename '/tmp/annotated.png'\"\n]\n\n\"Clipboard history\" = [\n    {name=\"entry\", command=\"cliphist list\", filter=\"s/\\t.*//\"},\n    \"cliphist decode '[entry]' | wl-copy\"\n]\n\n\"Copy password\" = [\n    {name=\"what\", command=\"gopass ls --flat\"},\n    \"gopass show -c [what]\"\n]\n\n\"Update/Change password\" = [\n    {name=\"what\", command=\"gopass ls --flat\"},\n    \"kitty -- gopass generate -s --strict -t '[what]' && gopass show -c '[what]'\"\n]\n```\n\n</details>\n\n\n## Commands\n\n<PluginCommands plugin=\"shortcuts_menu\" />\n\n> [!tip]\n> - If \"name\" is provided it will show the given sub-menu.\n> - You can use \".\" to reach any level of the configured menus.\n>\n>      Example to reach `shortcuts_menu.entries.utils.\"local commands\"`, use:\n>      ```sh\n>       pypr menu \"utils.local commands\"\n>      ```\n\n## Configuration\n\n<PluginConfig plugin=\"shortcuts_menu\" linkPrefix=\"config-\" />\n\n### `entries` <ConfigBadges plugin=\"shortcuts_menu\" option=\"entries\" /> {#config-entries}\n\n**Required.** Defines the menu entries. Supports [Variables](./Variables)\n\n```toml\n[shortcuts_menu.entries]\n\"entry 1\" = \"command to run\"\n\"entry 2\" = \"command to run\"\n```\n\nSubmenus can be defined too (there is no depth limit):\n\n```toml\n[shortcuts_menu.entries.\"My submenu\"]\n\"entry X\" = \"command\"\n\n[shortcuts_menu.entries.one.two.three.four.five]\nfoobar = \"ls\"\n```\n\n#### Advanced usage\n\nInstead of navigating a configured list of menu options and running a pre-defined command, you can collect various *variables* (either static list of options selected by the user, or generated from a shell command) and then run a command using those variables:\n\n```toml\n\"Play Video\" = [\n    {name=\"video_device\", command=\"ls -1 /dev/video*\"},\n    {name=\"player\", options=[\"mpv\", \"guvcview\"]},\n    \"[player] [video_device]\"\n]\n\n\"Ssh\" = [\n    {name=\"action\", options=[\"htop\", \"uptime\", \"sudo halt -p\"]},\n    {name=\"host\", options=[\"gamix\", \"gate\", \"idp\"]},\n    \"kitty --hold ssh [host] [action]\"\n]\n```\n\nYou must define a list of objects, containing:\n- `name`: the variable name\n- then the list of options, must be one of:\n    - `options` for a static list of options\n    - `command` to get the list of options from a shell command's output\n\n> [!tip]\n> You can apply post-filters to the `command` output, eg:\n> ```toml\n> {\n>   name = \"entry\",\n>   command = \"cliphist list\",\n>   filter = \"s/\\t.*//\"  # will remove everything after the TAB character\n> }\n> ```\n> Check the [filters](./filters) page for more details\n\nThe last item of the list must be a string which is the command to run. Variables can be used enclosed in `[]`.\n\n### `command_start` / `command_end` <ConfigBadges plugin=\"shortcuts_menu\" option=\"command_start\" /> {#config-command-start}\n\nAllow adding some text (eg: icon) before / after a menu entry for final commands.\n\n### `submenu_start` / `submenu_end` <ConfigBadges plugin=\"shortcuts_menu\" option=\"submenu_start\" /> {#config-submenu-start}\n\nAllow adding some text (eg: icon) before / after a menu entry leading to another menu.\n\nBy default `submenu_end` is set to a right arrow sign, while other attributes are not set.\n\n### `skip_single` <ConfigBadges plugin=\"shortcuts_menu\" option=\"skip_single\" /> {#config-skip-single}\n\nWhen disabled, shows the menu even for single options.\n\n## Hints\n\n### Multiple menus\n\nTo manage multiple distinct menus, always use a name when using the `pypr menu <name>` command.\n\nExample of a multi-menu configuration:\n\n```toml\n[shortcuts_menu.entries.\"Basic commands\"]\n\"entry X\" = \"command\"\n\"entry Y\" = \"command2\"\n\n[shortcuts_menu.entries.menu2]\n## ...\n```\n\nYou can then show the first menu using `pypr menu \"Basic commands\"`\n"
  },
  {
    "path": "site/sidebar.json",
    "content": "{\n  \"main\": [\n    {\n      \"text\": \"Getting Started\",\n      \"link\": \"./Getting-started\"\n    },\n    {\n      \"text\": \"Configuration\",\n      \"link\": \"./Configuration\"\n    },\n    {\n      \"text\": \"Commands\",\n      \"link\": \"./Commands\"\n    },\n    {\n      \"text\": \"Plugins\",\n      \"link\": \"./Plugins\"\n    },\n    {\n      \"text\": \"Troubleshooting\",\n      \"link\": \"./Troubleshooting\"\n    },\n    {\n      \"text\": \"Development\",\n      \"link\": \"./Development\"\n    },\n    {\n      \"text\": \"Examples\",\n      \"link\": \"./Examples\"\n    },\n    {\n      \"text\": \"Architecture\",\n      \"link\": \"./Architecture\",\n      \"collapsed\": true,\n      \"items\": [\n        {\n          \"text\": \"Overview\",\n          \"link\": \"./Architecture_overview\"\n        },\n        {\n          \"text\": \"Core Components\",\n          \"link\": \"./Architecture_core\"\n        }\n      ]\n    }\n  ],\n  \"plugins\": {\n    \"text\": \"Featured plugins\",\n    \"collapsed\": false,\n    \"items\": [\n      {\n        \"text\": \"Expose\",\n        \"link\": \"./expose\"\n      },\n      {\n        \"text\": \"Fcitx5 Switcher\",\n        \"link\": \"./fcitx5_switcher\"\n      },\n      {\n        \"text\": \"Fetch client menu\",\n        \"link\": \"./fetch_client_menu\"\n      },\n      {\n        \"text\": \"Gamemode\",\n        \"link\": \"./gamemode\"\n      },\n      {\n        \"text\": \"Menubar\",\n        \"link\": \"./menubar\"\n      },\n      {\n        \"text\": \"Layout center\",\n        \"link\": \"./layout_center\"\n      },\n      {\n        \"text\": \"Lost windows\",\n        \"link\": \"./lost_windows\"\n      },\n      {\n        \"text\": \"Magnify\",\n        \"link\": \"./magnify\"\n      },\n      {\n        \"text\": \"Monitors\",\n        \"link\": \"./monitors\"\n      },\n      {\n        \"text\": \"Scratchpads\",\n        \"link\": \"./scratchpads\",\n        \"items\": [\n          {\n            \"text\": \"Advanced\",\n            \"link\": \"./scratchpads_advanced\"\n          },\n          {\n            \"text\": \"Special cases\",\n            \"link\": \"./scratchpads_nonstandard\"\n          }\n        ]\n      },\n      {\n        \"text\": \"Shift monitors\",\n        \"link\": \"./shift_monitors\"\n      },\n      {\n        \"text\": \"Shortcuts menu\",\n        \"link\": \"./shortcuts_menu\"\n      },\n      {\n        \"text\": \"Stash\",\n        \"link\": \"./stash\"\n      },\n      {\n        \"text\": \"System notifier\",\n        \"link\": \"./system_notifier\"\n      },\n      {\n        \"text\": \"Toggle dpms\",\n        \"link\": \"./toggle_dpms\"\n      },\n      {\n        \"text\": \"Toggle special\",\n        \"link\": \"./toggle_special\"\n      },\n      {\n        \"text\": \"Wallpapers\",\n        \"link\": \"./wallpapers\",\n        \"items\": [\n          {\n            \"text\": \"Online\",\n            \"link\": \"./wallpapers_online\"\n          },\n          {\n            \"text\": \"Templates\",\n            \"link\": \"./wallpapers_templates\"\n          }\n        ]\n      },\n      {\n        \"text\": \"Workspaces follow focus\",\n        \"link\": \"./workspaces_follow_focus\"\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "site/stash.md",
    "content": "---\n---\n\n# stash\n\nStash and show windows in named groups using special workspaces.\n\nUnlike `toggle_special` which uses a single special workspace, `stash` supports multiple named stash groups. Windows can be quickly stashed away and retrieved later, appearing on whichever workspace you are currently on.\n\n## Usage\n\n```bash\nbind = $mainMod, S, exec, pypr stash          # toggle stash the focused window\nbind = $mainMod SHIFT, S, exec, pypr stash_toggle # show/hide stashed windows\n```\n\nFor multiple stash groups:\n\n```bash\nbind = $mainMod, S, exec, pypr stash default\nbind = $mainMod, W, exec, pypr stash work\nbind = $mainMod SHIFT, S, exec, pypr stash_toggle default\nbind = $mainMod SHIFT, W, exec, pypr stash_toggle work\n```\n\n## Commands\n\n<PluginCommands plugin=\"stash\" />\n\n## Configuration\n\n<PluginConfig plugin=\"stash\" />\n\n### Example\n\n```toml\n[stash]\nstyle = [\n    \"border_color rgb(ec8800)\",\n    \"border_size 3\",\n]\n```\n\nWhen `style` is set, shown stash windows are tagged with `stash` and the listed [window rules](https://wiki.hyprland.org/Configuring/Window-Rules/) are applied.\nThe tag is removed when windows are hidden or removed from the stash.\n"
  },
  {
    "path": "site/system_notifier.md",
    "content": "---\n---\n# system_notifier\n\nThis plugin adds system notifications based on journal logs (or any program's output).\nIt monitors specified **sources** for log entries matching predefined **patterns** and generates notifications accordingly (after applying an optional **filter**).\n\nSources are commands that return a stream of text (eg: journal, mqtt, `tail -f`, ...) which is sent to a parser that will use a [regular expression pattern](https://en.wikipedia.org/wiki/Regular_expression) to detect lines of interest and optionally transform them before sending the notification.\n\n<details>\n    <summary>Minimal configuration</summary>\n\n```toml\n[[system_notifier.sources]]\ncommand = \"journalctl -fx\"\nparser = \"journal\"\n```\n\nNo **sources** are defined by default, so you will need to define at least one.\n\nIn general you will also need to define some **parsers**.\nBy default a **\"journal\"** parser is provided, otherwise you need to define your own rules.\nThis built-in configuration is close to this one, provided as an example:\n\n```toml\n[[system_notifier.parsers.journal]]\npattern = \"([a-z0-9]+): Link UP$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is active/\"\ncolor = \"#00aa00\"\n\n[[system_notifier.parsers.journal]]\npattern = \"([a-z0-9]+): Link DOWN$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is inactive/\"\ncolor = \"#ff8800\"\nduration = 15\n\n[[system_notifier.parsers.journal]]\npattern = \"Process \\d+ \\(.*\\) of .* dumped core.\"\nfilter = \"s/.*Process \\d+ \\((.*)\\) of .* dumped core./\\1 dumped core/\"\ncolor = \"#aa0000\"\n\n[[system_notifier.parsers.journal]]\npattern = \"usb \\d+-[0-9.]+: Product: \"\nfilter = \"s/.*usb \\d+-[0-9.]+: Product: (.*)/USB plugged: \\1/\"\n```\n</details>\n\n## Commands\n\n<PluginCommands plugin=\"system_notifier\" />\n\n## Configuration\n\n<PluginConfig plugin=\"system_notifier\" linkPrefix=\"config-\" />\n\n### `sources` <ConfigBadges plugin=\"system_notifier\" option=\"sources\" /> {#config-sources}\n\nList of sources to monitor. Each source must contain a `command` to run and a `parser` to use:\n\n```toml\n[[system_notifier.sources]]\ncommand = \"journalctl -fx\"\nparser = \"journal\"\n```\n\nYou can also use multiple parsers:\n\n```toml\n[[system_notifier.sources]]\ncommand = \"sudo journalctl -fkn\"\nparser = [\"journal\", \"custom_parser\"]\n```\n\n### `parsers` <ConfigBadges plugin=\"system_notifier\" option=\"parsers\" /> {#config-parsers}\n\nNamed parser configurations. Each parser rule contains:\n- `pattern`: regex to match lines of interest\n- `filter`: optional [filter](./filters) to transform text (e.g., `s/.*value: (\\d+)/Value=\\1/`)\n- `color`: optional color in `\"#hex\"` or `\"rgb()\"` format\n- `duration`: notification display time in seconds (default: 3)\n\n```toml\n[[system_notifier.parsers.custom_parser]]\npattern = 'special value:'\nfilter = \"s/.*special value: (\\d+)/Value=\\1/\"\ncolor = \"#FF5500\"\nduration = 10\n```\n\n### Built-in \"journal\" parser\n\nA `journal` parser is provided, detecting link up/down, core dumps, and USB plugs.\n\n### `use_notify_send` <ConfigBadges plugin=\"system_notifier\" option=\"use_notify_send\" /> {#config-use-notify-send}\n\nWhen enabled, forces use of `notify-send` command instead of the compositor's native notification system.\n"
  },
  {
    "path": "site/toggle_dpms.md",
    "content": "---\n---\n\n# toggle_dpms\n\n## Commands\n\n<PluginCommands plugin=\"toggle_dpms\" />\n\n## Configuration\n\nThis plugin has no configuration options."
  },
  {
    "path": "site/toggle_special.md",
    "content": "---\n---\n\n# toggle_special\n\nAllows moving the focused window to a special workspace and back (based on the visibility status of that workspace).\n\nIt's a companion of the `togglespecialworkspace` Hyprland's command which toggles a special workspace's visibility.\n\nYou most likely will need to configure the two commands for a complete user experience, eg:\n\n```bash\nbind = $mainMod SHIFT, N, togglespecialworkspace, stash # toggles \"stash\" special workspace visibility\nbind = $mainMod, N, exec, pypr toggle_special stash # moves window to/from the \"stash\" workspace\n```\n\nNo other configuration needed, here `MOD+SHIFT+N` will show every window in \"stash\" while `MOD+N` will move the focused window out of it/ to it.\n\n## Commands\n\n<PluginCommands plugin=\"toggle_special\" />\n\n## Configuration\n\n<PluginConfig plugin=\"toggle_special\" linkPrefix=\"config-\" />\n\n### `name` <ConfigBadges plugin=\"toggle_special\" option=\"name\" /> {#config-name}\n\nDefault special workspace name.\n"
  },
  {
    "path": "site/versions/2.3.5/Development.md",
    "content": "# Development\n\nIt's easy to write your own plugin by making a python package and then indicating it's name as the plugin name.\n\n[Contributing guidelines](https://github.com/hyprland-community/pyprland/blob/main/CONTRIBUTING.md)\n\n# Writing plugins\n\nPlugins can be loaded with full python module path, eg: `\"mymodule.pyprlandplugin\"`, the loaded module must provide an `Extension` class.\n\nCheck the `interface.py` file to know the base methods, also have a look at the example below.\n\nTo get more details when an error is occurring, use `pypr --debug <log file path>`, it will also display the log in the console.\n\n> [!note]\n> To quickly get started, you can directly edit the `experimental` built-in plugin.\n> In order to distribute it, make your own Python package or trigger a pull request.\n> If you prefer to make a separate package, check the [examples](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/)'s package\n\nThe `Extension` interface provides a couple of built-in attributes:\n\n- `config` : object exposing the plugin section in `pyprland.toml`\n- `notify` ,`notify_error`, `notify_info` : access to Hyprland's notification system\n- `hyprctl`, `hyprctl_json` : invoke [Hyprland's IPC system](https://wiki.hyprland.org/Configuring/Dispatchers/)\n\n\n> [!important]\n> Contact me to get your extension listed on the home page\n\n> [!tip]\n> You can set a `plugins_paths=[\"/custom/path/example\"]` in the `hyprland` section of the configuration to add extra paths (eg: during development).\n\n> [!Note]\n> If your extension is at the root of the plugin (this is not recommended, preferable add a name space, as in `johns_pyprland.super_feature`, rather than `super_feature`) you can still import it using the `external:` prefix when you refer to it in the `plugins` list.\n\n# API Documentation\n\nRun `tox run -e doc` then visit `http://localhost:8080`\n\nThe most important to know are:\n\n- `hyprctl_json` to get a response from an IPC query\n- `hyprctl` to trigger general IPC commands\n- `on_reload` to be implemented, called when the config is (re)loaded\n- `run_<command_name>` to implement a command\n- `event_<event_name>` called when the given event is emitted by Hyprland\n\nAll those methods are _async_\n\nOn top of that:\n\n- the first line of a `run_*` command's docstring will be used by the `help` command\n- `self.config` in your _Extension_ contains the entry corresponding to your plugin name in the TOML file\n- `state` from `..common` module contains ready to use information\n- there is a `MenuMixin` in `..adapters.menus` to make menu-based plugins easy\n\n# Workflow\n\nJust `^C` when you make a change and repeat:\n\n```sh\npypr exit ; pypr --debug /tmp/output.log\n```\n\n\n## Creating a plugin\n\n```python\nfrom .interface import Plugin\n\n\nclass Extension(Plugin):\n    \" My plugin \"\n\n    async def init(self):\n        await self.notify(\"My plugin loaded\")\n```\n\n## Adding a command\n\nJust add a method called `run_<name of your command>` to your `Extension` class, eg with \"togglezoom\" command:\n\n```python\n    zoomed = False\n\n    async def run_togglezoom(self, args):\n        \"\"\" this doc string will show in `help` to document `togglezoom`\n        But this line will not show in the CLI help\n        \"\"\"\n      if self.zoomed:\n        await self.hyprctl('misc:cursor_zoom_factor 1', 'keyword')\n      else:\n        await self.hyprctl('misc:cursor_zoom_factor 2', 'keyword')\n      self.zoomed = not self.zoomed\n```\n\n## Reacting to an event\n\nSimilar as a command, implement some `async def event_<the event you are interested in>` method.\n\n## Code safety\n\nPypr ensures only one `run_` or `event_` handler runs at a time, allowing the plugins code to stay simple and avoid the need for concurrency handling.\nHowever, each plugin can run its handlers in parallel.\n\n# Reusable code\n\n```py\nfrom ..common import state, CastBoolMixin\n```\n\n- `state` provides a couple of handy variables so you don't have to fetch them, allow optimizing the most common operations\n- `Mixins` are providing common code, for instance the `CastBoolMixin` provides the `cast_bool` method to your `Extension`.\n\nIf you want to use menus, then the `MenuMixin` will provide:\n- `menu` to show a menu\n- `ensure_menu_configured` to call before you require a menu in your plugin\n\n# Example\n\nYou'll find a basic external plugin in the [examples](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/) folder.\n\nIt provides one command: `pypr dummy`.\n\nRead the [plugin code](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/pypr_examples/focus_counter.py)\n\nIt's a simple python package. To install it for development without a need to re-install it for testing, you can use `pip install -e .` in this folder.\nIt's ready to be published using `poetry publish`, don't forget to update the details in the `pyproject.toml` file.\n\n## Usage\n\nEnsure you added `pypr_examples.focus_counter` to your `plugins` list:\n\n```toml\n[pyprland]\nplugins = [\n  \"pypr_examples.focus_counter\"\n]\n```\n\nOptionally you can customize one color:\n\n```toml\n[\"pypr_examples.focus_counter\"]\ncolor = \"FFFF00\"\n```\n"
  },
  {
    "path": "site/versions/2.3.5/Getting-started.md",
    "content": "# Getting started\n\nPypr consists in two things:\n\n- **a tool**: `pypr` which runs the daemon (service), but also allows to interact with it\n- **some config file**: `~/.config/hypr/pyprland.toml` (or the path set using `--config`) using the [TOML](https://toml.io/en/) format\n\nThe `pypr` tool only have a few built-in commands:\n\n- `help` lists available commands (including plugins commands)\n- `exit` will terminate the service process\n- `edit` edit the configuration using your `$EDITOR` (or `vi`), reloads on exit - added in 2.2.4\n- `dumpjson` shows a JSON representation of the configuration (after other files have been `include`d) - added in 2.2.11\n- `reload` reads the configuration file and apply some changes:\n  - new plugins will be loaded\n  - configuration items will be updated (most plugins will use the new values on the next usage)\n\nOther commands are implemented by adding [plugins](./Plugins).\n\n> [!important]\n> - with no argument it runs the daemon (doesn't fork in the background)\n>\n> - if you pass parameters, it will interact with the daemon instead.\n\n> [!tip]\n> Pypr *command* names are documented using underscores (`_`) but you can use dashes (`-`) instead.\n> Eg: `pypr shift_monitors` and `pypr shift-monitors` will run the same command\n\n\n## Configuration file\n\nThe configuration file uses a [TOML format](https://toml.io/) with the following as the bare minimum:\n\n```toml\n[pyprland]\nplugins = [\"plugin_name\", \"other_plugin\"]\n```\n\nAdditionally some plugins require **Configuration** options, using the following format:\n\n```toml\n[plugin_name]\nplugin_option = 42\n\n[plugin_name.another_plugin_option]\nsuboption = \"config value\"\n```\n\nYou can also split your configuration into [Multiple configuration files](./MultipleConfigurationFiles).\n\n## Installation\n\nCheck your OS package manager first, eg:\n\n- Archlinux: you can find it on AUR, eg with [yay](https://github.com/Jguer/yay): `yay pyprland`\n- NixOS: Instructions in the [Nix](./Nix) page\n\nOtherwise, use the python package manager *inside a virtual environment* (`python -m venv somefolder && source ./somefolder/bin/activate`):\n\n```sh\npip install pyprland\n```\n\n## Running\n\n> [!caution]\n> If you messed with something else than your OS packaging system to get `pypr` installed, use the full path to the `pypr` command.\n\nPreferably start the process with hyprland, adding to `hyprland.conf`:\n\n```ini\nexec-once = /usr/bin/pypr\n```\n\nor if you run into troubles (use the first version once your configuration is stable):\n\n```ini\nexec-once = /usr/bin/pypr --debug /tmp/pypr.log\n```\n\n> [!warning]\n> To avoid issues (eg: you have a complex setup, maybe using a virtual environment), you may want to set the full path (eg: `/home/bob/venv/bin/pypr`).\n> You can get it from `which pypr` in a working terminal\n\nOnce the `pypr` daemon is started (cf `exec-once`), you can list the eventual commands which have been added by the plugins using `pypr -h` or `pypr help`, those commands are generally meant to be use via key bindings, see the `hyprland.conf` part of *Configuring* section below.\n\n## Configuring\n\nCreate a configuration file in `~/.config/hypr/pyprland.toml` enabling a list of plugins, each plugin may have its own configuration needs or don't need any configuration at all.\nMost default values should be acceptable for most users, options which hare not mandatory are marked as such.\n\n> [!important]\n> Provide the values for the configuration options which have no annotation such as \"(optional)\"\n\nCheck the [TOML format](https://toml.io/) for details about the syntax.\n\nSimple example:\n\n```toml\n[pyprland]\nplugins = [\n    \"shift_monitors\",\n    \"workspaces_follow_focus\"\n]\n```\n\n<details>\n  <summary>\nMore complex example\n  </summary>\n\n```toml\n[pyprland]\nplugins = [\n  \"scratchpads\",\n  \"lost_windows\",\n  \"monitors\",\n  \"toggle_dpms\",\n  \"magnify\",\n  \"expose\",\n  \"shift_monitors\",\n  \"workspaces_follow_focus\",\n]\n\n[monitors.placement]\n\"Acer\".top_center_of = \"Sony\"\n\n[workspaces_follow_focus]\nmax_workspaces = 9\n\n[expose]\ninclude_special = false\n\n[scratchpads.stb]\nanimation = \"fromBottom\"\ncommand = \"kitty --class kitty-stb sstb\"\nclass = \"kitty-stb\"\nlazy = true\nsize = \"75% 45%\"\n\n[scratchpads.stb-logs]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-stb-logs stbLog\"\nclass = \"kitty-stb-logs\"\nlazy = true\nsize = \"75% 40%\"\n\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nlazy = true\nsize = \"40% 90%\"\nunfocus = \"hide\"\n```\n\nSome of those plugins may require changes in your `hyprland.conf` to fully operate or to provide a convenient access to a command, eg:\n\n```bash\nbind = $mainMod SHIFT, Z, exec, pypr zoom\nbind = $mainMod ALT, P,exec, pypr toggle_dpms\nbind = $mainMod SHIFT, O, exec, pypr shift_monitors +1\nbind = $mainMod, B, exec, pypr expose\nbind = $mainMod, K, exec, pypr change_workspace +1\nbind = $mainMod, J, exec, pypr change_workspace -1\nbind = $mainMod,L,exec, pypr toggle_dpms\nbind = $mainMod SHIFT,M,exec,pypr toggle stb stb-logs\nbind = $mainMod,A,exec,pypr toggle term\nbind = $mainMod,V,exec,pypr toggle volume\n```\n\n</details>\n\n> [!tip]\n> Consult or share [configuration files](https://github.com/hyprland-community/pyprland/tree/main/examples)\n>\n> You might also be interested in [optimizations](./Optimizations).\n"
  },
  {
    "path": "site/versions/2.3.5/Menu.md",
    "content": "# Menu capability\n\nMenu based plugins have the following configuration options:\n\n### `engine` (optional)\n\nNot set by default, will autodetect the available menu engine.\n\nSupported engines:\n\n- tofi\n- rofi\n- wofi\n- bemenu\n- dmenu\n- anyrun\n\n> [!note]\n> If your menu system isn't supported, you can open a [feature request](https://github.com/hyprland-community/pyprland/issues/new?assignees=fdev31&labels=bug&projects=&template=feature_request.md&title=%5BFEAT%5D+Description+of+the+feature)\n>\n> In case the engine isn't recognized, `engine` + `parameters` configuration options will be used to start the process, it requires a dmenu-like behavior.\n\n### `parameters` (optional)\n\nExtra parameters added to the engine command, the default value is specific to each engine.\n\n> [!important]\n> Setting this will override the default value!\n>\n> In general, *rofi*-like programs will require at least `-dmenu` option.\n\n> [!tip]\n> *Since version 2.0*, you can use '[prompt]' in the parameters, it will be replaced by the prompt, eg:\n> ```sh\n> -dmenu -matching fuzzy -i -p '[prompt]'\n> ```\n"
  },
  {
    "path": "site/versions/2.3.5/MultipleConfigurationFiles.md",
    "content": "### Multiple configuration files\n\nYou can also split your configuration into multiple files that will be loaded in the provided order after the main file (added in 2.2.4):\n```toml\n[pyprland]\ninclude = [\"/shared/pyprland.toml\", \"~/pypr_extra_config.toml\"]\n```\nSince 2.2.16 you can also load folders, in which case TOML files in the folder will be loaded in alphabetical order:\n```toml\n[pyprland]\ninclude = [\"~/.config/pypr.d/\"]\n```\n\nAnd then add a `~/.config/pypr.d/monitors.toml` file:\n```toml\npyprland.plugins = [ \"monitors\" ]\n\n[monitors.placement]\nBenQ.Top_Center_Of = \"DP-1\" # projo\n\"CJFH277Q3HCB\".top_of = \"eDP-1\" # work\n```\n\n> [!tip]\n> To check the final merged configuration, you can use the `dumpjson` command.\n\n"
  },
  {
    "path": "site/versions/2.3.5/Nix.md",
    "content": "# Nix\n\nYou are recommended to get the latest pyprland package from the [flake.nix](https://github.com/hyprland-community/pyprland/blob/main/flake.nix)\nprovided within this repository. To use it in your system, you may add pyprland\nto your flake inputs.\n\n## Flake\n\n```nix\n## flake.nix\n{\n  inputs = {\n    nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n    pyprland.url = \"github:hyprland-community/pyprland\";\n  };\n\n  outputs = { self, nixpkgs, pyprland, ...}: let\n  in {\n    nixosConfigurations.<yourHostname> = nixpkgs.lib.nixosSystem {\n      #         remember to replace this with your system arch ↓\n      environment.systemPackages = [ pyprland.packages.\"x86_64-linux\".pyprland ];\n      # ...\n    };\n  };\n}\n```\n\nAlternatively, if you are using the Nix package manager but not NixOS as your\nmain distribution, you may use `nix profile` tool to install pyprland from this\nrepository using the following command.\n\n```bash\nnix profile install github:nix-community/pyprland\n```\n\nThe package will now be in your latest profile. You may use `nix profile list`\nto verify your installation.\n\n## Nixpkgs\n\nPyprland is available under nixpkgs, and can be installed by adding\n`pkgs.pyprland` to either `environment.systemPackages` or `home.packages`\ndepending on whether you want it available system-wide or to only a single\nuser using home-manager. If the derivation available in nixpkgs is out-of-date\nthen you may consider using `overrideAttrs` to update the source locally.\n\n```nix\nlet\n  pyprland = pkgs.pyprland.overrideAttrs {\n    version = \"your-version-here\";\n    src = fetchFromGitHub {\n      owner = \"hyprland-community\";\n      repo = \"pyprland\";\n      rev = \"tag-or-revision\";\n      # leave empty for the first time, add the new hash from the error message\n      hash = \"\";\n    };\n  };\nin {\n  # add the overridden package to systemPackages\n  environment.systemPackages = [pyprland];\n}\n```\n"
  },
  {
    "path": "site/versions/2.3.5/Optimizations.md",
    "content": "## Optimizing\n\n### Plugins\n\n- Only enable the plugins you are using in the `plugins` array (in `[pyprland]` section).\n- Leaving the configuration for plugins which are not enabled will have no impact.\n- Using multiple configuration files only have a small impact on the startup time.\n\n### Pypr command\n\nIn case you want to save some time when interacting with the daemon\nyou can use `socat` instead (needs to be installed). Example of a `pypr-cli` command (should be reachable from your environment's `PATH`):\n```sh\n#!/bin/sh\nsocat - \"UNIX-CONNECT:${XDG_RUNTIME_DIR}/hypr/${HYPRLAND_INSTANCE_SIGNATURE}/.pyprland.sock\" <<< $@\n```\nOn slow systems this may make a difference.\nNote that the \"help\" command will require usage of the standard `pypr` command.\n"
  },
  {
    "path": "site/versions/2.3.5/Plugins.md",
    "content": "<script setup>\nimport PluginList from './components/PluginList.vue'\n</script>\n\nThis page lists every plugin provided by Pyprland out of the box, more can be enabled if you install the matching package.\n\n\"🌟\" indicates some maturity & reliability level of the plugin, considering age, attention paid and complexity - from 0 to 3.\n\n<PluginList/>\n"
  },
  {
    "path": "site/versions/2.3.5/Troubleshooting.md",
    "content": "# Troubleshooting\n\n## General\n\nIn case of trouble running a `pypr` command:\n- kill the existing pypr if any (try `pypr exit` first)\n- run from the terminal adding `--debug /dev/null` to the arguments to get more information\n\nIf the client says it can't connect, then there is a high chance pypr daemon didn't start, check if it's running using `ps axuw |grep pypr`. You can try to run it from a terminal with the same technique: `pypr --debug /dev/null` and see if any error occurs.\n\n## Force hyprland version\n\n_Added in 2.3.3_\n\nIn case your `hyprctl version -j` command isn't returning an accurate version, you can make Pyprland ignore it and use a provided value instead:\n\n```toml\n[pyprland]\nhyprland_version = \"0.41.0\"\n```\n\n## Unresponsive scratchpads\n\nScratchpads aren't responding for few seconds after trying to show one (which didn't show!)\n\nThis may happen if an application is very slow to start.\nIn that case pypr will wait for a window blocking other scratchpad's operation, before giving up after a few seconds.\n\nNote that other plugins shouldn't be blocked by it.\n\nMore scratchpads troubleshooting can be found [here](./scratchpads_nonstandard).\n"
  },
  {
    "path": "site/versions/2.3.5/Variables.md",
    "content": "# Variables & substitutions\n\nSome commands support shared global variables, they must be defined in the *pyprland* section of the configuration:\n```toml\n[pyprland.variables]\nterm = \"foot\"\nterm_classed = \"foot -a\" # kitty uses --class\n```\n\nIf a plugin supports it, you can then use the variables in the attribute that supports it, eg:\n\n```toml\n[myplugin]\nsome_variable = \"the terminal is [term]\"\n```\n"
  },
  {
    "path": "site/versions/2.3.5/components/PluginList.vue",
    "content": "<template>\n    <div>\n        <h1>Built-in plugins</h1>\n        <div v-for=\"plugin in sortedPlugins\" :key=\"plugin.name\" class=\"plugin-item\">\n            <div class=\"plugin-info\">\n                <h3>\n                    <a :href=\"plugin.name + '.html'\">{{ plugin.name }}</a>\n                    <span>&nbsp;{{ '🌟'.repeat(plugin.stars) }}</span>\n                    <span v-if=\"plugin.multimon\">\n                        <Badge type=\"tip\" text=\"multi-monitor\" />\n                    </span>\n                </h3>\n                <p v-html=\"plugin.description\" />\n            </div>\n            <a v-if=\"plugin.demoVideoId\" :href=\"'https://www.youtube.com/watch?v=' + plugin.demoVideoId\"\n                class=\"plugin-video\">\n                <img :src=\"'https://img.youtube.com/vi/' + plugin.demoVideoId + '/1.jpg'\" alt=\"Demo video thumbnail\" />\n            </a>\n        </div>\n    </div>\n</template>\n\n<style scoped>\n.plugin-item {\n    display: flex;\n    align-items: flex-start;\n    margin-bottom: 1.5rem;\n}\n\n.plugin-info {\n    flex: 1;\n}\n\n.plugin-video {\n    margin-left: 1rem;\n}\n\n.plugin-video img {\n    max-width: 200px;\n    /* Adjust as needed */\n    height: auto;\n}\n</style>\n\n\n<script>\n\nexport default {\n    computed: {\n        sortedPlugins() {\n            if (this.sortByStars) {\n                return this.plugins.slice().sort((a, b) => {\n                    if (b.stars === a.stars) {\n                        return a.name.localeCompare(b.name);\n                    }\n                    return b.stars - a.stars;\n                });\n            } else {\n                return this.plugins.slice().sort((a, b) => a.name.localeCompare(b.name));\n            }\n        }\n    },\n    data() {\n        return {\n            sortByStars: false,\n            plugins: [\n                {\n                    name: 'scratchpads',\n                    stars: 3,\n                    description: 'makes your applications dropdowns & togglable poppups',\n                    demoVideoId: 'ZOhv59VYqkc'\n                },\n                {\n                    name: 'magnify',\n                    stars: 3,\n                    description: 'toggles zooming of viewport or sets a specific scaling factor',\n                    demoVideoId: 'yN-mhh9aDuo'\n\n                },\n                {\n                    name: 'toggle_special',\n                    stars: 3,\n                    description: 'moves windows from/to special workspaces',\n                    demoVideoId: 'BNZCMqkwTOo'\n                },\n                {\n                    name: 'shortcuts_menu',\n                    stars: 3,\n                    description: 'a flexible way to make your own shortcuts menus & launchers',\n                    demoVideoId: 'UCuS417BZK8'\n                },\n                {\n                    name: 'fetch_client_menu',\n                    stars: 3,\n                    description: 'select a window to be moved to your active workspace (using rofi, dmenu, etc...)',\n                },\n                {\n                    name: 'layout_center',\n                    stars: 3,\n                    description: 'a workspace layout where one client window is almost maximized and others seat in the background',\n                    demoVideoId: 'vEr9eeSJYDc'\n                },\n                {\n                    name: 'system_notifier',\n                    stars: 3,\n                    description: 'open streams (eg: journal logs) and trigger notifications',\n                },\n                {\n                    name: 'wallpapers',\n                    stars: 3,\n                    description: 'handles random wallpapers at regular interval (from a folder)'\n                },\n                {\n                    name: 'gbar',\n                    stars: 3,\n                    description: 'improves multi-monitor handling of the status bar - only <tt>gBar</tt> is supported at the moment'\n                },\n                {\n                    name: 'expose',\n                    stars: 2,\n                    description: 'exposes all the windows for a quick \"jump to\" feature',\n                    demoVideoId: 'ce5HQZ3na8M'\n                },\n                {\n                    name: 'lost_windows',\n                    stars: 1,\n                    description: 'brings lost floating windows (which are out of reach) to the current workspace'\n                },\n                {\n                    name: 'toggle_dpms',\n                    stars: 0,\n                    description: 'toggles the DPMS status of every plugged monitor'\n                },\n                {\n                    name: 'workspaces_follow_focus',\n                    multimon: true,\n                    stars: 3,\n                    description: 'makes non visible workspaces available on the currently focused screen.<br/> If you think the multi-screen behavior of Hyprland is not usable or broken/unexpected, this is probably for you.'\n\n                },\n                {\n                    name: 'shift_monitors',\n                    multimon: true,\n                    stars: 3,\n                    description: 'moves workspaces from monitor to monitor (caroussel)'\n                },\n                {\n                    name: 'monitors',\n                    stars: 3,\n                    description: 'allows relative placement and configuration of monitors'\n                }\n            ]\n        }\n    }\n}\n</script>\n"
  },
  {
    "path": "site/versions/2.3.5/expose.md",
    "content": "# expose\n\nImplements the \"expose\" effect, showing every client window on the focused screen.\n\nFor a similar feature using a menu, try the [fetch_client_menu](./fetch_client_menu) plugin (less intrusive).\n\nSample `hyprland.conf`:\n\n```bash\n## Setup the key binding\nbind = $mainMod, B, exec, pypr expose\n\n## Add some style to the \"exposed\" workspace\nworkspace = special:exposed,gapsout:60,gapsin:30,bordersize:5,border:true,shadow:false\n```\n\n`MOD+B` will bring every client to the focused workspace, pressed again it will go to this workspace.\n\nCheck [workspace rules](https://wiki.hyprland.org/Configuring/Workspace-Rules/#rules) for styling options.\n\n> [!note]\n> If you are looking for `toggle_minimized`, check the [toggle_special](./toggle_special) plugin\n\n## Commands\n\n- `expose`: expose every client on the active workspace. If expose is already active, then restores everything and move to the focused window.\n\n## Configuration\n\n\n### `include_special` (optional)\n\ndefault value is `false`\n\nAlso include windows in the special workspaces during the expose.\n\n"
  },
  {
    "path": "site/versions/2.3.5/fetch_client_menu.md",
    "content": "# fetch_client_menu\n\nBring any window to the active workspace using a menu.\n\nA bit like the [expose](./expose) plugin but using a menu instead (less intrusive).\n\nIt brings the window to the current workspace, while [expose](./expose) moves the currently focused screen to the application workspace.\n\n> _Added in 1.10_\n\n## Commands\n\n- `fetch_client_menu`: display the menu allowing selection of the client to show\n\n## Configuration\n\nAll the [Menu](Menu) configuration items are also available.\n\n### `separator` (optional)\n\ndefault value is `\"|\"`\n\nChanges the character (or string) used to separate a menu entry from its entry number.\n\n"
  },
  {
    "path": "site/versions/2.3.5/filters.md",
    "content": "# Text filters\n\nAt the moment there is only one filter, close match of [sed's \"s\" command](https://www.gnu.org/software/sed/manual/html_node/The-_0022s_0022-Command.html).\n\nThe only supported flag is \"g\"\n\n## Example\n\n```toml\nfilter = 's/foo/bar/'\nfilter = 's/.*started (.*)/\\1 has started/'\nfilter = 's#</?div>##g'\n```\n"
  },
  {
    "path": "site/versions/2.3.5/gbar.md",
    "content": "# gbar\n\nRuns [gBar](https://github.com/scorpion-26/gBar) on the \"best\" monitor from a list of monitors.\n\nWill take care of starting gbar on startup (you must not run it from another source like `hyprland.conf`).\n\n> _Added in 2.2.6_\n\n## Commands\n\n- `gbar restart` - Restart/refresh gBar on the \"best\" monitor.\n\n## Configuration\n\n\n### `monitors`\n\nList of monitors to chose from, the first have higher priority over the second one etc...\n\n\n## Example\n\n```sh\n[gbar]\nmonitors = [\"DP-1\", \"HDMI-1\", \"HDMI-1-A\"]\n```\n"
  },
  {
    "path": "site/versions/2.3.5/index.md",
    "content": "---\n# https://vitepress.dev/reference/default-theme-home-page\nlayout: home\n\nhero:\n  text: \"Hyprland extensions\"\n  tagline: Enhance your desktop capabilities with Pyprland\n  image:\n    src: /logo.png\n    alt: logo\n  actions:\n    - theme: brand\n      text: Getting started\n      link: ./Getting-started\n    - theme: alt\n      text: Plugins\n      link: ./Plugins\n    - theme: alt\n      text: Report something\n      link: https://github.com/hyprland-community/pyprland/issues/new/choose\n\nfeatures:\n  - title: TOML format\n    details: Simple but powerful configuration\n  - title: Customizable\n    details: Create your own Hyprland experience\n  - title: Fast and easy\n    details: Designed for performance and ease of use\n---\n\n## Version 2.3.5 archive\n"
  },
  {
    "path": "site/versions/2.3.5/layout_center.md",
    "content": "# layout_center\n\nImplements a workspace layout where one window is bigger and centered,\nother windows are tiled as usual in the background.\n\nOn `toggle`, the active window is made floating and centered if the layout wasn't enabled, else reverts the floating status.\n\nWith `next` and `prev` you can cycle the active window, keeping the same layout type.\nIf the layout_center isn't active and `next` or `prev` is used, it will call the \"next\" and \"prev\" configuration options.\nTo allow full override of the focus keys, `next2` and `prev2` are provided, they do the same actions as \"next\" and \"prev\" but allow different fallback commands.\n\n> _Added in version 1.8.0_\n\n<details>\n<summary>Configuration sample</summary>\n\n```toml\n[layout_center]\nmargin = 60\noffset = [0, 30]\nnext = \"movefocus r\"\nprev = \"movefocus l\"\nnext2 = \"movefocus d\"\nprev2 = \"movefocus u\"\n```\n\nusing the following in `hyprland.conf`:\n```sh\nbind = $mainMod, M, exec, pypr layout_center toggle # toggle the layout\n## focus change keys\nbind = $mainMod, left, exec, pypr layout_center prev\nbind = $mainMod, right, exec, pypr layout_center next\nbind = $mainMod, up, exec, pypr layout_center prev2\nbind = $mainMod, down, exec, pypr layout_center next2\n```\n\nYou can completely ignore `next2` and `prev2` if you are allowing focus change (when the layout is enabled) in a single direction, eg:\n\n```sh\nbind = $mainMod, up, movefocus, u\nbind = $mainMod, down, movefocus, d\n```\n\n</details>\n\n\n## Command\n\n- `layout_center [command]` where *[command]* can be:\n  - toggle\n  - next\n  - prev\n  - next2\n  - prev2\n\n## Configuration\n\n### `margin` (optional)\n\ndefault value is `60`\n\nmargin (in pixels) used when placing the center window, calculated from the border of the screen.\n\nExample to make the main window be 100px far from the monitor's limits:\n```toml\nmargin = 100\n```\n\n### `offset` (optional)\n\ndefault value is `[0, 0]`\n\noffset in pixels applied to the main window position\n\nExample shift the main window 20px down:\n```toml\noffset = [0, 20]\n```\n\n### `next` (optional)\n\nnot set by default\n\nWhen the *layout_center* isn't active and the *next* command is triggered, defines the hyprland dispatcher command to run.\n\n`next2` is a similar option, used by the `next2` command, allowing to map \"next\" to both vertical and horizontal focus change.\n\nEg:\n```toml\nnext = \"movefocus r\"\n```\n\n### `prev` and `prev2` (optional)\n\nSame as `next` but for the `prev` and `prev2` commands.\n\n\n### `captive_focus` (optional)\n\ndefault value is `false`\n\n```toml\ncaptive_focus = true\n```\n\nSets the focus on the main window when the focus changes.\nYou may love it or hate it...\n"
  },
  {
    "path": "site/versions/2.3.5/lost_windows.md",
    "content": "# lost_windows\n\nBring windows which are not reachable in the currently focused workspace.\n*Deprecated:* Was used to work around issues in Hyprland which have been fixed since then.\n\n## Command\n\n- `attract_lost`: brings the lost windows to the current screen / workspace\n\n"
  },
  {
    "path": "site/versions/2.3.5/magnify.md",
    "content": "# magnify\n\n## Command\n\n- `zoom [value]`\n    - If no value is provided, toggles magnification.\n    - If an integer is provided, it will set as scaling factor.\n    - If this integer is prefixed with \"+\" or \"-\", it will *update* the current scale factor (added in version 2.1.4).\n        - Use \"++\" or \"--\", to use a more natural non-linear scale (added in version 2.2.9).\n\n> [!NOTE]\n>\n> The non-linear scale is calculated as powers of two, eg:\n>\n> - `zoom ++1` → 2x, 4x, 8x, 16x...\n> - `zoom ++0.7` → 1.6x, 2.6x, 4.3x, 7.0x, 11.3x, 18.4x...\n> - `zoom ++0.5` → 1.4x, 2x, 2.8x, 4x, 5.7x, 8x, 11.3x, 16x...\n\n## Configuration\n\nSample `hyprland.conf`:\n\n```sh\nbind = $mainMod , Z, exec, pypr zoom ++0.5\nbind = $mainMod SHIFT, Z, exec, pypr zoom\n```\n\n\n### `factor` (optional)\n\ndefault value is `2`\n\nScaling factor to be used when no value is provided.\n\n## Example\n\n```sh\npypr zoom  # sets zoom to `factor` (2 by default)\npypr zoom +1  # will set zoom to 3x\npypr zoom  # will set zoom to 1x\npypr zoom 1 # will (also) set zoom to 1x - effectively doing nothing\n```\n\n### `duration` (optional)\n\n> _Added in version 2.2.9_\n\nDefault value is `15`\n\nDuration in tenths of a second for the zoom animation to last, set to `0` to disable animations.\n"
  },
  {
    "path": "site/versions/2.3.5/monitors.md",
    "content": "# monitors\n\n> [!note]\n> First version of the plugin is still available under the name `monitors_v0`\n\nAllows relative placement of monitors depending on the model (\"description\" returned by `hyprctl monitors`).\nUseful if you have multiple monitors connected to a video signal switch or using a laptop and plugging monitors having different relative positions.\n\nSyntax:\n\n```toml\n[monitors.placement]\n\"description match\".placement = \"other description match\"\n```\n\n<details>\n    <summary>Example to set a Sony monitor on top of a BenQ monitor</summary>\n\n```toml\n[monitors.placement]\nSony.topOf = \"BenQ\"\n\n## Character case is ignored, \"_\" can be added\nSony.Top_Of = [\"BenQ\"]\n\n## Thanks to TOML format, complex configurations can use separate \"sections\" for clarity, eg:\n\n[monitors.placement.\"My monitor brand\"]\n## You can also use \"port\" names such as *HDMI-A-1*, *DP-1*, etc...\nleftOf = \"eDP-1\"\n\n## lists are possible on the right part of the assignment:\nrightOf = [\"Sony\", \"BenQ\"]\n\n## > 2.3.2: you can also set scale, transform & rate for a given monitor\n[monitors.placement.Microstep]\nrate = 100\n```\n\nTry to keep the rules as simple as possible, but relatively complex scenarios are supported.\n\n> [!note]\n> Check [wlr layout UI](https://github.com/fdev31/wlr-layout-ui) which is a nice complement to configure your monitor settings.\n\n</details>\n\n## Commands\n\n- `relayout` : Apply the configuration and update the layout\n\n## Configuration\n\n### `placement`\n\nSupported placements are:\n\n- leftOf\n- topOf\n- rightOf\n- bottomOf\n- \\<one of the above>(center|middle|end)Of\n\n> [!important]\n> If you don't like the screen to align on the start of the given border,\n> you can use `center` (or `middle`) to center it or `end` to stick it to the opposite border.\n> Eg: \"topCenterOf\", \"leftEndOf\", etc...\n\nYou can separate the terms with \"_\" to improve the readability, as in \"top_center_of\".\n\n#### monitor settings (since 2.3.2)\n\nNot only can you place monitors relatively to each other, but you can also set specific settings for a given monitor.\n\nThe following settings are supported:\n\n- scale\n- transform\n- rate\n- resolution\n\n```toml\n[monitors.placement.\"My monitor brand\"]\nrate = 60\nscale = 1.5\ntransform = 1 # 0: normal, 1: 90°, 2: 180°, 3: 270°, 4: flipped, 5: flipped 90°, 6: flipped 180°, 7: flipped 270°\nresolution = \"1920x1080\"  # can also be expressed as [1920, 1080]\n```\n\n### `startup_relayout` (optional)\n\nDefault to `ŧrue`.\n\nWhen set to `false`,\ndo not initialize the monitor layout on startup or when configuration is reloaded.\n\n### `full_relayout` (legacy - always enabled now)\n\n### `new_monitor_delay` (optional)\n\nBy default,\nthe layout computation happens one second after the event is received to let time for things to settle.\n\nYou can change this value using this option.\n\n### `hotplug_command` (optional)\n\n> _Added in 2.3.0_\n\nNone by default, allows to run a command when any monitor is plugged.\n\n\n```toml\n[monitors]\nhotplug_commands = \"wlrlui -m\"\n```\n\n### `hotplug_commands` (optional)\n\n> _Added in 2.2.16_\n\nNone by default, allows to run a command when a given monitor is plugged.\n\nExample to load a specific profile using [wlr layout ui](https://github.com/fdev31/wlr-layout-ui):\n\n```toml\n[monitors.hotplug_commands]\n\"DELL P2417H CJFH277Q3HCB\" = \"wlrlui rotated\"\n```\n\n### `unknown` (optional)\n\n> _Added in 2.3.0_\n\nNone by default,\nallows to run a command when no monitor layout has been changed (no rule applied).\n\n```toml\n[monitors]\nunknown = \"wlrlui\"\n```\n\n### `trim_offset` (optional)\n\n> _Added in 2.3.2_\n\n`true` by default,\n\nPrevents having arbitrary window offsets or negative values.\n"
  },
  {
    "path": "site/versions/2.3.5/scratchpads.md",
    "content": "# scratchpads\n\nEasily toggle the visibility of applications you use the most.\n\nConfigurable and flexible, while supporting complex setups it's easy to get started with:\n\n```toml\n[scratchpads.name]\ncommand = \"command to run\"\nclass = \"the window's class\"  # check: hyprctl clients | grep class\nsize = \"<width> <height>\"  # size of the window relative to the screen size\n```\n\n<details>\n<summary>Example</summary>\n\nAs an example, defining two scratchpads:\n\n- _term_ which would be a kitty terminal on upper part of the screen\n- _volume_ which would be a pavucontrol window on the right part of the screen\n\n\n```toml\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\nmax_size = \"1920px 100%\"\nmargin = 50\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nsize = \"40% 90%\"\nunfocus = \"hide\"\nlazy = true\n```\n\nShortcuts are generally needed:\n\n```ini\nbind = $mainMod,V,exec,pypr toggle volume\nbind = $mainMod,A,exec,pypr toggle term\nbind = $mainMod,Y,exec,pypr attach\n```\n\nNote that when `class` is provided, the window is automatically managed by pyprland.\nWhen you create a scratchpad called \"name\", it will be hidden in `special:scratch_<name>`.\n\n> [!note]\n> If you wish to have a more generic space for any application you may run, check [toggle_special](./toggle_special).\n\n</details>\n\n## Commands\n\n- `toggle <scratchpad name>`  toggles the given scratchpads\n  - use multiple _space separated_ names to synchronize visibility based on the first scratchpad provided\n- `show <scratchpad name>`  shows the given scratchpad\n- `hide <scratchpad name>`  hides the given scratchpad\n- `attach`  toggles attaching/anchoring the currently focused window to the (last used) scratchpad\n\n> [!note]\n> In version 2.3.5 and up, you can use `\"*\"` as a _scratchpad name_ to target every scratchpad when using `show` or `hide`.\n> You'll need to quote or escape the `*` character to avoid interpretation from your shell.\n\n## Configuration\n\n### `command`\n\nThis is the command you wish to run in the scratchpad.\n\nIt supports [Variables](./Variables)\n\n### `animation` (optional)\n\nType of animation to use, default value is \"fromTop\":\n\n- `null` / `\"\"` (no animation)\n- \"fromTop\" (stays close to top screen border)\n- \"fromBottom\" (stays close to bottom screen border)\n- \"fromLeft\" (stays close to left screen border)\n- \"fromRight\" (stays close to right screen border)\n\n### `size` (recommended)\n\nNo default value.\n\nEach time scratchpad is shown, window will be resized according to the provided values.\n\nFor example on monitor of size `800x600` and `size= \"80% 80%\"` in config scratchpad always have size `640x480`,\nregardless of which monitor it was first launched on.\n\n#### Format\n\nString with \"x y\" (or \"width height\") values using some units suffix:\n\n- **percents** relative to the focused screen size (`%` suffix), eg: `60% 30%`\n- **pixels** for absolute values (`px` suffix), eg: `800px 600px`\n- a mix is possible, eg: `800px 40%`\n\n### `class` (recommended)\n\nNo default value.\n\nAllows _Pyprland_ prepare the window for a correct animation and initial positioning.\n\n> [!important]\n> This will set some rules to every matching class!\n\n### `position` (optional)\n\nNo default value, overrides the automatic margin-based position.\n\nSets the scratchpad client window position relative to the top-left corner.\n\nSame format as `size` (see above)\n\nExample of scratchpad that always seat on the top-right corner of the screen:\n\n```toml\n[scratchpads.term_quake]\ncommand = \"wezterm start --class term_quake\"\nposition = \"50% 0%\"\nsize = \"50% 50%\"\nclass = \"term_quake\"\n```\n\n> [!note]\n> If `position` is not provided, the window is placed according to `margin` on one axis and centered on the other.\n\n### `multi` (optional)\n\n> _Added in 2.2.13_\n\nDefaults to `true`.\n\nWhen set to `false`, only one client window is supported for this scratchpad.\nOtherwise other matching windows will be **attach**ed to the scratchpad.\n\n## Advanced configuration\n\nTo go beyond the basic setup and have a look at every configuration item, you can read the following pages:\n\n- [Advanced](./scratchpads_advanced) contains options for fine-tuners or specific tastes (eg: i3 compatibility)\n- [Non-Standard](./scratchpads_nonstandard) contains options for \"broken\" applications\nlike progressive web apps (PWA) or emacsclient, use only if you can't get it to work otherwise\n\n## Monitor specific overrides\n\n> _Added in 2.0.6_\n\nYou can use different settings for a specific screen.\nMost attributes related to the display can be changed (not `command`, `class` or `process_tracking` for instance).\n\nUse the `monitor.<monitor name>` configuration item to override values, eg:\n\n```toml\n[scratchpads.music.monitor.eDP-1]\nposition = \"30% 50%\"\nanimation = \"fromBottom\"\n```\n\nYou may want to inline it for simple cases:\n\n```toml\n[scratchpads.music]\nmonitor = {HDMI-A-1={size = \"30% 50%\"}}\n```\n"
  },
  {
    "path": "site/versions/2.3.5/scratchpads_advanced.md",
    "content": "# Fine tuning scratchpads\n\n## `excludes` (optional)\n\nNo default value.\n\nList of scratchpads to hide when this one is displayed, eg: `excludes = [\"term\", \"volume\"]`.\nIf you want to hide every displayed scratch you can set this to the string `\"*\"` instead of a list: `excludes = \"*\"`.\n\n## `unfocus` (optional)\n\nNo default value.\n\nWhen set to `\"hide\"`, allow to hide the window when the focus is lost.\n\nUse `hysteresis` to change the reactivity\n\n## `hysteresis` (optional)\n\n> _Added in 2.0.1_\n\nDefaults to `0.4` (seconds)\n\nControls how fast a scratchpad hiding on unfocus will react. Check `unfocus` option.\nSet to `0` to disable (immediate reaction, as in versions < 2.0.1)\n\n> [!important]\n> Only relevant when `unfocus=\"hide\"` is used.\n\n## `margin` (optional)\n\ndefault value is `60`.\n\nnumber of pixels separating the scratchpad from the screen border, depends on the [animation](./scratchpads#animation) set.\n\n> [!tip]\n> Since version 2.2.4 it is also possible to set a string to express percentages of the screen (eg: '`3%`').\n\n## `max_size` (optional)\n\nNo default value.\n\nSame format as `size` (see above), only used if `size` is also set.\n\nLimits the `size` of the window accordingly.\nTo ensure a window will not be too large on a wide screen for instance:\n\n```toml\nsize = \"60% 30%\"\nmax_size = \"1200px 100%\"\n```\n\n## `lazy` (optional)\n\ndefault to `false`.\n\nwhen set to `true`, prevents the command from being started when pypr starts, it will be started when the scratchpad is first used instead.\n\n- Good: saves resources when the scratchpad isn't needed\n- Bad: slows down the first display (app has to launch before showing)\n\n## `preserve_aspect` (optional)\n\n> _Added in 2.0.7_\n\nNot set by default.\nWhen set to `true`, will preserve the size and position of the scratchpad when called repeatedly from the same monitor and workspace even though an `animation` , `position` or `size` is used (those will be used for the initial setting only).\n\nForces the `lazy` option.\n\n## `offset` (optional)\n\nIn pixels, default to `0` (client's window size + margin).\n\nNumber of pixels for the **hide** sliding animation (how far the window will go).\n\n> [!tip]\n> - Since version 2.2.4 it is also possible to set a string to express percentages of the client window\n> - `margin` is automatically added to the offset\n> - automatic (value not set) is same as `\"100%\"`\n\n## `hide_delay` (optional)\n\n> _Added in 2.2.4_\n\nDefaults to `0.2`\n\nDelay (in seconds) after which the hide animation happens, before hiding the scratchpad.\n\nRule of thumb, if you have an animation with speed \"7\", as in:\n```bash\n    animation = windowsOut, 1, 7, easeInOut, popin 80%\n```\nYou can divide the value by two and round to the lowest value, here `3`, then divide by 10, leading to `hide_delay = 0.3`.\n\n## `restore_focus` (optional)\n\nEnabled by default, set to `false` if you don't want the focused state to be restored when a scratchpad is hidden.\n\n## `force_monitor` (optional)\n\n> _Added in 2.1.1_\n\nIf set to some monitor name (eg: `\"DP-1\"`), it will always use this monitor to show the scratchpad.\n\n## `alt_toggle` (optional)\n\n> _Added in 2.2.4_\n\nDefault value is `false`\n\nWhen enabled, use an alternative `toggle` command logic for multi-screen setups.\nIt applies when the `toggle` command is triggered and the toggled scratchpad is visible on a screen which is not the focused one.\n\nInstead of moving the scratchpad to the focused screen, it will hide the scratchpad.\n\n## `allow_special_workspaces` (optional)\n\n> _Added in 2.2.9_\n\nDefault value is `false` (can't be enabled when using *Hyprland* < 0.39 where this behavior can't be controlled and is disabled).\n\nWhen enabled, you can toggle a scratchpad over a special workspace.\nIt will always use the \"normal\" workspace otherwise.\n\n## `smart_focus` (optional)\n\n> _Added in 2.2.13_\n\nDefault value is `true`.\n\nWhen enabled, the focus will be restored in a best effort way as en attempt to improve the user experience.\nIf you face issues such as spontaneous workspace changes, you can disable this feature.\n\n"
  },
  {
    "path": "site/versions/2.3.5/scratchpads_nonstandard.md",
    "content": "# Troubleshooting scratchpads\n\n## `skip_windowrules` (optional)\n\n> _Added in 2.2.17_\n\nDefault value is `[]`\nAllows you to skip the window rules for a specific scratchpad.\nAvailable rules are:\n\n- \"aspect\" controlling size and position\n- \"float\" controlling the floating state\n- \"workspace\" which moves the window to its own workspace\n\nIf you are using an application which can spawn multiple windows and you can't see them, you can skip rules made to improve the initial display of the window.\n\n```toml\n[scratchpads.filemanager]\nanimation = \"fromBottom\"\ncommand = \"nemo\"\nclass = \"nemo\"\nsize = \"60% 60%\"\nskip_windowrules = [\"aspect\", \"workspace\"]\n```\n\n## `match_by` (optional)\n\n> _Added in 2.2.5_\n\nDefault value is `\"pid\"`\nWhen set to a sensitive client property value (eg: `class`, `initialClass`, `title`, `initialTitle`), will match the client window using the provided property instead of the PID of the process.\n\nThis property must be set accordingly, eg:\n\n```toml\nmatch_by = \"class\"\nclass = \"my-web-app\"\n```\n\nor\n\n```toml\nmatch_by = \"initialClass\"\ninitialClass = \"my-web-app\"\n```\n\nYou can add the \"re:\" prefix to use a regular expression, eg:\n\n```toml\nmatch_by = \"title\"\ntitle = \"re:.*some string.*\"\n```\n\n> [!note]\n> Some apps may open the graphical client window in a \"complicated\" way, to work around this, it is possible to disable the process PID matching algorithm and simply rely on window's class.\n>\n> The `match_by` attribute can be used to achieve this, eg. for emacsclient:\n> ```toml\n> [scratchpads.emacs]\n> command = \"/usr/local/bin/emacsStart.sh\"\n> class = \"Emacs\"\n> match_by = \"class\"\n> ```\n\n## `class_match` (DEPRECATED)\n\n> [!important]\n> Has been replaced by `match_by` in versions > 2.2.4\n\nWill set `match_by=\"class\"` if set to `true` - support will be dropped in the future.\n\n## `process_tracking` (optional)\n\nDefault value is `true`\n\nAllows disabling the process management. Use only if running a progressive web app (Chrome based apps) or similar.\n\nThis will automatically force `lazy = true` and set `match_by=\"class\"` if no `match_by` rule is provided, to help with the fuzzy client window matching.\n\nIt requires defining a `class` option (or the option matching your `match_by` value).\n\n```toml\n## Chat GPT on Brave\n[scratchpads.gpt]\nanimation = \"fromTop\"\ncommand = \"brave --profile-directory=Default --app=https://chat.openai.com\"\nclass = \"brave-chat.openai.com__-Default\"\nsize = \"75% 60%\"\nprocess_tracking = false\n\n## Some chrome app\n[scratchpads.music]\ncommand = \"google-chrome --profile-directory=Default --app-id=cinhimbnkkaeohfgghhklpknlkffjgod\"\nclass = \"chrome-cinhimbnkkaeohfgghhklpknlkffjgod-Default\"\nsize = \"50% 50%\"\nprocess_tracking = false\n```\n\n> [!tip]\n> To list windows by class and title you can use:\n> - `hyprctl -j clients | jq '.[]|[.class,.title]'`\n> - or if you prefer a graphical tool: `rofi -show window`\n\n> [!note]\n> Progressive web apps will share a single process for every window.\n> On top of requiring the class based window tracking (using `match_by`),\n> the process can not be managed the same way as usual apps and the correlation\n> between the process and the client window isn't as straightforward and can lead to false matches in extreme cases.\n"
  },
  {
    "path": "site/versions/2.3.5/shift_monitors.md",
    "content": "# shift_monitors\n\nSwaps the workspaces of every screen in the given direction.\n\n> [!Note]\n> the behavior can be hard to predict if you have more than 2 monitors (depending on your layout).\n> If you use this plugin with many monitors and have some ideas about a convenient configuration, you are welcome ;)\n\nExample usage in `hyprland.conf`:\n\n```\nbind = $mainMod, O, exec, pypr shift_monitors +1\nbind = $mainMod SHIFT, O, exec, pypr shift_monitors -1\n```\n\n## Command\n\n- `shift_monitors <direction>`: swaps every monitor's workspace in the given direction\n\n"
  },
  {
    "path": "site/versions/2.3.5/shortcuts_menu.md",
    "content": "# shortcuts_menu\n\nPresents some menu to run shortcut commands. Supports nested menus (aka categories / sub-menus).\n\n> _Added in version 1.9.0_\n\n<details>\n   <summary>Configuration example</summary>\n\n```toml\n[shortcuts_menu.entries]\n\n\"Open Jira ticket\" = 'open-jira-ticket \"$(wl-paste)\"'\nRelayout = \"pypr relayout\"\n\"Fetch window\" = \"pypr fetch_client_menu\"\n\"Hyprland socket\" = 'kitty  socat - \"UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock\"'\n\"Hyprland logs\" = 'kitty tail -f $XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/hyprland.log'\n\n\"Serial USB Term\" = [\n    {name=\"device\", command=\"ls -1 /dev/ttyUSB*; ls -1 /dev/ttyACM*\"},\n    {name=\"speed\", options=[\"115200\", \"9600\", \"38400\", \"115200\", \"256000\", \"512000\"]},\n    \"kitty miniterm --raw --eol LF [device] [speed]\"\n]\n\n\"Color picker\" = [\n    {name=\"format\", options=[\"hex\", \"rgb\", \"hsv\", \"hsl\", \"cmyk\"]},\n    \"sleep 0.2; hyprpicker --format [format] | wl-copy\" # sleep to let the menu close before the picker opens\n]\n```\n\n</details>\n\n\n## Command\n\n- `menu [name]`: shows a list of options which have been configured in \"entries\".\n\n  If \"name\" is provided it will show the given sub-menu.\n\n  - On versions >= 1.10.2 you can use \".\" to reach any level of the configured menus.\n      Example to reach `[shortcuts_menu.entries.utils.\"local commands\"]`:\n      ```sh\n       pypr menu \"utils.local commands\"\n      ```\n\n## Configuration\n\nAll the [Menu](./Menu) configuration items are also available.\n\n### `entries`\n\nDefines the menu entries. Supports [Variables](./Variables)\n\n```toml\n[shortcuts_menu.entries]\n\"entry 1\" = \"command to run\"\n\"entry 2\" = \"command to run\"\n```\nSubmenus can be defined too (there is no depth limit):\n\n```toml\n[shortcuts_menu.entries.\"My submenu\"]\n\"entry X\" = \"command\"\n\n[shortcuts_menu.entries.one.two.three.four.five]\nfoobar = \"ls\"\n```\n\n#### Advanced usage (since version 1.10)\n\nInstead of navigating a configured list of menu options and running a pre-defined command, you can collect various *variables* (either static list of options selected by the user, or generated from a shell command) and then run a command using those variables. Eg:\n\n```toml\n\"Play Video\" = [\n    {name=\"video_device\", command=\"ls -1 /dev/video*\"},\n    {name=\"player\",\n        options=[\"mpv\", \"guvcview\"]\n    },\n    \"[player] [video_device]\"\n]\n\n\"Ssh\" = [\n    {name=\"action\", options=[\"htop\", \"uptime\", \"sudo halt -p\"]},\n    {name=\"host\", options=[\"gamix\", \"gate\", \"idp\"]},\n    \"kitty --hold ssh [host] [action]\"\n]\n```\n\nYou must define a list of objects, containing:\n- `name`: the variable name\n- then the list of options, must one of:\n    - `options` for a static list of options\n    - `command` to get the list of options from a shell command's output\n\n> [!tip]\n> Since 2.0.2 you can apply post-filters to the `command` output, eg:\n> ```toml\n> {name=\"entry\", command=\"cliphist list\", filter=\"s/\\t.*//\"},\n> ```\n> check the [filters](filters) page for more details\n\nThe last item of the list must be a string which is the command to run. Variables can be used enclosed in `[]`.\n\n### `command_start` & `command_end` / `submenu_start` & `submenu_end`\n\n> _Added in 1.10.2_\n\nAllow adding some text (eg: icon) before / after a menu entry.\n\ncommand_* is for final commands, while submenu_* is for entries leading to another menu.\n\nBy default `submenu_end` is set to a right arrow sign, while other attributes are not set.\n\n### `skip_single` (optional)\n\n> _Added in version 2.0_\n\nDefaults to `true`.\nWhen disabled, shows the menu even for single options\n\n## Hints\n\n### Multiple menus\n\nTo manage multiple distinct menus, always use a name when using the `pypr menu <name>` command.\n\nExample of a multi-menu configuration:\n\n```toml\n[shortcuts_menu.entries.\"Basic commands\"]\n\"entry X\" = \"command\"\n\"entry Y\" = \"command2\"\n\n[shortcuts_menu.entries.menu2]\n## ...\n```\n\nYou can then show the first menu using `pypr menu \"Basic commands\"`\n"
  },
  {
    "path": "site/versions/2.3.5/sidebar.json",
    "content": "{\n  \"main\": [\n    {\n      \"text\": \"Getting started\",\n      \"link\": \"./Getting-started\"\n    },\n    {\n      \"text\": \"Plugins\",\n      \"link\": \"./Plugins\"\n    },\n    {\n      \"text\": \"Troubleshooting\",\n      \"link\": \"./Troubleshooting\"\n    },\n    {\n      \"text\": \"Development\",\n      \"link\": \"./Development\"\n    }\n  ],\n  \"plugins\": {\n    \"text\": \"Featured plugins\",\n    \"collapsed\": false,\n    \"items\": [\n      {\n        \"text\": \"Expose\",\n        \"link\": \"./expose\"\n      },\n      {\n        \"text\": \"Fetch client menu\",\n        \"link\": \"./fetch_client_menu\"\n      },\n      {\n        \"text\": \"Gbar\",\n        \"link\": \"./gbar\"\n      },\n      {\n        \"text\": \"Layout center\",\n        \"link\": \"./layout_center\"\n      },\n      {\n        \"text\": \"Lost windows\",\n        \"link\": \"./lost_windows\"\n      },\n      {\n        \"text\": \"Magnify\",\n        \"link\": \"./magnify\"\n      },\n      {\n        \"text\": \"Monitors\",\n        \"link\": \"./monitors\"\n      },\n      {\n        \"text\": \"Scratchpads\",\n        \"link\": \"./scratchpads\",\n        \"items\": [\n          {\n            \"text\": \"Advanced\",\n            \"link\": \"./scratchpads_advanced\"\n          },\n          {\n            \"text\": \"Special cases\",\n            \"link\": \"./scratchpads_nonstandard\"\n          }\n        ]\n      },\n      {\n        \"text\": \"Shift monitors\",\n        \"link\": \"./shift_monitors\"\n      },\n      {\n        \"text\": \"Shortcuts menu\",\n        \"link\": \"./shortcuts_menu\"\n      },\n      {\n        \"text\": \"System notifier\",\n        \"link\": \"./system_notifier\"\n      },\n      {\n        \"text\": \"Toggle dpms\",\n        \"link\": \"./toggle_dpms\"\n      },\n      {\n        \"text\": \"Toggle special\",\n        \"link\": \"./toggle_special\"\n      },\n      {\n        \"text\": \"Wallpapers\",\n        \"link\": \"./wallpapers\"\n      },\n      {\n        \"text\": \"Workspaces follow focus\",\n        \"link\": \"./workspaces_follow_focus\"\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "site/versions/2.3.5/system_notifier.md",
    "content": "# system_notifier\n\nThis plugin adds system notifications based on journal logs (or any program's output).\nIt monitors specified **sources** for log entries matching predefined **patterns** and generates notifications accordingly (after applying an optional **filter**).\n\nSources are commands that return a stream of text (eg: journal, mqtt, tail -f, ...) which is sent to a parser that will use a [regular expression pattern](https://en.wikipedia.org/wiki/Regular_expression) to detect lines of interest and optionally transform them before sending the notification.\n\n> _Added in version 2.2.0_\n\n<details>\n    <summary>Minimal configuration</summary>\n\n```toml\n[system_notifier.sources]\ncommand = \"sudo journalctl -fx\"\nparser = \"journal\"\n```\n\nIn general you will also need to define some **parsers**.\nBy default a **\"journal\"** parser is provided, otherwise you need to define your own rules.\nThis built-in configuration is close to this one, provided as an example:\n\n```toml\n[system_notifier.parsers.journal]\npattern = \"([a-z0-9]+): Link UP$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is active/\"\ncolor= \"#00aa00\"\n\n[system_notifier.parsers.journal]\npattern = \"([a-z0-9]+): Link DOWN$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is inactive/\"\ncolor= \"#ff8800\"\n\n[system_notifier.parsers.journal]\npattern = \"Process \\d+ \\(.*\\) of .* dumped core.\"\nfilter = \"s/.*Process \\d+ \\((.*)\\) of .* dumped core./\\1 dumped core/\"\ncolor= \"#aa0000\"\n\n[system_notifier.parsers.journal]\npattern = \"usb \\d+-[0-9.]+: Product: \"\nfilter = \"s/.*usb \\d+-[0-9.]+: Product: (.*)/USB plugged: \\1/\"\n```\n</details>\n\n\n## Configuration\n\n### `sources`\n\nList of sources to enable (by default nothing is enabled)\n\nEach source must contain a `command` to run and a `parser` to use.\n\nYou can also use a list of parsers, eg:\n\n```toml\n[system_notifier.sources](system_notifier.sources)\ncommand = \"sudo journalctl -fkn\"\nparser = [\"journal\", \"custom_parser\"]\n```\n\n#### command\n\nThis is the long-running command (eg: `tail -f <filename>`) returning the stream of text that will be updated. Aa common option is the system journal output (eg: `journalctl -u nginx`)\n\n#### parser\n\nSets the list of rules / parser to be used to extract lines of interest.\nMust match a list of rules defined as `system_notifier.parsers.<parser_name>`.\n\n### `parsers`\n\nA list of available parsers that can be used to detect lines of interest in the **sources** and re-format it before issuing a notification.\n\nEach parser definition must contain a **pattern** and optionally a **filter** and a **color**.\n\n#### pattern\n\n```toml\n[system_notifier.parsers.custom_parser](system_notifier.parsers.custom_parser)\n\npattern = 'special value:'\n```\n\nThe pattern is any regular expression.\n\n#### filter\n\nThe [filters](./filters) allows to change the text before the notification, eg:\n`filter=\"s/.*special value: (\\d+)/Value=\\1/\"`\nwill set a filter so a string \"special value: 42\" will lead to the notification \"Value=42\"\n\n#### color\n\nYou can also provide an optional **color** in `\"hex\"` or `\"rgb()\"` format\n\n```toml\ncolor = \"#FF5500\"\n```\n\n### default_color (optional)\n\nSets the notification color that will be used when none is provided in a *parser* definition.\n\n```toml\n[system_notifier]\ndefault_color = \"#bbccbb\"\n```\n"
  },
  {
    "path": "site/versions/2.3.5/toggle_dpms.md",
    "content": "# toggle_dpms\n\n## Command\n\n- `toggle_dpms`: if any screen is powered on, turn them all off, else turn them all on\n\n"
  },
  {
    "path": "site/versions/2.3.5/toggle_special.md",
    "content": "# toggle_special\n\nAllows moving the focused window to a special workspace and back (based on the visibility status of that workspace).\n\nIt's a companion of the `togglespecialworkspace` Hyprland's command which toggles a special workspace's visibility.\n\nYou most likely will need to configure the two commands for a complete user experience, eg:\n\n```bash\nbind = $mainMod SHIFT, N, togglespecialworkspace, stash # toggles \"stash\" special workspace visibility\nbind = $mainMod, N, exec, pypr toggle_special stash # moves window to/from the \"stash\" workspace\n```\n\nNo other configuration needed, here `MOD+SHIFT+N` will show every window in \"stash\" while `MOD+N` will move the focused window out of it/ to it.\n\n> _Added in version 1.8.0_\n\n## Commands\n\n- `toggle_special [name]`: moves the focused window to the special workspace `name`, or move it back to the active workspace.\n    If none set, \"minimized\" will be used.\n\n"
  },
  {
    "path": "site/versions/2.3.5/wallpapers.md",
    "content": "# wallpapers\n\nSearch folders for images and sets the background image at a regular interval.\nImages are selected randomly from the full list of images found.\n\nIt serves two purposes:\n\n- adding support for random images to any background setting tool\n- quickly testing different tools with a minimal effort\n\nIt allows \"zapping\" current backgrounds, clearing it to go distraction free and optionally make them different for each screen.\n\n> _Added in version 2.2.0, format changed in 2.2.5_\n\n<details>\n    <summary>Minimal example (requires <b>swaybg</b> by default)</summary>\n\n```toml\n[wallpapers]\npath = \"~/Images/wallpapers/\" # path to the folder with background images\nunique = true # set a different wallpaper for each screen\n```\n\n</details>\n\n<details>\n<summary>More complex, using <b>swww</b> as a backend (not recommended because of its stability)</summary>\n\n```toml\n[wallpapers]\npath = \"~/Images/wallpapers/\"\ninterval = 60 # change every hour\nextensions = [\"jpg\", \"jpeg\"]\nrecurse = true\n## Using swww\ncommand = 'swww img --transition-type any \"[file]\"'\nclear_command = \"swww clear\"\n```\n\nNote that for applications like `swww`, you'll need to start a daemon separately (eg: from `hyprland.conf`).\n</details>\n\n\n## Commands\n\n- `wall next`: Changes the current background image\n- `wall clear`: Removes the current background image\n\n## Configuration\n\n\n### `path`\n\npath to a folder or list of folders that will be searched. Can also be a list, eg:\n\n```toml\npath = [\"~/Images/Portraits/\", \"~/Images/Landscapes/\"]\n```\n\n### `interval` (optional)\n\ndefaults to `10`\n\nHow long (in minutes) a background should stay in place\n\n\n### `command` (optional)\n\nOverrides the default command to set the background image.\n\n[variables](./Variables) are replaced with the appropriate values, you must use a `\"[file]\"` placeholder for the image path. eg:\n\n```\nswaybg -m fill -i \"[file]\"\n```\n\n### `clear_command` (optional)\n\nBy default `clear` command kills the `command` program.\n\nInstead of that, you can provide a command to clear the background. eg:\n\n```\nclear_command = \"swaybg clear\"\n``````\n\n### `extensions` (optional)\n\ndefaults to `[\"png\", \"jpg\", \"jpeg\"]`\n\nList of valid wallpaper image extensions.\n\n### `recurse` (optional)\n\ndefaults to `false`\n\nWhen enabled, will also search sub-directories recursively.\n\n### `unique` (optional)\n\n> _Added in 2.2.5_\n\ndefaults to `false`\n\nWhen enabled, will set a different wallpaper for each screen.\n\nIf you are not using the default application, ensure you are using `\"[output]\"` in the [command](#command) template.\n\nExample for swaybg: `swaybg -o \"[output]\" -m fill -i \"[file]\"`\n"
  },
  {
    "path": "site/versions/2.3.5/workspaces_follow_focus.md",
    "content": "# workspaces_follow_focus\n\nMake non-visible workspaces follow the focused monitor.\nAlso provides commands to switch between workspaces while preserving the current monitor assignments:\n\nSyntax:\n```toml\n[workspaces_follow_focus]\nmax_workspaces = 4 # number of workspaces before cycling\n```\nExample usage in `hyprland.conf`:\n\n```ini\nbind = $mainMod, K, exec, pypr change_workspace +1\nbind = $mainMod, J, exec, pypr change_workspace -1\n ```\n\n## Command\n\n- `change_workspace` `<direction>`: changes the workspace of the focused monitor\n\n## Configuration\n\n### `max_workspaces`\n\nLimits the number of workspaces when switching, defaults value is `10`.\n"
  },
  {
    "path": "site/versions/2.3.6,7/Development.md",
    "content": "# Development\n\nIt's easy to write your own plugin by making a python package and then indicating it's name as the plugin name.\n\n[Contributing guidelines](https://github.com/hyprland-community/pyprland/blob/main/CONTRIBUTING.md)\n\n# Writing plugins\n\nPlugins can be loaded with full python module path, eg: `\"mymodule.pyprlandplugin\"`, the loaded module must provide an `Extension` class.\n\nCheck the `interface.py` file to know the base methods, also have a look at the example below.\n\nTo get more details when an error is occurring, use `pypr --debug <log file path>`, it will also display the log in the console.\n\n> [!note]\n> To quickly get started, you can directly edit the `experimental` built-in plugin.\n> In order to distribute it, make your own Python package or trigger a pull request.\n> If you prefer to make a separate package, check the [examples](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/)'s package\n\nThe `Extension` interface provides a couple of built-in attributes:\n\n- `config` : object exposing the plugin section in `pyprland.toml`\n- `notify` ,`notify_error`, `notify_info` : access to Hyprland's notification system\n- `hyprctl`, `hyprctl_json` : invoke [Hyprland's IPC system](https://wiki.hyprland.org/Configuring/Dispatchers/)\n\n\n> [!important]\n> Contact me to get your extension listed on the home page\n\n> [!tip]\n> You can set a `plugins_paths=[\"/custom/path/example\"]` in the `hyprland` section of the configuration to add extra paths (eg: during development).\n\n> [!Note]\n> If your extension is at the root of the plugin (this is not recommended, preferable add a name space, as in `johns_pyprland.super_feature`, rather than `super_feature`) you can still import it using the `external:` prefix when you refer to it in the `plugins` list.\n\n# API Documentation\n\nRun `tox run -e doc` then visit `http://localhost:8080`\n\nThe most important to know are:\n\n- `hyprctl_json` to get a response from an IPC query\n- `hyprctl` to trigger general IPC commands\n- `on_reload` to be implemented, called when the config is (re)loaded\n- `run_<command_name>` to implement a command\n- `event_<event_name>` called when the given event is emitted by Hyprland\n\nAll those methods are _async_\n\nOn top of that:\n\n- the first line of a `run_*` command's docstring will be used by the `help` command\n- `self.config` in your _Extension_ contains the entry corresponding to your plugin name in the TOML file\n- `state` from `..common` module contains ready to use information\n- there is a `MenuMixin` in `..adapters.menus` to make menu-based plugins easy\n\n# Workflow\n\nJust `^C` when you make a change and repeat:\n\n```sh\npypr exit ; pypr --debug /tmp/output.log\n```\n\n\n## Creating a plugin\n\n```python\nfrom .interface import Plugin\n\n\nclass Extension(Plugin):\n    \" My plugin \"\n\n    async def init(self):\n        await self.notify(\"My plugin loaded\")\n```\n\n## Adding a command\n\nJust add a method called `run_<name of your command>` to your `Extension` class, eg with \"togglezoom\" command:\n\n```python\n    zoomed = False\n\n    async def run_togglezoom(self, args):\n        \"\"\" this doc string will show in `help` to document `togglezoom`\n        But this line will not show in the CLI help\n        \"\"\"\n      if self.zoomed:\n        await self.hyprctl('misc:cursor_zoom_factor 1', 'keyword')\n      else:\n        await self.hyprctl('misc:cursor_zoom_factor 2', 'keyword')\n      self.zoomed = not self.zoomed\n```\n\n## Reacting to an event\n\nSimilar as a command, implement some `async def event_<the event you are interested in>` method.\n\n## Code safety\n\nPypr ensures only one `run_` or `event_` handler runs at a time, allowing the plugins code to stay simple and avoid the need for concurrency handling.\nHowever, each plugin can run its handlers in parallel.\n\n# Reusable code\n\n```py\nfrom ..common import state, CastBoolMixin\n```\n\n- `state` provides a couple of handy variables so you don't have to fetch them, allow optimizing the most common operations\n- `Mixins` are providing common code, for instance the `CastBoolMixin` provides the `cast_bool` method to your `Extension`.\n\nIf you want to use menus, then the `MenuMixin` will provide:\n- `menu` to show a menu\n- `ensure_menu_configured` to call before you require a menu in your plugin\n\n# Example\n\nYou'll find a basic external plugin in the [examples](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/) folder.\n\nIt provides one command: `pypr dummy`.\n\nRead the [plugin code](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/pypr_examples/focus_counter.py)\n\nIt's a simple python package. To install it for development without a need to re-install it for testing, you can use `pip install -e .` in this folder.\nIt's ready to be published using `poetry publish`, don't forget to update the details in the `pyproject.toml` file.\n\n## Usage\n\nEnsure you added `pypr_examples.focus_counter` to your `plugins` list:\n\n```toml\n[pyprland]\nplugins = [\n  \"pypr_examples.focus_counter\"\n]\n```\n\nOptionally you can customize one color:\n\n```toml\n[\"pypr_examples.focus_counter\"]\ncolor = \"FFFF00\"\n```\n"
  },
  {
    "path": "site/versions/2.3.6,7/Getting-started.md",
    "content": "# Getting started\n\nPypr consists in two things:\n\n- **a tool**: `pypr` which runs the daemon (service), but also allows to interact with it\n- **some config file**: `~/.config/hypr/pyprland.toml` (or the path set using `--config`) using the [TOML](https://toml.io/en/) format\n\nThe `pypr` tool only have a few built-in commands:\n\n- `help` lists available commands (including plugins commands)\n- `exit` will terminate the service process\n- `edit` edit the configuration using your `$EDITOR` (or `vi`), reloads on exit\n- `dumpjson` shows a JSON representation of the configuration (after other files have been `include`d)\n- `reload` reads the configuration file and apply some changes:\n  - new plugins will be loaded\n  - configuration items will be updated (most plugins will use the new values on the next usage)\n\nOther commands are implemented by adding [plugins](./Plugins).\n\n> [!important]\n> - with no argument it runs the daemon (doesn't fork in the background)\n>\n> - if you pass parameters, it will interact with the daemon instead.\n\n> [!tip]\n> Pypr *command* names are documented using underscores (`_`) but you can use dashes (`-`) instead.\n> Eg: `pypr shift_monitors` and `pypr shift-monitors` will run the same command\n\n\n## Configuration file\n\nThe configuration file uses a [TOML format](https://toml.io/) with the following as the bare minimum:\n\n```toml\n[pyprland]\nplugins = [\"plugin_name\", \"other_plugin\"]\n```\n\nAdditionally some plugins require **Configuration** options, using the following format:\n\n```toml\n[plugin_name]\nplugin_option = 42\n\n[plugin_name.another_plugin_option]\nsuboption = \"config value\"\n```\n\nYou can also split your configuration into [Multiple configuration files](./MultipleConfigurationFiles).\n\n## Installation\n\nCheck your OS package manager first, eg:\n\n- Archlinux: you can find it on AUR, eg with [yay](https://github.com/Jguer/yay): `yay pyprland`\n- NixOS: Instructions in the [Nix](./Nix) page\n\nOtherwise, use the python package manager *inside a virtual environment* (`python -m venv somefolder && source ./somefolder/bin/activate`):\n\n```sh\npip install pyprland\n```\n\n## Running\n\n> [!caution]\n> If you messed with something else than your OS packaging system to get `pypr` installed, use the full path to the `pypr` command.\n\nPreferably start the process with hyprland, adding to `hyprland.conf`:\n\n```ini\nexec-once = /usr/bin/pypr\n```\n\nor if you run into troubles (use the first version once your configuration is stable):\n\n```ini\nexec-once = /usr/bin/pypr --debug /tmp/pypr.log\n```\n\n> [!warning]\n> To avoid issues (eg: you have a complex setup, maybe using a virtual environment), you may want to set the full path (eg: `/home/bob/venv/bin/pypr`).\n> You can get it from `which pypr` in a working terminal\n\nOnce the `pypr` daemon is started (cf `exec-once`), you can list the eventual commands which have been added by the plugins using `pypr -h` or `pypr help`, those commands are generally meant to be use via key bindings, see the `hyprland.conf` part of *Configuring* section below.\n\n## Configuring\n\nCreate a configuration file in `~/.config/hypr/pyprland.toml` enabling a list of plugins, each plugin may have its own configuration needs or don't need any configuration at all.\nMost default values should be acceptable for most users, options which hare not mandatory are marked as such.\n\n> [!important]\n> Provide the values for the configuration options which have no annotation such as \"(optional)\"\n\nCheck the [TOML format](https://toml.io/) for details about the syntax.\n\nSimple example:\n\n```toml\n[pyprland]\nplugins = [\n    \"shift_monitors\",\n    \"workspaces_follow_focus\"\n]\n```\n\n<details>\n  <summary>\nMore complex example\n  </summary>\n\n```toml\n[pyprland]\nplugins = [\n  \"scratchpads\",\n  \"lost_windows\",\n  \"monitors\",\n  \"toggle_dpms\",\n  \"magnify\",\n  \"expose\",\n  \"shift_monitors\",\n  \"workspaces_follow_focus\",\n]\n\n[monitors.placement]\n\"Acer\".top_center_of = \"Sony\"\n\n[workspaces_follow_focus]\nmax_workspaces = 9\n\n[expose]\ninclude_special = false\n\n[scratchpads.stb]\nanimation = \"fromBottom\"\ncommand = \"kitty --class kitty-stb sstb\"\nclass = \"kitty-stb\"\nlazy = true\nsize = \"75% 45%\"\n\n[scratchpads.stb-logs]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-stb-logs stbLog\"\nclass = \"kitty-stb-logs\"\nlazy = true\nsize = \"75% 40%\"\n\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nlazy = true\nsize = \"40% 90%\"\nunfocus = \"hide\"\n```\n\nSome of those plugins may require changes in your `hyprland.conf` to fully operate or to provide a convenient access to a command, eg:\n\n```bash\nbind = $mainMod SHIFT, Z, exec, pypr zoom\nbind = $mainMod ALT, P,exec, pypr toggle_dpms\nbind = $mainMod SHIFT, O, exec, pypr shift_monitors +1\nbind = $mainMod, B, exec, pypr expose\nbind = $mainMod, K, exec, pypr change_workspace +1\nbind = $mainMod, J, exec, pypr change_workspace -1\nbind = $mainMod,L,exec, pypr toggle_dpms\nbind = $mainMod SHIFT,M,exec,pypr toggle stb stb-logs\nbind = $mainMod,A,exec,pypr toggle term\nbind = $mainMod,V,exec,pypr toggle volume\n```\n\n</details>\n\n> [!tip]\n> Consult or share [configuration files](https://github.com/hyprland-community/pyprland/tree/main/examples)\n>\n> You might also be interested in [optimizations](./Optimizations).\n"
  },
  {
    "path": "site/versions/2.3.6,7/Menu.md",
    "content": "# Menu capability\n\nMenu based plugins have the following configuration options:\n\n### `engine` (optional)\n\nNot set by default, will autodetect the available menu engine.\n\nSupported engines:\n\n- tofi\n- rofi\n- wofi\n- bemenu\n- dmenu\n- anyrun\n\n> [!note]\n> If your menu system isn't supported, you can open a [feature request](https://github.com/hyprland-community/pyprland/issues/new?assignees=fdev31&labels=bug&projects=&template=feature_request.md&title=%5BFEAT%5D+Description+of+the+feature)\n>\n> In case the engine isn't recognized, `engine` + `parameters` configuration options will be used to start the process, it requires a dmenu-like behavior.\n\n### `parameters` (optional)\n\nExtra parameters added to the engine command, the default value is specific to each engine.\n\n> [!important]\n> Setting this will override the default value!\n>\n> In general, *rofi*-like programs will require at least `-dmenu` option.\n\n> [!tip]\n> You can use '[prompt]' in the parameters, it will be replaced by the prompt, eg:\n> ```sh\n> -dmenu -matching fuzzy -i -p '[prompt]'\n> ```\n"
  },
  {
    "path": "site/versions/2.3.6,7/MultipleConfigurationFiles.md",
    "content": "### Multiple configuration files\n\nYou can also split your configuration into multiple files that will be loaded in the provided order after the main file:\n```toml\n[pyprland]\ninclude = [\"/shared/pyprland.toml\", \"~/pypr_extra_config.toml\"]\n```\nYou can also load folders, in which case TOML files in the folder will be loaded in alphabetical order:\n```toml\n[pyprland]\ninclude = [\"~/.config/pypr.d/\"]\n```\n\nAnd then add a `~/.config/pypr.d/monitors.toml` file:\n```toml\npyprland.plugins = [ \"monitors\" ]\n\n[monitors.placement]\nBenQ.Top_Center_Of = \"DP-1\" # projo\n\"CJFH277Q3HCB\".top_of = \"eDP-1\" # work\n```\n\n> [!tip]\n> To check the final merged configuration, you can use the `dumpjson` command.\n\n"
  },
  {
    "path": "site/versions/2.3.6,7/Nix.md",
    "content": "# Nix\n\nYou are recommended to get the latest pyprland package from the [flake.nix](https://github.com/hyprland-community/pyprland/blob/main/flake.nix)\nprovided within this repository. To use it in your system, you may add pyprland\nto your flake inputs.\n\n## Flake\n\n```nix\n## flake.nix\n{\n  inputs = {\n    nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n    pyprland.url = \"github:hyprland-community/pyprland\";\n  };\n\n  outputs = { self, nixpkgs, pyprland, ...}: let\n  in {\n    nixosConfigurations.<yourHostname> = nixpkgs.lib.nixosSystem {\n      #         remember to replace this with your system arch ↓\n      environment.systemPackages = [ pyprland.packages.\"x86_64-linux\".pyprland ];\n      # ...\n    };\n  };\n}\n```\n\nAlternatively, if you are using the Nix package manager but not NixOS as your\nmain distribution, you may use `nix profile` tool to install pyprland from this\nrepository using the following command.\n\n```bash\nnix profile install github:nix-community/pyprland\n```\n\nThe package will now be in your latest profile. You may use `nix profile list`\nto verify your installation.\n\n## Nixpkgs\n\nPyprland is available under nixpkgs, and can be installed by adding\n`pkgs.pyprland` to either `environment.systemPackages` or `home.packages`\ndepending on whether you want it available system-wide or to only a single\nuser using home-manager. If the derivation available in nixpkgs is out-of-date\nthen you may consider using `overrideAttrs` to update the source locally.\n\n```nix\nlet\n  pyprland = pkgs.pyprland.overrideAttrs {\n    version = \"your-version-here\";\n    src = fetchFromGitHub {\n      owner = \"hyprland-community\";\n      repo = \"pyprland\";\n      rev = \"tag-or-revision\";\n      # leave empty for the first time, add the new hash from the error message\n      hash = \"\";\n    };\n  };\nin {\n  # add the overridden package to systemPackages\n  environment.systemPackages = [pyprland];\n}\n```\n"
  },
  {
    "path": "site/versions/2.3.6,7/Optimizations.md",
    "content": "## Optimizing\n\n### Plugins\n\n- Only enable the plugins you are using in the `plugins` array (in `[pyprland]` section).\n- Leaving the configuration for plugins which are not enabled will have no impact.\n- Using multiple configuration files only have a small impact on the startup time.\n\n### Pypr command\n\nIn case you want to save some time when interacting with the daemon\nyou can use `socat` instead (needs to be installed). Example of a `pypr-cli` command (should be reachable from your environment's `PATH`):\n```sh\n#!/bin/sh\nsocat - \"UNIX-CONNECT:${XDG_RUNTIME_DIR}/hypr/${HYPRLAND_INSTANCE_SIGNATURE}/.pyprland.sock\" <<< $@\n```\nOn slow systems this may make a difference.\nNote that the \"help\" command will require usage of the standard `pypr` command.\n"
  },
  {
    "path": "site/versions/2.3.6,7/Plugins.md",
    "content": "<script setup>\nimport PluginList from './components/PluginList.vue'\n</script>\n\nThis page lists every plugin provided by Pyprland out of the box, more can be enabled if you install the matching package.\n\n\"🌟\" indicates some maturity & reliability level of the plugin, considering age, attention paid and complexity - from 0 to 3.\n\n<PluginList/>\n"
  },
  {
    "path": "site/versions/2.3.6,7/Troubleshooting.md",
    "content": "# Troubleshooting\n\n## General\n\nIn case of trouble running a `pypr` command:\n- kill the existing pypr if any (try `pypr exit` first)\n- run from the terminal adding `--debug /dev/null` to the arguments to get more information\n\nIf the client says it can't connect, then there is a high chance pypr daemon didn't start, check if it's running using `ps axuw |grep pypr`. You can try to run it from a terminal with the same technique: `pypr --debug /dev/null` and see if any error occurs.\n\n## Force hyprland version\n\nIn case your `hyprctl version -j` command isn't returning an accurate version, you can make Pyprland ignore it and use a provided value instead:\n\n```toml\n[pyprland]\nhyprland_version = \"0.41.0\"\n```\n\n## Unresponsive scratchpads\n\nScratchpads aren't responding for few seconds after trying to show one (which didn't show!)\n\nThis may happen if an application is very slow to start.\nIn that case pypr will wait for a window blocking other scratchpad's operation, before giving up after a few seconds.\n\nNote that other plugins shouldn't be blocked by it.\n\nMore scratchpads troubleshooting can be found [here](./scratchpads_nonstandard).\n"
  },
  {
    "path": "site/versions/2.3.6,7/Variables.md",
    "content": "# Variables & substitutions\n\nSome commands support shared global variables, they must be defined in the *pyprland* section of the configuration:\n```toml\n[pyprland.variables]\nterm = \"foot\"\nterm_classed = \"foot -a\" # kitty uses --class\n```\n\nIf a plugin supports it, you can then use the variables in the attribute that supports it, eg:\n\n```toml\n[myplugin]\nsome_variable = \"the terminal is [term]\"\n```\n"
  },
  {
    "path": "site/versions/2.3.6,7/components/CommandList.vue",
    "content": "<template>\n    <dl>\n    </dl>\n    <ul v-for=\"command in commands\" :key=\"command.name\">\n        <li><code v-html=\"command.name.replace(/[   ]*$/, '').replace(/ /g, '&ensp;')\" /> <span\n                v-html=\"command.description\" /></li>\n    </ul>\n</template>\n\n<script>\nexport default {\n    props: {\n        commands: {\n            type: Array,\n            required: true\n        }\n    }\n}\n</script>\n"
  },
  {
    "path": "site/versions/2.3.6,7/components/PluginList.vue",
    "content": "<template>\n    <div>\n        <h1>Built-in plugins</h1>\n        <div v-for=\"plugin in sortedPlugins\" :key=\"plugin.name\" class=\"plugin-item\">\n            <div class=\"plugin-info\">\n                <h3>\n                    <a :href=\"plugin.name + '.html'\">{{ plugin.name }}</a>\n                    <span>&nbsp;{{ '🌟'.repeat(plugin.stars) }}</span>\n                    <span v-if=\"plugin.multimon\">\n                        <Badge type=\"tip\" text=\"multi-monitor\" />\n                    </span>\n                </h3>\n                <p v-html=\"plugin.description\" />\n            </div>\n            <a v-if=\"plugin.demoVideoId\" :href=\"'https://www.youtube.com/watch?v=' + plugin.demoVideoId\"\n                class=\"plugin-video\">\n                <img :src=\"'https://img.youtube.com/vi/' + plugin.demoVideoId + '/1.jpg'\" alt=\"Demo video thumbnail\" />\n            </a>\n        </div>\n    </div>\n</template>\n\n<style scoped>\n.plugin-item {\n    display: flex;\n    align-items: flex-start;\n    margin-bottom: 1.5rem;\n}\n\n.plugin-info {\n    flex: 1;\n}\n\n.plugin-video {\n    margin-left: 1rem;\n}\n\n.plugin-video img {\n    max-width: 200px;\n    /* Adjust as needed */\n    height: auto;\n}\n</style>\n\n\n<script>\n\nexport default {\n    computed: {\n        sortedPlugins() {\n            if (this.sortByStars) {\n                return this.plugins.slice().sort((a, b) => {\n                    if (b.stars === a.stars) {\n                        return a.name.localeCompare(b.name);\n                    }\n                    return b.stars - a.stars;\n                });\n            } else {\n                return this.plugins.slice().sort((a, b) => a.name.localeCompare(b.name));\n            }\n        }\n    },\n    data() {\n        return {\n            sortByStars: false,\n            plugins: [\n                {\n                    name: 'scratchpads',\n                    stars: 3,\n                    description: 'makes your applications dropdowns & togglable poppups',\n                    demoVideoId: 'ZOhv59VYqkc'\n                },\n                {\n                    name: 'magnify',\n                    stars: 3,\n                    description: 'toggles zooming of viewport or sets a specific scaling factor',\n                    demoVideoId: 'yN-mhh9aDuo'\n\n                },\n                {\n                    name: 'toggle_special',\n                    stars: 3,\n                    description: 'moves windows from/to special workspaces',\n                    demoVideoId: 'BNZCMqkwTOo'\n                },\n                {\n                    name: 'shortcuts_menu',\n                    stars: 3,\n                    description: 'a flexible way to make your own shortcuts menus & launchers',\n                    demoVideoId: 'UCuS417BZK8'\n                },\n                {\n                    name: 'fetch_client_menu',\n                    stars: 3,\n                    description: 'select a window to be moved to your active workspace (using rofi, dmenu, etc...)',\n                },\n                {\n                    name: 'layout_center',\n                    stars: 3,\n                    description: 'a workspace layout where one client window is almost maximized and others seat in the background',\n                    demoVideoId: 'vEr9eeSJYDc'\n                },\n                {\n                    name: 'system_notifier',\n                    stars: 3,\n                    description: 'open streams (eg: journal logs) and trigger notifications',\n                },\n                {\n                    name: 'wallpapers',\n                    stars: 3,\n                    description: 'handles random wallpapers at regular interval (from a folder)'\n                },\n                {\n                    name: 'gbar',\n                    stars: 3,\n                    description: 'improves multi-monitor handling of the status bar - only <tt>gBar</tt> is supported at the moment'\n                },\n                {\n                    name: 'expose',\n                    stars: 2,\n                    description: 'exposes all the windows for a quick \"jump to\" feature',\n                    demoVideoId: 'ce5HQZ3na8M'\n                },\n                {\n                    name: 'lost_windows',\n                    stars: 1,\n                    description: 'brings lost floating windows (which are out of reach) to the current workspace'\n                },\n                {\n                    name: 'toggle_dpms',\n                    stars: 0,\n                    description: 'toggles the DPMS status of every plugged monitor'\n                },\n                {\n                    name: 'workspaces_follow_focus',\n                    multimon: true,\n                    stars: 3,\n                    description: 'makes non visible workspaces available on the currently focused screen.<br/> If you think the multi-screen behavior of Hyprland is not usable or broken/unexpected, this is probably for you.'\n\n                },\n                {\n                    name: 'shift_monitors',\n                    multimon: true,\n                    stars: 3,\n                    description: 'moves workspaces from monitor to monitor (caroussel)'\n                },\n                {\n                    name: 'monitors',\n                    stars: 3,\n                    description: 'allows relative placement and configuration of monitors'\n                }\n            ]\n        }\n    }\n}\n</script>\n"
  },
  {
    "path": "site/versions/2.3.6,7/expose.md",
    "content": "---\ncommands:\n  - name: expose\n    description: Expose every client on the active workspace. If expose is already active, then restores everything and move to the focused window.\n---\n\n# expose\n\nImplements the \"expose\" effect, showing every client window on the focused screen.\n\nFor a similar feature using a menu, try the [fetch_client_menu](./fetch_client_menu) plugin (less intrusive).\n\nSample `hyprland.conf`:\n\n```bash\n# Setup the key binding\nbind = $mainMod, B, exec, pypr expose\n\n# Add some style to the \"exposed\" workspace\nworkspace = special:exposed,gapsout:60,gapsin:30,bordersize:5,border:true,shadow:false\n```\n\n`MOD+B` will bring every client to the focused workspace, pressed again it will go to this workspace.\n\nCheck [workspace rules](https://wiki.hyprland.org/Configuring/Workspace-Rules/#rules) for styling options.\n\n> [!note]\n> If you are looking for `toggle_minimized`, check the [toggle_special](./toggle_special) plugin\n\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n\n### `include_special` (optional)\n\ndefault value is `false`\n\nAlso include windows in the special workspaces during the expose.\n\n"
  },
  {
    "path": "site/versions/2.3.6,7/fetch_client_menu.md",
    "content": "---\ncommands:\n  - name: fetch_client_menu\n    description: Bring any window to the active workspace using a menu.\n---\n\n# fetch_client_menu\n\nBring any window to the active workspace using a menu.\n\nA bit like the [expose](./expose) plugin but using a menu instead (less intrusive).\n\nIt brings the window to the current workspace, while [expose](./expose) moves the currently focused screen to the application workspace.\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\nAll the [Menu](Menu) configuration items are also available.\n\n### `separator` (optional)\n\ndefault value is `\"|\"`\n\nChanges the character (or string) used to separate a menu entry from its entry number.\n\n"
  },
  {
    "path": "site/versions/2.3.6,7/filters.md",
    "content": "# Text filters\n\nAt the moment there is only one filter, close match of [sed's \"s\" command](https://www.gnu.org/software/sed/manual/html_node/The-_0022s_0022-Command.html).\n\nThe only supported flag is \"g\"\n\n## Example\n\n```toml\nfilter = 's/foo/bar/'\nfilter = 's/.*started (.*)/\\1 has started/'\nfilter = 's#</?div>##g'\n```\n"
  },
  {
    "path": "site/versions/2.3.6,7/gbar.md",
    "content": "---\ncommands:\n  - name: gbar restart\n    description: Restart/refresh gBar on the \"best\" monitor.\n---\n\n# gbar\n\nRuns [gBar](https://github.com/scorpion-26/gBar) on the \"best\" monitor from a list of monitors.\n\nWill take care of starting gbar on startup (you must not run it from another source like `hyprland.conf`).\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n\n## Configuration\n\n\n### `monitors`\n\nList of monitors to chose from, the first have higher priority over the second one etc...\n\n\n## Example\n\n```sh\n[gbar]\nmonitors = [\"DP-1\", \"HDMI-1\", \"HDMI-1-A\"]\n```\n"
  },
  {
    "path": "site/versions/2.3.6,7/index.md",
    "content": "---\n# https://vitepress.dev/reference/default-theme-home-page\nlayout: home\n\nhero:\n  text: \"Hyprland extensions\"\n  tagline: Enhance your desktop capabilities with Pyprland\n  image:\n    src: /logo.png\n    alt: logo\n  actions:\n    - theme: brand\n      text: Getting started\n      link: ./Getting-started\n    - theme: alt\n      text: Plugins\n      link: ./Plugins\n    - theme: alt\n      text: Report something\n      link: https://github.com/hyprland-community/pyprland/issues/new/choose\n\nfeatures:\n  - title: TOML format\n    details: Simple but powerful configuration\n  - title: Customizable\n    details: Create your own Hyprland experience\n  - title: Fast and easy\n    details: Designed for performance and ease of use\n---\n\n## Version 2.3.6 archive\n"
  },
  {
    "path": "site/versions/2.3.6,7/layout_center.md",
    "content": "---\ncommands:\n  - name: layout_center toggle\n    description: toggles the layout on and off\n  - name: layout_center next\n    description: switches to the next window (if layout is on) else runs the `next` command\n  - name: layout_center prev\n    description: switches to the previous window (if layout is on) else runs the `prev` command\n  - name: layout_center next2\n    description: switches to the next window (if layout is on) else runs the `next2` command\n  - name: layout_center prev2\n    description: switches to the previous window (if layout is on) else runs the `prev2` command\n\n---\n# layout_center\n\nImplements a workspace layout where one window is bigger and centered,\nother windows are tiled as usual in the background.\n\nOn `toggle`, the active window is made floating and centered if the layout wasn't enabled, else reverts the floating status.\n\nWith `next` and `prev` you can cycle the active window, keeping the same layout type.\nIf the layout_center isn't active and `next` or `prev` is used, it will call the \"next\" and \"prev\" configuration options.\n\nTo allow full override of the focus keys, `next2` and `prev2` are provided, they do the same actions as \"next\" and \"prev\" but allow different fallback commands.\n\n<details>\n<summary>Configuration sample</summary>\n\n```toml\n[layout_center]\nmargin = 60\noffset = [0, 30]\nnext = \"movefocus r\"\nprev = \"movefocus l\"\nnext2 = \"movefocus d\"\nprev2 = \"movefocus u\"\n```\n\nusing the following in `hyprland.conf`:\n```sh\nbind = $mainMod, M, exec, pypr layout_center toggle # toggle the layout\n## focus change keys\nbind = $mainMod, left, exec, pypr layout_center prev\nbind = $mainMod, right, exec, pypr layout_center next\nbind = $mainMod, up, exec, pypr layout_center prev2\nbind = $mainMod, down, exec, pypr layout_center next2\n```\n\nYou can completely ignore `next2` and `prev2` if you are allowing focus change (when the layout is enabled) in a single direction, eg:\n\n```sh\nbind = $mainMod, up, movefocus, u\nbind = $mainMod, down, movefocus, d\n```\n\n</details>\n\n\n## Commands\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `on_new_client` (optional)\n\nDefaults to `\"foreground\"`.\n\nChanges the behavior when a new window opens, possible options are:\n\n- \"foreground\" to make the new window the main window\n- \"background\" to make the new window appear in the background\n- \"close\" to stop the centered layout when a new window opens\n\n### `style` (optional)\n\n| Requires Hyprland > 0.40.0\n\nNot set by default.\n\nAllow to set a list of styles to the main (centered) window, eg:\n\n```toml\nstyle = [\"opacity 1\", \"bordercolor rgb(FFFF00)\"]\n```\n\n### `margin` (optional)\n\ndefault value is `60`\n\nmargin (in pixels) used when placing the center window, calculated from the border of the screen.\n\nExample to make the main window be 100px far from the monitor's limits:\n```toml\nmargin = 100\n```\n\nYou can also set a different margin for width and height by using a list:\n```toml\nmargin = [10, 60]\n```\n\n### `offset` (optional)\n\ndefault value is `[0, 0]`\n\noffset in pixels applied to the main window position\n\nExample shift the main window 20px down:\n```toml\noffset = [0, 20]\n```\n\n### `next` (optional)\n\nnot set by default\n\nWhen the *layout_center* isn't active and the *next* command is triggered, defines the hyprland dispatcher command to run.\n\n`next2` is a similar option, used by the `next2` command, allowing to map \"next\" to both vertical and horizontal focus change.\n\nEg:\n```toml\nnext = \"movefocus r\"\n```\n\n### `prev` and `prev2` (optional)\n\nSame as `next` but for the `prev` and `prev2` commands.\n\n\n### `captive_focus` (optional)\n\ndefault value is `false`\n\n```toml\ncaptive_focus = true\n```\n\nSets the focus on the main window when the focus changes.\nYou may love it or hate it...\n"
  },
  {
    "path": "site/versions/2.3.6,7/lost_windows.md",
    "content": "---\ncommands:\n    - name: attract_lost\n      description: Bring windows which are not reachable in the currently focused workspace.\n---\n# lost_windows\n\nBring windows which are not reachable in the currently focused workspace.\n*Deprecated:* Was used to work around issues in Hyprland which have been fixed since then.\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n"
  },
  {
    "path": "site/versions/2.3.6,7/magnify.md",
    "content": "---\ncommands:\n    - name: zoom [value]\n      description: Set the current zoom level (absolute or relative) - toggle zooming if no value is provided\n---\n# magnify\n\nZooms in and out with an optional animation.\n\n\n<details>\n    <summary>Example</summary>\n\n```sh\npypr zoom  # sets zoom to `factor` (2 by default)\npypr zoom +1  # will set zoom to 3x\npypr zoom  # will set zoom to 1x\npypr zoom 1 # will (also) set zoom to 1x - effectively doing nothing\n```\n\nSample `hyprland.conf`:\n\n```sh\nbind = $mainMod , Z, exec, pypr zoom ++0.5\nbind = $mainMod SHIFT, Z, exec, pypr zoom\n```\n\n</details>\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n### `[value]`\n\n#### unset / not specified\n\nWill zoom to [factor](#factor) if not zoomed, else will set the zoom to 1x.\n\n#### floating or integer value\n\nWill set the zoom to the provided value.\n\n#### +value / -value\n\nUpdate (increment or decrement) the current zoom level by the provided value.\n\n#### ++value / --value\n\nUpdate (increment or decrement) the current zoom level by a non-linear scale.\nIt _looks_ more linear changes than using a single + or -.\n\n> [!NOTE]\n>\n> The non-linear scale is calculated as powers of two, eg:\n>\n> - `zoom ++1` → 2x, 4x, 8x, 16x...\n> - `zoom ++0.7` → 1.6x, 2.6x, 4.3x, 7.0x, 11.3x, 18.4x...\n> - `zoom ++0.5` → 1.4x, 2x, 2.8x, 4x, 5.7x, 8x, 11.3x, 16x...\n\n## Configuration\n\n### `factor` (optional)\n\ndefault value is `2`\n\nScaling factor to be used when no value is provided.\n\n### `duration` (optional)\n\nDefault value is `15`\n\nDuration in tenths of a second for the zoom animation to last, set to `0` to disable animations.\n"
  },
  {
    "path": "site/versions/2.3.6,7/monitors.md",
    "content": "---\ncommands:\n  - name: relayout\n    description: Apply the configuration and update the layout\n---\n\n# monitors\n\n> First, simpler version of the plugin is still available under the name `monitors_v0`\n\nAllows relative placement of monitors depending on the model (\"description\" returned by `hyprctl monitors`).\nUseful if you have multiple monitors connected to a video signal switch or using a laptop and plugging monitors having different relative positions.\n\nSyntax:\n\n```toml\n[monitors.placement]\n\"description match\".placement = \"other description match\"\n```\n\n<details>\n    <summary>Example to set a Sony monitor on top of a BenQ monitor</summary>\n\n```toml\n[monitors.placement]\nSony.topOf = \"BenQ\"\n\n## Character case is ignored, \"_\" can be added\nSony.Top_Of = [\"BenQ\"]\n\n## Thanks to TOML format, complex configurations can use separate \"sections\" for clarity, eg:\n\n[monitors.placement.\"My monitor brand\"]\n## You can also use \"port\" names such as *HDMI-A-1*, *DP-1*, etc...\nleftOf = \"eDP-1\"\n\n## lists are possible on the right part of the assignment:\nrightOf = [\"Sony\", \"BenQ\"]\n\n## > 2.3.2: you can also set scale, transform & rate for a given monitor\n[monitors.placement.Microstep]\nrate = 100\n```\n\nTry to keep the rules as simple as possible, but relatively complex scenarios are supported.\n\n> [!note]\n> Check [wlr layout UI](https://github.com/fdev31/wlr-layout-ui) which is a nice complement to configure your monitor settings.\n\n</details>\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `placement`\n\nSupported placements are:\n\n- leftOf\n- topOf\n- rightOf\n- bottomOf\n- \\<one of the above>(center|middle|end)Of\n\n> [!important]\n> If you don't like the screen to align on the start of the given border,\n> you can use `center` (or `middle`) to center it or `end` to stick it to the opposite border.\n> Eg: \"topCenterOf\", \"leftEndOf\", etc...\n\nYou can separate the terms with \"_\" to improve the readability, as in \"top_center_of\".\n\n#### monitor settings\n\nNot only can you place monitors relatively to each other, but you can also set specific settings for a given monitor.\n\nThe following settings are supported:\n\n- scale\n- transform\n- rate\n- resolution\n\n```toml\n[monitors.placement.\"My monitor brand\"]\nrate = 60\nscale = 1.5\ntransform = 1 # 0: normal, 1: 90°, 2: 180°, 3: 270°, 4: flipped, 5: flipped 90°, 6: flipped 180°, 7: flipped 270°\nresolution = \"1920x1080\"  # can also be expressed as [1920, 1080]\n```\n\n### `startup_relayout` (optional)\n\nDefault to `ŧrue`.\n\nWhen set to `false`,\ndo not initialize the monitor layout on startup or when configuration is reloaded.\n\n### `full_relayout` (legacy - always enabled now)\n\n### `new_monitor_delay` (optional)\n\nBy default,\nthe layout computation happens one second after the event is received to let time for things to settle.\n\nYou can change this value using this option.\n\n### `hotplug_command` (optional)\n\nNone by default, allows to run a command when any monitor is plugged.\n\n\n```toml\n[monitors]\nhotplug_commands = \"wlrlui -m\"\n```\n\n### `hotplug_commands` (optional)\n\nNone by default, allows to run a command when a given monitor is plugged.\n\nExample to load a specific profile using [wlr layout ui](https://github.com/fdev31/wlr-layout-ui):\n\n```toml\n[monitors.hotplug_commands]\n\"DELL P2417H CJFH277Q3HCB\" = \"wlrlui rotated\"\n```\n\n### `unknown` (optional)\n\nNone by default,\nallows to run a command when no monitor layout has been changed (no rule applied).\n\n```toml\n[monitors]\nunknown = \"wlrlui\"\n```\n\n### `trim_offset` (optional)\n\n`true` by default,\n\nPrevents having arbitrary window offsets or negative values.\n"
  },
  {
    "path": "site/versions/2.3.6,7/scratchpads.md",
    "content": "---\ncommands:\n  - name: toggle [scratchpad name]\n    description: Toggle the given scratchpad\n  - name: show [scratchpad name]\n    description: Show the given scratchpad\n  - name: hide [scratchpad name]\n    description: Hide the given scratchpad\n  - name: attach\n    description: Toggle attaching/anchoring the currently focused window to the (last used) scratchpad\n---\n# scratchpads\n\nEasily toggle the visibility of applications you use the most.\n\nConfigurable and flexible, while supporting complex setups it's easy to get started with:\n\n```toml\n[scratchpads.name]\ncommand = \"command to run\"\nclass = \"the window's class\"  # check: hyprctl clients | grep class\nsize = \"<width> <height>\"  # size of the window relative to the screen size\n```\n\n<details>\n<summary>Example</summary>\n\nAs an example, defining two scratchpads:\n\n- _term_ which would be a kitty terminal on upper part of the screen\n- _volume_ which would be a pavucontrol window on the right part of the screen\n\n\n```toml\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\nmax_size = \"1920px 100%\"\nmargin = 50\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nsize = \"40% 90%\"\nunfocus = \"hide\"\nlazy = true\n```\n\nShortcuts are generally needed:\n\n```ini\nbind = $mainMod,V,exec,pypr toggle volume\nbind = $mainMod,A,exec,pypr toggle term\nbind = $mainMod,Y,exec,pypr attach\n```\n\nNote that when `class` is provided, the window is automatically managed by pyprland.\nWhen you create a scratchpad called \"name\", it will be hidden in `special:scratch_<name>`.\n\n> [!note]\n> If you wish to have a more generic space for any application you may run, check [toggle_special](./toggle_special).\n\n</details>\n\n## Commands\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n> [!tip]\n> You can use `\"*\"` as a _scratchpad name_ to target every scratchpad when using `show` or `hide`.\n> You'll need to quote or escape the `*` character to avoid interpretation from your shell.\n\n## Configuration\n\n### `command`\n\nThis is the command you wish to run in the scratchpad.\n\nIt supports [Variables](./Variables)\n\n### `animation` (optional)\n\nType of animation to use, default value is \"fromTop\":\n\n- `null` / `\"\"` (no animation)\n- `fromTop` (stays close to top screen border)\n- `fromBottom` (stays close to bottom screen border)\n- `fromLeft` (stays close to left screen border)\n- `fromRight` (stays close to right screen border)\n\n### `size` (recommended)\n\nNo default value.\n\nEach time scratchpad is shown, window will be resized according to the provided values.\n\nFor example on monitor of size `800x600` and `size= \"80% 80%\"` in config scratchpad always have size `640x480`,\nregardless of which monitor it was first launched on.\n\n> #### Format\n>\n> String with \"x y\" (or \"width height\") values using some units suffix:\n>\n> - **percents** relative to the focused screen size (`%` suffix), eg: `60% 30%`\n> - **pixels** for absolute values (`px` suffix), eg: `800px 600px`\n> - a mix is possible, eg: `800px 40%`\n\n### `class` (recommended)\n\nNo default value.\n\nAllows _Pyprland_ prepare the window for a correct animation and initial positioning.\n\n> [!important]\n> This will set some rules to every matching class!\n\n### `position` (optional)\n\nNo default value, overrides the automatic margin-based position.\n\nSets the scratchpad client window position relative to the top-left corner.\n\nSame format as `size` (see above)\n\nExample of scratchpad that always seat on the top-right corner of the screen:\n\n```toml\n[scratchpads.term_quake]\ncommand = \"wezterm start --class term_quake\"\nposition = \"50% 0%\"\nsize = \"50% 50%\"\nclass = \"term_quake\"\n```\n\n> [!note]\n> If `position` is not provided, the window is placed according to `margin` on one axis and centered on the other.\n\n### `multi` (optional)\n\nDefaults to `true`.\n\nWhen set to `false`, only one client window is supported for this scratchpad.\nOtherwise other matching windows will be **attach**ed to the scratchpad.\n\n## Advanced configuration\n\nTo go beyond the basic setup and have a look at every configuration item, you can read the following pages:\n\n- [Advanced](./scratchpads_advanced) contains options for fine-tuners or specific tastes (eg: i3 compatibility)\n- [Non-Standard](./scratchpads_nonstandard) contains options for \"broken\" applications\nlike progressive web apps (PWA) or emacsclient, use only if you can't get it to work otherwise\n\n## Monitor specific overrides\n\nYou can use different settings for a specific screen.\nMost attributes related to the display can be changed (not `command`, `class` or `process_tracking` for instance).\n\nUse the `monitor.<monitor name>` configuration item to override values, eg:\n\n```toml\n[scratchpads.music.monitor.eDP-1]\nposition = \"30% 50%\"\nanimation = \"fromBottom\"\n```\n\nYou may want to inline it for simple cases:\n\n```toml\n[scratchpads.music]\nmonitor = {HDMI-A-1={size = \"30% 50%\"}}\n```\n"
  },
  {
    "path": "site/versions/2.3.6,7/scratchpads_advanced.md",
    "content": "# Fine tuning scratchpads\n\nAdvanced configuration options\n\n## `excludes` (optional)\n\nNo default value.\n\nList of scratchpads to hide when this one is displayed, eg: `excludes = [\"term\", \"volume\"]`.\nIf you want to hide every displayed scratch you can set this to the string `\"*\"` instead of a list: `excludes = \"*\"`.\n\n## `unfocus` (optional)\n\nNo default value.\n\nWhen set to `\"hide\"`, allow to hide the window when the focus is lost.\n\nUse `hysteresis` to change the reactivity\n\n## `hysteresis` (optional)\n\nDefaults to `0.4` (seconds)\n\nControls how fast a scratchpad hiding on unfocus will react. Check `unfocus` option.\nSet to `0` to disable.\n\n> [!important]\n> Only relevant when `unfocus=\"hide\"` is used.\n\n## `margin` (optional)\n\ndefault value is `60`.\n\nnumber of pixels separating the scratchpad from the screen border, depends on the [animation](./scratchpads#animation) set.\n\n> [!tip]\n> It is also possible to set a string to express percentages of the screen (eg: '`3%`').\n\n## `max_size` (optional)\n\nNo default value.\n\nSame format as `size` (see above), only used if `size` is also set.\n\nLimits the `size` of the window accordingly.\nTo ensure a window will not be too large on a wide screen for instance:\n\n```toml\nsize = \"60% 30%\"\nmax_size = \"1200px 100%\"\n```\n\n## `lazy` (optional)\n\ndefault to `false`.\n\nwhen set to `true`, prevents the command from being started when pypr starts, it will be started when the scratchpad is first used instead.\n\n- Good: saves resources when the scratchpad isn't needed\n- Bad: slows down the first display (app has to launch before showing)\n\n## `preserve_aspect` (optional)\n\nNot set by default.\nWhen set to `true`, will preserve the size and position of the scratchpad when called repeatedly from the same monitor and workspace even though an `animation` , `position` or `size` is used (those will be used for the initial setting only).\n\nForces the `lazy` option.\n\n## `offset` (optional)\n\nIn pixels, default to `0` (client's window size + margin).\n\nNumber of pixels for the **hide** sliding animation (how far the window will go).\n\n> [!tip]\n> - It is also possible to set a string to express percentages of the client window\n> - `margin` is automatically added to the offset\n> - automatic (value not set) is same as `\"100%\"`\n\n## `hide_delay` (optional)\n\nDefaults to `0.2`\n\nDelay (in seconds) after which the hide animation happens, before hiding the scratchpad.\n\nRule of thumb, if you have an animation with speed \"7\", as in:\n```bash\n    animation = windowsOut, 1, 7, easeInOut, popin 80%\n```\nYou can divide the value by two and round to the lowest value, here `3`, then divide by 10, leading to `hide_delay = 0.3`.\n\n## `restore_focus` (optional)\n\nEnabled by default, set to `false` if you don't want the focused state to be restored when a scratchpad is hidden.\n\n## `force_monitor` (optional)\n\nIf set to some monitor name (eg: `\"DP-1\"`), it will always use this monitor to show the scratchpad.\n\n## `alt_toggle` (optional)\n\nDefault value is `false`\n\nWhen enabled, use an alternative `toggle` command logic for multi-screen setups.\nIt applies when the `toggle` command is triggered and the toggled scratchpad is visible on a screen which is not the focused one.\n\nInstead of moving the scratchpad to the focused screen, it will hide the scratchpad.\n\n## `allow_special_workspaces` (optional)\n\nDefault value is `false` (can't be enabled when using *Hyprland* < 0.39 where this behavior can't be controlled and is disabled).\n\nWhen enabled, you can toggle a scratchpad over a special workspace.\nIt will always use the \"normal\" workspace otherwise.\n\n## `smart_focus` (optional)\n\nDefault value is `true`.\n\nWhen enabled, the focus will be restored in a best effort way as en attempt to improve the user experience.\nIf you face issues such as spontaneous workspace changes, you can disable this feature.\n\n"
  },
  {
    "path": "site/versions/2.3.6,7/scratchpads_nonstandard.md",
    "content": "# Troubleshooting scratchpads\n\nOptions that should only be used for applications that are not behaving in a \"standard\" way.\n\n## `match_by` (optional)\n\nDefault value is `\"pid\"`\nWhen set to a sensitive client property value (eg: `class`, `initialClass`, `title`, `initialTitle`), will match the client window using the provided property instead of the PID of the process.\n\nThis property must be set accordingly, eg:\n\n```toml\nmatch_by = \"class\"\nclass = \"my-web-app\"\n```\n\nor\n\n```toml\nmatch_by = \"initialClass\"\ninitialClass = \"my-web-app\"\n```\n\nYou can add the \"re:\" prefix to use a regular expression, eg:\n\n```toml\nmatch_by = \"title\"\ntitle = \"re:.*some string.*\"\n```\n\n> [!note]\n> Some apps may open the graphical client window in a \"complicated\" way, to work around this, it is possible to disable the process PID matching algorithm and simply rely on window's class.\n>\n> The `match_by` attribute can be used to achieve this, eg. for emacsclient:\n> ```toml\n> [scratchpads.emacs]\n> command = \"/usr/local/bin/emacsStart.sh\"\n> class = \"Emacs\"\n> match_by = \"class\"\n> ```\n\n## `process_tracking` (optional)\n\nDefault value is `true`\n\nAllows disabling the process management. Use only if running a progressive web app (Chrome based apps) or similar.\n\nThis will automatically force `lazy = true` and set `match_by=\"class\"` if no `match_by` rule is provided, to help with the fuzzy client window matching.\n\nIt requires defining a `class` option (or the option matching your `match_by` value).\n\n```toml\n## Chat GPT on Brave\n[scratchpads.gpt]\nanimation = \"fromTop\"\ncommand = \"brave --profile-directory=Default --app=https://chat.openai.com\"\nclass = \"brave-chat.openai.com__-Default\"\nsize = \"75% 60%\"\nprocess_tracking = false\n\n## Some chrome app\n[scratchpads.music]\ncommand = \"google-chrome --profile-directory=Default --app-id=cinhimbnkkaeohfgghhklpknlkffjgod\"\nclass = \"chrome-cinhimbnkkaeohfgghhklpknlkffjgod-Default\"\nsize = \"50% 50%\"\nprocess_tracking = false\n```\n\n> [!tip]\n> To list windows by class and title you can use:\n> - `hyprctl -j clients | jq '.[]|[.class,.title]'`\n> - or if you prefer a graphical tool: `rofi -show window`\n\n> [!note]\n> Progressive web apps will share a single process for every window.\n> On top of requiring the class based window tracking (using `match_by`),\n> the process can not be managed the same way as usual apps and the correlation\n> between the process and the client window isn't as straightforward and can lead to false matches in extreme cases.\n\n## `skip_windowrules` (optional)\n\nDefault value is `[]`\nAllows you to skip the window rules for a specific scratchpad.\nAvailable rules are:\n\n- \"aspect\" controlling size and position\n- \"float\" controlling the floating state\n- \"workspace\" which moves the window to its own workspace\n\nIf you are using an application which can spawn multiple windows and you can't see them, you can skip rules made to improve the initial display of the window.\n\n```toml\n[scratchpads.filemanager]\nanimation = \"fromBottom\"\ncommand = \"nemo\"\nclass = \"nemo\"\nsize = \"60% 60%\"\nskip_windowrules = [\"aspect\", \"workspace\"]\n```\n"
  },
  {
    "path": "site/versions/2.3.6,7/shift_monitors.md",
    "content": "---\ncommands:\n    - name: shift_monitors <direction>\n      description: Swaps the workspaces of every screen in the given direction.\n---\n\n# shift_monitors\n\nSwaps the workspaces of every screen in the given direction.\n\n> [!Note]\n> the behavior can be hard to predict if you have more than 2 monitors (depending on your layout).\n> If you use this plugin with many monitors and have some ideas about a convenient configuration, you are welcome ;)\n\nExample usage in `hyprland.conf`:\n\n```\nbind = $mainMod, O, exec, pypr shift_monitors +1\nbind = $mainMod SHIFT, O, exec, pypr shift_monitors -1\n```\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n"
  },
  {
    "path": "site/versions/2.3.6,7/shortcuts_menu.md",
    "content": "---\ncommands:\n    - name: menu [name]\n      description: Displays the full menu or part of it if \"name\" is provided\n---\n\n# shortcuts_menu\n\nPresents some menu to run shortcut commands. Supports nested menus (aka categories / sub-menus).\n\n<details>\n   <summary>Configuration example</summary>\n\n```toml\n[shortcuts_menu.entries]\n\n\"Open Jira ticket\" = 'open-jira-ticket \"$(wl-paste)\"'\nRelayout = \"pypr relayout\"\n\"Fetch window\" = \"pypr fetch_client_menu\"\n\"Hyprland socket\" = 'kitty  socat - \"UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock\"'\n\"Hyprland logs\" = 'kitty tail -f $XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/hyprland.log'\n\n\"Serial USB Term\" = [\n    {name=\"device\", command=\"ls -1 /dev/ttyUSB*; ls -1 /dev/ttyACM*\"},\n    {name=\"speed\", options=[\"115200\", \"9600\", \"38400\", \"115200\", \"256000\", \"512000\"]},\n    \"kitty miniterm --raw --eol LF [device] [speed]\"\n]\n\n\"Color picker\" = [\n    {name=\"format\", options=[\"hex\", \"rgb\", \"hsv\", \"hsl\", \"cmyk\"]},\n    \"sleep 0.2; hyprpicker --format [format] | wl-copy\" # sleep to let the menu close before the picker opens\n]\n```\n\n</details>\n\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n> [!tip]\n> - If \"name\" is provided it will show the given sub-menu.\n> - You can use \".\" to reach any level of the configured menus.\n>\n>      Example to reach `shortcuts_menu.entries.utils.\"local commands\"`, use:\n>      ```sh\n>       pypr menu \"utils.local commands\"\n>      ```\n\n## Configuration\n\nAll the [Menu](./Menu) configuration items are also available.\n\n### `entries`\n\nDefines the menu entries. Supports [Variables](./Variables)\n\n```toml\n[shortcuts_menu.entries]\n\"entry 1\" = \"command to run\"\n\"entry 2\" = \"command to run\"\n```\nSubmenus can be defined too (there is no depth limit):\n\n```toml\n[shortcuts_menu.entries.\"My submenu\"]\n\"entry X\" = \"command\"\n\n[shortcuts_menu.entries.one.two.three.four.five]\nfoobar = \"ls\"\n```\n\n#### Advanced usage\n\nInstead of navigating a configured list of menu options and running a pre-defined command, you can collect various *variables* (either static list of options selected by the user, or generated from a shell command) and then run a command using those variables. Eg:\n\n```toml\n\"Play Video\" = [\n    {name=\"video_device\", command=\"ls -1 /dev/video*\"},\n    {name=\"player\",\n        options=[\"mpv\", \"guvcview\"]\n    },\n    \"[player] [video_device]\"\n]\n\n\"Ssh\" = [\n    {name=\"action\", options=[\"htop\", \"uptime\", \"sudo halt -p\"]},\n    {name=\"host\", options=[\"gamix\", \"gate\", \"idp\"]},\n    \"kitty --hold ssh [host] [action]\"\n]\n```\n\nYou must define a list of objects, containing:\n- `name`: the variable name\n- then the list of options, must be one of:\n    - `options` for a static list of options\n    - `command` to get the list of options from a shell command's output\n\n> [!tip]\n> You can apply post-filters to the `command` output, eg:\n> ```toml\n> {\n>   name = \"entry\",\n>   command = \"cliphist list\",\n>   filter = \"s/\\t.*//\"  # will remove everything after the TAB character\n> }\n> ```\n> check the [filters](./filters) page for more details\n\nThe last item of the list must be a string which is the command to run. Variables can be used enclosed in `[]`.\n\n### `command_start` (optional)\n### `command_end` (optional)\n### `submenu_start` (optional)\n### `submenu_end` (optional)\n\nAllow adding some text (eg: icon) before / after a menu entry.\n\ncommand_* is for final commands, while submenu_* is for entries leading to another menu.\n\nBy default `submenu_end` is set to a right arrow sign, while other attributes are not set.\n\n### `skip_single` (optional)\n\nDefaults to `true`.\nWhen disabled, shows the menu even for single options\n\n## Hints\n\n### Multiple menus\n\nTo manage multiple distinct menus, always use a name when using the `pypr menu <name>` command.\n\nExample of a multi-menu configuration:\n\n```toml\n[shortcuts_menu.entries.\"Basic commands\"]\n\"entry X\" = \"command\"\n\"entry Y\" = \"command2\"\n\n[shortcuts_menu.entries.menu2]\n## ...\n```\n\nYou can then show the first menu using `pypr menu \"Basic commands\"`\n"
  },
  {
    "path": "site/versions/2.3.6,7/sidebar.json",
    "content": "{\n  \"main\": [\n    {\n      \"text\": \"Getting started\",\n      \"link\": \"./Getting-started\"\n    },\n    {\n      \"text\": \"Plugins\",\n      \"link\": \"./Plugins\"\n    },\n    {\n      \"text\": \"Troubleshooting\",\n      \"link\": \"./Troubleshooting\"\n    },\n    {\n      \"text\": \"Development\",\n      \"link\": \"./Development\"\n    }\n  ],\n  \"plugins\": {\n    \"text\": \"Featured plugins\",\n    \"collapsed\": false,\n    \"items\": [\n      {\n        \"text\": \"Expose\",\n        \"link\": \"./expose\"\n      },\n      {\n        \"text\": \"Fetch client menu\",\n        \"link\": \"./fetch_client_menu\"\n      },\n      {\n        \"text\": \"Gbar\",\n        \"link\": \"./gbar\"\n      },\n      {\n        \"text\": \"Layout center\",\n        \"link\": \"./layout_center\"\n      },\n      {\n        \"text\": \"Lost windows\",\n        \"link\": \"./lost_windows\"\n      },\n      {\n        \"text\": \"Magnify\",\n        \"link\": \"./magnify\"\n      },\n      {\n        \"text\": \"Monitors\",\n        \"link\": \"./monitors\"\n      },\n      {\n        \"text\": \"Scratchpads\",\n        \"link\": \"./scratchpads\",\n        \"items\": [\n          {\n            \"text\": \"Advanced\",\n            \"link\": \"./scratchpads_advanced\"\n          },\n          {\n            \"text\": \"Special cases\",\n            \"link\": \"./scratchpads_nonstandard\"\n          }\n        ]\n      },\n      {\n        \"text\": \"Shift monitors\",\n        \"link\": \"./shift_monitors\"\n      },\n      {\n        \"text\": \"Shortcuts menu\",\n        \"link\": \"./shortcuts_menu\"\n      },\n      {\n        \"text\": \"System notifier\",\n        \"link\": \"./system_notifier\"\n      },\n      {\n        \"text\": \"Toggle dpms\",\n        \"link\": \"./toggle_dpms\"\n      },\n      {\n        \"text\": \"Toggle special\",\n        \"link\": \"./toggle_special\"\n      },\n      {\n        \"text\": \"Wallpapers\",\n        \"link\": \"./wallpapers\"\n      },\n      {\n        \"text\": \"Workspaces follow focus\",\n        \"link\": \"./workspaces_follow_focus\"\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "site/versions/2.3.6,7/system_notifier.md",
    "content": "# system_notifier\n\nThis plugin adds system notifications based on journal logs (or any program's output).\nIt monitors specified **sources** for log entries matching predefined **patterns** and generates notifications accordingly (after applying an optional **filter**).\n\nSources are commands that return a stream of text (eg: journal, mqtt, tail -f, ...) which is sent to a parser that will use a [regular expression pattern](https://en.wikipedia.org/wiki/Regular_expression) to detect lines of interest and optionally transform them before sending the notification.\n\n<details>\n    <summary>Minimal configuration</summary>\n\n```toml\n[system_notifier.sources]\ncommand = \"sudo journalctl -fx\"\nparser = \"journal\"\n```\n\nIn general you will also need to define some **parsers**.\nBy default a **\"journal\"** parser is provided, otherwise you need to define your own rules.\nThis built-in configuration is close to this one, provided as an example:\n\n```toml\n[system_notifier.parsers.journal]\npattern = \"([a-z0-9]+): Link UP$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is active/\"\ncolor= \"#00aa00\"\n\n[system_notifier.parsers.journal]\npattern = \"([a-z0-9]+): Link DOWN$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is inactive/\"\ncolor= \"#ff8800\"\n\n[system_notifier.parsers.journal]\npattern = \"Process \\d+ \\(.*\\) of .* dumped core.\"\nfilter = \"s/.*Process \\d+ \\((.*)\\) of .* dumped core./\\1 dumped core/\"\ncolor= \"#aa0000\"\n\n[system_notifier.parsers.journal]\npattern = \"usb \\d+-[0-9.]+: Product: \"\nfilter = \"s/.*usb \\d+-[0-9.]+: Product: (.*)/USB plugged: \\1/\"\n```\n</details>\n\n\n## Configuration\n\n### `sources`\n\nList of sources to enable (by default nothing is enabled)\n\nEach source must contain a `command` to run and a `parser` to use.\n\nYou can also use a list of parsers, eg:\n\n```toml\n[system_notifier.sources](system_notifier.sources)\ncommand = \"sudo journalctl -fkn\"\nparser = [\"journal\", \"custom_parser\"]\n```\n\n#### command\n\nThis is the long-running command (eg: `tail -f <filename>`) returning the stream of text that will be updated. Aa common option is the system journal output (eg: `journalctl -u nginx`)\n\n#### parser\n\nSets the list of rules / parser to be used to extract lines of interest.\nMust match a list of rules defined as `system_notifier.parsers.<parser_name>`.\n\n### `parsers`\n\nA list of available parsers that can be used to detect lines of interest in the **sources** and re-format it before issuing a notification.\n\nEach parser definition must contain a **pattern** and optionally a **filter** and a **color**.\n\n#### pattern\n\n```toml\n[system_notifier.parsers.custom_parser](system_notifier.parsers.custom_parser)\n\npattern = 'special value:'\n```\n\nThe pattern is any regular expression.\n\n#### filter\n\nThe [filters](./filters) allows to change the text before the notification, eg:\n`filter=\"s/.*special value: (\\d+)/Value=\\1/\"`\nwill set a filter so a string \"special value: 42\" will lead to the notification \"Value=42\"\n\n#### color\n\nYou can also provide an optional **color** in `\"hex\"` or `\"rgb()\"` format\n\n```toml\ncolor = \"#FF5500\"\n```\n\n### default_color (optional)\n\nSets the notification color that will be used when none is provided in a *parser* definition.\n\n```toml\n[system_notifier]\ndefault_color = \"#bbccbb\"\n```\n"
  },
  {
    "path": "site/versions/2.3.6,7/toggle_dpms.md",
    "content": "---\ncommands:\n    - name: toggle_dpms\n      description: if any screen is powered on, turn them all off, else turn them all on\n---\n\n# toggle_dpms\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n"
  },
  {
    "path": "site/versions/2.3.6,7/toggle_special.md",
    "content": "---\ncommands:\n    - name: toggle_special [name]\n      description: moves the focused window to the special workspace <code>name</code>, or move it back to the active workspace\n---\n\n# toggle_special\n\nAllows moving the focused window to a special workspace and back (based on the visibility status of that workspace).\n\nIt's a companion of the `togglespecialworkspace` Hyprland's command which toggles a special workspace's visibility.\n\nYou most likely will need to configure the two commands for a complete user experience, eg:\n\n```bash\nbind = $mainMod SHIFT, N, togglespecialworkspace, stash # toggles \"stash\" special workspace visibility\nbind = $mainMod, N, exec, pypr toggle_special stash # moves window to/from the \"stash\" workspace\n```\n\nNo other configuration needed, here `MOD+SHIFT+N` will show every window in \"stash\" while `MOD+N` will move the focused window out of it/ to it.\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n"
  },
  {
    "path": "site/versions/2.3.6,7/wallpapers.md",
    "content": "---\ncommands:\n    - name: wall next\n      description: Changes the current background image\n    - name: wall clear\n      description: Removes the current background image\n---\n\n# wallpapers\n\nSearch folders for images and sets the background image at a regular interval.\nImages are selected randomly from the full list of images found.\n\nIt serves two purposes:\n\n- adding support for random images to any background setting tool\n- quickly testing different tools with a minimal effort\n\nIt allows \"zapping\" current backgrounds, clearing it to go distraction free and optionally make them different for each screen.\n\n<details>\n    <summary>Minimal example (requires <b>swaybg</b> by default)</summary>\n\n```toml\n[wallpapers]\npath = \"~/Images/wallpapers/\" # path to the folder with background images\nunique = true # set a different wallpaper for each screen\n```\n\n</details>\n\n<details>\n<summary>More complex, using <b>swww</b> as a backend (not recommended because of its stability)</summary>\n\n```toml\n[wallpapers]\npath = \"~/Images/wallpapers/\"\ninterval = 60 # change every hour\nextensions = [\"jpg\", \"jpeg\"]\nrecurse = true\n## Using swww\ncommand = 'swww img --transition-type any \"[file]\"'\nclear_command = \"swww clear\"\n```\n\nNote that for applications like `swww`, you'll need to start a daemon separately (eg: from `hyprland.conf`).\n</details>\n\n\n## Commands\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n\n### `path`\n\npath to a folder or list of folders that will be searched. Can also be a list, eg:\n\n```toml\npath = [\"~/Images/Portraits/\", \"~/Images/Landscapes/\"]\n```\n\n### `interval` (optional)\n\ndefaults to `10`\n\nHow long (in minutes) a background should stay in place\n\n\n### `command` (optional)\n\nOverrides the default command to set the background image.\n\n[variables](./Variables) are replaced with the appropriate values, you must use a `\"[file]\"` placeholder for the image path. eg:\n\n```\nswaybg -m fill -i \"[file]\"\n```\n\n### `clear_command` (optional)\n\nBy default `clear` command kills the `command` program.\n\nInstead of that, you can provide a command to clear the background. eg:\n\n```\nclear_command = \"swaybg clear\"\n``````\n\n### `extensions` (optional)\n\ndefaults to `[\"png\", \"jpg\", \"jpeg\"]`\n\nList of valid wallpaper image extensions.\n\n### `recurse` (optional)\n\ndefaults to `false`\n\nWhen enabled, will also search sub-directories recursively.\n\n### `unique` (optional)\n\ndefaults to `false`\n\nWhen enabled, will set a different wallpaper for each screen.\n\nIf you are not using the default application, ensure you are using `\"[output]\"` in the [command](#command) template.\n\nExample for swaybg: `swaybg -o \"[output]\" -m fill -i \"[file]\"`\n"
  },
  {
    "path": "site/versions/2.3.6,7/workspaces_follow_focus.md",
    "content": "---\ncommands:\n    - name: change_workspace [direction]\n      description: Changes the workspace of the focused monitor in the given direction.\n---\n\n# workspaces_follow_focus\n\nMake non-visible workspaces follow the focused monitor.\nAlso provides commands to switch between workspaces while preserving the current monitor assignments:\n\nSyntax:\n```toml\n[workspaces_follow_focus]\nmax_workspaces = 4 # number of workspaces before cycling\n```\nExample usage in `hyprland.conf`:\n\n```ini\nbind = $mainMod, K, exec, pypr change_workspace +1\nbind = $mainMod, J, exec, pypr change_workspace -1\n ```\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `max_workspaces` (optional)\n\nLimits the number of workspaces when switching, defaults value is `10`.\n"
  },
  {
    "path": "site/versions/2.3.8/Development.md",
    "content": "# Development\n\nIt's easy to write your own plugin by making a python package and then indicating it's name as the plugin name.\n\n[Contributing guidelines](https://github.com/hyprland-community/pyprland/blob/main/CONTRIBUTING.md)\n\n# Writing plugins\n\nPlugins can be loaded with full python module path, eg: `\"mymodule.pyprlandplugin\"`, the loaded module must provide an `Extension` class.\n\nCheck the `interface.py` file to know the base methods, also have a look at the example below.\n\nTo get more details when an error is occurring, use `pypr --debug <log file path>`, it will also display the log in the console.\n\n> [!note]\n> To quickly get started, you can directly edit the `experimental` built-in plugin.\n> In order to distribute it, make your own Python package or trigger a pull request.\n> If you prefer to make a separate package, check the [examples](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/)'s package\n\nThe `Extension` interface provides a couple of built-in attributes:\n\n- `config` : object exposing the plugin section in `pyprland.toml`\n- `notify` ,`notify_error`, `notify_info` : access to Hyprland's notification system\n- `hyprctl`, `hyprctl_json` : invoke [Hyprland's IPC system](https://wiki.hyprland.org/Configuring/Dispatchers/)\n\n\n> [!important]\n> Contact me to get your extension listed on the home page\n\n> [!tip]\n> You can set a `plugins_paths=[\"/custom/path/example\"]` in the `hyprland` section of the configuration to add extra paths (eg: during development).\n\n> [!Note]\n> If your extension is at the root of the plugin (this is not recommended, preferable add a name space, as in `johns_pyprland.super_feature`, rather than `super_feature`) you can still import it using the `external:` prefix when you refer to it in the `plugins` list.\n\n# API Documentation\n\nRun `tox run -e doc` then visit `http://localhost:8080`\n\nThe most important to know are:\n\n- `hyprctl_json` to get a response from an IPC query\n- `hyprctl` to trigger general IPC commands\n- `on_reload` to be implemented, called when the config is (re)loaded\n- `run_<command_name>` to implement a command\n- `event_<event_name>` called when the given event is emitted by Hyprland\n\nAll those methods are _async_\n\nOn top of that:\n\n- the first line of a `run_*` command's docstring will be used by the `help` command\n- `self.config` in your _Extension_ contains the entry corresponding to your plugin name in the TOML file\n- `state` from `..common` module contains ready to use information\n- there is a `MenuMixin` in `..adapters.menus` to make menu-based plugins easy\n\n# Workflow\n\nJust `^C` when you make a change and repeat:\n\n```sh\npypr exit ; pypr --debug /tmp/output.log\n```\n\n\n## Creating a plugin\n\n```python\nfrom .interface import Plugin\n\n\nclass Extension(Plugin):\n    \" My plugin \"\n\n    async def init(self):\n        await self.notify(\"My plugin loaded\")\n```\n\n## Adding a command\n\nJust add a method called `run_<name of your command>` to your `Extension` class, eg with \"togglezoom\" command:\n\n```python\n    zoomed = False\n\n    async def run_togglezoom(self, args):\n        \"\"\" this doc string will show in `help` to document `togglezoom`\n        But this line will not show in the CLI help\n        \"\"\"\n      if self.zoomed:\n        await self.hyprctl('misc:cursor_zoom_factor 1', 'keyword')\n      else:\n        await self.hyprctl('misc:cursor_zoom_factor 2', 'keyword')\n      self.zoomed = not self.zoomed\n```\n\n## Reacting to an event\n\nSimilar as a command, implement some `async def event_<the event you are interested in>` method.\n\n## Code safety\n\nPypr ensures only one `run_` or `event_` handler runs at a time, allowing the plugins code to stay simple and avoid the need for concurrency handling.\nHowever, each plugin can run its handlers in parallel.\n\n# Reusable code\n\n```py\nfrom ..common import state, CastBoolMixin\n```\n\n- `state` provides a couple of handy variables so you don't have to fetch them, allow optimizing the most common operations\n- `Mixins` are providing common code, for instance the `CastBoolMixin` provides the `cast_bool` method to your `Extension`.\n\nIf you want to use menus, then the `MenuMixin` will provide:\n- `menu` to show a menu\n- `ensure_menu_configured` to call before you require a menu in your plugin\n\n# Example\n\nYou'll find a basic external plugin in the [examples](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/) folder.\n\nIt provides one command: `pypr dummy`.\n\nRead the [plugin code](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/pypr_examples/focus_counter.py)\n\nIt's a simple python package. To install it for development without a need to re-install it for testing, you can use `pip install -e .` in this folder.\nIt's ready to be published using `poetry publish`, don't forget to update the details in the `pyproject.toml` file.\n\n## Usage\n\nEnsure you added `pypr_examples.focus_counter` to your `plugins` list:\n\n```toml\n[pyprland]\nplugins = [\n  \"pypr_examples.focus_counter\"\n]\n```\n\nOptionally you can customize one color:\n\n```toml\n[\"pypr_examples.focus_counter\"]\ncolor = \"FFFF00\"\n```\n"
  },
  {
    "path": "site/versions/2.3.8/Getting-started.md",
    "content": "# Getting started\n\nPypr consists in two things:\n\n- **a tool**: `pypr` which runs the daemon (service), but also allows to interact with it\n- **some config file**: `~/.config/hypr/pyprland.toml` (or the path set using `--config`) using the [TOML](https://toml.io/en/) format\n\nThe `pypr` tool only have a few built-in commands:\n\n- `help` lists available commands (including plugins commands)\n- `exit` will terminate the service process\n- `edit` edit the configuration using your `$EDITOR` (or `vi`), reloads on exit\n- `dumpjson` shows a JSON representation of the configuration (after other files have been `include`d)\n- `reload` reads the configuration file and apply some changes:\n  - new plugins will be loaded\n  - configuration items will be updated (most plugins will use the new values on the next usage)\n\nOther commands are implemented by adding [plugins](./Plugins).\n\n> [!important]\n> - with no argument it runs the daemon (doesn't fork in the background)\n>\n> - if you pass parameters, it will interact with the daemon instead.\n\n> [!tip]\n> Pypr *command* names are documented using underscores (`_`) but you can use dashes (`-`) instead.\n> Eg: `pypr shift_monitors` and `pypr shift-monitors` will run the same command\n\n\n## Configuration file\n\nThe configuration file uses a [TOML format](https://toml.io/) with the following as the bare minimum:\n\n```toml\n[pyprland]\nplugins = [\"plugin_name\", \"other_plugin\"]\n```\n\nAdditionally some plugins require **Configuration** options, using the following format:\n\n```toml\n[plugin_name]\nplugin_option = 42\n\n[plugin_name.another_plugin_option]\nsuboption = \"config value\"\n```\n\nYou can also split your configuration into [Multiple configuration files](./MultipleConfigurationFiles).\n\n## Installation\n\nCheck your OS package manager first, eg:\n\n- Archlinux: you can find it on AUR, eg with [yay](https://github.com/Jguer/yay): `yay pyprland`\n- NixOS: Instructions in the [Nix](./Nix) page\n\nOtherwise, use the python package manager *inside a virtual environment* (`python -m venv somefolder && source ./somefolder/bin/activate`):\n\n```sh\npip install pyprland\n```\n\n## Running\n\n> [!caution]\n> If you messed with something else than your OS packaging system to get `pypr` installed, use the full path to the `pypr` command.\n\nPreferably start the process with hyprland, adding to `hyprland.conf`:\n\n```ini\nexec-once = /usr/bin/pypr\n```\n\nor if you run into troubles (use the first version once your configuration is stable):\n\n```ini\nexec-once = /usr/bin/pypr --debug /tmp/pypr.log\n```\n\n> [!warning]\n> To avoid issues (eg: you have a complex setup, maybe using a virtual environment), you may want to set the full path (eg: `/home/bob/venv/bin/pypr`).\n> You can get it from `which pypr` in a working terminal\n\nOnce the `pypr` daemon is started (cf `exec-once`), you can list the eventual commands which have been added by the plugins using `pypr -h` or `pypr help`, those commands are generally meant to be use via key bindings, see the `hyprland.conf` part of *Configuring* section below.\n\n## Configuring\n\nCreate a configuration file in `~/.config/hypr/pyprland.toml` enabling a list of plugins, each plugin may have its own configuration needs or don't need any configuration at all.\nMost default values should be acceptable for most users, options which hare not mandatory are marked as such.\n\n> [!important]\n> Provide the values for the configuration options which have no annotation such as \"(optional)\"\n\nCheck the [TOML format](https://toml.io/) for details about the syntax.\n\nSimple example:\n\n```toml\n[pyprland]\nplugins = [\n    \"shift_monitors\",\n    \"workspaces_follow_focus\"\n]\n```\n\n<details>\n  <summary>\nMore complex example\n  </summary>\n\n```toml\n[pyprland]\nplugins = [\n  \"scratchpads\",\n  \"lost_windows\",\n  \"monitors\",\n  \"toggle_dpms\",\n  \"magnify\",\n  \"expose\",\n  \"shift_monitors\",\n  \"workspaces_follow_focus\",\n]\n\n[monitors.placement]\n\"Acer\".top_center_of = \"Sony\"\n\n[workspaces_follow_focus]\nmax_workspaces = 9\n\n[expose]\ninclude_special = false\n\n[scratchpads.stb]\nanimation = \"fromBottom\"\ncommand = \"kitty --class kitty-stb sstb\"\nclass = \"kitty-stb\"\nlazy = true\nsize = \"75% 45%\"\n\n[scratchpads.stb-logs]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-stb-logs stbLog\"\nclass = \"kitty-stb-logs\"\nlazy = true\nsize = \"75% 40%\"\n\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nlazy = true\nsize = \"40% 90%\"\nunfocus = \"hide\"\n```\n\nSome of those plugins may require changes in your `hyprland.conf` to fully operate or to provide a convenient access to a command, eg:\n\n```bash\nbind = $mainMod SHIFT, Z, exec, pypr zoom\nbind = $mainMod ALT, P,exec, pypr toggle_dpms\nbind = $mainMod SHIFT, O, exec, pypr shift_monitors +1\nbind = $mainMod, B, exec, pypr expose\nbind = $mainMod, K, exec, pypr change_workspace +1\nbind = $mainMod, J, exec, pypr change_workspace -1\nbind = $mainMod,L,exec, pypr toggle_dpms\nbind = $mainMod SHIFT,M,exec,pypr toggle stb stb-logs\nbind = $mainMod,A,exec,pypr toggle term\nbind = $mainMod,V,exec,pypr toggle volume\n```\n\n</details>\n\n> [!tip]\n> Consult or share [configuration files](https://github.com/hyprland-community/pyprland/tree/main/examples)\n>\n> You might also be interested in [optimizations](./Optimizations).\n"
  },
  {
    "path": "site/versions/2.3.8/Menu.md",
    "content": "# Menu capability\n\nMenu based plugins have the following configuration options:\n\n### `engine` (optional)\n\nNot set by default, will autodetect the available menu engine.\n\nSupported engines:\n\n- tofi\n- rofi\n- wofi\n- bemenu\n- dmenu\n- anyrun\n\n> [!note]\n> If your menu system isn't supported, you can open a [feature request](https://github.com/hyprland-community/pyprland/issues/new?assignees=fdev31&labels=bug&projects=&template=feature_request.md&title=%5BFEAT%5D+Description+of+the+feature)\n>\n> In case the engine isn't recognized, `engine` + `parameters` configuration options will be used to start the process, it requires a dmenu-like behavior.\n\n### `parameters` (optional)\n\nExtra parameters added to the engine command, the default value is specific to each engine.\n\n> [!important]\n> Setting this will override the default value!\n>\n> In general, *rofi*-like programs will require at least `-dmenu` option.\n\n> [!tip]\n> You can use '[prompt]' in the parameters, it will be replaced by the prompt, eg:\n> ```sh\n> -dmenu -matching fuzzy -i -p '[prompt]'\n> ```\n"
  },
  {
    "path": "site/versions/2.3.8/MultipleConfigurationFiles.md",
    "content": "### Multiple configuration files\n\nYou can also split your configuration into multiple files that will be loaded in the provided order after the main file:\n```toml\n[pyprland]\ninclude = [\"/shared/pyprland.toml\", \"~/pypr_extra_config.toml\"]\n```\nYou can also load folders, in which case TOML files in the folder will be loaded in alphabetical order:\n```toml\n[pyprland]\ninclude = [\"~/.config/pypr.d/\"]\n```\n\nAnd then add a `~/.config/pypr.d/monitors.toml` file:\n```toml\npyprland.plugins = [ \"monitors\" ]\n\n[monitors.placement]\nBenQ.Top_Center_Of = \"DP-1\" # projo\n\"CJFH277Q3HCB\".top_of = \"eDP-1\" # work\n```\n\n> [!tip]\n> To check the final merged configuration, you can use the `dumpjson` command.\n\n"
  },
  {
    "path": "site/versions/2.3.8/Nix.md",
    "content": "# Nix\n\nYou are recommended to get the latest pyprland package from the [flake.nix](https://github.com/hyprland-community/pyprland/blob/main/flake.nix)\nprovided within this repository. To use it in your system, you may add pyprland\nto your flake inputs.\n\n## Flake\n\n```nix\n## flake.nix\n{\n  inputs = {\n    nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n    pyprland.url = \"github:hyprland-community/pyprland\";\n  };\n\n  outputs = { self, nixpkgs, pyprland, ...}: let\n  in {\n    nixosConfigurations.<yourHostname> = nixpkgs.lib.nixosSystem {\n      #         remember to replace this with your system arch ↓\n      environment.systemPackages = [ pyprland.packages.\"x86_64-linux\".pyprland ];\n      # ...\n    };\n  };\n}\n```\n\nAlternatively, if you are using the Nix package manager but not NixOS as your\nmain distribution, you may use `nix profile` tool to install pyprland from this\nrepository using the following command.\n\n```bash\nnix profile install github:nix-community/pyprland\n```\n\nThe package will now be in your latest profile. You may use `nix profile list`\nto verify your installation.\n\n## Nixpkgs\n\nPyprland is available under nixpkgs, and can be installed by adding\n`pkgs.pyprland` to either `environment.systemPackages` or `home.packages`\ndepending on whether you want it available system-wide or to only a single\nuser using home-manager. If the derivation available in nixpkgs is out-of-date\nthen you may consider using `overrideAttrs` to update the source locally.\n\n```nix\nlet\n  pyprland = pkgs.pyprland.overrideAttrs {\n    version = \"your-version-here\";\n    src = fetchFromGitHub {\n      owner = \"hyprland-community\";\n      repo = \"pyprland\";\n      rev = \"tag-or-revision\";\n      # leave empty for the first time, add the new hash from the error message\n      hash = \"\";\n    };\n  };\nin {\n  # add the overridden package to systemPackages\n  environment.systemPackages = [pyprland];\n}\n```\n"
  },
  {
    "path": "site/versions/2.3.8/Optimizations.md",
    "content": "## Optimizing\n\n### Plugins\n\n- Only enable the plugins you are using in the `plugins` array (in `[pyprland]` section).\n- Leaving the configuration for plugins which are not enabled will have no impact.\n- Using multiple configuration files only have a small impact on the startup time.\n\n### Pypr command\n\nIn case you want to save some time when interacting with the daemon\nyou can use `socat` instead (needs to be installed). Example of a `pypr-cli` command (should be reachable from your environment's `PATH`):\n```sh\n#!/bin/sh\nsocat - \"UNIX-CONNECT:${XDG_RUNTIME_DIR}/hypr/${HYPRLAND_INSTANCE_SIGNATURE}/.pyprland.sock\" <<< $@\n```\nOn slow systems this may make a difference.\nNote that the \"help\" command will require usage of the standard `pypr` command.\n"
  },
  {
    "path": "site/versions/2.3.8/Plugins.md",
    "content": "<script setup>\nimport PluginList from './components/PluginList.vue'\n</script>\n\nThis page lists every plugin provided by Pyprland out of the box, more can be enabled if you install the matching package.\n\n\"🌟\" indicates some maturity & reliability level of the plugin, considering age, attention paid and complexity - from 0 to 3.\n\n<PluginList/>\n"
  },
  {
    "path": "site/versions/2.3.8/Troubleshooting.md",
    "content": "# Troubleshooting\n\n## General\n\nIn case of trouble running a `pypr` command:\n- kill the existing pypr if any (try `pypr exit` first)\n- run from the terminal adding `--debug /dev/null` to the arguments to get more information\n\nIf the client says it can't connect, then there is a high chance pypr daemon didn't start, check if it's running using `ps axuw |grep pypr`. You can try to run it from a terminal with the same technique: `pypr --debug /dev/null` and see if any error occurs.\n\n## Force hyprland version\n\nIn case your `hyprctl version -j` command isn't returning an accurate version, you can make Pyprland ignore it and use a provided value instead:\n\n```toml\n[pyprland]\nhyprland_version = \"0.41.0\"\n```\n\n## Unresponsive scratchpads\n\nScratchpads aren't responding for few seconds after trying to show one (which didn't show!)\n\nThis may happen if an application is very slow to start.\nIn that case pypr will wait for a window blocking other scratchpad's operation, before giving up after a few seconds.\n\nNote that other plugins shouldn't be blocked by it.\n\nMore scratchpads troubleshooting can be found [here](./scratchpads_nonstandard).\n"
  },
  {
    "path": "site/versions/2.3.8/Variables.md",
    "content": "# Variables & substitutions\n\nSome commands support shared global variables, they must be defined in the *pyprland* section of the configuration:\n```toml\n[pyprland.variables]\nterm = \"foot\"\nterm_classed = \"foot -a\" # kitty uses --class\n```\n\nIf a plugin supports it, you can then use the variables in the attribute that supports it, eg:\n\n```toml\n[myplugin]\nsome_variable = \"the terminal is [term]\"\n```\n"
  },
  {
    "path": "site/versions/2.3.8/components/CommandList.vue",
    "content": "<template>\n    <dl>\n    </dl>\n    <ul v-for=\"command in commands\" :key=\"command.name\">\n        <li><code v-html=\"command.name.replace(/[   ]*$/, '').replace(/ /g, '&ensp;')\" /> <span\n                v-html=\"command.description\" /></li>\n    </ul>\n</template>\n\n<script>\nexport default {\n    props: {\n        commands: {\n            type: Array,\n            required: true\n        }\n    }\n}\n</script>\n"
  },
  {
    "path": "site/versions/2.3.8/components/PluginList.vue",
    "content": "<template>\n    <div>\n        <h1>Built-in plugins</h1>\n        <div v-for=\"plugin in sortedPlugins\" :key=\"plugin.name\" class=\"plugin-item\">\n            <div class=\"plugin-info\">\n                <h3>\n                    <a :href=\"plugin.name + '.html'\">{{ plugin.name }}</a>\n                    <span>&nbsp;{{ '🌟'.repeat(plugin.stars) }}</span>\n                    <span v-if=\"plugin.multimon\">\n                        <Badge type=\"tip\" text=\"multi-monitor\" />\n                    </span>\n                </h3>\n                <p v-html=\"plugin.description\" />\n            </div>\n            <a v-if=\"plugin.demoVideoId\" :href=\"'https://www.youtube.com/watch?v=' + plugin.demoVideoId\"\n                class=\"plugin-video\">\n                <img :src=\"'https://img.youtube.com/vi/' + plugin.demoVideoId + '/1.jpg'\" alt=\"Demo video thumbnail\" />\n            </a>\n        </div>\n    </div>\n</template>\n\n<style scoped>\n.plugin-item {\n    display: flex;\n    align-items: flex-start;\n    margin-bottom: 1.5rem;\n}\n\n.plugin-info {\n    flex: 1;\n}\n\n.plugin-video {\n    margin-left: 1rem;\n}\n\n.plugin-video img {\n    max-width: 200px;\n    /* Adjust as needed */\n    height: auto;\n}\n</style>\n\n\n<script>\n\nexport default {\n    computed: {\n        sortedPlugins() {\n            if (this.sortByStars) {\n                return this.plugins.slice().sort((a, b) => {\n                    if (b.stars === a.stars) {\n                        return a.name.localeCompare(b.name);\n                    }\n                    return b.stars - a.stars;\n                });\n            } else {\n                return this.plugins.slice().sort((a, b) => a.name.localeCompare(b.name));\n            }\n        }\n    },\n    data() {\n        return {\n            sortByStars: false,\n            plugins: [\n                {\n                    name: 'scratchpads',\n                    stars: 3,\n                    description: 'makes your applications dropdowns & togglable poppups',\n                    demoVideoId: 'ZOhv59VYqkc'\n                },\n                {\n                    name: 'magnify',\n                    stars: 3,\n                    description: 'toggles zooming of viewport or sets a specific scaling factor',\n                    demoVideoId: 'yN-mhh9aDuo'\n\n                },\n                {\n                    name: 'toggle_special',\n                    stars: 3,\n                    description: 'moves windows from/to special workspaces',\n                    demoVideoId: 'BNZCMqkwTOo'\n                },\n                {\n                    name: 'shortcuts_menu',\n                    stars: 3,\n                    description: 'a flexible way to make your own shortcuts menus & launchers',\n                    demoVideoId: 'UCuS417BZK8'\n                },\n                {\n                    name: 'fetch_client_menu',\n                    stars: 3,\n                    description: 'select a window to be moved to your active workspace (using rofi, dmenu, etc...)',\n                },\n                {\n                    name: 'layout_center',\n                    stars: 3,\n                    description: 'a workspace layout where one client window is almost maximized and others seat in the background',\n                    demoVideoId: 'vEr9eeSJYDc'\n                },\n                {\n                    name: 'system_notifier',\n                    stars: 3,\n                    description: 'open streams (eg: journal logs) and trigger notifications',\n                },\n                {\n                    name: 'wallpapers',\n                    stars: 3,\n                    description: 'handles random wallpapers at regular interval (from a folder)'\n                },\n                {\n                    name: 'gbar',\n                    stars: 3,\n                    description: 'improves multi-monitor handling of the status bar - only <tt>gBar</tt> is supported at the moment'\n                },\n                {\n                    name: 'expose',\n                    stars: 2,\n                    description: 'exposes all the windows for a quick \"jump to\" feature',\n                    demoVideoId: 'ce5HQZ3na8M'\n                },\n                {\n                    name: 'lost_windows',\n                    stars: 1,\n                    description: 'brings lost floating windows (which are out of reach) to the current workspace'\n                },\n                {\n                    name: 'toggle_dpms',\n                    stars: 0,\n                    description: 'toggles the DPMS status of every plugged monitor'\n                },\n                {\n                    name: 'workspaces_follow_focus',\n                    multimon: true,\n                    stars: 3,\n                    description: 'makes non visible workspaces available on the currently focused screen.<br/> If you think the multi-screen behavior of Hyprland is not usable or broken/unexpected, this is probably for you.'\n\n                },\n                {\n                    name: 'shift_monitors',\n                    multimon: true,\n                    stars: 3,\n                    description: 'moves workspaces from monitor to monitor (caroussel)'\n                },\n                {\n                    name: 'monitors',\n                    stars: 3,\n                    description: 'allows relative placement and configuration of monitors'\n                }\n            ]\n        }\n    }\n}\n</script>\n"
  },
  {
    "path": "site/versions/2.3.8/expose.md",
    "content": "---\ncommands:\n  - name: expose\n    description: Expose every client on the active workspace. If expose is already active, then restores everything and move to the focused window.\n---\n\n# expose\n\nImplements the \"expose\" effect, showing every client window on the focused screen.\n\nFor a similar feature using a menu, try the [fetch_client_menu](./fetch_client_menu) plugin (less intrusive).\n\nSample `hyprland.conf`:\n\n```bash\n# Setup the key binding\nbind = $mainMod, B, exec, pypr expose\n\n# Add some style to the \"exposed\" workspace\nworkspace = special:exposed,gapsout:60,gapsin:30,bordersize:5,border:true,shadow:false\n```\n\n`MOD+B` will bring every client to the focused workspace, pressed again it will go to this workspace.\n\nCheck [workspace rules](https://wiki.hyprland.org/Configuring/Workspace-Rules/#rules) for styling options.\n\n> [!note]\n> If you are looking for `toggle_minimized`, check the [toggle_special](./toggle_special) plugin\n\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n\n### `include_special` (optional)\n\ndefault value is `false`\n\nAlso include windows in the special workspaces during the expose.\n\n"
  },
  {
    "path": "site/versions/2.3.8/fetch_client_menu.md",
    "content": "---\ncommands:\n  - name: fetch_client_menu\n    description: Bring any window to the active workspace using a menu.\n---\n\n# fetch_client_menu\n\nBring any window to the active workspace using a menu.\n\nA bit like the [expose](./expose) plugin but using a menu instead (less intrusive).\n\nIt brings the window to the current workspace, while [expose](./expose) moves the currently focused screen to the application workspace.\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\nAll the [Menu](Menu) configuration items are also available.\n\n### `separator` (optional)\n\ndefault value is `\"|\"`\n\nChanges the character (or string) used to separate a menu entry from its entry number.\n\n"
  },
  {
    "path": "site/versions/2.3.8/filters.md",
    "content": "# Text filters\n\nAt the moment there is only one filter, close match of [sed's \"s\" command](https://www.gnu.org/software/sed/manual/html_node/The-_0022s_0022-Command.html).\n\nThe only supported flag is \"g\"\n\n## Example\n\n```toml\nfilter = 's/foo/bar/'\nfilter = 's/.*started (.*)/\\1 has started/'\nfilter = 's#</?div>##g'\n```\n"
  },
  {
    "path": "site/versions/2.3.8/gbar.md",
    "content": "---\ncommands:\n  - name: gbar restart\n    description: Restart/refresh gBar on the \"best\" monitor.\n---\n\n# gbar\n\nRuns [gBar](https://github.com/scorpion-26/gBar) on the \"best\" monitor from a list of monitors.\n\n- Will take care of starting gbar on startup (you must not run it from another source like `hyprland.conf`).\n- Automatically restarts gbar on crash\n- Checks which monitors are on and take the best one from a provided list\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n\n## Configuration\n\n\n### `monitors`\n\nList of monitors to chose from, the first have higher priority over the second one etc...\n\n\n## Example\n\n```sh\n[gbar]\nmonitors = [\"DP-1\", \"HDMI-1\", \"HDMI-1-A\"]\n```\n"
  },
  {
    "path": "site/versions/2.3.8/index.md",
    "content": "---\n# https://vitepress.dev/reference/default-theme-home-page\nlayout: home\n\nhero:\n  text: \"Hyprland extensions\"\n  tagline: Enhance your desktop capabilities with Pyprland\n  image:\n    src: /logo.svg\n    alt: logo\n  actions:\n    - theme: brand\n      text: Getting started\n      link: ./Getting-started\n    - theme: alt\n      text: Plugins\n      link: ./Plugins\n    - theme: alt\n      text: Report something\n      link: https://github.com/hyprland-community/pyprland/issues/new/choose\n\nfeatures:\n  - title: TOML format\n    details: Simple but powerful configuration\n  - title: Customizable\n    details: Create your own Hyprland experience\n  - title: Fast and easy\n    details: Designed for performance and ease of use\n---\n\n## Version 2.3.8 archive\n"
  },
  {
    "path": "site/versions/2.3.8/layout_center.md",
    "content": "---\ncommands:\n  - name: layout_center toggle\n    description: toggles the layout on and off\n  - name: layout_center next\n    description: switches to the next window (if layout is on) else runs the <a href=\"#next-optional\">next</a> command\n  - name: layout_center prev\n    description: switches to the previous window (if layout is on) else runs the <a href=\"#next-optional\">prev</a> command\n  - name: layout_center next2\n    description: switches to the next window (if layout is on) else runs the <a href=\"#next-optional\">next2</a> command\n  - name: layout_center prev2\n    description: switches to the previous window (if layout is on) else runs the <a href=\"#next-optional\">prev2</a> command\n\n---\n# layout_center\n\nImplements a workspace layout where one window is bigger and centered,\nother windows are tiled as usual in the background.\n\nOn `toggle`, the active window is made floating and centered if the layout wasn't enabled, else reverts the floating status.\n\nWith `next` and `prev` you can cycle the active window, keeping the same layout type.\nIf the layout_center isn't active and `next` or `prev` is used, it will call the [next](#next-optional) and [prev](#prev-optional) configuration options.\n\nTo allow full override of the focus keys, [next2](#next2-optional) and [prev2](#prev2-optional) are provided, they do the same actions as \"next\" and \"prev\" but allow different fallback commands.\n\n<details>\n<summary>Configuration sample</summary>\n\n```toml\n[layout_center]\nmargin = 60\noffset = [0, 30]\nnext = \"movefocus r\"\nprev = \"movefocus l\"\nnext2 = \"movefocus d\"\nprev2 = \"movefocus u\"\n```\n\nusing the following in `hyprland.conf`:\n```sh\nbind = $mainMod, M, exec, pypr layout_center toggle # toggle the layout\n## focus change keys\nbind = $mainMod, left, exec, pypr layout_center prev\nbind = $mainMod, right, exec, pypr layout_center next\nbind = $mainMod, up, exec, pypr layout_center prev2\nbind = $mainMod, down, exec, pypr layout_center next2\n```\n\nYou can completely ignore `next2` and `prev2` if you are allowing focus change (when the layout is enabled) in a single direction, eg:\n\n```sh\nbind = $mainMod, up, movefocus, u\nbind = $mainMod, down, movefocus, d\n```\n\n</details>\n\n\n## Commands\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `on_new_client` (optional)\n\nDefaults to `\"foreground\"`.\n\nChanges the behavior when a new window opens, possible options are:\n\n- \"foreground\" to make the new window the main window\n- \"background\" to make the new window appear in the background\n- \"close\" to stop the centered layout when a new window opens\n\n### `style` (optional)\n\n| Requires Hyprland > 0.40.0\n\nNot set by default.\n\nAllow to set a list of styles to the main (centered) window, eg:\n\n```toml\nstyle = [\"opacity 1\", \"bordercolor rgb(FFFF00)\"]\n```\n\n### `margin` (optional)\n\ndefault value is `60`\n\nmargin (in pixels) used when placing the center window, calculated from the border of the screen.\n\nExample to make the main window be 100px far from the monitor's limits:\n```toml\nmargin = 100\n```\n\nYou can also set a different margin for width and height by using a list:\n```toml\nmargin = [10, 60]\n```\n\n### `offset` (optional)\n\ndefault value is `[0, 0]`\n\noffset in pixels applied to the main window position\n\nExample shift the main window 20px down:\n```toml\noffset = [0, 20]\n```\n\n### `next` (optional)\n### `next2` (optional)\n\nnot set by default\n\nWhen the *layout_center* isn't active and the *next* command is triggered, defines the hyprland dispatcher command to run.\n\n`next2` is a similar option, used by the `next2` command, allowing to map \"next\" to both vertical and horizontal focus change.\n\nEg:\n```toml\nnext = \"movefocus r\"\n```\n\n### `prev` (optional)\n### `prev2` (optional)\n\nSame as `next` but for the `prev` and `prev2` commands.\n\n\n### `captive_focus` (optional)\n\ndefault value is `false`\n\n```toml\ncaptive_focus = true\n```\n\nSets the focus on the main window when the focus changes.\nYou may love it or hate it...\n"
  },
  {
    "path": "site/versions/2.3.8/lost_windows.md",
    "content": "---\ncommands:\n    - name: attract_lost\n      description: Bring windows which are not reachable in the currently focused workspace.\n---\n# lost_windows\n\nBring windows which are not reachable in the currently focused workspace.\n*Deprecated:* Was used to work around issues in Hyprland which have been fixed since then.\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n"
  },
  {
    "path": "site/versions/2.3.8/magnify.md",
    "content": "---\ncommands:\n    - name: zoom [value]\n      description: Set the current zoom level (absolute or relative) - toggle zooming if no value is provided\n---\n# magnify\n\nZooms in and out with an optional animation.\n\n\n<details>\n    <summary>Example</summary>\n\n```sh\npypr zoom  # sets zoom to `factor` (2 by default)\npypr zoom +1  # will set zoom to 3x\npypr zoom  # will set zoom to 1x\npypr zoom 1 # will (also) set zoom to 1x - effectively doing nothing\n```\n\nSample `hyprland.conf`:\n\n```sh\nbind = $mainMod , Z, exec, pypr zoom ++0.5\nbind = $mainMod SHIFT, Z, exec, pypr zoom\n```\n\n</details>\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n### `[value]`\n\n#### unset / not specified\n\nWill zoom to [factor](#factor) if not zoomed, else will set the zoom to 1x.\n\n#### floating or integer value\n\nWill set the zoom to the provided value.\n\n#### +value / -value\n\nUpdate (increment or decrement) the current zoom level by the provided value.\n\n#### ++value / --value\n\nUpdate (increment or decrement) the current zoom level by a non-linear scale.\nIt _looks_ more linear changes than using a single + or -.\n\n> [!NOTE]\n>\n> The non-linear scale is calculated as powers of two, eg:\n>\n> - `zoom ++1` → 2x, 4x, 8x, 16x...\n> - `zoom ++0.7` → 1.6x, 2.6x, 4.3x, 7.0x, 11.3x, 18.4x...\n> - `zoom ++0.5` → 1.4x, 2x, 2.8x, 4x, 5.7x, 8x, 11.3x, 16x...\n\n## Configuration\n\n### `factor` (optional)\n\ndefault value is `2`\n\nScaling factor to be used when no value is provided.\n\n### `duration` (optional)\n\nDefault value is `15`\n\nDuration in tenths of a second for the zoom animation to last, set to `0` to disable animations.\n"
  },
  {
    "path": "site/versions/2.3.8/monitors.md",
    "content": "---\ncommands:\n  - name: relayout\n    description: Apply the configuration and update the layout\n---\n\n# monitors\n\n> First, simpler version of the plugin is still available under the name `monitors_v0`\n\nAllows relative placement of monitors depending on the model (\"description\" returned by `hyprctl monitors`).\nUseful if you have multiple monitors connected to a video signal switch or using a laptop and plugging monitors having different relative positions.\n\nSyntax:\n\n```toml\n[monitors.placement]\n\"description match\".placement = \"other description match\"\n```\n\n<details>\n    <summary>Example to set a Sony monitor on top of a BenQ monitor</summary>\n\n```toml\n[monitors.placement]\nSony.topOf = \"BenQ\"\n\n## Character case is ignored, \"_\" can be added\nSony.Top_Of = [\"BenQ\"]\n\n## Thanks to TOML format, complex configurations can use separate \"sections\" for clarity, eg:\n\n[monitors.placement.\"My monitor brand\"]\n## You can also use \"port\" names such as *HDMI-A-1*, *DP-1*, etc...\nleftOf = \"eDP-1\"\n\n## lists are possible on the right part of the assignment:\nrightOf = [\"Sony\", \"BenQ\"]\n\n## > 2.3.2: you can also set scale, transform & rate for a given monitor\n[monitors.placement.Microstep]\nrate = 100\n```\n\nTry to keep the rules as simple as possible, but relatively complex scenarios are supported.\n\n> [!note]\n> Check [wlr layout UI](https://github.com/fdev31/wlr-layout-ui) which is a nice complement to configure your monitor settings.\n\n</details>\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `placement`\n\nSupported placements are:\n\n- leftOf\n- topOf\n- rightOf\n- bottomOf\n- \\<one of the above>(center|middle|end)Of\n\n> [!important]\n> If you don't like the screen to align on the start of the given border,\n> you can use `center` (or `middle`) to center it or `end` to stick it to the opposite border.\n> Eg: \"topCenterOf\", \"leftEndOf\", etc...\n\nYou can separate the terms with \"_\" to improve the readability, as in \"top_center_of\".\n\n#### monitor settings\n\nNot only can you place monitors relatively to each other, but you can also set specific settings for a given monitor.\n\nThe following settings are supported:\n\n- scale\n- transform\n- rate\n- resolution\n\n```toml\n[monitors.placement.\"My monitor brand\"]\nrate = 60\nscale = 1.5\ntransform = 1 # 0: normal, 1: 90°, 2: 180°, 3: 270°, 4: flipped, 5: flipped 90°, 6: flipped 180°, 7: flipped 270°\nresolution = \"1920x1080\"  # can also be expressed as [1920, 1080]\n```\n\n### `startup_relayout` (optional)\n\nDefault to `ŧrue`.\n\nWhen set to `false`,\ndo not initialize the monitor layout on startup or when configuration is reloaded.\n\n### `full_relayout` (legacy - always enabled now)\n\n### `new_monitor_delay` (optional)\n\nBy default,\nthe layout computation happens one second after the event is received to let time for things to settle.\n\nYou can change this value using this option.\n\n### `hotplug_command` (optional)\n\nNone by default, allows to run a command when any monitor is plugged.\n\n\n```toml\n[monitors]\nhotplug_commands = \"wlrlui -m\"\n```\n\n### `hotplug_commands` (optional)\n\nNone by default, allows to run a command when a given monitor is plugged.\n\nExample to load a specific profile using [wlr layout ui](https://github.com/fdev31/wlr-layout-ui):\n\n```toml\n[monitors.hotplug_commands]\n\"DELL P2417H CJFH277Q3HCB\" = \"wlrlui rotated\"\n```\n\n### `unknown` (optional)\n\nNone by default,\nallows to run a command when no monitor layout has been changed (no rule applied).\n\n```toml\n[monitors]\nunknown = \"wlrlui\"\n```\n\n### `trim_offset` (optional)\n\n`true` by default,\n\nPrevents having arbitrary window offsets or negative values.\n"
  },
  {
    "path": "site/versions/2.3.8/scratchpads.md",
    "content": "---\ncommands:\n  - name: toggle [scratchpad name]\n    description: Toggle the given scratchpad\n  - name: show [scratchpad name]\n    description: Show the given scratchpad\n  - name: hide [scratchpad name]\n    description: Hide the given scratchpad\n  - name: attach\n    description: Toggle attaching/anchoring the currently focused window to the (last used) scratchpad\n---\n# scratchpads\n\nEasily toggle the visibility of applications you use the most.\n\nConfigurable and flexible, while supporting complex setups it's easy to get started with:\n\n```toml\n[scratchpads.name]\ncommand = \"command to run\"\nclass = \"the window's class\"  # check: hyprctl clients | grep class\nsize = \"[width] [height]\"  # size of the window relative to the screen size\n```\n\n<details>\n<summary>Example</summary>\n\nAs an example, defining two scratchpads:\n\n- _term_ which would be a kitty terminal on upper part of the screen\n- _volume_ which would be a pavucontrol window on the right part of the screen\n\n\n```toml\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\nmax_size = \"1920px 100%\"\nmargin = 50\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nsize = \"40% 90%\"\nunfocus = \"hide\"\nlazy = true\n```\n\nShortcuts are generally needed:\n\n```ini\nbind = $mainMod,V,exec,pypr toggle volume\nbind = $mainMod,A,exec,pypr toggle term\nbind = $mainMod,Y,exec,pypr attach\n```\n\nNote that when `class` is provided, the window is automatically managed by pyprland.\nWhen you create a scratchpad called \"name\", it will be hidden in `special:scratch_<name>`.\n\n> [!note]\n> If you wish to have a more generic space for any application you may run, check [toggle_special](./toggle_special).\n\n</details>\n\n## Commands\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n> [!tip]\n> You can use `\"*\"` as a _scratchpad name_ to target every scratchpad when using `show` or `hide`.\n> You'll need to quote or escape the `*` character to avoid interpretation from your shell.\n\n## Configuration\n\n### `command`\n\nThis is the command you wish to run in the scratchpad.\n\nIt supports [Variables](./Variables)\n\n### `animation` (optional)\n\nType of animation to use, default value is \"fromTop\":\n\n- `null` / `\"\"` (no animation)\n- `fromTop` (stays close to top screen border)\n- `fromBottom` (stays close to bottom screen border)\n- `fromLeft` (stays close to left screen border)\n- `fromRight` (stays close to right screen border)\n\n### `size` (recommended)\n\nNo default value.\n\nEach time scratchpad is shown, window will be resized according to the provided values.\n\nFor example on monitor of size `800x600` and `size= \"80% 80%\"` in config scratchpad always have size `640x480`,\nregardless of which monitor it was first launched on.\n\n> #### Format\n>\n> String with \"x y\" (or \"width height\") values using some units suffix:\n>\n> - **percents** relative to the focused screen size (`%` suffix), eg: `60% 30%`\n> - **pixels** for absolute values (`px` suffix), eg: `800px 600px`\n> - a mix is possible, eg: `800px 40%`\n\n### `class` (recommended)\n\nNo default value.\n\nAllows _Pyprland_ prepare the window for a correct animation and initial positioning.\n\n### `position` (optional)\n\nNo default value, overrides the automatic margin-based position.\n\nSets the scratchpad client window position relative to the top-left corner.\n\nSame format as `size` (see above)\n\nExample of scratchpad that always seat on the top-right corner of the screen:\n\n```toml\n[scratchpads.term_quake]\ncommand = \"wezterm start --class term_quake\"\nposition = \"50% 0%\"\nsize = \"50% 50%\"\nclass = \"term_quake\"\n```\n\n> [!note]\n> If `position` is not provided, the window is placed according to `margin` on one axis and centered on the other.\n\n### `multi` (optional)\n\nDefaults to `true`.\n\nWhen set to `false`, only one client window is supported for this scratchpad.\nOtherwise other matching windows will be **attach**ed to the scratchpad.\n\n## Advanced configuration\n\nTo go beyond the basic setup and have a look at every configuration item, you can read the following pages:\n\n- [Advanced](./scratchpads_advanced) contains options for fine-tuners or specific tastes (eg: i3 compatibility)\n- [Non-Standard](./scratchpads_nonstandard) contains options for \"broken\" applications\nlike progressive web apps (PWA) or emacsclient, use only if you can't get it to work otherwise\n\n## Monitor specific overrides\n\nYou can use different settings for a specific screen.\nMost attributes related to the display can be changed (not `command`, `class` or `process_tracking` for instance).\n\nUse the `monitor.<monitor name>` configuration item to override values, eg:\n\n```toml\n[scratchpads.music.monitor.eDP-1]\nposition = \"30% 50%\"\nanimation = \"fromBottom\"\n```\n\nYou may want to inline it for simple cases:\n\n```toml\n[scratchpads.music]\nmonitor = {HDMI-A-1={size = \"30% 50%\"}}\n```\n"
  },
  {
    "path": "site/versions/2.3.8/scratchpads_advanced.md",
    "content": "# Fine tuning scratchpads\n\nAdvanced configuration options\n\n## `excludes` (optional)\n\nNo default value.\n\nList of scratchpads to hide when this one is displayed, eg: `excludes = [\"term\", \"volume\"]`.\nIf you want to hide every displayed scratch you can set this to the string `\"*\"` instead of a list: `excludes = \"*\"`.\n\n## `unfocus` (optional)\n\nNo default value.\n\nWhen set to `\"hide\"`, allow to hide the window when the focus is lost.\n\nUse `hysteresis` to change the reactivity\n\n## `hysteresis` (optional)\n\nDefaults to `0.4` (seconds)\n\nControls how fast a scratchpad hiding on unfocus will react. Check `unfocus` option.\nSet to `0` to disable.\n\n> [!important]\n> Only relevant when `unfocus=\"hide\"` is used.\n\n## `margin` (optional)\n\ndefault value is `60`.\n\nnumber of pixels separating the scratchpad from the screen border, depends on the [animation](./scratchpads#animation) set.\n\n> [!tip]\n> It is also possible to set a string to express percentages of the screen (eg: '`3%`').\n\n## `max_size` (optional)\n\nNo default value.\n\nSame format as `size` (see above), only used if `size` is also set.\n\nLimits the `size` of the window accordingly.\nTo ensure a window will not be too large on a wide screen for instance:\n\n```toml\nsize = \"60% 30%\"\nmax_size = \"1200px 100%\"\n```\n\n## `lazy` (optional)\n\ndefault to `false`.\n\nwhen set to `true`, prevents the command from being started when pypr starts, it will be started when the scratchpad is first used instead.\n\n- Good: saves resources when the scratchpad isn't needed\n- Bad: slows down the first display (app has to launch before showing)\n\n## `preserve_aspect` (optional)\n\nNot set by default.\nWhen set to `true`, will preserve the size and position of the scratchpad when called repeatedly from the same monitor and workspace even though an `animation` , `position` or `size` is used (those will be used for the initial setting only).\n\nForces the `lazy` option.\n\n## `offset` (optional)\n\nIn pixels, default to `0` (client's window size + margin).\n\nNumber of pixels for the **hide** sliding animation (how far the window will go).\n\n> [!tip]\n> - It is also possible to set a string to express percentages of the client window\n> - `margin` is automatically added to the offset\n> - automatic (value not set) is same as `\"100%\"`\n\n## `hide_delay` (optional)\n\nDefaults to `0.2`\n\nDelay (in seconds) after which the hide animation happens, before hiding the scratchpad.\n\nRule of thumb, if you have an animation with speed \"7\", as in:\n```bash\n    animation = windowsOut, 1, 7, easeInOut, popin 80%\n```\nYou can divide the value by two and round to the lowest value, here `3`, then divide by 10, leading to `hide_delay = 0.3`.\n\n## `restore_focus` (optional)\n\nEnabled by default, set to `false` if you don't want the focused state to be restored when a scratchpad is hidden.\n\n## `force_monitor` (optional)\n\nIf set to some monitor name (eg: `\"DP-1\"`), it will always use this monitor to show the scratchpad.\n\n## `alt_toggle` (optional)\n\nDefault value is `false`\n\nWhen enabled, use an alternative `toggle` command logic for multi-screen setups.\nIt applies when the `toggle` command is triggered and the toggled scratchpad is visible on a screen which is not the focused one.\n\nInstead of moving the scratchpad to the focused screen, it will hide the scratchpad.\n\n## `allow_special_workspaces` (optional)\n\nDefault value is `false` (can't be enabled when using *Hyprland* < 0.39 where this behavior can't be controlled and is disabled).\n\nWhen enabled, you can toggle a scratchpad over a special workspace.\nIt will always use the \"normal\" workspace otherwise.\n\n## `smart_focus` (optional)\n\nDefault value is `true`.\n\nWhen enabled, the focus will be restored in a best effort way as en attempt to improve the user experience.\nIf you face issues such as spontaneous workspace changes, you can disable this feature.\n\n"
  },
  {
    "path": "site/versions/2.3.8/scratchpads_nonstandard.md",
    "content": "# Troubleshooting scratchpads\n\nOptions that should only be used for applications that are not behaving in a \"standard\" way.\n\n## `match_by` (optional)\n\nDefault value is `\"pid\"`\nWhen set to a sensitive client property value (eg: `class`, `initialClass`, `title`, `initialTitle`), will match the client window using the provided property instead of the PID of the process.\n\nThis property must be set accordingly, eg:\n\n```toml\nmatch_by = \"class\"\nclass = \"my-web-app\"\n```\n\nor\n\n```toml\nmatch_by = \"initialClass\"\ninitialClass = \"my-web-app\"\n```\n\nYou can add the \"re:\" prefix to use a regular expression, eg:\n\n```toml\nmatch_by = \"title\"\ntitle = \"re:.*some string.*\"\n```\n\n> [!note]\n> Some apps may open the graphical client window in a \"complicated\" way, to work around this, it is possible to disable the process PID matching algorithm and simply rely on window's class.\n>\n> The `match_by` attribute can be used to achieve this, eg. for emacsclient:\n> ```toml\n> [scratchpads.emacs]\n> command = \"/usr/local/bin/emacsStart.sh\"\n> class = \"Emacs\"\n> match_by = \"class\"\n> ```\n\n## `process_tracking` (optional)\n\nDefault value is `true`\n\nAllows disabling the process management. Use only if running a progressive web app (Chrome based apps) or similar.\n\nThis will automatically force `lazy = true` and set `match_by=\"class\"` if no `match_by` rule is provided, to help with the fuzzy client window matching.\n\nIt requires defining a `class` option (or the option matching your `match_by` value).\n\n```toml\n## Chat GPT on Brave\n[scratchpads.gpt]\nanimation = \"fromTop\"\ncommand = \"brave --profile-directory=Default --app=https://chat.openai.com\"\nclass = \"brave-chat.openai.com__-Default\"\nsize = \"75% 60%\"\nprocess_tracking = false\n\n## Some chrome app\n[scratchpads.music]\ncommand = \"google-chrome --profile-directory=Default --app-id=cinhimbnkkaeohfgghhklpknlkffjgod\"\nclass = \"chrome-cinhimbnkkaeohfgghhklpknlkffjgod-Default\"\nsize = \"50% 50%\"\nprocess_tracking = false\n```\n\n> [!tip]\n> To list windows by class and title you can use:\n> - `hyprctl -j clients | jq '.[]|[.class,.title]'`\n> - or if you prefer a graphical tool: `rofi -show window`\n\n> [!note]\n> Progressive web apps will share a single process for every window.\n> On top of requiring the class based window tracking (using `match_by`),\n> the process can not be managed the same way as usual apps and the correlation\n> between the process and the client window isn't as straightforward and can lead to false matches in extreme cases.\n\n## `skip_windowrules` (optional)\n\nDefault value is `[]`\nAllows you to skip the window rules for a specific scratchpad.\nAvailable rules are:\n\n- \"aspect\" controlling size and position\n- \"float\" controlling the floating state\n- \"workspace\" which moves the window to its own workspace\n\nIf you are using an application which can spawn multiple windows and you can't see them, you can skip rules made to improve the initial display of the window.\n\n```toml\n[scratchpads.filemanager]\nanimation = \"fromBottom\"\ncommand = \"nemo\"\nclass = \"nemo\"\nsize = \"60% 60%\"\nskip_windowrules = [\"aspect\", \"workspace\"]\n```\n"
  },
  {
    "path": "site/versions/2.3.8/shift_monitors.md",
    "content": "---\ncommands:\n    - name: shift_monitors <direction>\n      description: Swaps the workspaces of every screen in the given direction.\n---\n\n# shift_monitors\n\nSwaps the workspaces of every screen in the given direction.\n\n> [!Note]\n> the behavior can be hard to predict if you have more than 2 monitors (depending on your layout).\n> If you use this plugin with many monitors and have some ideas about a convenient configuration, you are welcome ;)\n\nExample usage in `hyprland.conf`:\n\n```\nbind = $mainMod, O, exec, pypr shift_monitors +1\nbind = $mainMod SHIFT, O, exec, pypr shift_monitors -1\n```\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n"
  },
  {
    "path": "site/versions/2.3.8/shortcuts_menu.md",
    "content": "---\ncommands:\n    - name: menu [name]\n      description: Displays the full menu or part of it if \"name\" is provided\n---\n\n# shortcuts_menu\n\nPresents some menu to run shortcut commands. Supports nested menus (aka categories / sub-menus).\n\n<details>\n   <summary>Configuration example</summary>\n\n```toml\n[shortcuts_menu.entries]\n\n\"Open Jira ticket\" = 'open-jira-ticket \"$(wl-paste)\"'\nRelayout = \"pypr relayout\"\n\"Fetch window\" = \"pypr fetch_client_menu\"\n\"Hyprland socket\" = 'kitty  socat - \"UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock\"'\n\"Hyprland logs\" = 'kitty tail -f $XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/hyprland.log'\n\n\"Serial USB Term\" = [\n    {name=\"device\", command=\"ls -1 /dev/ttyUSB*; ls -1 /dev/ttyACM*\"},\n    {name=\"speed\", options=[\"115200\", \"9600\", \"38400\", \"115200\", \"256000\", \"512000\"]},\n    \"kitty miniterm --raw --eol LF [device] [speed]\"\n]\n\n\"Color picker\" = [\n    {name=\"format\", options=[\"hex\", \"rgb\", \"hsv\", \"hsl\", \"cmyk\"]},\n    \"sleep 0.2; hyprpicker --format [format] | wl-copy\" # sleep to let the menu close before the picker opens\n]\n```\n\n</details>\n\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n> [!tip]\n> - If \"name\" is provided it will show the given sub-menu.\n> - You can use \".\" to reach any level of the configured menus.\n>\n>      Example to reach `shortcuts_menu.entries.utils.\"local commands\"`, use:\n>      ```sh\n>       pypr menu \"utils.local commands\"\n>      ```\n\n## Configuration\n\nAll the [Menu](./Menu) configuration items are also available.\n\n### `entries`\n\nDefines the menu entries. Supports [Variables](./Variables)\n\n```toml\n[shortcuts_menu.entries]\n\"entry 1\" = \"command to run\"\n\"entry 2\" = \"command to run\"\n```\nSubmenus can be defined too (there is no depth limit):\n\n```toml\n[shortcuts_menu.entries.\"My submenu\"]\n\"entry X\" = \"command\"\n\n[shortcuts_menu.entries.one.two.three.four.five]\nfoobar = \"ls\"\n```\n\n#### Advanced usage\n\nInstead of navigating a configured list of menu options and running a pre-defined command, you can collect various *variables* (either static list of options selected by the user, or generated from a shell command) and then run a command using those variables. Eg:\n\n```toml\n\"Play Video\" = [\n    {name=\"video_device\", command=\"ls -1 /dev/video*\"},\n    {name=\"player\",\n        options=[\"mpv\", \"guvcview\"]\n    },\n    \"[player] [video_device]\"\n]\n\n\"Ssh\" = [\n    {name=\"action\", options=[\"htop\", \"uptime\", \"sudo halt -p\"]},\n    {name=\"host\", options=[\"gamix\", \"gate\", \"idp\"]},\n    \"kitty --hold ssh [host] [action]\"\n]\n```\n\nYou must define a list of objects, containing:\n- `name`: the variable name\n- then the list of options, must be one of:\n    - `options` for a static list of options\n    - `command` to get the list of options from a shell command's output\n\n> [!tip]\n> You can apply post-filters to the `command` output, eg:\n> ```toml\n> {\n>   name = \"entry\",\n>   command = \"cliphist list\",\n>   filter = \"s/\\t.*//\"  # will remove everything after the TAB character\n> }\n> ```\n> check the [filters](./filters) page for more details\n\nThe last item of the list must be a string which is the command to run. Variables can be used enclosed in `[]`.\n\n### `command_start` (optional)\n### `command_end` (optional)\n### `submenu_start` (optional)\n### `submenu_end` (optional)\n\nAllow adding some text (eg: icon) before / after a menu entry.\n\ncommand_* is for final commands, while submenu_* is for entries leading to another menu.\n\nBy default `submenu_end` is set to a right arrow sign, while other attributes are not set.\n\n### `skip_single` (optional)\n\nDefaults to `true`.\nWhen disabled, shows the menu even for single options\n\n## Hints\n\n### Multiple menus\n\nTo manage multiple distinct menus, always use a name when using the `pypr menu <name>` command.\n\nExample of a multi-menu configuration:\n\n```toml\n[shortcuts_menu.entries.\"Basic commands\"]\n\"entry X\" = \"command\"\n\"entry Y\" = \"command2\"\n\n[shortcuts_menu.entries.menu2]\n## ...\n```\n\nYou can then show the first menu using `pypr menu \"Basic commands\"`\n"
  },
  {
    "path": "site/versions/2.3.8/sidebar.json",
    "content": "{\n  \"main\": [\n    {\n      \"text\": \"Getting started\",\n      \"link\": \"./Getting-started\"\n    },\n    {\n      \"text\": \"Plugins\",\n      \"link\": \"./Plugins\"\n    },\n    {\n      \"text\": \"Troubleshooting\",\n      \"link\": \"./Troubleshooting\"\n    },\n    {\n      \"text\": \"Development\",\n      \"link\": \"./Development\"\n    }\n  ],\n  \"plugins\": {\n    \"text\": \"Featured plugins\",\n    \"collapsed\": false,\n    \"items\": [\n      {\n        \"text\": \"Expose\",\n        \"link\": \"./expose\"\n      },\n      {\n        \"text\": \"Fetch client menu\",\n        \"link\": \"./fetch_client_menu\"\n      },\n      {\n        \"text\": \"Gbar\",\n        \"link\": \"./gbar\"\n      },\n      {\n        \"text\": \"Layout center\",\n        \"link\": \"./layout_center\"\n      },\n      {\n        \"text\": \"Lost windows\",\n        \"link\": \"./lost_windows\"\n      },\n      {\n        \"text\": \"Magnify\",\n        \"link\": \"./magnify\"\n      },\n      {\n        \"text\": \"Monitors\",\n        \"link\": \"./monitors\"\n      },\n      {\n        \"text\": \"Scratchpads\",\n        \"link\": \"./scratchpads\",\n        \"items\": [\n          {\n            \"text\": \"Advanced\",\n            \"link\": \"./scratchpads_advanced\"\n          },\n          {\n            \"text\": \"Special cases\",\n            \"link\": \"./scratchpads_nonstandard\"\n          }\n        ]\n      },\n      {\n        \"text\": \"Shift monitors\",\n        \"link\": \"./shift_monitors\"\n      },\n      {\n        \"text\": \"Shortcuts menu\",\n        \"link\": \"./shortcuts_menu\"\n      },\n      {\n        \"text\": \"System notifier\",\n        \"link\": \"./system_notifier\"\n      },\n      {\n        \"text\": \"Toggle dpms\",\n        \"link\": \"./toggle_dpms\"\n      },\n      {\n        \"text\": \"Toggle special\",\n        \"link\": \"./toggle_special\"\n      },\n      {\n        \"text\": \"Wallpapers\",\n        \"link\": \"./wallpapers\"\n      },\n      {\n        \"text\": \"Workspaces follow focus\",\n        \"link\": \"./workspaces_follow_focus\"\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "site/versions/2.3.8/system_notifier.md",
    "content": "# system_notifier\n\nThis plugin adds system notifications based on journal logs (or any program's output).\nIt monitors specified **sources** for log entries matching predefined **patterns** and generates notifications accordingly (after applying an optional **filter**).\n\nSources are commands that return a stream of text (eg: journal, mqtt, tail -f, ...) which is sent to a parser that will use a [regular expression pattern](https://en.wikipedia.org/wiki/Regular_expression) to detect lines of interest and optionally transform them before sending the notification.\n\n<details>\n    <summary>Minimal configuration</summary>\n\n```toml\n[system_notifier.sources]\ncommand = \"sudo journalctl -fx\"\nparser = \"journal\"\n```\n\nIn general you will also need to define some **parsers**.\nBy default a **\"journal\"** parser is provided, otherwise you need to define your own rules.\nThis built-in configuration is close to this one, provided as an example:\n\n```toml\n[system_notifier.parsers.journal]\npattern = \"([a-z0-9]+): Link UP$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is active/\"\ncolor= \"#00aa00\"\n\n[system_notifier.parsers.journal]\npattern = \"([a-z0-9]+): Link DOWN$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is inactive/\"\ncolor= \"#ff8800\"\n\n[system_notifier.parsers.journal]\npattern = \"Process \\d+ \\(.*\\) of .* dumped core.\"\nfilter = \"s/.*Process \\d+ \\((.*)\\) of .* dumped core./\\1 dumped core/\"\ncolor= \"#aa0000\"\n\n[system_notifier.parsers.journal]\npattern = \"usb \\d+-[0-9.]+: Product: \"\nfilter = \"s/.*usb \\d+-[0-9.]+: Product: (.*)/USB plugged: \\1/\"\n```\n</details>\n\n\n## Configuration\n\n### `sources`\n\nList of sources to enable (by default nothing is enabled)\n\nEach source must contain a `command` to run and a `parser` to use.\n\nYou can also use a list of parsers, eg:\n\n```toml\n[system_notifier.sources](system_notifier.sources)\ncommand = \"sudo journalctl -fkn\"\nparser = [\"journal\", \"custom_parser\"]\n```\n\n#### command\n\nThis is the long-running command (eg: `tail -f <filename>`) returning the stream of text that will be updated. Aa common option is the system journal output (eg: `journalctl -u nginx`)\n\n#### parser\n\nSets the list of rules / parser to be used to extract lines of interest.\nMust match a list of rules defined as `system_notifier.parsers.<parser_name>`.\n\n### `parsers`\n\nA list of available parsers that can be used to detect lines of interest in the **sources** and re-format it before issuing a notification.\n\nEach parser definition must contain a **pattern** and optionally a **filter** and a **color**.\n\n#### pattern\n\n```toml\n[system_notifier.parsers.custom_parser](system_notifier.parsers.custom_parser)\n\npattern = 'special value:'\n```\n\nThe pattern is any regular expression.\n\n#### filter\n\nThe [filters](./filters) allows to change the text before the notification, eg:\n`filter=\"s/.*special value: (\\d+)/Value=\\1/\"`\nwill set a filter so a string \"special value: 42\" will lead to the notification \"Value=42\"\n\n#### color\n\nYou can also provide an optional **color** in `\"hex\"` or `\"rgb()\"` format\n\n```toml\ncolor = \"#FF5500\"\n```\n\n### default_color (optional)\n\nSets the notification color that will be used when none is provided in a *parser* definition.\n\n```toml\n[system_notifier]\ndefault_color = \"#bbccbb\"\n```\n"
  },
  {
    "path": "site/versions/2.3.8/toggle_dpms.md",
    "content": "---\ncommands:\n    - name: toggle_dpms\n      description: if any screen is powered on, turn them all off, else turn them all on\n---\n\n# toggle_dpms\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n"
  },
  {
    "path": "site/versions/2.3.8/toggle_special.md",
    "content": "---\ncommands:\n    - name: toggle_special [name]\n      description: moves the focused window to the special workspace <code>name</code>, or move it back to the active workspace\n---\n\n# toggle_special\n\nAllows moving the focused window to a special workspace and back (based on the visibility status of that workspace).\n\nIt's a companion of the `togglespecialworkspace` Hyprland's command which toggles a special workspace's visibility.\n\nYou most likely will need to configure the two commands for a complete user experience, eg:\n\n```bash\nbind = $mainMod SHIFT, N, togglespecialworkspace, stash # toggles \"stash\" special workspace visibility\nbind = $mainMod, N, exec, pypr toggle_special stash # moves window to/from the \"stash\" workspace\n```\n\nNo other configuration needed, here `MOD+SHIFT+N` will show every window in \"stash\" while `MOD+N` will move the focused window out of it/ to it.\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n"
  },
  {
    "path": "site/versions/2.3.8/wallpapers.md",
    "content": "---\ncommands:\n    - name: wall next\n      description: Changes the current background image\n    - name: wall clear\n      description: Removes the current background image\n---\n\n# wallpapers\n\nSearch folders for images and sets the background image at a regular interval.\nImages are selected randomly from the full list of images found.\n\nIt serves two purposes:\n\n- adding support for random images to any background setting tool\n- quickly testing different tools with a minimal effort\n\nIt allows \"zapping\" current backgrounds, clearing it to go distraction free and optionally make them different for each screen.\n\n<details>\n    <summary>Minimal example (requires <b>swaybg</b> by default)</summary>\n\n```toml\n[wallpapers]\npath = \"~/Images/wallpapers/\" # path to the folder with background images\nunique = true # set a different wallpaper for each screen\n```\n\n</details>\n\n<details>\n<summary>More complex, using <b>swww</b> as a backend (not recommended because of its stability)</summary>\n\n```toml\n[wallpapers]\npath = \"~/Images/wallpapers/\"\ninterval = 60 # change every hour\nextensions = [\"jpg\", \"jpeg\"]\nrecurse = true\n## Using swww\ncommand = 'swww img --transition-type any \"[file]\"'\nclear_command = \"swww clear\"\n```\n\nNote that for applications like `swww`, you'll need to start a daemon separately (eg: from `hyprland.conf`).\n</details>\n\n\n## Commands\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n\n### `path`\n\npath to a folder or list of folders that will be searched. Can also be a list, eg:\n\n```toml\npath = [\"~/Images/Portraits/\", \"~/Images/Landscapes/\"]\n```\n\n### `interval` (optional)\n\ndefaults to `10`\n\nHow long (in minutes) a background should stay in place\n\n\n### `command` (optional)\n\nOverrides the default command to set the background image.\n\n[variables](./Variables) are replaced with the appropriate values, you must use a `\"[file]\"` placeholder for the image path. eg:\n\n```\nswaybg -m fill -i \"[file]\"\n```\n\n### `clear_command` (optional)\n\nBy default `clear` command kills the `command` program.\n\nInstead of that, you can provide a command to clear the background. eg:\n\n```\nclear_command = \"swaybg clear\"\n``````\n\n### `extensions` (optional)\n\ndefaults to `[\"png\", \"jpg\", \"jpeg\"]`\n\nList of valid wallpaper image extensions.\n\n### `recurse` (optional)\n\ndefaults to `false`\n\nWhen enabled, will also search sub-directories recursively.\n\n### `unique` (optional)\n\ndefaults to `false`\n\nWhen enabled, will set a different wallpaper for each screen.\n\nIf you are not using the default application, ensure you are using `\"[output]\"` in the [command](#command) template.\n\nExample for swaybg: `swaybg -o \"[output]\" -m fill -i \"[file]\"`\n"
  },
  {
    "path": "site/versions/2.3.8/workspaces_follow_focus.md",
    "content": "---\ncommands:\n    - name: change_workspace [direction]\n      description: Changes the workspace of the focused monitor in the given direction.\n---\n\n# workspaces_follow_focus\n\nMake non-visible workspaces follow the focused monitor.\nAlso provides commands to switch between workspaces while preserving the current monitor assignments:\n\nSyntax:\n```toml\n[workspaces_follow_focus]\nmax_workspaces = 4 # number of workspaces before cycling\n```\nExample usage in `hyprland.conf`:\n\n```ini\nbind = $mainMod, K, exec, pypr change_workspace +1\nbind = $mainMod, J, exec, pypr change_workspace -1\n ```\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `max_workspaces` (optional)\n\nLimits the number of workspaces when switching, defaults value is `10`.\n"
  },
  {
    "path": "site/versions/2.4.0/Development.md",
    "content": "# Development\n\nIt's easy to write your own plugin by making a python package and then indicating it's name as the plugin name.\n\n[Contributing guidelines](https://github.com/hyprland-community/pyprland/blob/main/CONTRIBUTING.md)\n\n# Writing plugins\n\nPlugins can be loaded with full python module path, eg: `\"mymodule.pyprlandplugin\"`, the loaded module must provide an `Extension` class.\n\nCheck the `interface.py` file to know the base methods, also have a look at the example below.\n\nTo get more details when an error is occurring, use `pypr --debug <log file path>`, it will also display the log in the console.\n\n> [!note]\n> To quickly get started, you can directly edit the `experimental` built-in plugin.\n> In order to distribute it, make your own Python package or trigger a pull request.\n> If you prefer to make a separate package, check the [examples](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/)'s package\n\nThe `Extension` interface provides a couple of built-in attributes:\n\n- `config` : object exposing the plugin section in `pyprland.toml`\n- `notify` ,`notify_error`, `notify_info` : access to Hyprland's notification system\n- `hyprctl`, `hyprctl_json` : invoke [Hyprland's IPC system](https://wiki.hyprland.org/Configuring/Dispatchers/)\n\n\n> [!important]\n> Contact me to get your extension listed on the home page\n\n> [!tip]\n> You can set a `plugins_paths=[\"/custom/path/example\"]` in the `hyprland` section of the configuration to add extra paths (eg: during development).\n\n> [!Note]\n> If your extension is at the root of the plugin (this is not recommended, preferable add a name space, as in `johns_pyprland.super_feature`, rather than `super_feature`) you can still import it using the `external:` prefix when you refer to it in the `plugins` list.\n\n# API Documentation\n\nRun `tox run -e doc` then visit `http://localhost:8080`\n\nThe most important to know are:\n\n- `hyprctl_json` to get a response from an IPC query\n- `hyprctl` to trigger general IPC commands\n- `on_reload` to be implemented, called when the config is (re)loaded\n- `run_<command_name>` to implement a command\n- `event_<event_name>` called when the given event is emitted by Hyprland\n\nAll those methods are _async_\n\nOn top of that:\n\n- the first line of a `run_*` command's docstring will be used by the `help` command\n- `self.config` in your _Extension_ contains the entry corresponding to your plugin name in the TOML file\n- `state` from `..common` module contains ready to use information\n- there is a `MenuMixin` in `..adapters.menus` to make menu-based plugins easy\n\n# Workflow\n\nJust `^C` when you make a change and repeat:\n\n```sh\npypr exit ; pypr --debug /tmp/output.log\n```\n\n\n## Creating a plugin\n\n```python\nfrom .interface import Plugin\n\n\nclass Extension(Plugin):\n    \" My plugin \"\n\n    async def init(self):\n        await self.notify(\"My plugin loaded\")\n```\n\n## Adding a command\n\nJust add a method called `run_<name of your command>` to your `Extension` class, eg with \"togglezoom\" command:\n\n```python\n    zoomed = False\n\n    async def run_togglezoom(self, args):\n        \"\"\" this doc string will show in `help` to document `togglezoom`\n        But this line will not show in the CLI help\n        \"\"\"\n      if self.zoomed:\n        await self.hyprctl('misc:cursor_zoom_factor 1', 'keyword')\n      else:\n        await self.hyprctl('misc:cursor_zoom_factor 2', 'keyword')\n      self.zoomed = not self.zoomed\n```\n\n## Reacting to an event\n\nSimilar as a command, implement some `async def event_<the event you are interested in>` method.\n\n## Code safety\n\nPypr ensures only one `run_` or `event_` handler runs at a time, allowing the plugins code to stay simple and avoid the need for concurrency handling.\nHowever, each plugin can run its handlers in parallel.\n\n# Reusable code\n\n```py\nfrom ..common import state, CastBoolMixin\n```\n\n- `state` provides a couple of handy variables so you don't have to fetch them, allow optimizing the most common operations\n- `Mixins` are providing common code, for instance the `CastBoolMixin` provides the `cast_bool` method to your `Extension`.\n\nIf you want to use menus, then the `MenuMixin` will provide:\n- `menu` to show a menu\n- `ensure_menu_configured` to call before you require a menu in your plugin\n\n# Example\n\nYou'll find a basic external plugin in the [examples](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/) folder.\n\nIt provides one command: `pypr dummy`.\n\nRead the [plugin code](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/pypr_examples/focus_counter.py)\n\nIt's a simple python package. To install it for development without a need to re-install it for testing, you can use `pip install -e .` in this folder.\nIt's ready to be published using `poetry publish`, don't forget to update the details in the `pyproject.toml` file.\n\n## Usage\n\nEnsure you added `pypr_examples.focus_counter` to your `plugins` list:\n\n```toml\n[pyprland]\nplugins = [\n  \"pypr_examples.focus_counter\"\n]\n```\n\nOptionally you can customize one color:\n\n```toml\n[\"pypr_examples.focus_counter\"]\ncolor = \"FFFF00\"\n```\n"
  },
  {
    "path": "site/versions/2.4.0/Getting-started.md",
    "content": "# Getting started\n\nPypr consists in two things:\n\n- **a tool**: `pypr` which runs the daemon (service), but also allows to interact with it\n- **some config file**: `~/.config/hypr/pyprland.toml` (or the path set using `--config`) using the [TOML](https://toml.io/en/) format\n\nThe `pypr` tool only have a few built-in commands:\n\n- `help` lists available commands (including plugins commands)\n- `exit` will terminate the service process\n- `edit` edit the configuration using your `$EDITOR` (or `vi`), reloads on exit\n- `dumpjson` shows a JSON representation of the configuration (after other files have been `include`d)\n- `reload` reads the configuration file and apply some changes:\n  - new plugins will be loaded\n  - configuration items will be updated (most plugins will use the new values on the next usage)\n\nOther commands are implemented by adding [plugins](./Plugins).\n\n> [!important]\n> - with no argument it runs the daemon (doesn't fork in the background)\n>\n> - if you pass parameters, it will interact with the daemon instead.\n\n> [!tip]\n> Pypr *command* names are documented using underscores (`_`) but you can use dashes (`-`) instead.\n> Eg: `pypr shift_monitors` and `pypr shift-monitors` will run the same command\n\n\n## Configuration file\n\nThe configuration file uses a [TOML format](https://toml.io/) with the following as the bare minimum:\n\n```toml\n[pyprland]\nplugins = [\"plugin_name\", \"other_plugin\"]\n```\n\nAdditionally some plugins require **Configuration** options, using the following format:\n\n```toml\n[plugin_name]\nplugin_option = 42\n\n[plugin_name.another_plugin_option]\nsuboption = \"config value\"\n```\n\nYou can also split your configuration into [Multiple configuration files](./MultipleConfigurationFiles).\n\n## Installation\n\nCheck your OS package manager first, eg:\n\n- Archlinux: you can find it on AUR, eg with [yay](https://github.com/Jguer/yay): `yay pyprland`\n- NixOS: Instructions in the [Nix](./Nix) page\n\nOtherwise, use the python package manager (pip) [inside a virtual environment](InstallVirtualEnvironment)\n\n```sh\npip install pyprland\n```\n\n## Running\n\n> [!caution]\n> If you messed with something else than your OS packaging system to get `pypr` installed, use the full path to the `pypr` command.\n\nPreferably start the process with hyprland, adding to `hyprland.conf`:\n\n```ini\nexec-once = /usr/bin/pypr\n```\n\nor if you run into troubles (use the first version once your configuration is stable):\n\n```ini\nexec-once = /usr/bin/pypr --debug /tmp/pypr.log\n```\n\n> [!warning]\n> To avoid issues (eg: you have a complex setup, maybe using a virtual environment), you may want to set the full path (eg: `/home/bob/venv/bin/pypr`).\n> You can get it from `which pypr` in a working terminal\n\nOnce the `pypr` daemon is started (cf `exec-once`), you can list the eventual commands which have been added by the plugins using `pypr -h` or `pypr help`, those commands are generally meant to be use via key bindings, see the `hyprland.conf` part of *Configuring* section below.\n\n## Configuring\n\nCreate a configuration file in `~/.config/hypr/pyprland.toml` enabling a list of plugins, each plugin may have its own configuration needs or don't need any configuration at all.\nMost default values should be acceptable for most users, options which hare not mandatory are marked as such.\n\n> [!important]\n> Provide the values for the configuration options which have no annotation such as \"(optional)\"\n\nCheck the [TOML format](https://toml.io/) for details about the syntax.\n\nSimple example:\n\n```toml\n[pyprland]\nplugins = [\n    \"shift_monitors\",\n    \"workspaces_follow_focus\"\n]\n```\n\n<details>\n  <summary>\nMore complex example\n  </summary>\n\n```toml\n[pyprland]\nplugins = [\n  \"scratchpads\",\n  \"lost_windows\",\n  \"monitors\",\n  \"toggle_dpms\",\n  \"magnify\",\n  \"expose\",\n  \"shift_monitors\",\n  \"workspaces_follow_focus\",\n]\n\n[monitors.placement]\n\"Acer\".top_center_of = \"Sony\"\n\n[workspaces_follow_focus]\nmax_workspaces = 9\n\n[expose]\ninclude_special = false\n\n[scratchpads.stb]\nanimation = \"fromBottom\"\ncommand = \"kitty --class kitty-stb sstb\"\nclass = \"kitty-stb\"\nlazy = true\nsize = \"75% 45%\"\n\n[scratchpads.stb-logs]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-stb-logs stbLog\"\nclass = \"kitty-stb-logs\"\nlazy = true\nsize = \"75% 40%\"\n\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nlazy = true\nsize = \"40% 90%\"\nunfocus = \"hide\"\n```\n\nSome of those plugins may require changes in your `hyprland.conf` to fully operate or to provide a convenient access to a command, eg:\n\n```bash\nbind = $mainMod SHIFT, Z, exec, pypr zoom\nbind = $mainMod ALT, P,exec, pypr toggle_dpms\nbind = $mainMod SHIFT, O, exec, pypr shift_monitors +1\nbind = $mainMod, B, exec, pypr expose\nbind = $mainMod, K, exec, pypr change_workspace +1\nbind = $mainMod, J, exec, pypr change_workspace -1\nbind = $mainMod,L,exec, pypr toggle_dpms\nbind = $mainMod SHIFT,M,exec,pypr toggle stb stb-logs\nbind = $mainMod,A,exec,pypr toggle term\nbind = $mainMod,V,exec,pypr toggle volume\n```\n\n</details>\n\n> [!tip]\n> Consult or share [configuration files](https://github.com/hyprland-community/pyprland/tree/main/examples)\n>\n> You might also be interested in [optimizations](./Optimizations).\n"
  },
  {
    "path": "site/versions/2.4.0/InstallVirtualEnvironment.md",
    "content": "# Virtual env\n\nEven though the best way to get Pyprland installed is to use your operating system package manager,\nfor some usages or users it can be convenient to use a virtual environment.\n\nThis is very easy to achieve in a couple of steps:\n\n```shell\npython -m venv ~/pypr-env\n~/pypr-env/bin/pip install pyprland\n```\n\n**That's all folks!**\n\nThe only extra care to take is to use `pypr` from the virtual environment, eg:\n\n- adding the environment's \"bin\" folder to the `PATH` (using `export PATH=\"$PATH:~/pypr-env/bin/\"` in your shell configuration file)\n- always using the full path to the pypr command (in `hyprland.conf`: `exec-once = ~/pypr-env/bin/pypr --debug /tmp/pypr.log`)\n\n# Going bleeding edge!\n\nIf you would rather like to use the latest version available (not released yet), then you can clone the git repository and install from it:\n\n```shell\ncd ~/pypr-env\ngit clone git@github.com:hyprland-community/pyprland.git pyprland-sources\ncd pyprland-sources\n../bin/pip install -e .\n```\n\n## Updating\n\n```shell\ncd ~/pypr-env\ngit pull -r\n```\n\n# Troubelshooting\n\nIf things go wrong, try (eg: after a system upgrade where Python got updated):\n\n```shell\npython -m venv --upgrade ~/pypr-env\ncd  ~/pypr-env\n../bin/pip install -e .\n```\n"
  },
  {
    "path": "site/versions/2.4.0/Menu.md",
    "content": "# Menu capability\n\nMenu based plugins have the following configuration options:\n\n### `engine`\n\nNot set by default, will autodetect the available menu engine.\n\nSupported engines:\n\n- tofi\n- rofi\n- wofi\n- bemenu\n- dmenu\n- anyrun\n\n> [!note]\n> If your menu system isn't supported, you can open a [feature request](https://github.com/hyprland-community/pyprland/issues/new?assignees=fdev31&labels=bug&projects=&template=feature_request.md&title=%5BFEAT%5D+Description+of+the+feature)\n>\n> In case the engine isn't recognized, `engine` + `parameters` configuration options will be used to start the process, it requires a dmenu-like behavior.\n\n### `parameters`\n\nExtra parameters added to the engine command, the default value is specific to each engine.\n\n> [!important]\n> Setting this will override the default value!\n>\n> In general, *rofi*-like programs will require at least `-dmenu` option.\n\n> [!tip]\n> You can use '[prompt]' in the parameters, it will be replaced by the prompt, eg:\n> ```sh\n> -dmenu -matching fuzzy -i -p '[prompt]'\n> ```\n"
  },
  {
    "path": "site/versions/2.4.0/MultipleConfigurationFiles.md",
    "content": "### Multiple configuration files\n\nYou can also split your configuration into multiple files that will be loaded in the provided order after the main file:\n```toml\n[pyprland]\ninclude = [\"/shared/pyprland.toml\", \"~/pypr_extra_config.toml\"]\n```\nYou can also load folders, in which case TOML files in the folder will be loaded in alphabetical order:\n```toml\n[pyprland]\ninclude = [\"~/.config/pypr.d/\"]\n```\n\nAnd then add a `~/.config/pypr.d/monitors.toml` file:\n```toml\npyprland.plugins = [ \"monitors\" ]\n\n[monitors.placement]\nBenQ.Top_Center_Of = \"DP-1\" # projo\n\"CJFH277Q3HCB\".top_of = \"eDP-1\" # work\n```\n\n> [!tip]\n> To check the final merged configuration, you can use the `dumpjson` command.\n\n"
  },
  {
    "path": "site/versions/2.4.0/Nix.md",
    "content": "# Nix\n\nYou are recommended to get the latest pyprland package from the [flake.nix](https://github.com/hyprland-community/pyprland/blob/main/flake.nix)\nprovided within this repository. To use it in your system, you may add pyprland\nto your flake inputs.\n\n## Flake\n\n```nix\n## flake.nix\n{\n  inputs = {\n    nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n    pyprland.url = \"github:hyprland-community/pyprland\";\n  };\n\n  outputs = { self, nixpkgs, pyprland, ...}: let\n  in {\n    nixosConfigurations.<yourHostname> = nixpkgs.lib.nixosSystem {\n      #         remember to replace this with your system arch ↓\n      environment.systemPackages = [ pyprland.packages.\"x86_64-linux\".pyprland ];\n      # ...\n    };\n  };\n}\n```\n\nAlternatively, if you are using the Nix package manager but not NixOS as your\nmain distribution, you may use `nix profile` tool to install pyprland from this\nrepository using the following command.\n\n```bash\nnix profile install github:nix-community/pyprland\n```\n\nThe package will now be in your latest profile. You may use `nix profile list`\nto verify your installation.\n\n## Nixpkgs\n\nPyprland is available under nixpkgs, and can be installed by adding\n`pkgs.pyprland` to either `environment.systemPackages` or `home.packages`\ndepending on whether you want it available system-wide or to only a single\nuser using home-manager. If the derivation available in nixpkgs is out-of-date\nthen you may consider using `overrideAttrs` to update the source locally.\n\n```nix\nlet\n  pyprland = pkgs.pyprland.overrideAttrs {\n    version = \"your-version-here\";\n    src = fetchFromGitHub {\n      owner = \"hyprland-community\";\n      repo = \"pyprland\";\n      rev = \"tag-or-revision\";\n      # leave empty for the first time, add the new hash from the error message\n      hash = \"\";\n    };\n  };\nin {\n  # add the overridden package to systemPackages\n  environment.systemPackages = [pyprland];\n}\n```\n"
  },
  {
    "path": "site/versions/2.4.0/Optimizations.md",
    "content": "## Optimizing\n\n### Plugins\n\n- Only enable the plugins you are using in the `plugins` array (in `[pyprland]` section).\n- Leaving the configuration for plugins which are not enabled will have no impact.\n- Using multiple configuration files only have a small impact on the startup time.\n\n### Pypr command\n\nIn case you want to save some time when interacting with the daemon\nyou can use `socat` instead (needs to be installed). Example of a `pypr-cli` command (should be reachable from your environment's `PATH`):\n```sh\n#!/bin/sh\nsocat - \"UNIX-CONNECT:${XDG_RUNTIME_DIR}/hypr/${HYPRLAND_INSTANCE_SIGNATURE}/.pyprland.sock\" <<< $@\n```\nOn slow systems this may make a difference.\nNote that the \"help\" command will require usage of the standard `pypr` command.\n"
  },
  {
    "path": "site/versions/2.4.0/Plugins.md",
    "content": "<script setup>\nimport PluginList from './components/PluginList.vue'\n</script>\n\nThis page lists every plugin provided by Pyprland out of the box, more can be enabled if you install the matching package.\n\n\"🌟\" indicates some maturity & reliability level of the plugin, considering age, attention paid and complexity - from 0 to 3.\n\n<PluginList/>\n"
  },
  {
    "path": "site/versions/2.4.0/Troubleshooting.md",
    "content": "# Troubleshooting\n\n## General\n\nIn case of trouble running a `pypr` command:\n- kill the existing pypr if any (try `pypr exit` first)\n- run from the terminal adding `--debug /dev/null` to the arguments to get more information\n\nIf the client says it can't connect, then there is a high chance pypr daemon didn't start, check if it's running using `ps axuw |grep pypr`. You can try to run it from a terminal with the same technique: `pypr --debug /dev/null` and see if any error occurs.\n\n## Force hyprland version\n\nIn case your `hyprctl version -j` command isn't returning an accurate version, you can make Pyprland ignore it and use a provided value instead:\n\n```toml\n[pyprland]\nhyprland_version = \"0.41.0\"\n```\n\n## Unresponsive scratchpads\n\nScratchpads aren't responding for few seconds after trying to show one (which didn't show!)\n\nThis may happen if an application is very slow to start.\nIn that case pypr will wait for a window blocking other scratchpad's operation, before giving up after a few seconds.\n\nNote that other plugins shouldn't be blocked by it.\n\nMore scratchpads troubleshooting can be found [here](./scratchpads_nonstandard).\n"
  },
  {
    "path": "site/versions/2.4.0/Variables.md",
    "content": "# Variables & substitutions\n\nSome commands support shared global variables, they must be defined in the *pyprland* section of the configuration:\n```toml\n[pyprland.variables]\nterm = \"foot\"\nterm_classed = \"foot -a\" # kitty uses --class\n```\n\nIf a plugin supports it, you can then use the variables in the attribute that supports it, eg:\n\n```toml\n[myplugin]\nsome_variable = \"the terminal is [term]\"\n```\n"
  },
  {
    "path": "site/versions/2.4.0/components/CommandList.vue",
    "content": "<template>\n    <dl>\n    </dl>\n    <ul v-for=\"command in commands\" :key=\"command.name\">\n        <li><code v-html=\"command.name.replace(/[   ]*$/, '').replace(/ /g, '&ensp;')\" /> <span\n                v-html=\"command.description\" /></li>\n    </ul>\n</template>\n\n<script>\nexport default {\n    props: {\n        commands: {\n            type: Array,\n            required: true\n        }\n    }\n}\n</script>\n"
  },
  {
    "path": "site/versions/2.4.0/components/PluginList.vue",
    "content": "<template>\n    <div>\n        <h1>Built-in plugins</h1>\n        <div v-for=\"plugin in sortedPlugins\" :key=\"plugin.name\" class=\"plugin-item\">\n            <div class=\"plugin-info\">\n                <h3>\n                    <a :href=\"plugin.name + '.html'\">{{ plugin.name }}</a>\n                    <span>&nbsp;{{ '🌟'.repeat(plugin.stars) }}</span>\n                    <span v-if=\"plugin.multimon\">\n                        <Badge type=\"tip\" text=\"multi-monitor\" />\n                    </span>\n                </h3>\n                <p v-html=\"plugin.description\" />\n            </div>\n            <a v-if=\"plugin.demoVideoId\" :href=\"'https://www.youtube.com/watch?v=' + plugin.demoVideoId\"\n                class=\"plugin-video\">\n                <img :src=\"'https://img.youtube.com/vi/' + plugin.demoVideoId + '/1.jpg'\" alt=\"Demo video thumbnail\" />\n            </a>\n        </div>\n    </div>\n</template>\n\n<style scoped>\n.plugin-item {\n    display: flex;\n    align-items: flex-start;\n    margin-bottom: 1.5rem;\n}\n\n.plugin-info {\n    flex: 1;\n}\n\n.plugin-video {\n    margin-left: 1rem;\n}\n\n.plugin-video img {\n    max-width: 200px;\n    /* Adjust as needed */\n    height: auto;\n}\n</style>\n\n\n<script>\n\nexport default {\n    computed: {\n        sortedPlugins() {\n            if (this.sortByStars) {\n                return this.plugins.slice().sort((a, b) => {\n                    if (b.stars === a.stars) {\n                        return a.name.localeCompare(b.name);\n                    }\n                    return b.stars - a.stars;\n                });\n            } else {\n                return this.plugins.slice().sort((a, b) => a.name.localeCompare(b.name));\n            }\n        }\n    },\n    data() {\n        return {\n            sortByStars: false,\n            plugins: [\n                {\n                    name: 'scratchpads',\n                    stars: 3,\n                    description: 'makes your applications dropdowns & togglable poppups',\n                    demoVideoId: 'ZOhv59VYqkc'\n                },\n                {\n                    name: 'magnify',\n                    stars: 3,\n                    description: 'toggles zooming of viewport or sets a specific scaling factor',\n                    demoVideoId: 'yN-mhh9aDuo'\n\n                },\n                {\n                    name: 'toggle_special',\n                    stars: 3,\n                    description: 'moves windows from/to special workspaces',\n                    demoVideoId: 'BNZCMqkwTOo'\n                },\n                {\n                    name: 'shortcuts_menu',\n                    stars: 3,\n                    description: 'a flexible way to make your own shortcuts menus & launchers',\n                    demoVideoId: 'UCuS417BZK8'\n                },\n                {\n                    name: 'fetch_client_menu',\n                    stars: 3,\n                    description: 'select a window to be moved to your active workspace (using rofi, dmenu, etc...)',\n                },\n                {\n                    name: 'layout_center',\n                    stars: 3,\n                    description: 'a workspace layout where one client window is almost maximized and others seat in the background',\n                    demoVideoId: 'vEr9eeSJYDc'\n                },\n                {\n                    name: 'system_notifier',\n                    stars: 3,\n                    description: 'open streams (eg: journal logs) and trigger notifications',\n                },\n                {\n                    name: 'wallpapers',\n                    stars: 3,\n                    description: 'handles random wallpapers at regular interval (from a folder)'\n                },\n                {\n                    name: 'gbar',\n                    stars: 3,\n                    description: 'improves multi-monitor handling of the status bar - only <tt>gBar</tt> is supported at the moment'\n                },\n                {\n                    name: 'expose',\n                    stars: 2,\n                    description: 'exposes all the windows for a quick \"jump to\" feature',\n                    demoVideoId: 'ce5HQZ3na8M'\n                },\n                {\n                    name: 'lost_windows',\n                    stars: 1,\n                    description: 'brings lost floating windows (which are out of reach) to the current workspace'\n                },\n                {\n                    name: 'toggle_dpms',\n                    stars: 0,\n                    description: 'toggles the DPMS status of every plugged monitor'\n                },\n                {\n                    name: 'workspaces_follow_focus',\n                    multimon: true,\n                    stars: 3,\n                    description: 'makes non visible workspaces available on the currently focused screen.<br/> If you think the multi-screen behavior of Hyprland is not usable or broken/unexpected, this is probably for you.'\n\n                },\n                {\n                    name: 'shift_monitors',\n                    multimon: true,\n                    stars: 3,\n                    description: 'moves workspaces from monitor to monitor (caroussel)'\n                },\n                {\n                    name: 'monitors',\n                    stars: 3,\n                    description: 'allows relative placement and configuration of monitors'\n                }\n            ]\n        }\n    }\n}\n</script>\n"
  },
  {
    "path": "site/versions/2.4.0/expose.md",
    "content": "---\ncommands:\n  - name: expose\n    description: Expose every client on the active workspace. If expose is already active, then restores everything and move to the focused window.\n---\n\n# expose\n\nImplements the \"expose\" effect, showing every client window on the focused screen.\n\nFor a similar feature using a menu, try the [fetch_client_menu](./fetch_client_menu) plugin (less intrusive).\n\nSample `hyprland.conf`:\n\n```bash\n# Setup the key binding\nbind = $mainMod, B, exec, pypr expose\n\n# Add some style to the \"exposed\" workspace\nworkspace = special:exposed,gapsout:60,gapsin:30,bordersize:5,border:true,shadow:false\n```\n\n`MOD+B` will bring every client to the focused workspace, pressed again it will go to this workspace.\n\nCheck [workspace rules](https://wiki.hyprland.org/Configuring/Workspace-Rules/#rules) for styling options.\n\n> [!note]\n> If you are looking for `toggle_minimized`, check the [toggle_special](./toggle_special) plugin\n\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n\n### `include_special`\n\ndefault value is `false`\n\nAlso include windows in the special workspaces during the expose.\n\n"
  },
  {
    "path": "site/versions/2.4.0/fetch_client_menu.md",
    "content": "---\ncommands:\n  - name: fetch_client_menu\n    description: Bring any window to the active workspace using a menu.\n---\n\n# fetch_client_menu\n\nBring any window to the active workspace using a menu.\n\nA bit like the [expose](./expose) plugin but using a menu instead (less intrusive).\n\nIt brings the window to the current workspace, while [expose](./expose) moves the currently focused screen to the application workspace.\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\nAll the [Menu](Menu) configuration items are also available.\n\n### `separator`\n\ndefault value is `\"|\"`\n\nChanges the character (or string) used to separate a menu entry from its entry number.\n\n"
  },
  {
    "path": "site/versions/2.4.0/filters.md",
    "content": "# Text filters\n\nAt the moment there is only one filter, close match of [sed's \"s\" command](https://www.gnu.org/software/sed/manual/html_node/The-_0022s_0022-Command.html).\n\nThe only supported flag is \"g\"\n\n## Example\n\n```toml\nfilter = 's/foo/bar/'\nfilter = 's/.*started (.*)/\\1 has started/'\nfilter = 's#</?div>##g'\n```\n"
  },
  {
    "path": "site/versions/2.4.0/gbar.md",
    "content": "---\ncommands:\n  - name: gbar restart\n    description: Restart/refresh gBar on the \"best\" monitor.\n---\n\n# gbar\n\nRuns [gBar](https://github.com/scorpion-26/gBar) on the \"best\" monitor from a list of monitors.\n\n- Will take care of starting gbar on startup (you must not run it from another source like `hyprland.conf`).\n- Automatically restarts gbar on crash\n- Checks which monitors are on and take the best one from a provided list\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n\n## Configuration\n\n\n### `monitors` (REQUIRED)\n\nList of monitors to chose from, the first have higher priority over the second one etc...\n\n\n## Example\n\n```sh\n[gbar]\nmonitors = [\"DP-1\", \"HDMI-1\", \"HDMI-1-A\"]\n```\n"
  },
  {
    "path": "site/versions/2.4.0/index.md",
    "content": "---\n# https://vitepress.dev/reference/default-theme-home-page\nlayout: home\n\nhero:\n  text: \"Hyprland extensions\"\n  tagline: Enhance your desktop capabilities with Pyprland\n  image:\n    src: /logo.svg\n    alt: logo\n  actions:\n    - theme: brand\n      text: Getting started\n      link: ./Getting-started\n    - theme: alt\n      text: Plugins\n      link: ./Plugins\n    - theme: alt\n      text: Report something\n      link: https://github.com/hyprland-community/pyprland/issues/new/choose\n\nfeatures:\n  - title: TOML format\n    details: Simple and flexible configuration file(s)\n  - title: Customizable\n    details: Create your own Hyprland experience\n  - title: Fast and easy\n    details: Designed for performance and simplicity\n---\n\n## Version 2.4.0 archive\n"
  },
  {
    "path": "site/versions/2.4.0/layout_center.md",
    "content": "---\ncommands:\n  - name: layout_center toggle\n    description: toggles the layout on and off\n  - name: layout_center next\n    description: switches to the next window (if layout is on) else runs the <a href=\"#next-optional\">next</a> command\n  - name: layout_center prev\n    description: switches to the previous window (if layout is on) else runs the <a href=\"#next-optional\">prev</a> command\n  - name: layout_center next2\n    description: switches to the next window (if layout is on) else runs the <a href=\"#next-optional\">next2</a> command\n  - name: layout_center prev2\n    description: switches to the previous window (if layout is on) else runs the <a href=\"#next-optional\">prev2</a> command\n\n---\n# layout_center\n\nImplements a workspace layout where one window is bigger and centered,\nother windows are tiled as usual in the background.\n\nOn `toggle`, the active window is made floating and centered if the layout wasn't enabled, else reverts the floating status.\n\nWith `next` and `prev` you can cycle the active window, keeping the same layout type.\nIf the layout_center isn't active and `next` or `prev` is used, it will call the [next](#next-optional) and [prev](#prev-optional) configuration options.\n\nTo allow full override of the focus keys, [next2](#next2-optional) and [prev2](#prev2-optional) are provided, they do the same actions as \"next\" and \"prev\" but allow different fallback commands.\n\n<details>\n<summary>Configuration sample</summary>\n\n```toml\n[layout_center]\nmargin = 60\noffset = [0, 30]\nnext = \"movefocus r\"\nprev = \"movefocus l\"\nnext2 = \"movefocus d\"\nprev2 = \"movefocus u\"\n```\n\nusing the following in `hyprland.conf`:\n```sh\nbind = $mainMod, M, exec, pypr layout_center toggle # toggle the layout\n## focus change keys\nbind = $mainMod, left, exec, pypr layout_center prev\nbind = $mainMod, right, exec, pypr layout_center next\nbind = $mainMod, up, exec, pypr layout_center prev2\nbind = $mainMod, down, exec, pypr layout_center next2\n```\n\nYou can completely ignore `next2` and `prev2` if you are allowing focus change in a single direction (when the layout is enabled), eg:\n\n```sh\nbind = $mainMod, up, movefocus, u\nbind = $mainMod, down, movefocus, d\n```\n\n</details>\n\n\n## Commands\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `on_new_client`\n\nDefaults to `\"foreground\"`.\n\nChanges the behavior when a new window opens, possible options are:\n\n- \"foreground\" to make the new window the main window\n- \"background\" to make the new window appear in the background\n- \"close\" to stop the centered layout when a new window opens\n\n### `style`\n\n| Requires Hyprland > 0.40.0\n\nNot set by default.\n\nAllow to set a list of styles to the main (centered) window, eg:\n\n```toml\nstyle = [\"opacity 1\", \"bordercolor rgb(FFFF00)\"]\n```\n\n### `margin`\n\ndefault value is `60`\n\nmargin (in pixels) used when placing the center window, calculated from the border of the screen.\n\nExample to make the main window be 100px far from the monitor's limits:\n```toml\nmargin = 100\n```\n\nYou can also set a different margin for width and height by using a list:\n```toml\nmargin = [10, 60]\n```\n\n### `offset`\n\ndefault value is `[0, 0]`\n\noffset in pixels applied to the main window position\n\nExample shift the main window 20px down:\n```toml\noffset = [0, 20]\n```\n\n### `next`\n### `next2`\n\nnot set by default\n\nWhen the *layout_center* isn't active and the *next* command is triggered, defines the hyprland dispatcher command to run.\n\n`next2` is a similar option, used by the `next2` command, allowing to map \"next\" to both vertical and horizontal focus change.\n\nEg:\n```toml\nnext = \"movefocus r\"\n```\n\n### `prev`\n### `prev2`\n\nSame as `next` but for the `prev` and `prev2` commands.\n\n\n### `captive_focus`\n\ndefault value is `false`\n\n```toml\ncaptive_focus = true\n```\n\nSets the focus on the main window when the focus changes.\nYou may love it or hate it...\n"
  },
  {
    "path": "site/versions/2.4.0/lost_windows.md",
    "content": "---\ncommands:\n    - name: attract_lost\n      description: Bring windows which are not reachable in the currently focused workspace.\n---\n# lost_windows\n\nBring windows which are not reachable in the currently focused workspace.\n*Deprecated:* Was used to work around issues in Hyprland which have been fixed since then.\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n"
  },
  {
    "path": "site/versions/2.4.0/magnify.md",
    "content": "---\ncommands:\n    - name: zoom [value]\n      description: Set the current zoom level (absolute or relative) - toggle zooming if no value is provided\n---\n# magnify\n\nZooms in and out with an optional animation.\n\n\n<details>\n    <summary>Example</summary>\n\n```sh\npypr zoom  # sets zoom to `factor` (2 by default)\npypr zoom +1  # will set zoom to 3x\npypr zoom  # will set zoom to 1x\npypr zoom 1 # will (also) set zoom to 1x - effectively doing nothing\n```\n\nSample `hyprland.conf`:\n\n```sh\nbind = $mainMod , Z, exec, pypr zoom ++0.5\nbind = $mainMod SHIFT, Z, exec, pypr zoom\n```\n\n</details>\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n### `[value]`\n\n#### unset / not specified\n\nWill zoom to [factor](#factor-optional) if not zoomed, else will set the zoom to 1x.\n\n#### floating or integer value\n\nWill set the zoom to the provided value.\n\n#### +value / -value\n\nUpdate (increment or decrement) the current zoom level by the provided value.\n\n#### ++value / --value\n\nUpdate (increment or decrement) the current zoom level by a non-linear scale.\nIt _looks_ more linear changes than using a single + or -.\n\n> [!NOTE]\n>\n> The non-linear scale is calculated as powers of two, eg:\n>\n> - `zoom ++1` → 2x, 4x, 8x, 16x...\n> - `zoom ++0.7` → 1.6x, 2.6x, 4.3x, 7.0x, 11.3x, 18.4x...\n> - `zoom ++0.5` → 1.4x, 2x, 2.8x, 4x, 5.7x, 8x, 11.3x, 16x...\n\n## Configuration\n\n### `factor`\n\ndefault value is `2`\n\nScaling factor to be used when no value is provided.\n\n### `duration`\n\nDefault value is `15`\n\nDuration in tenths of a second for the zoom animation to last, set to `0` to disable animations.\n"
  },
  {
    "path": "site/versions/2.4.0/monitors.md",
    "content": "---\ncommands:\n  - name: relayout\n    description: Apply the configuration and update the layout\n---\n\n# monitors\n\n> First, simpler version of the plugin is still available under the name `monitors_v0`\n\nAllows relative placement of monitors depending on the model (\"description\" returned by `hyprctl monitors`).\nUseful if you have multiple monitors connected to a video signal switch or using a laptop and plugging monitors having different relative positions.\n\nSyntax:\n\n```toml\n[monitors.placement]\n\"description match\".placement = \"other description match\"\n```\n\n<details>\n    <summary>Example to set a Sony monitor on top of a BenQ monitor</summary>\n\n```toml\n[monitors.placement]\nSony.topOf = \"BenQ\"\n\n## Character case is ignored, \"_\" can be added\nSony.Top_Of = [\"BenQ\"]\n\n## Thanks to TOML format, complex configurations can use separate \"sections\" for clarity, eg:\n\n[monitors.placement.\"My monitor brand\"]\n## You can also use \"port\" names such as *HDMI-A-1*, *DP-1*, etc...\nleftOf = \"eDP-1\"\n\n## lists are possible on the right part of the assignment:\nrightOf = [\"Sony\", \"BenQ\"]\n\n## > 2.3.2: you can also set scale, transform & rate for a given monitor\n[monitors.placement.Microstep]\nrate = 100\n```\n\nTry to keep the rules as simple as possible, but relatively complex scenarios are supported.\n\n> [!note]\n> Check [wlr layout UI](https://github.com/fdev31/wlr-layout-ui) which is a nice complement to configure your monitor settings.\n\n</details>\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `placement` (REQUIRED)\n\nSupported placements are:\n\n- leftOf\n- topOf\n- rightOf\n- bottomOf\n- \\<one of the above>(center|middle|end)Of\n\n> [!important]\n> If you don't like the screen to align on the start of the given border,\n> you can use `center` (or `middle`) to center it or `end` to stick it to the opposite border.\n> Eg: \"topCenterOf\", \"leftEndOf\", etc...\n\nYou can separate the terms with \"_\" to improve the readability, as in \"top_center_of\".\n\n#### monitor settings\n\nNot only can you place monitors relatively to each other, but you can also set specific settings for a given monitor.\n\nThe following settings are supported:\n\n- scale\n- transform\n- rate\n- resolution\n\n```toml\n[monitors.placement.\"My monitor brand\"]\nrate = 60\nscale = 1.5\ntransform = 1 # 0: normal, 1: 90°, 2: 180°, 3: 270°, 4: flipped, 5: flipped 90°, 6: flipped 180°, 7: flipped 270°\nresolution = \"1920x1080\"  # can also be expressed as [1920, 1080]\n```\n\n### `startup_relayout`\n\nDefault to `ŧrue`.\n\nWhen set to `false`,\ndo not initialize the monitor layout on startup or when configuration is reloaded.\n\n### `new_monitor_delay`\n\nBy default,\nthe layout computation happens one second after the event is received to let time for things to settle.\n\nYou can change this value using this option.\n\n### `hotplug_command`\n\nNone by default, allows to run a command when any monitor is plugged.\n\n\n```toml\n[monitors]\nhotplug_commands = \"wlrlui -m\"\n```\n\n### `hotplug_commands`\n\nNone by default, allows to run a command when a given monitor is plugged.\n\nExample to load a specific profile using [wlr layout ui](https://github.com/fdev31/wlr-layout-ui):\n\n```toml\n[monitors.hotplug_commands]\n\"DELL P2417H CJFH277Q3HCB\" = \"wlrlui rotated\"\n```\n\n### `unknown`\n\nNone by default,\nallows to run a command when no monitor layout has been changed (no rule applied).\n\n```toml\n[monitors]\nunknown = \"wlrlui\"\n```\n\n### `trim_offset`\n\n`true` by default,\n\nPrevents having arbitrary window offsets or negative values.\n"
  },
  {
    "path": "site/versions/2.4.0/scratchpads.md",
    "content": "---\ncommands:\n  - name: toggle [scratchpad name]\n    description: Toggle the given scratchpad\n  - name: show [scratchpad name]\n    description: Show the given scratchpad\n  - name: hide [scratchpad name]\n    description: Hide the given scratchpad\n  - name: attach\n    description: Toggle attaching/anchoring the currently focused window to the (last used) scratchpad\n---\n# scratchpads\n\nEasily toggle the visibility of applications you use the most.\n\nConfigurable and flexible, while supporting complex setups it's easy to get started with:\n\n```toml\n[scratchpads.name]\ncommand = \"command to run\"\nclass = \"the window's class\"  # check: hyprctl clients | grep class\nsize = \"[width] [height]\"  # size of the window relative to the screen size\n```\n\n<details>\n<summary>Example</summary>\n\nAs an example, defining two scratchpads:\n\n- _term_ which would be a kitty terminal on upper part of the screen\n- _volume_ which would be a pavucontrol window on the right part of the screen\n\n\n```toml\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\nmax_size = \"1920px 100%\"\nmargin = 50\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nsize = \"40% 90%\"\nunfocus = \"hide\"\nlazy = true\n```\n\nShortcuts are generally needed:\n\n```ini\nbind = $mainMod,V,exec,pypr toggle volume\nbind = $mainMod,A,exec,pypr toggle term\nbind = $mainMod,Y,exec,pypr attach\n```\n</details>\n\n- If you wish to have a more generic space for any application you may run, check [toggle_special](./toggle_special).\n- When you create a scratchpad called \"name\", it will be hidden in `special:scratch_<name>`.\n- Providing `class` allows a glitch free experience, mostly noticeable when using animations\n\n\n## Commands\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n> [!tip]\n> You can use `\"*\"` as a _scratchpad name_ to target every scratchpad when using `show` or `hide`.\n> You'll need to quote or escape the `*` character to avoid interpretation from your shell.\n\n## Configuration\n\n### `command` (REQUIRED)\n\nThis is the command you wish to run in the scratchpad.\n\nIt supports [Variables](./Variables)\n\n### `animation`\n\nType of animation to use, default value is \"fromTop\":\n\n- `null` / `\"\"` (no animation)\n- `fromTop` (stays close to upper screen border)\n- `fromBottom` (stays close to lower screen border)\n- `fromLeft` (stays close to left screen border)\n- `fromRight` (stays close to right screen border)\n\n### `size` (recommended)\n\nNo default value.\n\nEach time scratchpad is shown, window will be resized according to the provided values.\n\nFor example on monitor of size `800x600` and `size= \"80% 80%\"` in config scratchpad always have size `640x480`,\nregardless of which monitor it was first launched on.\n\n> #### Format\n>\n> String with \"x y\" (or \"width height\") values using some units suffix:\n>\n> - **percents** relative to the focused screen size (`%` suffix), eg: `60% 30%`\n> - **pixels** for absolute values (`px` suffix), eg: `800px 600px`\n> - a mix is possible, eg: `800px 40%`\n\n### `class` (recommended)\n\nNo default value.\n\nAllows _Pyprland_ prepare the window for a correct animation and initial positioning.\n\n### `position`\n\nNo default value, overrides the automatic margin-based position.\n\nSets the scratchpad client window position relative to the top-left corner.\n\nSame format as `size` (see above)\n\nExample of scratchpad that always seat on the top-right corner of the screen:\n\n```toml\n[scratchpads.term_quake]\ncommand = \"wezterm start --class term_quake\"\nposition = \"50% 0%\"\nsize = \"50% 50%\"\nclass = \"term_quake\"\n```\n\n> [!note]\n> If `position` is not provided, the window is placed according to `margin` on one axis and centered on the other.\n\n### `multi`\n\nDefaults to `true`.\n\nWhen set to `false`, only one client window is supported for this scratchpad.\nOtherwise other matching windows will be **attach**ed to the scratchpad.\n\n## Advanced configuration\n\nTo go beyond the basic setup and have a look at every configuration item, you can read the following pages:\n\n- [Advanced](./scratchpads_advanced) contains options for fine-tuners or specific tastes (eg: i3 compatibility)\n- [Non-Standard](./scratchpads_nonstandard) contains options for \"broken\" applications\nlike progressive web apps (PWA) or emacsclient, use only if you can't get it to work otherwise\n\n## Monitor specific overrides\n\nYou can use different settings for a specific screen.\nMost attributes related to the display can be changed (not `command`, `class` or `process_tracking` for instance).\n\nUse the `monitor.<monitor name>` configuration item to override values, eg:\n\n```toml\n[scratchpads.music.monitor.eDP-1]\nposition = \"30% 50%\"\nanimation = \"fromBottom\"\n```\n\nYou may want to inline it for simple cases:\n\n```toml\n[scratchpads.music]\nmonitor = {HDMI-A-1={size = \"30% 50%\"}}\n```\n"
  },
  {
    "path": "site/versions/2.4.0/scratchpads_advanced.md",
    "content": "# Fine tuning scratchpads\n\nAdvanced configuration options\n\n## `pinned`\n\n`true` by default\n\nMakes the scratchpad \"sticky\" to the monitor, following any workspace change.\n\n## `excludes`\n\nNo default value.\n\nList of scratchpads to hide when this one is displayed, eg: `excludes = [\"term\", \"volume\"]`.\nIf you want to hide every displayed scratch you can set this to the string `\"*\"` instead of a list: `excludes = \"*\"`.\n\n## `restore_excluded`\n\n`false` by default.\n\nWhen enabled, will remember the scratchpads which have been closed due to `excludes` rules, so when the scratchpad is hidden, those previously hidden scratchpads will be shown again.\n\n## `unfocus`\n\nNo default value.\n\nWhen set to `\"hide\"`, allow to hide the window when the focus is lost.\n\nUse `hysteresis` to change the reactivity\n\n## `hysteresis`\n\nDefaults to `0.4` (seconds)\n\nControls how fast a scratchpad hiding on unfocus will react. Check `unfocus` option.\nSet to `0` to disable.\n\n> [!important]\n> Only relevant when `unfocus=\"hide\"` is used.\n\n## `margin`\n\ndefault value is `60`.\n\nnumber of pixels separating the scratchpad from the screen border, depends on the [animation](./scratchpads#animation) set.\n\n> [!tip]\n> It is also possible to set a string to express percentages of the screen (eg: '`3%`').\n\n## `max_size`\n\nNo default value.\n\nSame format as `size` (see above), only used if `size` is also set.\n\nLimits the `size` of the window accordingly.\nTo ensure a window will not be too large on a wide screen for instance:\n\n```toml\nsize = \"60% 30%\"\nmax_size = \"1200px 100%\"\n```\n\n## `lazy`\n\ndefault to `false`.\n\nwhen set to `true`, prevents the command from being started when pypr starts, it will be started when the scratchpad is first used instead.\n\n- Good: saves resources when the scratchpad isn't needed\n- Bad: slows down the first display (app has to launch before showing)\n\n## `preserve_aspect`\n\nNot set by default.\nWhen set to `true`, will preserve the size and position of the scratchpad when called repeatedly from the same monitor and workspace even though an `animation` , `position` or `size` is used (those will be used for the initial setting only).\n\nForces the `lazy` option.\n\n## `offset`\n\nIn pixels, default to `0` (client's window size + margin).\n\nNumber of pixels for the **hide** sliding animation (how far the window will go).\n\n> [!tip]\n> - It is also possible to set a string to express percentages of the client window\n> - `margin` is automatically added to the offset\n> - automatic (value not set) is same as `\"100%\"`\n\n## `hide_delay`\n\nDefaults to `0.2`\n\nDelay (in seconds) after which the hide animation happens, before hiding the scratchpad.\n\nRule of thumb, if you have an animation with speed \"7\", as in:\n```bash\n    animation = windowsOut, 1, 7, easeInOut, popin 80%\n```\nYou can divide the value by two and round to the lowest value, here `3`, then divide by 10, leading to `hide_delay = 0.3`.\n\n## `restore_focus`\n\nEnabled by default, set to `false` if you don't want the focused state to be restored when a scratchpad is hidden.\n\n## `force_monitor`\n\nIf set to some monitor name (eg: `\"DP-1\"`), it will always use this monitor to show the scratchpad.\n\n## `alt_toggle`\n\nDefault value is `false`\n\nWhen enabled, use an alternative `toggle` command logic for multi-screen setups.\nIt applies when the `toggle` command is triggered and the toggled scratchpad is visible on a screen which is not the focused one.\n\nInstead of moving the scratchpad to the focused screen, it will hide the scratchpad.\n\n## `allow_special_workspaces`\n\nDefault value is `false` (can't be enabled when using *Hyprland* < 0.39 where this behavior can't be controlled and is disabled).\n\nWhen enabled, you can toggle a scratchpad over a special workspace.\nIt will always use the \"normal\" workspace otherwise.\n\n## `smart_focus`\n\nDefault value is `true`.\n\nWhen enabled, the focus will be restored in a best effort way as en attempt to improve the user experience.\nIf you face issues such as spontaneous workspace changes, you can disable this feature.\n\n"
  },
  {
    "path": "site/versions/2.4.0/scratchpads_nonstandard.md",
    "content": "# Troubleshooting scratchpads\n\nOptions that should only be used for applications that are not behaving in a \"standard\" way, such as `emacsclient` or progressive web apps.\n\n## `match_by`\n\nDefault value is `\"pid\"`\nWhen set to a sensitive client property value (eg: `class`, `initialClass`, `title`, `initialTitle`), will match the client window using the provided property instead of the PID of the process.\n\nThis property must be set accordingly, eg:\n\n```toml\nmatch_by = \"class\"\nclass = \"my-web-app\"\n```\n\nor\n\n```toml\nmatch_by = \"initialClass\"\ninitialClass = \"my-web-app\"\n```\n\nYou can add the \"re:\" prefix to use a regular expression, eg:\n\n```toml\nmatch_by = \"title\"\ntitle = \"re:.*some string.*\"\n```\n\n> [!note]\n> Some apps may open the graphical client window in a \"complicated\" way, to work around this, it is possible to disable the process PID matching algorithm and simply rely on window's class.\n>\n> The `match_by` attribute can be used to achieve this, eg. for emacsclient:\n> ```toml\n> [scratchpads.emacs]\n> command = \"/usr/local/bin/emacsStart.sh\"\n> class = \"Emacs\"\n> match_by = \"class\"\n> ```\n\n## `process_tracking`\n\nDefault value is `true`\n\nAllows disabling the process management. Use only if running a progressive web app (Chrome based apps) or similar.\n\nThis will automatically force `lazy = true` and set `match_by=\"class\"` if no `match_by` rule is provided, to help with the fuzzy client window matching.\n\nIt requires defining a `class` option (or the option matching your `match_by` value).\n\n```toml\n## Chat GPT on Brave\n[scratchpads.gpt]\nanimation = \"fromTop\"\ncommand = \"brave --profile-directory=Default --app=https://chat.openai.com\"\nclass = \"brave-chat.openai.com__-Default\"\nsize = \"75% 60%\"\nprocess_tracking = false\n\n## Some chrome app\n[scratchpads.music]\ncommand = \"google-chrome --profile-directory=Default --app-id=cinhimbnkkaeohfgghhklpknlkffjgod\"\nclass = \"chrome-cinhimbnkkaeohfgghhklpknlkffjgod-Default\"\nsize = \"50% 50%\"\nprocess_tracking = false\n```\n\n> [!tip]\n> To list windows by class and title you can use:\n> - `hyprctl -j clients | jq '.[]|[.class,.title]'`\n> - or if you prefer a graphical tool: `rofi -show window`\n\n> [!note]\n> Progressive web apps will share a single process for every window.\n> On top of requiring the class based window tracking (using `match_by`),\n> the process can not be managed the same way as usual apps and the correlation\n> between the process and the client window isn't as straightforward and can lead to false matches in extreme cases.\n\n## `skip_windowrules`\n\nDefault value is `[]`\nAllows you to skip the window rules for a specific scratchpad.\nAvailable rules are:\n\n- \"aspect\" controlling size and position\n- \"float\" controlling the floating state\n- \"workspace\" which moves the window to its own workspace\n\nIf you are using an application which can spawn multiple windows and you can't see them, you can skip rules made to improve the initial display of the window.\n\n```toml\n[scratchpads.filemanager]\nanimation = \"fromBottom\"\ncommand = \"nemo\"\nclass = \"nemo\"\nsize = \"60% 60%\"\nskip_windowrules = [\"aspect\", \"workspace\"]\n```\n"
  },
  {
    "path": "site/versions/2.4.0/shift_monitors.md",
    "content": "---\ncommands:\n    - name: shift_monitors <direction>\n      description: Swaps the workspaces of every screen in the given direction.\n---\n\n# shift_monitors\n\nSwaps the workspaces of every screen in the given direction.\n\n> [!Note]\n> the behavior can be hard to predict if you have more than 2 monitors (depending on your layout).\n> If you use this plugin with many monitors and have some ideas about a convenient configuration, you are welcome ;)\n\nExample usage in `hyprland.conf`:\n\n```\nbind = $mainMod, O, exec, pypr shift_monitors +1\nbind = $mainMod SHIFT, O, exec, pypr shift_monitors -1\n```\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n"
  },
  {
    "path": "site/versions/2.4.0/shortcuts_menu.md",
    "content": "---\ncommands:\n    - name: menu [name]\n      description: Displays the full menu or part of it if \"name\" is provided\n---\n\n# shortcuts_menu\n\nPresents some menu to run shortcut commands. Supports nested menus (aka categories / sub-menus).\n\n<details>\n   <summary>Configuration examples</summary>\n\n```toml\n[shortcuts_menu.entries]\n\n\"Open Jira ticket\" = 'open-jira-ticket \"$(wl-paste)\"'\nRelayout = \"pypr relayout\"\n\"Fetch window\" = \"pypr fetch_client_menu\"\n\"Hyprland socket\" = 'kitty  socat - \"UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock\"'\n\"Hyprland logs\" = 'kitty tail -f $XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/hyprland.log'\n\n\"Serial USB Term\" = [\n    {name=\"device\", command=\"ls -1 /dev/ttyUSB*; ls -1 /dev/ttyACM*\"},\n    {name=\"speed\", options=[\"115200\", \"9600\", \"38400\", \"115200\", \"256000\", \"512000\"]},\n    \"kitty miniterm --raw --eol LF [device] [speed]\"\n]\n\n\"Color picker\" = [\n    {name=\"format\", options=[\"hex\", \"rgb\", \"hsv\", \"hsl\", \"cmyk\"]},\n    \"sleep 0.2; hyprpicker --format [format] | wl-copy\" # sleep to let the menu close before the picker opens\n]\n\nscreenshot = [\n    {name=\"what\", options=[\"output\", \"window\", \"region\", \"active\"]},\n    \"hyprshot -m [what] -o /tmp -f shot_[what].png\"\n]\n\nannotate = [\n    {name=\"fname\", command=\"ls /tmp/shot_*.png\"},\n    \"satty --filename '[fname]' --output-filename '/tmp/annotated.png'\"\n]\n\n\"Clipboard history\" = [\n    {name=\"entry\", command=\"cliphist list\", filter=\"s/\\t.*//\"},\n    \"cliphist decode '[entry]' | wl-copy\"\n]\n\n\"Copy password\" = [\n    {name=\"what\", command=\"gopass ls --flat\"},\n    \"gopass show -c [what]\"\n]\n\n\"Update/Change password\" = [\n    {name=\"what\", command=\"gopass ls --flat\"},\n    \"kitty -- gopass generate -s --strict -t '[what]' && gopass show -c '[what]'\"\n]\n```\n\n</details>\n\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n> [!tip]\n> - If \"name\" is provided it will show the given sub-menu.\n> - You can use \".\" to reach any level of the configured menus.\n>\n>      Example to reach `shortcuts_menu.entries.utils.\"local commands\"`, use:\n>      ```sh\n>       pypr menu \"utils.local commands\"\n>      ```\n\n## Configuration\n\nAll the [Menu](./Menu) configuration items are also available.\n\n### `entries` (REQUIRED)\n\nDefines the menu entries. Supports [Variables](./Variables)\n\n```toml\n[shortcuts_menu.entries]\n\"entry 1\" = \"command to run\"\n\"entry 2\" = \"command to run\"\n```\nSubmenus can be defined too (there is no depth limit):\n\n```toml\n[shortcuts_menu.entries.\"My submenu\"]\n\"entry X\" = \"command\"\n\n[shortcuts_menu.entries.one.two.three.four.five]\nfoobar = \"ls\"\n```\n\n#### Advanced usage\n\nInstead of navigating a configured list of menu options and running a pre-defined command, you can collect various *variables* (either static list of options selected by the user, or generated from a shell command) and then run a command using those variables. Eg:\n\n```toml\n\"Play Video\" = [\n    {name=\"video_device\", command=\"ls -1 /dev/video*\"},\n    {name=\"player\",\n        options=[\"mpv\", \"guvcview\"]\n    },\n    \"[player] [video_device]\"\n]\n\n\"Ssh\" = [\n    {name=\"action\", options=[\"htop\", \"uptime\", \"sudo halt -p\"]},\n    {name=\"host\", options=[\"gamix\", \"gate\", \"idp\"]},\n    \"kitty --hold ssh [host] [action]\"\n]\n```\n\nYou must define a list of objects, containing:\n- `name`: the variable name\n- then the list of options, must be one of:\n    - `options` for a static list of options\n    - `command` to get the list of options from a shell command's output\n\n> [!tip]\n> You can apply post-filters to the `command` output, eg:\n> ```toml\n> {\n>   name = \"entry\",\n>   command = \"cliphist list\",\n>   filter = \"s/\\t.*//\"  # will remove everything after the TAB character\n> }\n> ```\n> check the [filters](./filters) page for more details\n\nThe last item of the list must be a string which is the command to run. Variables can be used enclosed in `[]`.\n\n### `command_start`\n### `command_end`\n### `submenu_start`\n### `submenu_end`\n\nAllow adding some text (eg: icon) before / after a menu entry.\n\ncommand_* is for final commands, while submenu_* is for entries leading to another menu.\n\nBy default `submenu_end` is set to a right arrow sign, while other attributes are not set.\n\n### `skip_single`\n\nDefaults to `true`.\nWhen disabled, shows the menu even for single options\n\n## Hints\n\n### Multiple menus\n\nTo manage multiple distinct menus, always use a name when using the `pypr menu <name>` command.\n\nExample of a multi-menu configuration:\n\n```toml\n[shortcuts_menu.entries.\"Basic commands\"]\n\"entry X\" = \"command\"\n\"entry Y\" = \"command2\"\n\n[shortcuts_menu.entries.menu2]\n## ...\n```\n\nYou can then show the first menu using `pypr menu \"Basic commands\"`\n"
  },
  {
    "path": "site/versions/2.4.0/sidebar.json",
    "content": "{\n  \"main\": [\n    {\n      \"text\": \"Getting started\",\n      \"link\": \"./Getting-started\"\n    },\n    {\n      \"text\": \"Plugins\",\n      \"link\": \"./Plugins\"\n    },\n    {\n      \"text\": \"Troubleshooting\",\n      \"link\": \"./Troubleshooting\"\n    },\n    {\n      \"text\": \"Development\",\n      \"link\": \"./Development\"\n    }\n  ],\n  \"plugins\": {\n    \"text\": \"Featured plugins\",\n    \"collapsed\": false,\n    \"items\": [\n      {\n        \"text\": \"Expose\",\n        \"link\": \"./expose\"\n      },\n      {\n        \"text\": \"Fetch client menu\",\n        \"link\": \"./fetch_client_menu\"\n      },\n      {\n        \"text\": \"Gbar\",\n        \"link\": \"./gbar\"\n      },\n      {\n        \"text\": \"Layout center\",\n        \"link\": \"./layout_center\"\n      },\n      {\n        \"text\": \"Lost windows\",\n        \"link\": \"./lost_windows\"\n      },\n      {\n        \"text\": \"Magnify\",\n        \"link\": \"./magnify\"\n      },\n      {\n        \"text\": \"Monitors\",\n        \"link\": \"./monitors\"\n      },\n      {\n        \"text\": \"Scratchpads\",\n        \"link\": \"./scratchpads\",\n        \"items\": [\n          {\n            \"text\": \"Advanced\",\n            \"link\": \"./scratchpads_advanced\"\n          },\n          {\n            \"text\": \"Special cases\",\n            \"link\": \"./scratchpads_nonstandard\"\n          }\n        ]\n      },\n      {\n        \"text\": \"Shift monitors\",\n        \"link\": \"./shift_monitors\"\n      },\n      {\n        \"text\": \"Shortcuts menu\",\n        \"link\": \"./shortcuts_menu\"\n      },\n      {\n        \"text\": \"System notifier\",\n        \"link\": \"./system_notifier\"\n      },\n      {\n        \"text\": \"Toggle dpms\",\n        \"link\": \"./toggle_dpms\"\n      },\n      {\n        \"text\": \"Toggle special\",\n        \"link\": \"./toggle_special\"\n      },\n      {\n        \"text\": \"Wallpapers\",\n        \"link\": \"./wallpapers\"\n      },\n      {\n        \"text\": \"Workspaces follow focus\",\n        \"link\": \"./workspaces_follow_focus\"\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "site/versions/2.4.0/system_notifier.md",
    "content": "# system_notifier\n\nThis plugin adds system notifications based on journal logs (or any program's output).\nIt monitors specified **sources** for log entries matching predefined **patterns** and generates notifications accordingly (after applying an optional **filter**).\n\nSources are commands that return a stream of text (eg: journal, mqtt, tail -f, ...) which is sent to a parser that will use a [regular expression pattern](https://en.wikipedia.org/wiki/Regular_expression) to detect lines of interest and optionally transform them before sending the notification.\n\n<details>\n    <summary>Minimal configuration</summary>\n\n```toml\n[system_notifier.sources]\ncommand = \"sudo journalctl -fx\"\nparser = \"journal\"\n```\n\nIn general you will also need to define some **parsers**.\nBy default a **\"journal\"** parser is provided, otherwise you need to define your own rules.\nThis built-in configuration is close to this one, provided as an example:\n\n```toml\n[system_notifier.parsers.journal]\npattern = \"([a-z0-9]+): Link UP$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is active/\"\ncolor= \"#00aa00\"\n\n[system_notifier.parsers.journal]\npattern = \"([a-z0-9]+): Link DOWN$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is inactive/\"\ncolor= \"#ff8800\"\n\n[system_notifier.parsers.journal]\npattern = \"Process \\d+ \\(.*\\) of .* dumped core.\"\nfilter = \"s/.*Process \\d+ \\((.*)\\) of .* dumped core./\\1 dumped core/\"\ncolor= \"#aa0000\"\n\n[system_notifier.parsers.journal]\npattern = \"usb \\d+-[0-9.]+: Product: \"\nfilter = \"s/.*usb \\d+-[0-9.]+: Product: (.*)/USB plugged: \\1/\"\n```\n</details>\n\n\n## Configuration\n\n### `sources` (recommended)\n\nList of sources to enable (by default nothing is enabled)\n\nEach source must contain a `command` to run and a `parser` to use.\n\nYou can also use a list of parsers, eg:\n\n```toml\n[system_notifier.sources](system_notifier.sources)\ncommand = \"sudo journalctl -fkn\"\nparser = [\"journal\", \"custom_parser\"]\n```\n\n#### command (recommended)\n\nThis is the long-running command (eg: `tail -f <filename>`) returning the stream of text that will be updated. Aa common option is the system journal output (eg: `journalctl -u nginx`)\n\n#### parser\n\nSets the list of rules / parser to be used to extract lines of interest.\nMust match a list of rules defined as `system_notifier.parsers.<parser_name>`.\n\n### `parsers` (recommended)\n\nA list of available parsers that can be used to detect lines of interest in the **sources** and re-format it before issuing a notification.\n\nEach parser definition must contain a **pattern** and optionally a **filter** and a **color**.\n\n#### pattern\n\n```toml\n[system_notifier.parsers.custom_parser](system_notifier.parsers.custom_parser)\n\npattern = 'special value:'\n```\n\nThe pattern is any regular expression.\n\n#### filter\n\nThe [filters](./filters) allows to change the text before the notification, eg:\n`filter=\"s/.*special value: (\\d+)/Value=\\1/\"`\nwill set a filter so a string \"special value: 42\" will lead to the notification \"Value=42\"\n\n#### color\n\nYou can also provide an optional **color** in `\"hex\"` or `\"rgb()\"` format\n\n```toml\ncolor = \"#FF5500\"\n```\n\n### default_color\n\nSets the notification color that will be used when none is provided in a *parser* definition.\n\n```toml\n[system_notifier]\ndefault_color = \"#bbccbb\"\n```\n"
  },
  {
    "path": "site/versions/2.4.0/toggle_dpms.md",
    "content": "---\ncommands:\n    - name: toggle_dpms\n      description: if any screen is powered on, turn them all off, else turn them all on\n---\n\n# toggle_dpms\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n"
  },
  {
    "path": "site/versions/2.4.0/toggle_special.md",
    "content": "---\ncommands:\n    - name: toggle_special [name]\n      description: moves the focused window to the special workspace <code>name</code>, or move it back to the active workspace\n---\n\n# toggle_special\n\nAllows moving the focused window to a special workspace and back (based on the visibility status of that workspace).\n\nIt's a companion of the `togglespecialworkspace` Hyprland's command which toggles a special workspace's visibility.\n\nYou most likely will need to configure the two commands for a complete user experience, eg:\n\n```bash\nbind = $mainMod SHIFT, N, togglespecialworkspace, stash # toggles \"stash\" special workspace visibility\nbind = $mainMod, N, exec, pypr toggle_special stash # moves window to/from the \"stash\" workspace\n```\n\nNo other configuration needed, here `MOD+SHIFT+N` will show every window in \"stash\" while `MOD+N` will move the focused window out of it/ to it.\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n"
  },
  {
    "path": "site/versions/2.4.0/wallpapers.md",
    "content": "---\ncommands:\n    - name: wall next\n      description: Changes the current background image\n    - name: wall clear\n      description: Removes the current background image\n---\n\n# wallpapers\n\nSearch folders for images and sets the background image at a regular interval.\nImages are selected randomly from the full list of images found.\n\nIt serves two purposes:\n\n- adding support for random images to any background setting tool\n- quickly testing different tools with a minimal effort\n\nIt allows \"zapping\" current backgrounds, clearing it to go distraction free and optionally make them different for each screen.\n\n> [!tip]\n> Uses **swaybg** by default, but can be configured to use any other application.\n\n<details>\n    <summary>Minimal example using defaults(requires <b>swaybg</b>)</summary>\n\n```toml\n[wallpapers]\npath = \"~/Images/wallpapers/\" # path to the folder with background images\n```\n\n</details>\n\n<details>\n<summary>More complete, using the custom <b>swww</b> command (not recommended because of its stability)</summary>\n\n```toml\n[wallpapers]\nunique = true # set a different wallpaper for each screen\npath = \"~/Images/wallpapers/\"\ninterval = 60 # change every hour\nextensions = [\"jpg\", \"jpeg\"]\nrecurse = true\n## Using swww\ncommand = 'swww img --transition-type any \"[file]\"'\nclear_command = \"swww clear\"\n```\n\nNote that for applications like `swww`, you'll need to start a daemon separately (eg: from `hyprland.conf`).\n</details>\n\n\n## Commands\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n\n### `path` (REQUIRED)\n\npath to a folder or list of folders that will be searched. Can also be a list, eg:\n\n```toml\npath = [\"~/Images/Portraits/\", \"~/Images/Landscapes/\"]\n```\n\n### `interval`\n\ndefaults to `10`\n\nHow long (in minutes) a background should stay in place\n\n\n### `command`\n\nOverrides the default command to set the background image.\n\n[variables](./Variables) are replaced with the appropriate values, you must use a `\"[file]\"` placeholder for the image path. eg:\n\n```\nswaybg -m fill -i \"[file]\"\n```\n\n### `clear_command`\n\nBy default `clear` command kills the `command` program.\n\nInstead of that, you can provide a command to clear the background. eg:\n\n```\nclear_command = \"swaybg clear\"\n``````\n\n### `extensions`\n\ndefaults to `[\"png\", \"jpg\", \"jpeg\"]`\n\nList of valid wallpaper image extensions.\n\n### `recurse`\n\ndefaults to `false`\n\nWhen enabled, will also search sub-directories recursively.\n\n### `unique`\n\ndefaults to `false`\n\nWhen enabled, will set a different wallpaper for each screen.\n\nIf you are not using the default application, ensure you are using `\"[output]\"` in the [command](#command) template.\n\nExample for swaybg: `swaybg -o \"[output]\" -m fill -i \"[file]\"`\n"
  },
  {
    "path": "site/versions/2.4.0/workspaces_follow_focus.md",
    "content": "---\ncommands:\n    - name: change_workspace [direction]\n      description: Changes the workspace of the focused monitor in the given direction.\n---\n\n# workspaces_follow_focus\n\nMake non-visible workspaces follow the focused monitor.\nAlso provides commands to switch between workspaces while preserving the current monitor assignments:\n\nSyntax:\n```toml\n[workspaces_follow_focus]\nmax_workspaces = 4 # number of workspaces before cycling\n```\nExample usage in `hyprland.conf`:\n\n```ini\nbind = $mainMod, K, exec, pypr change_workspace +1\nbind = $mainMod, J, exec, pypr change_workspace -1\n ```\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `max_workspaces`\n\nLimits the number of workspaces when switching, defaults value is `10`.\n"
  },
  {
    "path": "site/versions/2.4.1+/Development.md",
    "content": "# Development\n\nIt's easy to write your own plugin by making a python package and then indicating it's name as the plugin name.\n\n[Contributing guidelines](https://github.com/hyprland-community/pyprland/blob/main/CONTRIBUTING.md)\n\n# Writing plugins\n\nPlugins can be loaded with full python module path, eg: `\"mymodule.pyprlandplugin\"`, the loaded module must provide an `Extension` class.\n\nCheck the `interface.py` file to know the base methods, also have a look at the example below.\n\nTo get more details when an error is occurring, use `pypr --debug <log file path>`, it will also display the log in the console.\n\n> [!note]\n> To quickly get started, you can directly edit the `experimental` built-in plugin.\n> In order to distribute it, make your own Python package or trigger a pull request.\n> If you prefer to make a separate package, check the [examples](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/)'s package\n\nThe `Extension` interface provides a couple of built-in attributes:\n\n- `config` : object exposing the plugin section in `pyprland.toml`\n- `notify` ,`notify_error`, `notify_info` : access to Hyprland's notification system\n- `hyprctl`, `hyprctl_json` : invoke [Hyprland's IPC system](https://wiki.hyprland.org/Configuring/Dispatchers/)\n\n\n> [!important]\n> Contact me to get your extension listed on the home page\n\n> [!tip]\n> You can set a `plugins_paths=[\"/custom/path/example\"]` in the `hyprland` section of the configuration to add extra paths (eg: during development).\n\n> [!Note]\n> If your extension is at the root of the plugin (this is not recommended, preferable add a name space, as in `johns_pyprland.super_feature`, rather than `super_feature`) you can still import it using the `external:` prefix when you refer to it in the `plugins` list.\n\n# API Documentation\n\nRun `tox run -e doc` then visit `http://localhost:8080`\n\nThe most important to know are:\n\n- `hyprctl_json` to get a response from an IPC query\n- `hyprctl` to trigger general IPC commands\n- `on_reload` to be implemented, called when the config is (re)loaded\n- `run_<command_name>` to implement a command\n- `event_<event_name>` called when the given event is emitted by Hyprland\n\nAll those methods are _async_\n\nOn top of that:\n\n- the first line of a `run_*` command's docstring will be used by the `help` command\n- `self.config` in your _Extension_ contains the entry corresponding to your plugin name in the TOML file\n- `state` from `..common` module contains ready to use information\n- there is a `MenuMixin` in `..adapters.menus` to make menu-based plugins easy\n\n# Workflow\n\nJust `^C` when you make a change and repeat:\n\n```sh\npypr exit ; pypr --debug /tmp/output.log\n```\n\n\n## Creating a plugin\n\n```python\nfrom .interface import Plugin\n\n\nclass Extension(Plugin):\n    \" My plugin \"\n\n    async def init(self):\n        await self.notify(\"My plugin loaded\")\n```\n\n## Adding a command\n\nJust add a method called `run_<name of your command>` to your `Extension` class, eg with \"togglezoom\" command:\n\n```python\n    zoomed = False\n\n    async def run_togglezoom(self, args):\n        \"\"\" this doc string will show in `help` to document `togglezoom`\n        But this line will not show in the CLI help\n        \"\"\"\n      if self.zoomed:\n        await self.hyprctl('misc:cursor_zoom_factor 1', 'keyword')\n      else:\n        await self.hyprctl('misc:cursor_zoom_factor 2', 'keyword')\n      self.zoomed = not self.zoomed\n```\n\n## Reacting to an event\n\nSimilar as a command, implement some `async def event_<the event you are interested in>` method.\n\n## Code safety\n\nPypr ensures only one `run_` or `event_` handler runs at a time, allowing the plugins code to stay simple and avoid the need for concurrency handling.\nHowever, each plugin can run its handlers in parallel.\n\n# Reusable code\n\n```py\nfrom ..common import state, CastBoolMixin\n```\n\n- `state` provides a couple of handy variables so you don't have to fetch them, allow optimizing the most common operations\n- `Mixins` are providing common code, for instance the `CastBoolMixin` provides the `cast_bool` method to your `Extension`.\n\nIf you want to use menus, then the `MenuMixin` will provide:\n- `menu` to show a menu\n- `ensure_menu_configured` to call before you require a menu in your plugin\n\n# Example\n\nYou'll find a basic external plugin in the [examples](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/) folder.\n\nIt provides one command: `pypr dummy`.\n\nRead the [plugin code](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/pypr_examples/focus_counter.py)\n\nIt's a simple python package. To install it for development without a need to re-install it for testing, you can use `pip install -e .` in this folder.\nIt's ready to be published using `poetry publish`, don't forget to update the details in the `pyproject.toml` file.\n\n## Usage\n\nEnsure you added `pypr_examples.focus_counter` to your `plugins` list:\n\n```toml\n[pyprland]\nplugins = [\n  \"pypr_examples.focus_counter\"\n]\n```\n\nOptionally you can customize one color:\n\n```toml\n[\"pypr_examples.focus_counter\"]\ncolor = \"FFFF00\"\n```\n"
  },
  {
    "path": "site/versions/2.4.1+/Getting-started.md",
    "content": "# Getting started\n\nPypr consists in two things:\n\n- **a tool**: `pypr` which runs the daemon (service), but also allows to interact with it\n- **some config file**: `~/.config/hypr/pyprland.toml` (or the path set using `--config`) using the [TOML](https://toml.io/en/) format\n\nThe `pypr` tool only have a few built-in commands:\n\n- `help` lists available commands (including plugins commands)\n- `exit` will terminate the service process\n- `edit` edit the configuration using your `$EDITOR` (or `vi`), reloads on exit\n- `dumpjson` shows a JSON representation of the configuration (after other files have been `include`d)\n- `reload` reads the configuration file and apply some changes:\n  - new plugins will be loaded\n  - configuration items will be updated (most plugins will use the new values on the next usage)\n\nOther commands are implemented by adding [plugins](./Plugins).\n\n> [!important]\n> - with no argument it runs the daemon (doesn't fork in the background)\n>\n> - if you pass parameters, it will interact with the daemon instead.\n\n> [!tip]\n> Pypr *command* names are documented using underscores (`_`) but you can use dashes (`-`) instead.\n> Eg: `pypr shift_monitors` and `pypr shift-monitors` will run the same command\n\n\n## Configuration file\n\nThe configuration file uses a [TOML format](https://toml.io/) with the following as the bare minimum:\n\n```toml\n[pyprland]\nplugins = [\"plugin_name\", \"other_plugin\"]\n```\n\nAdditionally some plugins require **Configuration** options, using the following format:\n\n```toml\n[plugin_name]\nplugin_option = 42\n\n[plugin_name.another_plugin_option]\nsuboption = \"config value\"\n```\n\nYou can also split your configuration into [Multiple configuration files](./MultipleConfigurationFiles).\n\n## Installation\n\nCheck your OS package manager first, eg:\n\n- Archlinux: you can find it on AUR, eg with [yay](https://github.com/Jguer/yay): `yay pyprland`\n- NixOS: Instructions in the [Nix](./Nix) page\n\nOtherwise, use the python package manager (pip) [inside a virtual environment](InstallVirtualEnvironment)\n\n```sh\npip install pyprland\n```\n\n## Running\n\n> [!caution]\n> If you messed with something else than your OS packaging system to get `pypr` installed, use the full path to the `pypr` command.\n\nPreferably start the process with hyprland, adding to `hyprland.conf`:\n\n```ini\nexec-once = /usr/bin/pypr\n```\n\nor if you run into troubles (use the first version once your configuration is stable):\n\n```ini\nexec-once = /usr/bin/pypr --debug /tmp/pypr.log\n```\n\n> [!warning]\n> To avoid issues (eg: you have a complex setup, maybe using a virtual environment), you may want to set the full path (eg: `/home/bob/venv/bin/pypr`).\n> You can get it from `which pypr` in a working terminal\n\nOnce the `pypr` daemon is started (cf `exec-once`), you can list the eventual commands which have been added by the plugins using `pypr -h` or `pypr help`, those commands are generally meant to be use via key bindings, see the `hyprland.conf` part of *Configuring* section below.\n\n## Configuring\n\nCreate a configuration file in `~/.config/hypr/pyprland.toml` enabling a list of plugins, each plugin may have its own configuration needs or don't need any configuration at all.\nMost default values should be acceptable for most users, options which hare not mandatory are marked as such.\n\n> [!important]\n> Provide the values for the configuration options which have no annotation such as \"(optional)\"\n\nCheck the [TOML format](https://toml.io/) for details about the syntax.\n\nSimple example:\n\n```toml\n[pyprland]\nplugins = [\n    \"shift_monitors\",\n    \"workspaces_follow_focus\"\n]\n```\n\n<details>\n  <summary>\nMore complex example\n  </summary>\n\n```toml\n[pyprland]\nplugins = [\n  \"scratchpads\",\n  \"lost_windows\",\n  \"monitors\",\n  \"toggle_dpms\",\n  \"magnify\",\n  \"expose\",\n  \"shift_monitors\",\n  \"workspaces_follow_focus\",\n]\n\n[monitors.placement]\n\"Acer\".top_center_of = \"Sony\"\n\n[workspaces_follow_focus]\nmax_workspaces = 9\n\n[expose]\ninclude_special = false\n\n[scratchpads.stb]\nanimation = \"fromBottom\"\ncommand = \"kitty --class kitty-stb sstb\"\nclass = \"kitty-stb\"\nlazy = true\nsize = \"75% 45%\"\n\n[scratchpads.stb-logs]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-stb-logs stbLog\"\nclass = \"kitty-stb-logs\"\nlazy = true\nsize = \"75% 40%\"\n\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nlazy = true\nsize = \"40% 90%\"\nunfocus = \"hide\"\n```\n\nSome of those plugins may require changes in your `hyprland.conf` to fully operate or to provide a convenient access to a command, eg:\n\n```bash\nbind = $mainMod SHIFT, Z, exec, pypr zoom\nbind = $mainMod ALT, P,exec, pypr toggle_dpms\nbind = $mainMod SHIFT, O, exec, pypr shift_monitors +1\nbind = $mainMod, B, exec, pypr expose\nbind = $mainMod, K, exec, pypr change_workspace +1\nbind = $mainMod, J, exec, pypr change_workspace -1\nbind = $mainMod,L,exec, pypr toggle_dpms\nbind = $mainMod SHIFT,M,exec,pypr toggle stb stb-logs\nbind = $mainMod,A,exec,pypr toggle term\nbind = $mainMod,V,exec,pypr toggle volume\n```\n\n</details>\n\n> [!tip]\n> Consult or share [configuration files](https://github.com/hyprland-community/pyprland/tree/main/examples)\n>\n> You might also be interested in [optimizations](./Optimizations).\n"
  },
  {
    "path": "site/versions/2.4.1+/InstallVirtualEnvironment.md",
    "content": "# Virtual env\n\nEven though the best way to get Pyprland installed is to use your operating system package manager,\nfor some usages or users it can be convenient to use a virtual environment.\n\nThis is very easy to achieve in a couple of steps:\n\n```shell\npython -m venv ~/pypr-env\n~/pypr-env/bin/pip install pyprland\n```\n\n**That's all folks!**\n\nThe only extra care to take is to use `pypr` from the virtual environment, eg:\n\n- adding the environment's \"bin\" folder to the `PATH` (using `export PATH=\"$PATH:~/pypr-env/bin/\"` in your shell configuration file)\n- always using the full path to the pypr command (in `hyprland.conf`: `exec-once = ~/pypr-env/bin/pypr --debug /tmp/pypr.log`)\n\n# Going bleeding edge!\n\nIf you would rather like to use the latest version available (not released yet), then you can clone the git repository and install from it:\n\n```shell\ncd ~/pypr-env\ngit clone git@github.com:hyprland-community/pyprland.git pyprland-sources\ncd pyprland-sources\n../bin/pip install -e .\n```\n\n## Updating\n\n```shell\ncd ~/pypr-env\ngit pull -r\n```\n\n# Troubelshooting\n\nIf things go wrong, try (eg: after a system upgrade where Python got updated):\n\n```shell\npython -m venv --upgrade ~/pypr-env\ncd  ~/pypr-env\n../bin/pip install -e .\n```\n"
  },
  {
    "path": "site/versions/2.4.1+/Menu.md",
    "content": "# Menu capability\n\nMenu based plugins have the following configuration options:\n\n### `engine`\n\nNot set by default, will autodetect the available menu engine.\n\nSupported engines:\n\n- tofi\n- rofi\n- wofi\n- bemenu\n- dmenu\n- anyrun\n\n> [!note]\n> If your menu system isn't supported, you can open a [feature request](https://github.com/hyprland-community/pyprland/issues/new?assignees=fdev31&labels=bug&projects=&template=feature_request.md&title=%5BFEAT%5D+Description+of+the+feature)\n>\n> In case the engine isn't recognized, `engine` + `parameters` configuration options will be used to start the process, it requires a dmenu-like behavior.\n\n### `parameters`\n\nExtra parameters added to the engine command, the default value is specific to each engine.\n\n> [!important]\n> Setting this will override the default value!\n>\n> In general, *rofi*-like programs will require at least `-dmenu` option.\n\n> [!tip]\n> You can use '[prompt]' in the parameters, it will be replaced by the prompt, eg:\n> ```sh\n> -dmenu -matching fuzzy -i -p '[prompt]'\n> ```\n"
  },
  {
    "path": "site/versions/2.4.1+/MultipleConfigurationFiles.md",
    "content": "### Multiple configuration files\n\nYou can also split your configuration into multiple files that will be loaded in the provided order after the main file:\n```toml\n[pyprland]\ninclude = [\"/shared/pyprland.toml\", \"~/pypr_extra_config.toml\"]\n```\nYou can also load folders, in which case TOML files in the folder will be loaded in alphabetical order:\n```toml\n[pyprland]\ninclude = [\"~/.config/pypr.d/\"]\n```\n\nAnd then add a `~/.config/pypr.d/monitors.toml` file:\n```toml\npyprland.plugins = [ \"monitors\" ]\n\n[monitors.placement]\nBenQ.Top_Center_Of = \"DP-1\" # projo\n\"CJFH277Q3HCB\".top_of = \"eDP-1\" # work\n```\n\n> [!tip]\n> To check the final merged configuration, you can use the `dumpjson` command.\n\n"
  },
  {
    "path": "site/versions/2.4.1+/Nix.md",
    "content": "# Nix\n\nYou are recommended to get the latest pyprland package from the [flake.nix](https://github.com/hyprland-community/pyprland/blob/main/flake.nix)\nprovided within this repository. To use it in your system, you may add pyprland\nto your flake inputs.\n\n## Flake\n\n```nix\n## flake.nix\n{\n  inputs = {\n    nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n    pyprland.url = \"github:hyprland-community/pyprland\";\n  };\n\n  outputs = { self, nixpkgs, pyprland, ...}: let\n  in {\n    nixosConfigurations.<yourHostname> = nixpkgs.lib.nixosSystem {\n      #         remember to replace this with your system arch ↓\n      environment.systemPackages = [ pyprland.packages.\"x86_64-linux\".pyprland ];\n      # ...\n    };\n  };\n}\n```\n\nAlternatively, if you are using the Nix package manager but not NixOS as your\nmain distribution, you may use `nix profile` tool to install pyprland from this\nrepository using the following command.\n\n```bash\nnix profile install github:nix-community/pyprland\n```\n\nThe package will now be in your latest profile. You may use `nix profile list`\nto verify your installation.\n\n## Nixpkgs\n\nPyprland is available under nixpkgs, and can be installed by adding\n`pkgs.pyprland` to either `environment.systemPackages` or `home.packages`\ndepending on whether you want it available system-wide or to only a single\nuser using home-manager. If the derivation available in nixpkgs is out-of-date\nthen you may consider using `overrideAttrs` to update the source locally.\n\n```nix\nlet\n  pyprland = pkgs.pyprland.overrideAttrs {\n    version = \"your-version-here\";\n    src = fetchFromGitHub {\n      owner = \"hyprland-community\";\n      repo = \"pyprland\";\n      rev = \"tag-or-revision\";\n      # leave empty for the first time, add the new hash from the error message\n      hash = \"\";\n    };\n  };\nin {\n  # add the overridden package to systemPackages\n  environment.systemPackages = [pyprland];\n}\n```\n"
  },
  {
    "path": "site/versions/2.4.1+/Optimizations.md",
    "content": "## Optimizing\n\n### Plugins\n\n- Only enable the plugins you are using in the `plugins` array (in `[pyprland]` section).\n- Leaving the configuration for plugins which are not enabled will have no impact.\n- Using multiple configuration files only have a small impact on the startup time.\n\n### Pypr command\n\nIn case you want to save some time when interacting with the daemon\nyou can use `socat` instead (needs to be installed). Example of a `pypr-cli` command (should be reachable from your environment's `PATH`):\n```sh\n#!/bin/sh\nsocat - \"UNIX-CONNECT:${XDG_RUNTIME_DIR}/hypr/${HYPRLAND_INSTANCE_SIGNATURE}/.pyprland.sock\" <<< $@\n```\nOn slow systems this may make a difference.\nNote that the \"help\" command will require usage of the standard `pypr` command.\n"
  },
  {
    "path": "site/versions/2.4.1+/Plugins.md",
    "content": "<script setup>\nimport PluginList from './components/PluginList.vue'\n</script>\n\nThis page lists every plugin provided by Pyprland out of the box, more can be enabled if you install the matching package.\n\n\"🌟\" indicates some maturity & reliability level of the plugin, considering age, attention paid and complexity - from 0 to 3.\n\n<PluginList/>\n"
  },
  {
    "path": "site/versions/2.4.1+/Troubleshooting.md",
    "content": "# Troubleshooting\n\n## General\n\nIn case of trouble running a `pypr` command:\n- kill the existing pypr if any (try `pypr exit` first)\n- run from the terminal adding `--debug /dev/null` to the arguments to get more information\n\nIf the client says it can't connect, then there is a high chance pypr daemon didn't start, check if it's running using `ps axuw |grep pypr`. You can try to run it from a terminal with the same technique: `pypr --debug /dev/null` and see if any error occurs.\n\n## Force hyprland version\n\nIn case your `hyprctl version -j` command isn't returning an accurate version, you can make Pyprland ignore it and use a provided value instead:\n\n```toml\n[pyprland]\nhyprland_version = \"0.41.0\"\n```\n\n## Unresponsive scratchpads\n\nScratchpads aren't responding for few seconds after trying to show one (which didn't show!)\n\nThis may happen if an application is very slow to start.\nIn that case pypr will wait for a window blocking other scratchpad's operation, before giving up after a few seconds.\n\nNote that other plugins shouldn't be blocked by it.\n\nMore scratchpads troubleshooting can be found [here](./scratchpads_nonstandard).\n"
  },
  {
    "path": "site/versions/2.4.1+/Variables.md",
    "content": "# Variables & substitutions\n\nSome commands support shared global variables, they must be defined in the *pyprland* section of the configuration:\n```toml\n[pyprland.variables]\nterm = \"foot\"\nterm_classed = \"foot -a\" # kitty uses --class\n```\n\nIf a plugin supports it, you can then use the variables in the attribute that supports it, eg:\n\n```toml\n[myplugin]\nsome_variable = \"the terminal is [term]\"\n```\n"
  },
  {
    "path": "site/versions/2.4.1+/components/CommandList.vue",
    "content": "<template>\n    <dl>\n    </dl>\n    <ul v-for=\"command in commands\" :key=\"command.name\">\n        <li><code v-html=\"command.name.replace(/[   ]*$/, '').replace(/ /g, '&ensp;')\" /> <span\n                v-html=\"command.description\" /></li>\n    </ul>\n</template>\n\n<script>\nexport default {\n    props: {\n        commands: {\n            type: Array,\n            required: true\n        }\n    }\n}\n</script>\n"
  },
  {
    "path": "site/versions/2.4.1+/components/PluginList.vue",
    "content": "<template>\n    <div>\n        <h1>Built-in plugins</h1>\n        <div v-for=\"plugin in sortedPlugins\" :key=\"plugin.name\" class=\"plugin-item\">\n            <div class=\"plugin-info\">\n                <h3>\n                    <a :href=\"plugin.name + '.html'\">{{ plugin.name }}</a>\n                    <span>&nbsp;{{ '🌟'.repeat(plugin.stars) }}</span>\n                    <span v-if=\"plugin.multimon\">\n                        <Badge type=\"tip\" text=\"multi-monitor\" />\n                    </span>\n                </h3>\n                <p v-html=\"plugin.description\" />\n            </div>\n            <a v-if=\"plugin.demoVideoId\" :href=\"'https://www.youtube.com/watch?v=' + plugin.demoVideoId\"\n                class=\"plugin-video\">\n                <img :src=\"'https://img.youtube.com/vi/' + plugin.demoVideoId + '/1.jpg'\" alt=\"Demo video thumbnail\" />\n            </a>\n        </div>\n    </div>\n</template>\n\n<style scoped>\n.plugin-item {\n    display: flex;\n    align-items: flex-start;\n    margin-bottom: 1.5rem;\n}\n\n.plugin-info {\n    flex: 1;\n}\n\n.plugin-video {\n    margin-left: 1rem;\n}\n\n.plugin-video img {\n    max-width: 200px;\n    /* Adjust as needed */\n    height: auto;\n}\n</style>\n\n\n<script>\n\nexport default {\n    computed: {\n        sortedPlugins() {\n            if (this.sortByStars) {\n                return this.plugins.slice().sort((a, b) => {\n                    if (b.stars === a.stars) {\n                        return a.name.localeCompare(b.name);\n                    }\n                    return b.stars - a.stars;\n                });\n            } else {\n                return this.plugins.slice().sort((a, b) => a.name.localeCompare(b.name));\n            }\n        }\n    },\n    data() {\n        return {\n            sortByStars: false,\n            plugins: [\n                {\n                    name: 'scratchpads',\n                    stars: 3,\n                    description: 'makes your applications dropdowns & togglable poppups',\n                    demoVideoId: 'ZOhv59VYqkc'\n                },\n                {\n                    name: 'magnify',\n                    stars: 3,\n                    description: 'toggles zooming of viewport or sets a specific scaling factor',\n                    demoVideoId: 'yN-mhh9aDuo'\n\n                },\n                {\n                    name: 'toggle_special',\n                    stars: 3,\n                    description: 'moves windows from/to special workspaces',\n                    demoVideoId: 'BNZCMqkwTOo'\n                },\n                {\n                    name: 'shortcuts_menu',\n                    stars: 3,\n                    description: 'a flexible way to make your own shortcuts menus & launchers',\n                    demoVideoId: 'UCuS417BZK8'\n                },\n                {\n                    name: 'fetch_client_menu',\n                    stars: 3,\n                    description: 'select a window to be moved to your active workspace (using rofi, dmenu, etc...)',\n                },\n                {\n                    name: 'layout_center',\n                    stars: 3,\n                    description: 'a workspace layout where one client window is almost maximized and others seat in the background',\n                    demoVideoId: 'vEr9eeSJYDc'\n                },\n                {\n                    name: 'system_notifier',\n                    stars: 3,\n                    description: 'open streams (eg: journal logs) and trigger notifications',\n                },\n                {\n                    name: 'wallpapers',\n                    stars: 3,\n                    description: 'handles random wallpapers at regular interval (from a folder)'\n                },\n                {\n                    name: 'gbar',\n                    stars: 3,\n                    description: 'improves multi-monitor handling of the status bar - only <tt>gBar</tt> is supported at the moment'\n                },\n                {\n                    name: 'expose',\n                    stars: 2,\n                    description: 'exposes all the windows for a quick \"jump to\" feature',\n                    demoVideoId: 'ce5HQZ3na8M'\n                },\n                {\n                    name: 'lost_windows',\n                    stars: 1,\n                    description: 'brings lost floating windows (which are out of reach) to the current workspace'\n                },\n                {\n                    name: 'toggle_dpms',\n                    stars: 0,\n                    description: 'toggles the DPMS status of every plugged monitor'\n                },\n                {\n                    name: 'workspaces_follow_focus',\n                    multimon: true,\n                    stars: 3,\n                    description: 'makes non visible workspaces available on the currently focused screen.<br/> If you think the multi-screen behavior of Hyprland is not usable or broken/unexpected, this is probably for you.'\n\n                },\n                {\n                    name: 'shift_monitors',\n                    multimon: true,\n                    stars: 3,\n                    description: 'moves workspaces from monitor to monitor (caroussel)'\n                },\n                {\n                    name: 'monitors',\n                    stars: 3,\n                    description: 'allows relative placement and configuration of monitors'\n                }\n            ]\n        }\n    }\n}\n</script>\n"
  },
  {
    "path": "site/versions/2.4.1+/expose.md",
    "content": "---\ncommands:\n  - name: expose\n    description: Expose every client on the active workspace. If expose is already active, then restores everything and move to the focused window.\n---\n\n# expose\n\nImplements the \"expose\" effect, showing every client window on the focused screen.\n\nFor a similar feature using a menu, try the [fetch_client_menu](./fetch_client_menu) plugin (less intrusive).\n\nSample `hyprland.conf`:\n\n```bash\n# Setup the key binding\nbind = $mainMod, B, exec, pypr expose\n\n# Add some style to the \"exposed\" workspace\nworkspace = special:exposed,gapsout:60,gapsin:30,bordersize:5,border:true,shadow:false\n```\n\n`MOD+B` will bring every client to the focused workspace, pressed again it will go to this workspace.\n\nCheck [workspace rules](https://wiki.hyprland.org/Configuring/Workspace-Rules/#rules) for styling options.\n\n> [!note]\n> If you are looking for `toggle_minimized`, check the [toggle_special](./toggle_special) plugin\n\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n\n### `include_special`\n\ndefault value is `false`\n\nAlso include windows in the special workspaces during the expose.\n\n"
  },
  {
    "path": "site/versions/2.4.1+/fetch_client_menu.md",
    "content": "---\ncommands:\n  - name: fetch_client_menu\n    description: Bring any window to the active workspace using a menu.\n---\n\n# fetch_client_menu\n\nBring any window to the active workspace using a menu.\n\nA bit like the [expose](./expose) plugin but using a menu instead (less intrusive).\n\nIt brings the window to the current workspace, while [expose](./expose) moves the currently focused screen to the application workspace.\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\nAll the [Menu](Menu) configuration items are also available.\n\n### `separator`\n\ndefault value is `\"|\"`\n\nChanges the character (or string) used to separate a menu entry from its entry number.\n\n"
  },
  {
    "path": "site/versions/2.4.1+/filters.md",
    "content": "# Text filters\n\nAt the moment there is only one filter, close match of [sed's \"s\" command](https://www.gnu.org/software/sed/manual/html_node/The-_0022s_0022-Command.html).\n\nThe only supported flag is \"g\"\n\n## Example\n\n```toml\nfilter = 's/foo/bar/'\nfilter = 's/.*started (.*)/\\1 has started/'\nfilter = 's#</?div>##g'\n```\n"
  },
  {
    "path": "site/versions/2.4.1+/gbar.md",
    "content": "---\ncommands:\n  - name: gbar restart\n    description: Restart/refresh gBar on the \"best\" monitor.\n---\n\n# gbar\n\nRuns [gBar](https://github.com/scorpion-26/gBar) on the \"best\" monitor from a list of monitors.\n\n- Will take care of starting gbar on startup (you must not run it from another source like `hyprland.conf`).\n- Automatically restarts gbar on crash\n- Checks which monitors are on and take the best one from a provided list\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n\n## Configuration\n\n\n### `monitors` (REQUIRED)\n\nList of monitors to chose from, the first have higher priority over the second one etc...\n\n\n## Example\n\n```sh\n[gbar]\nmonitors = [\"DP-1\", \"HDMI-1\", \"HDMI-1-A\"]\n```\n"
  },
  {
    "path": "site/versions/2.4.1+/index.md",
    "content": "---\n# https://vitepress.dev/reference/default-theme-home-page\nlayout: home\n\nhero:\n  text: \"Hyprland extensions\"\n  tagline: Enhance your desktop capabilities with Pyprland\n  image:\n    src: /logo.svg\n    alt: logo\n  actions:\n    - theme: brand\n      text: Getting started\n      link: ./Getting-started\n    - theme: alt\n      text: Plugins\n      link: ./Plugins\n    - theme: alt\n      text: Report something\n      link: https://github.com/hyprland-community/pyprland/issues/new/choose\n\nfeatures:\n  - title: TOML format\n    details: Simple and flexible configuration file(s)\n  - title: Customizable\n    details: Create your own Hyprland experience\n  - title: Fast and easy\n    details: Designed for performance and simplicity\n---\n\n## Version 2.4.1+ archive\n"
  },
  {
    "path": "site/versions/2.4.1+/layout_center.md",
    "content": "---\ncommands:\n  - name: layout_center toggle\n    description: toggles the layout on and off\n  - name: layout_center next\n    description: switches to the next window (if layout is on) else runs the <a href=\"#next-optional\">next</a> command\n  - name: layout_center prev\n    description: switches to the previous window (if layout is on) else runs the <a href=\"#next-optional\">prev</a> command\n  - name: layout_center next2\n    description: switches to the next window (if layout is on) else runs the <a href=\"#next-optional\">next2</a> command\n  - name: layout_center prev2\n    description: switches to the previous window (if layout is on) else runs the <a href=\"#next-optional\">prev2</a> command\n\n---\n# layout_center\n\nImplements a workspace layout where one window is bigger and centered,\nother windows are tiled as usual in the background.\n\nOn `toggle`, the active window is made floating and centered if the layout wasn't enabled, else reverts the floating status.\n\nWith `next` and `prev` you can cycle the active window, keeping the same layout type.\nIf the layout_center isn't active and `next` or `prev` is used, it will call the [next](#next-optional) and [prev](#prev-optional) configuration options.\n\nTo allow full override of the focus keys, [next2](#next2-optional) and [prev2](#prev2-optional) are provided, they do the same actions as \"next\" and \"prev\" but allow different fallback commands.\n\n<details>\n<summary>Configuration sample</summary>\n\n```toml\n[layout_center]\nmargin = 60\noffset = [0, 30]\nnext = \"movefocus r\"\nprev = \"movefocus l\"\nnext2 = \"movefocus d\"\nprev2 = \"movefocus u\"\n```\n\nusing the following in `hyprland.conf`:\n```sh\nbind = $mainMod, M, exec, pypr layout_center toggle # toggle the layout\n## focus change keys\nbind = $mainMod, left, exec, pypr layout_center prev\nbind = $mainMod, right, exec, pypr layout_center next\nbind = $mainMod, up, exec, pypr layout_center prev2\nbind = $mainMod, down, exec, pypr layout_center next2\n```\n\nYou can completely ignore `next2` and `prev2` if you are allowing focus change in a single direction (when the layout is enabled), eg:\n\n```sh\nbind = $mainMod, up, movefocus, u\nbind = $mainMod, down, movefocus, d\n```\n\n</details>\n\n\n## Commands\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `on_new_client`\n\nDefaults to `\"foreground\"`.\n\nChanges the behavior when a new window opens, possible options are:\n\n- \"foreground\" to make the new window the main window\n- \"background\" to make the new window appear in the background\n- \"close\" to stop the centered layout when a new window opens\n\n### `style`\n\n| Requires Hyprland > 0.40.0\n\nNot set by default.\n\nAllow to set a list of styles to the main (centered) window, eg:\n\n```toml\nstyle = [\"opacity 1\", \"bordercolor rgb(FFFF00)\"]\n```\n\n### `margin`\n\ndefault value is `60`\n\nmargin (in pixels) used when placing the center window, calculated from the border of the screen.\n\nExample to make the main window be 100px far from the monitor's limits:\n```toml\nmargin = 100\n```\n\nYou can also set a different margin for width and height by using a list:\n```toml\nmargin = [10, 60]\n```\n\n### `offset`\n\ndefault value is `[0, 0]`\n\noffset in pixels applied to the main window position\n\nExample shift the main window 20px down:\n```toml\noffset = [0, 20]\n```\n\n### `next`\n### `next2`\n\nnot set by default\n\nWhen the *layout_center* isn't active and the *next* command is triggered, defines the hyprland dispatcher command to run.\n\n`next2` is a similar option, used by the `next2` command, allowing to map \"next\" to both vertical and horizontal focus change.\n\nEg:\n```toml\nnext = \"movefocus r\"\n```\n\n### `prev`\n### `prev2`\n\nSame as `next` but for the `prev` and `prev2` commands.\n\n\n### `captive_focus`\n\ndefault value is `false`\n\n```toml\ncaptive_focus = true\n```\n\nSets the focus on the main window when the focus changes.\nYou may love it or hate it...\n"
  },
  {
    "path": "site/versions/2.4.1+/lost_windows.md",
    "content": "---\ncommands:\n    - name: attract_lost\n      description: Bring windows which are not reachable in the currently focused workspace.\n---\n# lost_windows\n\nBring windows which are not reachable in the currently focused workspace.\n*Deprecated:* Was used to work around issues in Hyprland which have been fixed since then.\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n"
  },
  {
    "path": "site/versions/2.4.1+/magnify.md",
    "content": "---\ncommands:\n    - name: zoom [value]\n      description: Set the current zoom level (absolute or relative) - toggle zooming if no value is provided\n---\n# magnify\n\nZooms in and out with an optional animation.\n\n\n<details>\n    <summary>Example</summary>\n\n```sh\npypr zoom  # sets zoom to `factor` (2 by default)\npypr zoom +1  # will set zoom to 3x\npypr zoom  # will set zoom to 1x\npypr zoom 1 # will (also) set zoom to 1x - effectively doing nothing\n```\n\nSample `hyprland.conf`:\n\n```sh\nbind = $mainMod , Z, exec, pypr zoom ++0.5\nbind = $mainMod SHIFT, Z, exec, pypr zoom\n```\n\n</details>\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n### `[value]`\n\n#### unset / not specified\n\nWill zoom to [factor](#factor-optional) if not zoomed, else will set the zoom to 1x.\n\n#### floating or integer value\n\nWill set the zoom to the provided value.\n\n#### +value / -value\n\nUpdate (increment or decrement) the current zoom level by the provided value.\n\n#### ++value / --value\n\nUpdate (increment or decrement) the current zoom level by a non-linear scale.\nIt _looks_ more linear changes than using a single + or -.\n\n> [!NOTE]\n>\n> The non-linear scale is calculated as powers of two, eg:\n>\n> - `zoom ++1` → 2x, 4x, 8x, 16x...\n> - `zoom ++0.7` → 1.6x, 2.6x, 4.3x, 7.0x, 11.3x, 18.4x...\n> - `zoom ++0.5` → 1.4x, 2x, 2.8x, 4x, 5.7x, 8x, 11.3x, 16x...\n\n## Configuration\n\n### `factor`\n\ndefault value is `2`\n\nScaling factor to be used when no value is provided.\n\n### `duration`\n\nDefault value is `15`\n\nDuration in tenths of a second for the zoom animation to last, set to `0` to disable animations.\n"
  },
  {
    "path": "site/versions/2.4.1+/monitors.md",
    "content": "---\ncommands:\n  - name: relayout\n    description: Apply the configuration and update the layout\n---\n\n# monitors\n\n> First, simpler version of the plugin is still available under the name `monitors_v0`\n\nAllows relative placement of monitors depending on the model (\"description\" returned by `hyprctl monitors`).\nUseful if you have multiple monitors connected to a video signal switch or using a laptop and plugging monitors having different relative positions.\n\nSyntax:\n\n```toml\n[monitors.placement]\n\"description match\".placement = \"other description match\"\n```\n\n<details>\n    <summary>Example to set a Sony monitor on top of a BenQ monitor</summary>\n\n```toml\n[monitors.placement]\nSony.topOf = \"BenQ\"\n\n## Character case is ignored, \"_\" can be added\nSony.Top_Of = [\"BenQ\"]\n\n## Thanks to TOML format, complex configurations can use separate \"sections\" for clarity, eg:\n\n[monitors.placement.\"My monitor brand\"]\n## You can also use \"port\" names such as *HDMI-A-1*, *DP-1*, etc...\nleftOf = \"eDP-1\"\n\n## lists are possible on the right part of the assignment:\nrightOf = [\"Sony\", \"BenQ\"]\n\n## > 2.3.2: you can also set scale, transform & rate for a given monitor\n[monitors.placement.Microstep]\nrate = 100\n```\n\nTry to keep the rules as simple as possible, but relatively complex scenarios are supported.\n\n> [!note]\n> Check [wlr layout UI](https://github.com/fdev31/wlr-layout-ui) which is a nice complement to configure your monitor settings.\n\n</details>\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `placement` (REQUIRED)\n\nSupported placements are:\n\n- leftOf\n- topOf\n- rightOf\n- bottomOf\n- \\<one of the above>(center|middle|end)Of\n\n> [!important]\n> If you don't like the screen to align on the start of the given border,\n> you can use `center` (or `middle`) to center it or `end` to stick it to the opposite border.\n> Eg: \"topCenterOf\", \"leftEndOf\", etc...\n\nYou can separate the terms with \"_\" to improve the readability, as in \"top_center_of\".\n\n#### monitor settings\n\nNot only can you place monitors relatively to each other, but you can also set specific settings for a given monitor.\n\nThe following settings are supported:\n\n- scale\n- transform\n- rate\n- resolution\n\n```toml\n[monitors.placement.\"My monitor brand\"]\nrate = 60\nscale = 1.5\ntransform = 1 # 0: normal, 1: 90°, 2: 180°, 3: 270°, 4: flipped, 5: flipped 90°, 6: flipped 180°, 7: flipped 270°\nresolution = \"1920x1080\"  # can also be expressed as [1920, 1080]\n```\n\n### `startup_relayout`\n\nDefault to `ŧrue`.\n\nWhen set to `false`,\ndo not initialize the monitor layout on startup or when configuration is reloaded.\n\n### `new_monitor_delay`\n\nBy default,\nthe layout computation happens one second after the event is received to let time for things to settle.\n\nYou can change this value using this option.\n\n### `hotplug_command`\n\nNone by default, allows to run a command when any monitor is plugged.\n\n\n```toml\n[monitors]\nhotplug_commands = \"wlrlui -m\"\n```\n\n### `hotplug_commands`\n\nNone by default, allows to run a command when a given monitor is plugged.\n\nExample to load a specific profile using [wlr layout ui](https://github.com/fdev31/wlr-layout-ui):\n\n```toml\n[monitors.hotplug_commands]\n\"DELL P2417H CJFH277Q3HCB\" = \"wlrlui rotated\"\n```\n\n### `unknown`\n\nNone by default,\nallows to run a command when no monitor layout has been changed (no rule applied).\n\n```toml\n[monitors]\nunknown = \"wlrlui\"\n```\n\n### `trim_offset`\n\n`true` by default,\n\nPrevents having arbitrary window offsets or negative values.\n"
  },
  {
    "path": "site/versions/2.4.1+/scratchpads.md",
    "content": "---\ncommands:\n  - name: toggle [scratchpad name]\n    description: Toggle the given scratchpad\n  - name: show [scratchpad name]\n    description: Show the given scratchpad\n  - name: hide [scratchpad name]\n    description: Hide the given scratchpad\n  - name: attach\n    description: Toggle attaching/anchoring the currently focused window to the (last used) scratchpad\n---\n# scratchpads\n\nEasily toggle the visibility of applications you use the most.\n\nConfigurable and flexible, while supporting complex setups it's easy to get started with:\n\n```toml\n[scratchpads.name]\ncommand = \"command to run\"\nclass = \"the window's class\"  # check: hyprctl clients | grep class\nsize = \"[width] [height]\"  # size of the window relative to the screen size\n```\n\n<details>\n<summary>Example</summary>\n\nAs an example, defining two scratchpads:\n\n- _term_ which would be a kitty terminal on upper part of the screen\n- _volume_ which would be a pavucontrol window on the right part of the screen\n\n\n```toml\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\nmax_size = \"1920px 100%\"\nmargin = 50\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nsize = \"40% 90%\"\nunfocus = \"hide\"\nlazy = true\n```\n\nShortcuts are generally needed:\n\n```ini\nbind = $mainMod,V,exec,pypr toggle volume\nbind = $mainMod,A,exec,pypr toggle term\nbind = $mainMod,Y,exec,pypr attach\n```\n</details>\n\n- If you wish to have a more generic space for any application you may run, check [toggle_special](./toggle_special).\n- When you create a scratchpad called \"name\", it will be hidden in `special:scratch_<name>`.\n- Providing `class` allows a glitch free experience, mostly noticeable when using animations\n\n\n## Commands\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n> [!tip]\n> You can use `\"*\"` as a _scratchpad name_ to target every scratchpad when using `show` or `hide`.\n> You'll need to quote or escape the `*` character to avoid interpretation from your shell.\n\n## Configuration\n\n### `command` (REQUIRED)\n\nThis is the command you wish to run in the scratchpad.\n\nIt supports [Variables](./Variables)\n\n### `animation`\n\nType of animation to use, default value is \"fromTop\":\n\n- `null` / `\"\"` (no animation)\n- `fromTop` (stays close to upper screen border)\n- `fromBottom` (stays close to lower screen border)\n- `fromLeft` (stays close to left screen border)\n- `fromRight` (stays close to right screen border)\n\n### `size` (recommended)\n\nNo default value.\n\nEach time scratchpad is shown, window will be resized according to the provided values.\n\nFor example on monitor of size `800x600` and `size= \"80% 80%\"` in config scratchpad always have size `640x480`,\nregardless of which monitor it was first launched on.\n\n> #### Format\n>\n> String with \"x y\" (or \"width height\") values using some units suffix:\n>\n> - **percents** relative to the focused screen size (`%` suffix), eg: `60% 30%`\n> - **pixels** for absolute values (`px` suffix), eg: `800px 600px`\n> - a mix is possible, eg: `800px 40%`\n\n### `class` (recommended)\n\nNo default value.\n\nAllows _Pyprland_ prepare the window for a correct animation and initial positioning.\n\n### `position`\n\nNo default value, overrides the automatic margin-based position.\n\nSets the scratchpad client window position relative to the top-left corner.\n\nSame format as `size` (see above)\n\nExample of scratchpad that always seat on the top-right corner of the screen:\n\n```toml\n[scratchpads.term_quake]\ncommand = \"wezterm start --class term_quake\"\nposition = \"50% 0%\"\nsize = \"50% 50%\"\nclass = \"term_quake\"\n```\n\n> [!note]\n> If `position` is not provided, the window is placed according to `margin` on one axis and centered on the other.\n\n### `multi`\n\nDefaults to `true`.\n\nWhen set to `false`, only one client window is supported for this scratchpad.\nOtherwise other matching windows will be **attach**ed to the scratchpad.\n\n## Advanced configuration\n\nTo go beyond the basic setup and have a look at every configuration item, you can read the following pages:\n\n- [Advanced](./scratchpads_advanced) contains options for fine-tuners or specific tastes (eg: i3 compatibility)\n- [Non-Standard](./scratchpads_nonstandard) contains options for \"broken\" applications\nlike progressive web apps (PWA) or emacsclient, use only if you can't get it to work otherwise\n\n## Monitor specific overrides\n\nYou can use different settings for a specific screen.\nMost attributes related to the display can be changed (not `command`, `class` or `process_tracking` for instance).\n\nUse the `monitor.<monitor name>` configuration item to override values, eg:\n\n```toml\n[scratchpads.music.monitor.eDP-1]\nposition = \"30% 50%\"\nanimation = \"fromBottom\"\n```\n\nYou may want to inline it for simple cases:\n\n```toml\n[scratchpads.music]\nmonitor = {HDMI-A-1={size = \"30% 50%\"}}\n```\n"
  },
  {
    "path": "site/versions/2.4.1+/scratchpads_advanced.md",
    "content": "# Fine tuning scratchpads\n\nAdvanced configuration options\n\n## `use`\n\nNo default value.\n\nList of scratchpads (or single string) that will be used for the default values of this scratchpad.\nThink about *templates*:\n\n```toml\n[scratchpads.terminals]\nanimation = \"fromTop\"\nmargin = 50\nsize = \"75% 60%\"\nmax_size = \"1920px 100%\"\n\n[scratchpads.term]\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nuse = \"terminals\"\n```\n\n## `pinned`\n\n`true` by default\n\nMakes the scratchpad \"sticky\" to the monitor, following any workspace change.\n\n## `excludes`\n\nNo default value.\n\nList of scratchpads to hide when this one is displayed, eg: `excludes = [\"term\", \"volume\"]`.\nIf you want to hide every displayed scratch you can set this to the string `\"*\"` instead of a list: `excludes = \"*\"`.\n\n## `restore_excluded`\n\n`false` by default.\n\nWhen enabled, will remember the scratchpads which have been closed due to `excludes` rules, so when the scratchpad is hidden, those previously hidden scratchpads will be shown again.\n\n## `unfocus`\n\nNo default value.\n\nWhen set to `\"hide\"`, allow to hide the window when the focus is lost.\n\nUse `hysteresis` to change the reactivity\n\n## `hysteresis`\n\nDefaults to `0.4` (seconds)\n\nControls how fast a scratchpad hiding on unfocus will react. Check `unfocus` option.\nSet to `0` to disable.\n\n> [!important]\n> Only relevant when `unfocus=\"hide\"` is used.\n\n## `margin`\n\ndefault value is `60`.\n\nnumber of pixels separating the scratchpad from the screen border, depends on the [animation](./scratchpads#animation) set.\n\n> [!tip]\n> It is also possible to set a string to express percentages of the screen (eg: '`3%`').\n\n## `max_size`\n\nNo default value.\n\nSame format as `size` (see above), only used if `size` is also set.\n\nLimits the `size` of the window accordingly.\nTo ensure a window will not be too large on a wide screen for instance:\n\n```toml\nsize = \"60% 30%\"\nmax_size = \"1200px 100%\"\n```\n\n## `lazy`\n\ndefault to `false`.\n\nwhen set to `true`, prevents the command from being started when pypr starts, it will be started when the scratchpad is first used instead.\n\n- Good: saves resources when the scratchpad isn't needed\n- Bad: slows down the first display (app has to launch before showing)\n\n## `preserve_aspect`\n\nNot set by default.\nWhen set to `true`, will preserve the size and position of the scratchpad when called repeatedly from the same monitor and workspace even though an `animation` , `position` or `size` is used (those will be used for the initial setting only).\n\nForces the `lazy` option.\n\n## `offset`\n\nIn pixels, default to `0` (client's window size + margin).\n\nNumber of pixels for the **hide** sliding animation (how far the window will go).\n\n> [!tip]\n> - It is also possible to set a string to express percentages of the client window\n> - `margin` is automatically added to the offset\n> - automatic (value not set) is same as `\"100%\"`\n\n## `hide_delay`\n\nDefaults to `0.2`\n\nDelay (in seconds) after which the hide animation happens, before hiding the scratchpad.\n\nRule of thumb, if you have an animation with speed \"7\", as in:\n```bash\n    animation = windowsOut, 1, 7, easeInOut, popin 80%\n```\nYou can divide the value by two and round to the lowest value, here `3`, then divide by 10, leading to `hide_delay = 0.3`.\n\n## `restore_focus`\n\nEnabled by default, set to `false` if you don't want the focused state to be restored when a scratchpad is hidden.\n\n## `force_monitor`\n\nIf set to some monitor name (eg: `\"DP-1\"`), it will always use this monitor to show the scratchpad.\n\n## `alt_toggle`\n\nDefault value is `false`\n\nWhen enabled, use an alternative `toggle` command logic for multi-screen setups.\nIt applies when the `toggle` command is triggered and the toggled scratchpad is visible on a screen which is not the focused one.\n\nInstead of moving the scratchpad to the focused screen, it will hide the scratchpad.\n\n## `allow_special_workspaces`\n\nDefault value is `false` (can't be enabled when using *Hyprland* < 0.39 where this behavior can't be controlled and is disabled).\n\nWhen enabled, you can toggle a scratchpad over a special workspace.\nIt will always use the \"normal\" workspace otherwise.\n\n## `smart_focus`\n\nDefault value is `true`.\n\nWhen enabled, the focus will be restored in a best effort way as en attempt to improve the user experience.\nIf you face issues such as spontaneous workspace changes, you can disable this feature.\n\n"
  },
  {
    "path": "site/versions/2.4.1+/scratchpads_nonstandard.md",
    "content": "# Troubleshooting scratchpads\n\nOptions that should only be used for applications that are not behaving in a \"standard\" way, such as `emacsclient` or progressive web apps.\n\n## `match_by`\n\nDefault value is `\"pid\"`\nWhen set to a sensitive client property value (eg: `class`, `initialClass`, `title`, `initialTitle`), will match the client window using the provided property instead of the PID of the process.\n\nThis property must be set accordingly, eg:\n\n```toml\nmatch_by = \"class\"\nclass = \"my-web-app\"\n```\n\nor\n\n```toml\nmatch_by = \"initialClass\"\ninitialClass = \"my-web-app\"\n```\n\nYou can add the \"re:\" prefix to use a regular expression, eg:\n\n```toml\nmatch_by = \"title\"\ntitle = \"re:.*some string.*\"\n```\n\n> [!note]\n> Some apps may open the graphical client window in a \"complicated\" way, to work around this, it is possible to disable the process PID matching algorithm and simply rely on window's class.\n>\n> The `match_by` attribute can be used to achieve this, eg. for emacsclient:\n> ```toml\n> [scratchpads.emacs]\n> command = \"/usr/local/bin/emacsStart.sh\"\n> class = \"Emacs\"\n> match_by = \"class\"\n> ```\n\n## `process_tracking`\n\nDefault value is `true`\n\nAllows disabling the process management. Use only if running a progressive web app (Chrome based apps) or similar.\n\nThis will automatically force `lazy = true` and set `match_by=\"class\"` if no `match_by` rule is provided, to help with the fuzzy client window matching.\n\nIt requires defining a `class` option (or the option matching your `match_by` value).\n\n```toml\n## Chat GPT on Brave\n[scratchpads.gpt]\nanimation = \"fromTop\"\ncommand = \"brave --profile-directory=Default --app=https://chat.openai.com\"\nclass = \"brave-chat.openai.com__-Default\"\nsize = \"75% 60%\"\nprocess_tracking = false\n\n## Some chrome app\n[scratchpads.music]\ncommand = \"google-chrome --profile-directory=Default --app-id=cinhimbnkkaeohfgghhklpknlkffjgod\"\nclass = \"chrome-cinhimbnkkaeohfgghhklpknlkffjgod-Default\"\nsize = \"50% 50%\"\nprocess_tracking = false\n```\n\n> [!tip]\n> To list windows by class and title you can use:\n> - `hyprctl -j clients | jq '.[]|[.class,.title]'`\n> - or if you prefer a graphical tool: `rofi -show window`\n\n> [!note]\n> Progressive web apps will share a single process for every window.\n> On top of requiring the class based window tracking (using `match_by`),\n> the process can not be managed the same way as usual apps and the correlation\n> between the process and the client window isn't as straightforward and can lead to false matches in extreme cases.\n\n## `skip_windowrules`\n\nDefault value is `[]`\nAllows you to skip the window rules for a specific scratchpad.\nAvailable rules are:\n\n- \"aspect\" controlling size and position\n- \"float\" controlling the floating state\n- \"workspace\" which moves the window to its own workspace\n\nIf you are using an application which can spawn multiple windows and you can't see them, you can skip rules made to improve the initial display of the window.\n\n```toml\n[scratchpads.filemanager]\nanimation = \"fromBottom\"\ncommand = \"nemo\"\nclass = \"nemo\"\nsize = \"60% 60%\"\nskip_windowrules = [\"aspect\", \"workspace\"]\n```\n"
  },
  {
    "path": "site/versions/2.4.1+/shift_monitors.md",
    "content": "---\ncommands:\n    - name: shift_monitors <direction>\n      description: Swaps the workspaces of every screen in the given direction.\n---\n\n# shift_monitors\n\nSwaps the workspaces of every screen in the given direction.\n\n> [!Note]\n> the behavior can be hard to predict if you have more than 2 monitors (depending on your layout).\n> If you use this plugin with many monitors and have some ideas about a convenient configuration, you are welcome ;)\n\nExample usage in `hyprland.conf`:\n\n```\nbind = $mainMod, O, exec, pypr shift_monitors +1\nbind = $mainMod SHIFT, O, exec, pypr shift_monitors -1\n```\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n"
  },
  {
    "path": "site/versions/2.4.1+/shortcuts_menu.md",
    "content": "---\ncommands:\n    - name: menu [name]\n      description: Displays the full menu or part of it if \"name\" is provided\n---\n\n# shortcuts_menu\n\nPresents some menu to run shortcut commands. Supports nested menus (aka categories / sub-menus).\n\n<details>\n   <summary>Configuration examples</summary>\n\n```toml\n[shortcuts_menu.entries]\n\n\"Open Jira ticket\" = 'open-jira-ticket \"$(wl-paste)\"'\nRelayout = \"pypr relayout\"\n\"Fetch window\" = \"pypr fetch_client_menu\"\n\"Hyprland socket\" = 'kitty  socat - \"UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock\"'\n\"Hyprland logs\" = 'kitty tail -f $XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/hyprland.log'\n\n\"Serial USB Term\" = [\n    {name=\"device\", command=\"ls -1 /dev/ttyUSB*; ls -1 /dev/ttyACM*\"},\n    {name=\"speed\", options=[\"115200\", \"9600\", \"38400\", \"115200\", \"256000\", \"512000\"]},\n    \"kitty miniterm --raw --eol LF [device] [speed]\"\n]\n\n\"Color picker\" = [\n    {name=\"format\", options=[\"hex\", \"rgb\", \"hsv\", \"hsl\", \"cmyk\"]},\n    \"sleep 0.2; hyprpicker --format [format] | wl-copy\" # sleep to let the menu close before the picker opens\n]\n\nscreenshot = [\n    {name=\"what\", options=[\"output\", \"window\", \"region\", \"active\"]},\n    \"hyprshot -m [what] -o /tmp -f shot_[what].png\"\n]\n\nannotate = [\n    {name=\"fname\", command=\"ls /tmp/shot_*.png\"},\n    \"satty --filename '[fname]' --output-filename '/tmp/annotated.png'\"\n]\n\n\"Clipboard history\" = [\n    {name=\"entry\", command=\"cliphist list\", filter=\"s/\\t.*//\"},\n    \"cliphist decode '[entry]' | wl-copy\"\n]\n\n\"Copy password\" = [\n    {name=\"what\", command=\"gopass ls --flat\"},\n    \"gopass show -c [what]\"\n]\n\n\"Update/Change password\" = [\n    {name=\"what\", command=\"gopass ls --flat\"},\n    \"kitty -- gopass generate -s --strict -t '[what]' && gopass show -c '[what]'\"\n]\n```\n\n</details>\n\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n> [!tip]\n> - If \"name\" is provided it will show the given sub-menu.\n> - You can use \".\" to reach any level of the configured menus.\n>\n>      Example to reach `shortcuts_menu.entries.utils.\"local commands\"`, use:\n>      ```sh\n>       pypr menu \"utils.local commands\"\n>      ```\n\n## Configuration\n\nAll the [Menu](./Menu) configuration items are also available.\n\n### `entries` (REQUIRED)\n\nDefines the menu entries. Supports [Variables](./Variables)\n\n```toml\n[shortcuts_menu.entries]\n\"entry 1\" = \"command to run\"\n\"entry 2\" = \"command to run\"\n```\nSubmenus can be defined too (there is no depth limit):\n\n```toml\n[shortcuts_menu.entries.\"My submenu\"]\n\"entry X\" = \"command\"\n\n[shortcuts_menu.entries.one.two.three.four.five]\nfoobar = \"ls\"\n```\n\n#### Advanced usage\n\nInstead of navigating a configured list of menu options and running a pre-defined command, you can collect various *variables* (either static list of options selected by the user, or generated from a shell command) and then run a command using those variables. Eg:\n\n```toml\n\"Play Video\" = [\n    {name=\"video_device\", command=\"ls -1 /dev/video*\"},\n    {name=\"player\",\n        options=[\"mpv\", \"guvcview\"]\n    },\n    \"[player] [video_device]\"\n]\n\n\"Ssh\" = [\n    {name=\"action\", options=[\"htop\", \"uptime\", \"sudo halt -p\"]},\n    {name=\"host\", options=[\"gamix\", \"gate\", \"idp\"]},\n    \"kitty --hold ssh [host] [action]\"\n]\n```\n\nYou must define a list of objects, containing:\n- `name`: the variable name\n- then the list of options, must be one of:\n    - `options` for a static list of options\n    - `command` to get the list of options from a shell command's output\n\n> [!tip]\n> You can apply post-filters to the `command` output, eg:\n> ```toml\n> {\n>   name = \"entry\",\n>   command = \"cliphist list\",\n>   filter = \"s/\\t.*//\"  # will remove everything after the TAB character\n> }\n> ```\n> check the [filters](./filters) page for more details\n\nThe last item of the list must be a string which is the command to run. Variables can be used enclosed in `[]`.\n\n### `command_start`\n### `command_end`\n### `submenu_start`\n### `submenu_end`\n\nAllow adding some text (eg: icon) before / after a menu entry.\n\ncommand_* is for final commands, while submenu_* is for entries leading to another menu.\n\nBy default `submenu_end` is set to a right arrow sign, while other attributes are not set.\n\n### `skip_single`\n\nDefaults to `true`.\nWhen disabled, shows the menu even for single options\n\n## Hints\n\n### Multiple menus\n\nTo manage multiple distinct menus, always use a name when using the `pypr menu <name>` command.\n\nExample of a multi-menu configuration:\n\n```toml\n[shortcuts_menu.entries.\"Basic commands\"]\n\"entry X\" = \"command\"\n\"entry Y\" = \"command2\"\n\n[shortcuts_menu.entries.menu2]\n## ...\n```\n\nYou can then show the first menu using `pypr menu \"Basic commands\"`\n"
  },
  {
    "path": "site/versions/2.4.1+/sidebar.json",
    "content": "{\n  \"main\": [\n    {\n      \"text\": \"Getting started\",\n      \"link\": \"./Getting-started\"\n    },\n    {\n      \"text\": \"Plugins\",\n      \"link\": \"./Plugins\"\n    },\n    {\n      \"text\": \"Troubleshooting\",\n      \"link\": \"./Troubleshooting\"\n    },\n    {\n      \"text\": \"Development\",\n      \"link\": \"./Development\"\n    }\n  ],\n  \"plugins\": {\n    \"text\": \"Featured plugins\",\n    \"collapsed\": false,\n    \"items\": [\n      {\n        \"text\": \"Expose\",\n        \"link\": \"./expose\"\n      },\n      {\n        \"text\": \"Fetch client menu\",\n        \"link\": \"./fetch_client_menu\"\n      },\n      {\n        \"text\": \"Gbar\",\n        \"link\": \"./gbar\"\n      },\n      {\n        \"text\": \"Layout center\",\n        \"link\": \"./layout_center\"\n      },\n      {\n        \"text\": \"Lost windows\",\n        \"link\": \"./lost_windows\"\n      },\n      {\n        \"text\": \"Magnify\",\n        \"link\": \"./magnify\"\n      },\n      {\n        \"text\": \"Monitors\",\n        \"link\": \"./monitors\"\n      },\n      {\n        \"text\": \"Scratchpads\",\n        \"link\": \"./scratchpads\",\n        \"items\": [\n          {\n            \"text\": \"Advanced\",\n            \"link\": \"./scratchpads_advanced\"\n          },\n          {\n            \"text\": \"Special cases\",\n            \"link\": \"./scratchpads_nonstandard\"\n          }\n        ]\n      },\n      {\n        \"text\": \"Shift monitors\",\n        \"link\": \"./shift_monitors\"\n      },\n      {\n        \"text\": \"Shortcuts menu\",\n        \"link\": \"./shortcuts_menu\"\n      },\n      {\n        \"text\": \"System notifier\",\n        \"link\": \"./system_notifier\"\n      },\n      {\n        \"text\": \"Toggle dpms\",\n        \"link\": \"./toggle_dpms\"\n      },\n      {\n        \"text\": \"Toggle special\",\n        \"link\": \"./toggle_special\"\n      },\n      {\n        \"text\": \"Wallpapers\",\n        \"link\": \"./wallpapers\"\n      },\n      {\n        \"text\": \"Workspaces follow focus\",\n        \"link\": \"./workspaces_follow_focus\"\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "site/versions/2.4.1+/system_notifier.md",
    "content": "# system_notifier\n\nThis plugin adds system notifications based on journal logs (or any program's output).\nIt monitors specified **sources** for log entries matching predefined **patterns** and generates notifications accordingly (after applying an optional **filter**).\n\nSources are commands that return a stream of text (eg: journal, mqtt, tail -f, ...) which is sent to a parser that will use a [regular expression pattern](https://en.wikipedia.org/wiki/Regular_expression) to detect lines of interest and optionally transform them before sending the notification.\n\n<details>\n    <summary>Minimal configuration</summary>\n\n```toml\n[system_notifier.sources]\ncommand = \"sudo journalctl -fx\"\nparser = \"journal\"\n```\n\nIn general you will also need to define some **parsers**.\nBy default a **\"journal\"** parser is provided, otherwise you need to define your own rules.\nThis built-in configuration is close to this one, provided as an example:\n\n```toml\n[system_notifier.parsers.journal]\npattern = \"([a-z0-9]+): Link UP$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is active/\"\ncolor= \"#00aa00\"\n\n[system_notifier.parsers.journal]\npattern = \"([a-z0-9]+): Link DOWN$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is inactive/\"\ncolor= \"#ff8800\"\n\n[system_notifier.parsers.journal]\npattern = \"Process \\d+ \\(.*\\) of .* dumped core.\"\nfilter = \"s/.*Process \\d+ \\((.*)\\) of .* dumped core./\\1 dumped core/\"\ncolor= \"#aa0000\"\n\n[system_notifier.parsers.journal]\npattern = \"usb \\d+-[0-9.]+: Product: \"\nfilter = \"s/.*usb \\d+-[0-9.]+: Product: (.*)/USB plugged: \\1/\"\n```\n</details>\n\n\n## Configuration\n\n### `sources` (recommended)\n\nList of sources to enable (by default nothing is enabled)\n\nEach source must contain a `command` to run and a `parser` to use.\n\nYou can also use a list of parsers, eg:\n\n```toml\n[system_notifier.sources](system_notifier.sources)\ncommand = \"sudo journalctl -fkn\"\nparser = [\"journal\", \"custom_parser\"]\n```\n\n#### command (recommended)\n\nThis is the long-running command (eg: `tail -f <filename>`) returning the stream of text that will be updated. Aa common option is the system journal output (eg: `journalctl -u nginx`)\n\n#### parser\n\nSets the list of rules / parser to be used to extract lines of interest.\nMust match a list of rules defined as `system_notifier.parsers.<parser_name>`.\n\n### `parsers` (recommended)\n\nA list of available parsers that can be used to detect lines of interest in the **sources** and re-format it before issuing a notification.\n\nEach parser definition must contain a **pattern** and optionally a **filter** and a **color**.\n\n#### pattern\n\n```toml\n[system_notifier.parsers.custom_parser](system_notifier.parsers.custom_parser)\n\npattern = 'special value:'\n```\n\nThe pattern is any regular expression.\n\n#### filter\n\nThe [filters](./filters) allows to change the text before the notification, eg:\n`filter=\"s/.*special value: (\\d+)/Value=\\1/\"`\nwill set a filter so a string \"special value: 42\" will lead to the notification \"Value=42\"\n\n#### color\n\nYou can also provide an optional **color** in `\"hex\"` or `\"rgb()\"` format\n\n```toml\ncolor = \"#FF5500\"\n```\n\n### default_color\n\nSets the notification color that will be used when none is provided in a *parser* definition.\n\n```toml\n[system_notifier]\ndefault_color = \"#bbccbb\"\n```\n"
  },
  {
    "path": "site/versions/2.4.1+/toggle_dpms.md",
    "content": "---\ncommands:\n    - name: toggle_dpms\n      description: if any screen is powered on, turn them all off, else turn them all on\n---\n\n# toggle_dpms\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n"
  },
  {
    "path": "site/versions/2.4.1+/toggle_special.md",
    "content": "---\ncommands:\n    - name: toggle_special [name]\n      description: moves the focused window to the special workspace <code>name</code>, or move it back to the active workspace\n---\n\n# toggle_special\n\nAllows moving the focused window to a special workspace and back (based on the visibility status of that workspace).\n\nIt's a companion of the `togglespecialworkspace` Hyprland's command which toggles a special workspace's visibility.\n\nYou most likely will need to configure the two commands for a complete user experience, eg:\n\n```bash\nbind = $mainMod SHIFT, N, togglespecialworkspace, stash # toggles \"stash\" special workspace visibility\nbind = $mainMod, N, exec, pypr toggle_special stash # moves window to/from the \"stash\" workspace\n```\n\nNo other configuration needed, here `MOD+SHIFT+N` will show every window in \"stash\" while `MOD+N` will move the focused window out of it/ to it.\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n"
  },
  {
    "path": "site/versions/2.4.1+/wallpapers.md",
    "content": "---\ncommands:\n    - name: wall next\n      description: Changes the current background image\n    - name: wall clear\n      description: Removes the current background image\n---\n\n# wallpapers\n\nSearch folders for images and sets the background image at a regular interval.\nImages are selected randomly from the full list of images found.\n\nIt serves two purposes:\n\n- adding support for random images to any background setting tool\n- quickly testing different tools with a minimal effort\n\nIt allows \"zapping\" current backgrounds, clearing it to go distraction free and optionally make them different for each screen.\n\n> [!tip]\n> Uses **swaybg** by default, but can be configured to use any other application.\n\n<details>\n    <summary>Minimal example using defaults(requires <b>swaybg</b>)</summary>\n\n```toml\n[wallpapers]\npath = \"~/Images/wallpapers/\" # path to the folder with background images\n```\n\n</details>\n\n<details>\n<summary>More complete, using the custom <b>swww</b> command (not recommended because of its stability)</summary>\n\n```toml\n[wallpapers]\nunique = true # set a different wallpaper for each screen\npath = \"~/Images/wallpapers/\"\ninterval = 60 # change every hour\nextensions = [\"jpg\", \"jpeg\"]\nrecurse = true\n## Using swww\ncommand = 'swww img --transition-type any \"[file]\"'\nclear_command = \"swww clear\"\n```\n\nNote that for applications like `swww`, you'll need to start a daemon separately (eg: from `hyprland.conf`).\n</details>\n\n\n## Commands\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n\n### `path` (REQUIRED)\n\npath to a folder or list of folders that will be searched. Can also be a list, eg:\n\n```toml\npath = [\"~/Images/Portraits/\", \"~/Images/Landscapes/\"]\n```\n\n### `interval`\n\ndefaults to `10`\n\nHow long (in minutes) a background should stay in place\n\n\n### `command`\n\nOverrides the default command to set the background image.\n\n[variables](./Variables) are replaced with the appropriate values, you must use a `\"[file]\"` placeholder for the image path. eg:\n\n```\nswaybg -m fill -i \"[file]\"\n```\n\n### `clear_command`\n\nBy default `clear` command kills the `command` program.\n\nInstead of that, you can provide a command to clear the background. eg:\n\n```\nclear_command = \"swaybg clear\"\n``````\n\n### `extensions`\n\ndefaults to `[\"png\", \"jpg\", \"jpeg\"]`\n\nList of valid wallpaper image extensions.\n\n### `recurse`\n\ndefaults to `false`\n\nWhen enabled, will also search sub-directories recursively.\n\n### `unique`\n\ndefaults to `false`\n\nWhen enabled, will set a different wallpaper for each screen.\n\nIf you are not using the default application, ensure you are using `\"[output]\"` in the [command](#command) template.\n\nExample for swaybg: `swaybg -o \"[output]\" -m fill -i \"[file]\"`\n"
  },
  {
    "path": "site/versions/2.4.1+/workspaces_follow_focus.md",
    "content": "---\ncommands:\n    - name: change_workspace [direction]\n      description: Changes the workspace of the focused monitor in the given direction.\n---\n\n# workspaces_follow_focus\n\nMake non-visible workspaces follow the focused monitor.\nAlso provides commands to switch between workspaces while preserving the current monitor assignments:\n\nSyntax:\n```toml\n[workspaces_follow_focus]\nmax_workspaces = 4 # number of workspaces before cycling\n```\nExample usage in `hyprland.conf`:\n\n```ini\nbind = $mainMod, K, exec, pypr change_workspace +1\nbind = $mainMod, J, exec, pypr change_workspace -1\n ```\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `max_workspaces`\n\nLimits the number of workspaces when switching, defaults value is `10`.\n"
  },
  {
    "path": "site/versions/2.4.6/Development.md",
    "content": "# Development\n\nIt's easy to write your own plugin by making a python package and then indicating it's name as the plugin name.\n\n[Contributing guidelines](https://github.com/hyprland-community/pyprland/blob/main/CONTRIBUTING.md)\n\n# Writing plugins\n\nPlugins can be loaded with full python module path, eg: `\"mymodule.pyprlandplugin\"`, the loaded module must provide an `Extension` class.\n\nCheck the `interface.py` file to know the base methods, also have a look at the example below.\n\nTo get more details when an error is occurring, use `pypr --debug <log file path>`, it will also display the log in the console.\n\n> [!note]\n> To quickly get started, you can directly edit the `experimental` built-in plugin.\n> In order to distribute it, make your own Python package or trigger a pull request.\n> If you prefer to make a separate package, check the [examples](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/)'s package\n\nThe `Extension` interface provides a couple of built-in attributes:\n\n- `config` : object exposing the plugin section in `pyprland.toml`\n- `notify` ,`notify_error`, `notify_info` : access to Hyprland's notification system\n- `hyprctl`, `hyprctl_json` : invoke [Hyprland's IPC system](https://wiki.hyprland.org/Configuring/Dispatchers/)\n\n\n> [!important]\n> Contact me to get your extension listed on the home page\n\n> [!tip]\n> You can set a `plugins_paths=[\"/custom/path/example\"]` in the `hyprland` section of the configuration to add extra paths (eg: during development).\n\n> [!Note]\n> If your extension is at the root of the plugin (this is not recommended, preferable add a name space, as in `johns_pyprland.super_feature`, rather than `super_feature`) you can still import it using the `external:` prefix when you refer to it in the `plugins` list.\n\n# API Documentation\n\nRun `tox run -e doc` then visit `http://localhost:8080`\n\nThe most important to know are:\n\n- `hyprctl_json` to get a response from an IPC query\n- `hyprctl` to trigger general IPC commands\n- `on_reload` to be implemented, called when the config is (re)loaded\n- `run_<command_name>` to implement a command\n- `event_<event_name>` called when the given event is emitted by Hyprland\n\nAll those methods are _async_\n\nOn top of that:\n\n- the first line of a `run_*` command's docstring will be used by the `help` command\n- `self.config` in your _Extension_ contains the entry corresponding to your plugin name in the TOML file\n- `state` from `..common` module contains ready to use information\n- there is a `MenuMixin` in `..adapters.menus` to make menu-based plugins easy\n\n# Workflow\n\nJust `^C` when you make a change and repeat:\n\n```sh\npypr exit ; pypr --debug /tmp/output.log\n```\n\n\n## Creating a plugin\n\n```python\nfrom .interface import Plugin\n\n\nclass Extension(Plugin):\n    \" My plugin \"\n\n    async def init(self):\n        await self.notify(\"My plugin loaded\")\n```\n\n## Adding a command\n\nJust add a method called `run_<name of your command>` to your `Extension` class, eg with \"togglezoom\" command:\n\n```python\n    zoomed = False\n\n    async def run_togglezoom(self, args):\n        \"\"\" this doc string will show in `help` to document `togglezoom`\n        But this line will not show in the CLI help\n        \"\"\"\n      if self.zoomed:\n        await self.hyprctl('misc:cursor_zoom_factor 1', 'keyword')\n      else:\n        await self.hyprctl('misc:cursor_zoom_factor 2', 'keyword')\n      self.zoomed = not self.zoomed\n```\n\n## Reacting to an event\n\nSimilar as a command, implement some `async def event_<the event you are interested in>` method.\n\n## Code safety\n\nPypr ensures only one `run_` or `event_` handler runs at a time, allowing the plugins code to stay simple and avoid the need for concurrency handling.\nHowever, each plugin can run its handlers in parallel.\n\n# Reusable code\n\n```py\nfrom ..common import state, CastBoolMixin\n```\n\n- `state` provides a couple of handy variables so you don't have to fetch them, allow optimizing the most common operations\n- `Mixins` are providing common code, for instance the `CastBoolMixin` provides the `cast_bool` method to your `Extension`.\n\nIf you want to use menus, then the `MenuMixin` will provide:\n- `menu` to show a menu\n- `ensure_menu_configured` to call before you require a menu in your plugin\n\n# Example\n\nYou'll find a basic external plugin in the [examples](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/) folder.\n\nIt provides one command: `pypr dummy`.\n\nRead the [plugin code](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/pypr_examples/focus_counter.py)\n\nIt's a simple python package. To install it for development without a need to re-install it for testing, you can use `pip install -e .` in this folder.\nIt's ready to be published using `poetry publish`, don't forget to update the details in the `pyproject.toml` file.\n\n## Usage\n\nEnsure you added `pypr_examples.focus_counter` to your `plugins` list:\n\n```toml\n[pyprland]\nplugins = [\n  \"pypr_examples.focus_counter\"\n]\n```\n\nOptionally you can customize one color:\n\n```toml\n[\"pypr_examples.focus_counter\"]\ncolor = \"FFFF00\"\n```\n"
  },
  {
    "path": "site/versions/2.4.6/Getting-started.md",
    "content": "# Getting started\n\nPypr consists in two things:\n\n- **a tool**: `pypr` which runs the daemon (service), but also allows to interact with it\n  - `pypr-client` is a more limited, faster version, meant to be used in your `hyprland.conf` keyboard bindings **only**\n- **some config file**: `~/.config/hypr/pyprland.toml` (or the path set using `--config`) using the [TOML](https://toml.io/en/) format\n\nThe `pypr` tool only have a few built-in commands:\n\n- `help` lists available commands (including plugins commands)\n- `exit` will terminate the service process\n- `edit` edit the configuration using your `$EDITOR` (or `vi`), reloads on exit\n- `dumpjson` shows a JSON representation of the configuration (after other files have been `include`d)\n- `reload` reads the configuration file and apply some changes:\n  - new plugins will be loaded\n  - configuration items will be updated (most plugins will use the new values on the next usage)\n\nOther commands are implemented by adding [plugins](./Plugins).\n\n> [!important]\n> - with no argument it runs the daemon (doesn't fork in the background)\n>\n> - if you pass parameters, it will interact with the daemon instead.\n\n> [!tip]\n> Pypr *command* names are documented using underscores (`_`) but you can use dashes (`-`) instead.\n> Eg: `pypr shift_monitors` and `pypr shift-monitors` will run the same command\n\n\n## Configuration file\n\nThe configuration file uses a [TOML format](https://toml.io/) with the following as the bare minimum:\n\n```toml\n[pyprland]\nplugins = [\"plugin_name\", \"other_plugin\"]\n```\n\nAdditionally some plugins require **Configuration** options, using the following format:\n\n```toml\n[plugin_name]\nplugin_option = 42\n\n[plugin_name.another_plugin_option]\nsuboption = \"config value\"\n```\n\nYou can also split your configuration into [Multiple configuration files](./MultipleConfigurationFiles).\n\n## Installation\n\nCheck your OS package manager first, eg:\n\n- Archlinux: you can find it on AUR, eg with [yay](https://github.com/Jguer/yay): `yay pyprland`\n- NixOS: Instructions in the [Nix](./Nix) page\n\nOtherwise, use the python package manager (pip) [inside a virtual environment](InstallVirtualEnvironment)\n\n```sh\npip install pyprland\n```\n\n## Running\n\n> [!caution]\n> If you messed with something else than your OS packaging system to get `pypr` installed, use the full path to the `pypr` command.\n\nPreferably start the process with hyprland, adding to `hyprland.conf`:\n\n```ini\nexec-once = /usr/bin/pypr\n```\n\nor if you run into troubles (use the first version once your configuration is stable):\n\n```ini\nexec-once = /usr/bin/pypr --debug /tmp/pypr.log\n```\n\n> [!warning]\n> To avoid issues (eg: you have a complex setup, maybe using a virtual environment), you may want to set the full path (eg: `/home/bob/venv/bin/pypr`).\n> You can get it from `which pypr` in a working terminal\n\nOnce the `pypr` daemon is started (cf `exec-once`), you can list the eventual commands which have been added by the plugins using `pypr -h` or `pypr help`, those commands are generally meant to be use via key bindings, see the `hyprland.conf` part of *Configuring* section below.\n\n## Configuring\n\nCreate a configuration file in `~/.config/hypr/pyprland.toml` enabling a list of plugins, each plugin may have its own configuration needs or don't need any configuration at all.\nMost default values should be acceptable for most users, options which hare not mandatory are marked as such.\n\n> [!important]\n> Provide the values for the configuration options which have no annotation such as \"(optional)\"\n\nCheck the [TOML format](https://toml.io/) for details about the syntax.\n\nSimple example:\n\n```toml\n[pyprland]\nplugins = [\n    \"shift_monitors\",\n    \"workspaces_follow_focus\"\n]\n```\n\n<details>\n  <summary>\nMore complex example\n  </summary>\n\n```toml\n[pyprland]\nplugins = [\n  \"scratchpads\",\n  \"lost_windows\",\n  \"monitors\",\n  \"toggle_dpms\",\n  \"magnify\",\n  \"expose\",\n  \"shift_monitors\",\n  \"workspaces_follow_focus\",\n]\n\n[monitors.placement]\n\"Acer\".top_center_of = \"Sony\"\n\n[workspaces_follow_focus]\nmax_workspaces = 9\n\n[expose]\ninclude_special = false\n\n[scratchpads.stb]\nanimation = \"fromBottom\"\ncommand = \"kitty --class kitty-stb sstb\"\nclass = \"kitty-stb\"\nlazy = true\nsize = \"75% 45%\"\n\n[scratchpads.stb-logs]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-stb-logs stbLog\"\nclass = \"kitty-stb-logs\"\nlazy = true\nsize = \"75% 40%\"\n\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nlazy = true\nsize = \"40% 90%\"\nunfocus = \"hide\"\n```\n\nSome of those plugins may require changes in your `hyprland.conf` to fully operate or to provide a convenient access to a command, eg:\n\n```bash\n\n$pypr = uwsm-app -- /usr/local/bin/pypr-client\n\nbind = $mainMod SHIFT,  Z, exec, $pypr zoom\nbind = $mainMod ALT,    P, exec, $pypr toggle_dpms\nbind = $mainMod SHIFT,  O, exec, $pypr shift_monitors +1\nbind = $mainMod,        B, exec, $pypr expose\nbind = $mainMod,        K, exec, $pypr change_workspace +1\nbind = $mainMod,        J, exec, $pypr change_workspace -1\nbind = $mainMod,        L, exec, $pypr toggle_dpms\nbind = $mainMod  SHIFT, M, exec, $pypr toggle stb stb-logs\nbind = $mainMod,        A, exec, $pypr toggle term\nbind = $mainMod,        V, exec, $pypr toggle volume\n```\n\nThis example makes use of the faster `pypr-client` which may not be available depending on how you installed,\nin case you want to install it manually, just download [the source code](https://github.com/hyprland-community/pyprland/blob/main/client/) and compile it (eg: `gcc -o pypr-client pypr-client.c`).\nRust, Go and C versions are available.\n\n\n</details>\n\n> [!tip]\n> Consult or share [configuration files](https://github.com/hyprland-community/pyprland/tree/main/examples)\n>\n> You might also be interested in [optimizations](./Optimizations).\n"
  },
  {
    "path": "site/versions/2.4.6/InstallVirtualEnvironment.md",
    "content": "# Virtual env\n\nEven though the best way to get Pyprland installed is to use your operating system package manager,\nfor some usages or users it can be convenient to use a virtual environment.\n\nThis is very easy to achieve in a couple of steps:\n\n```shell\npython -m venv ~/pypr-env\n~/pypr-env/bin/pip install pyprland\n```\n\n**That's all folks!**\n\nThe only extra care to take is to use `pypr` from the virtual environment, eg:\n\n- adding the environment's \"bin\" folder to the `PATH` (using `export PATH=\"$PATH:~/pypr-env/bin/\"` in your shell configuration file)\n- always using the full path to the pypr command (in `hyprland.conf`: `exec-once = ~/pypr-env/bin/pypr --debug /tmp/pypr.log`)\n\n# Going bleeding edge!\n\nIf you would rather like to use the latest version available (not released yet), then you can clone the git repository and install from it:\n\n```shell\ncd ~/pypr-env\ngit clone git@github.com:hyprland-community/pyprland.git pyprland-sources\ncd pyprland-sources\n../bin/pip install -e .\n```\n\n## Updating\n\n```shell\ncd ~/pypr-env\ngit pull -r\n```\n\n# Troubelshooting\n\nIf things go wrong, try (eg: after a system upgrade where Python got updated):\n\n```shell\npython -m venv --upgrade ~/pypr-env\ncd  ~/pypr-env\n../bin/pip install -e .\n```\n"
  },
  {
    "path": "site/versions/2.4.6/Menu.md",
    "content": "# Menu capability\n\nMenu based plugins have the following configuration options:\n\n### `engine`\n\nNot set by default, will autodetect the available menu engine.\n\nSupported engines:\n\n- tofi\n- rofi\n- wofi\n- bemenu\n- dmenu\n- anyrun\n\n> [!note]\n> If your menu system isn't supported, you can open a [feature request](https://github.com/hyprland-community/pyprland/issues/new?assignees=fdev31&labels=bug&projects=&template=feature_request.md&title=%5BFEAT%5D+Description+of+the+feature)\n>\n> In case the engine isn't recognized, `engine` + `parameters` configuration options will be used to start the process, it requires a dmenu-like behavior.\n\n### `parameters`\n\nExtra parameters added to the engine command, the default value is specific to each engine.\n\n> [!important]\n> Setting this will override the default value!\n>\n> In general, *rofi*-like programs will require at least `-dmenu` option.\n\n> [!tip]\n> You can use '[prompt]' in the parameters, it will be replaced by the prompt, eg:\n> ```sh\n> -dmenu -matching fuzzy -i -p '[prompt]'\n> ```\n"
  },
  {
    "path": "site/versions/2.4.6/MultipleConfigurationFiles.md",
    "content": "### Multiple configuration files\n\nYou can also split your configuration into multiple files that will be loaded in the provided order after the main file:\n```toml\n[pyprland]\ninclude = [\"/shared/pyprland.toml\", \"~/pypr_extra_config.toml\"]\n```\nYou can also load folders, in which case TOML files in the folder will be loaded in alphabetical order:\n```toml\n[pyprland]\ninclude = [\"~/.config/pypr.d/\"]\n```\n\nAnd then add a `~/.config/pypr.d/monitors.toml` file:\n```toml\npyprland.plugins = [ \"monitors\" ]\n\n[monitors.placement]\nBenQ.Top_Center_Of = \"DP-1\" # projo\n\"CJFH277Q3HCB\".top_of = \"eDP-1\" # work\n```\n\n> [!tip]\n> To check the final merged configuration, you can use the `dumpjson` command.\n\n"
  },
  {
    "path": "site/versions/2.4.6/Nix.md",
    "content": "# Nix\n\nYou are recommended to get the latest pyprland package from the [flake.nix](https://github.com/hyprland-community/pyprland/blob/main/flake.nix)\nprovided within this repository. To use it in your system, you may add pyprland\nto your flake inputs.\n\n## Flake\n\n```nix\n## flake.nix\n{\n  inputs = {\n    nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n    pyprland.url = \"github:hyprland-community/pyprland\";\n  };\n\n  outputs = { self, nixpkgs, pyprland, ...}: let\n  in {\n    nixosConfigurations.<yourHostname> = nixpkgs.lib.nixosSystem {\n      #         remember to replace this with your system arch ↓\n      environment.systemPackages = [ pyprland.packages.\"x86_64-linux\".pyprland ];\n      # ...\n    };\n  };\n}\n```\n\nAlternatively, if you are using the Nix package manager but not NixOS as your\nmain distribution, you may use `nix profile` tool to install pyprland from this\nrepository using the following command.\n\n```bash\nnix profile install github:nix-community/pyprland\n```\n\nThe package will now be in your latest profile. You may use `nix profile list`\nto verify your installation.\n\n## Nixpkgs\n\nPyprland is available under nixpkgs, and can be installed by adding\n`pkgs.pyprland` to either `environment.systemPackages` or `home.packages`\ndepending on whether you want it available system-wide or to only a single\nuser using home-manager. If the derivation available in nixpkgs is out-of-date\nthen you may consider using `overrideAttrs` to update the source locally.\n\n```nix\nlet\n  pyprland = pkgs.pyprland.overrideAttrs {\n    version = \"your-version-here\";\n    src = fetchFromGitHub {\n      owner = \"hyprland-community\";\n      repo = \"pyprland\";\n      rev = \"tag-or-revision\";\n      # leave empty for the first time, add the new hash from the error message\n      hash = \"\";\n    };\n  };\nin {\n  # add the overridden package to systemPackages\n  environment.systemPackages = [pyprland];\n}\n```\n"
  },
  {
    "path": "site/versions/2.4.6/Optimizations.md",
    "content": "## Optimizing\n\n### Plugins\n\n- Only enable the plugins you are using in the `plugins` array (in `[pyprland]` section).\n- Leaving the configuration for plugins which are not enabled will have no impact.\n- Using multiple configuration files only have a small impact on the startup time.\n\n### Pypr\n\nYou can run `pypr` using `pypy` (version 3) for a more snappy experience.\nOne way is to use a `pypy3` virtual environment:\n\n```bash\npypy3 -m venv pypr-venv\nsource ./pypr-venv/bin/activate\ncd <pypr source folder>\npip install -e .\n```\n\n### Pypr command\n\nIn case you want to save some time when interacting with the daemon, the simplest is to use one of the `pypr-client` implementations as mentioned in the [getting started page](./Getting-started.md). If for some reason `pypr-client` isn't made available by the package of your OS and can not compile code,\nyou can use `socat` instead (needs to be installed).\n\nExample of a `pypr-cli` command (should be reachable from your environment's `PATH`):\n```sh\n#!/bin/sh\nsocat - \"UNIX-CONNECT:${XDG_RUNTIME_DIR}/hypr/${HYPRLAND_INSTANCE_SIGNATURE}/.pyprland.sock\" <<< $@\n```\nOn slow systems this may make a difference.\nNote that the \"help\" command will require usage of the standard `pypr` command.\n"
  },
  {
    "path": "site/versions/2.4.6/Plugins.md",
    "content": "<script setup>\nimport PluginList from './components/PluginList.vue'\n</script>\n\nThis page lists every plugin provided by Pyprland out of the box, more can be enabled if you install the matching package.\n\n\"🌟\" indicates some maturity & reliability level of the plugin, considering age, attention paid and complexity - from 0 to 3.\n\n<PluginList/>\n"
  },
  {
    "path": "site/versions/2.4.6/Troubleshooting.md",
    "content": "# Troubleshooting\n\n## General\n\nIn case of trouble running a `pypr` command:\n- kill the existing pypr if any (try `pypr exit` first)\n- run from the terminal adding `--debug /dev/null` to the arguments to get more information\n\nIf the client says it can't connect, then there is a high chance pypr daemon didn't start, check if it's running using `ps axuw |grep pypr`. You can try to run it from a terminal with the same technique: `pypr --debug /dev/null` and see if any error occurs.\n\n## Force hyprland version\n\nIn case your `hyprctl version -j` command isn't returning an accurate version, you can make Pyprland ignore it and use a provided value instead:\n\n```toml\n[pyprland]\nhyprland_version = \"0.41.0\"\n```\n\n## Unresponsive scratchpads\n\nScratchpads aren't responding for few seconds after trying to show one (which didn't show!)\n\nThis may happen if an application is very slow to start.\nIn that case pypr will wait for a window blocking other scratchpad's operation, before giving up after a few seconds.\n\nNote that other plugins shouldn't be blocked by it.\n\nMore scratchpads troubleshooting can be found [here](./scratchpads_nonstandard).\n"
  },
  {
    "path": "site/versions/2.4.6/Variables.md",
    "content": "# Variables & substitutions\n\nSome commands support shared global variables, they must be defined in the *pyprland* section of the configuration:\n```toml\n[pyprland.variables]\nterm = \"foot\"\nterm_classed = \"foot -a\" # kitty uses --class\n```\n\nIf a plugin supports it, you can then use the variables in the attribute that supports it, eg:\n\n```toml\n[myplugin]\nsome_variable = \"the terminal is [term]\"\n```\n"
  },
  {
    "path": "site/versions/2.4.6/bar.md",
    "content": "---\ncommands:\n  - name: bar restart\n    description: Restart/refresh Menu Bar on the \"best\" monitor.\n---\n\n# menubar\n\nRuns your favorite bar app (gbar, ags / hyprpanel, waybar, ...) with option to pass the \"best\" monitor from a list of monitors.\n\n- Will take care of starting the command on startup (you must not run it from another source like `hyprland.conf`).\n- Automatically restarts the menu bar on crash\n- Checks which monitors are on and take the best one from a provided list\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n\n## Configuration\n\n### `command` (REQUIRED)\n\nThe command which runs the menu bar. The string `[monitor]` will be replaced by the best monitor.\n\n### `monitors`\n\nList of monitors to chose from, the first have higher priority over the second one etc...\n\n\n## Example\n\n```sh\n[gbar]\nmonitors = [\"DP-1\", \"HDMI-1\", \"HDMI-1-A\"]\n```\n"
  },
  {
    "path": "site/versions/2.4.6/components/CommandList.vue",
    "content": "<template>\n    <dl>\n    </dl>\n    <ul v-for=\"command in commands\" :key=\"command.name\">\n        <li><code v-html=\"command.name.replace(/[   ]*$/, '').replace(/ /g, '&ensp;')\" /> <span\n                v-html=\"command.description\" /></li>\n    </ul>\n</template>\n\n<script>\nexport default {\n    props: {\n        commands: {\n            type: Array,\n            required: true\n        }\n    }\n}\n</script>\n"
  },
  {
    "path": "site/versions/2.4.6/components/PluginList.vue",
    "content": "<template>\n    <div>\n        <h1>Built-in plugins</h1>\n        <div v-for=\"plugin in sortedPlugins\" :key=\"plugin.name\" class=\"plugin-item\">\n            <div class=\"plugin-info\">\n                <h3>\n                    <a :href=\"plugin.name + '.html'\">{{ plugin.name }}</a>\n                    <span>&nbsp;{{ '🌟'.repeat(plugin.stars) }}</span>\n                    <span v-if=\"plugin.multimon\">\n                        <Badge type=\"tip\" text=\"multi-monitor\" />\n                    </span>\n                </h3>\n                <p v-html=\"plugin.description\" />\n            </div>\n            <a v-if=\"plugin.demoVideoId\" :href=\"'https://www.youtube.com/watch?v=' + plugin.demoVideoId\"\n                class=\"plugin-video\">\n                <img :src=\"'https://img.youtube.com/vi/' + plugin.demoVideoId + '/1.jpg'\" alt=\"Demo video thumbnail\" />\n            </a>\n        </div>\n    </div>\n</template>\n\n<style scoped>\n.plugin-item {\n    display: flex;\n    align-items: flex-start;\n    margin-bottom: 1.5rem;\n}\n\n.plugin-info {\n    flex: 1;\n}\n\n.plugin-video {\n    margin-left: 1rem;\n}\n\n.plugin-video img {\n    max-width: 200px;\n    /* Adjust as needed */\n    height: auto;\n}\n</style>\n\n\n<script>\n\nexport default {\n    computed: {\n        sortedPlugins() {\n            if (this.sortByStars) {\n                return this.plugins.slice().sort((a, b) => {\n                    if (b.stars === a.stars) {\n                        return a.name.localeCompare(b.name);\n                    }\n                    return b.stars - a.stars;\n                });\n            } else {\n                return this.plugins.slice().sort((a, b) => a.name.localeCompare(b.name));\n            }\n        }\n    },\n    data() {\n        return {\n            sortByStars: false,\n            plugins: [\n                {\n                    name: 'scratchpads',\n                    stars: 3,\n                    description: 'makes your applications dropdowns & togglable poppups',\n                    demoVideoId: 'ZOhv59VYqkc'\n                },\n                {\n                    name: 'magnify',\n                    stars: 3,\n                    description: 'toggles zooming of viewport or sets a specific scaling factor',\n                    demoVideoId: 'yN-mhh9aDuo'\n\n                },\n                {\n                    name: 'toggle_special',\n                    stars: 3,\n                    description: 'moves windows from/to special workspaces',\n                    demoVideoId: 'BNZCMqkwTOo'\n                },\n                {\n                    name: 'shortcuts_menu',\n                    stars: 3,\n                    description: 'a flexible way to make your own shortcuts menus & launchers',\n                    demoVideoId: 'UCuS417BZK8'\n                },\n                {\n                    name: 'fetch_client_menu',\n                    stars: 3,\n                    description: 'select a window to be moved to your active workspace (using rofi, dmenu, etc...)',\n                },\n                {\n                    name: 'layout_center',\n                    stars: 3,\n                    description: 'a workspace layout where one client window is almost maximized and others seat in the background',\n                    demoVideoId: 'vEr9eeSJYDc'\n                },\n                {\n                    name: 'system_notifier',\n                    stars: 3,\n                    description: 'open streams (eg: journal logs) and trigger notifications',\n                },\n                {\n                    name: 'wallpapers',\n                    stars: 3,\n                    description: 'handles random wallpapers at regular interval (from a folder). Supports rounded corners and color scheme generation.'\n                },\n                {\n                    name: 'bar',\n                    stars: 3,\n                    description: 'improves multi-monitor handling of the status bar - restarts it on crashes'\n                },\n                {\n                    name: 'expose',\n                    stars: 2,\n                    description: 'exposes all the windows for a quick \"jump to\" feature',\n                    demoVideoId: 'ce5HQZ3na8M'\n                },\n                {\n                    name: 'lost_windows',\n                    stars: 1,\n                    description: 'brings lost floating windows (which are out of reach) to the current workspace'\n                },\n                {\n                    name: 'toggle_dpms',\n                    stars: 0,\n                    description: 'toggles the DPMS status of every plugged monitor'\n                },\n                {\n                    name: 'workspaces_follow_focus',\n                    multimon: true,\n                    stars: 3,\n                    description: 'makes non visible workspaces available on the currently focused screen.<br/> If you think the multi-screen behavior of Hyprland is not usable or broken/unexpected, this is probably for you.'\n\n                },\n                {\n                    name: 'shift_monitors',\n                    multimon: true,\n                    stars: 3,\n                    description: 'moves workspaces from monitor to monitor (caroussel)'\n                },\n                {\n                    name: 'monitors',\n                    stars: 3,\n                    description: 'allows relative placement and configuration of monitors'\n                },\n                {\n                    name: 'fcitx5_switcher',\n                    stars: 0,\n                    description: 'Automatically switch fcitx5 input method status based on window class and title.'\n                }\n            ]\n        }\n    }\n}\n</script>\n"
  },
  {
    "path": "site/versions/2.4.6/expose.md",
    "content": "---\ncommands:\n  - name: expose\n    description: Expose every client on the active workspace. If expose is already active, then restores everything and move to the focused window.\n---\n\n# expose\n\nImplements the \"expose\" effect, showing every client window on the focused screen.\n\nFor a similar feature using a menu, try the [fetch_client_menu](./fetch_client_menu) plugin (less intrusive).\n\nSample `hyprland.conf`:\n\n```bash\n# Setup the key binding\nbind = $mainMod, B, exec, pypr expose\n\n# Add some style to the \"exposed\" workspace\nworkspace = special:exposed,gapsout:60,gapsin:30,bordersize:5,border:true,shadow:false\n```\n\n`MOD+B` will bring every client to the focused workspace, pressed again it will go to this workspace.\n\nCheck [workspace rules](https://wiki.hyprland.org/Configuring/Workspace-Rules/#rules) for styling options.\n\n> [!note]\n> If you are looking for `toggle_minimized`, check the [toggle_special](./toggle_special) plugin\n\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n\n### `include_special`\n\ndefault value is `false`\n\nAlso include windows in the special workspaces during the expose.\n\n"
  },
  {
    "path": "site/versions/2.4.6/fcitx5_switcher.md",
    "content": "---\ncommand:\n  - name: fcitx5_switcher\n    description: Automatically switch fcitx5 input method status based on window class and title.\n---\n\n# fcitx5_switcher\n\nThis is a useful tool for CJK input method users.\n\nIt can automatically switch fcitx5 input method status based on window class and title.\n\nExample:\n```toml\n[fcitx5_switcher]\nactive_classes = [\"wechat\", \"QQ\", \"zoom\"]\ninactive_classes = [\n    \"code\",\n    \"kitty\",\n    \"google-chrome\",\n]\nactive_titles = []\ninactive_titles = []\n```\n\nIn this example, if the window class is \"wechat\" or \"QQ\" or \"zoom\", the input method will be activated. If the window class is \"code\" or \"kitty\" or \"google-chrome\", the input method will be inactivated."
  },
  {
    "path": "site/versions/2.4.6/fetch_client_menu.md",
    "content": "---\ncommands:\n  - name: fetch_client_menu\n    description: Bring any window to the active workspace using a menu.\n---\n\n# fetch_client_menu\n\nBring any window to the active workspace using a menu.\n\nA bit like the [expose](./expose) plugin but using a menu instead (less intrusive).\n\nIt brings the window to the current workspace, while [expose](./expose) moves the currently focused screen to the application workspace.\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\nAll the [Menu](Menu) configuration items are also available.\n\n### `separator`\n\ndefault value is `\"|\"`\n\nChanges the character (or string) used to separate a menu entry from its entry number.\n\n"
  },
  {
    "path": "site/versions/2.4.6/filters.md",
    "content": "# Text filters\n\nAt the moment there is only one filter, close match of [sed's \"s\" command](https://www.gnu.org/software/sed/manual/html_node/The-_0022s_0022-Command.html).\n\nThe only supported flag is \"g\"\n\n## Example\n\n```toml\nfilter = 's/foo/bar/'\nfilter = 's/.*started (.*)/\\1 has started/'\nfilter = 's#</?div>##g'\n```\n"
  },
  {
    "path": "site/versions/2.4.6/index.md",
    "content": "---\n# https://vitepress.dev/reference/default-theme-home-page\nlayout: home\n\nhero:\n  text: \"Hyprland extensions\"\n  tagline: Enhance your desktop capabilities with Pyprland\n  image:\n    src: /logo.svg\n    alt: logo\n  actions:\n    - theme: brand\n      text: Getting started\n      link: ./Getting-started\n    - theme: alt\n      text: Plugins\n      link: ./Plugins\n    - theme: alt\n      text: Report something\n      link: https://github.com/hyprland-community/pyprland/issues/new/choose\n\nfeatures:\n  - title: TOML format\n    details: Simple and flexible configuration file(s)\n  - title: Customizable\n    details: Create your own Hyprland experience\n  - title: Fast and easy\n    details: Designed for performance and simplicity\n---\n\n## Version 2.4.6 archive\n"
  },
  {
    "path": "site/versions/2.4.6/layout_center.md",
    "content": "---\ncommands:\n  - name: layout_center toggle\n    description: toggles the layout on and off\n  - name: layout_center next\n    description: switches to the next window (if layout is on) else runs the <a href=\"#next-optional\">next</a> command\n  - name: layout_center prev\n    description: switches to the previous window (if layout is on) else runs the <a href=\"#next-optional\">prev</a> command\n  - name: layout_center next2\n    description: switches to the next window (if layout is on) else runs the <a href=\"#next-optional\">next2</a> command\n  - name: layout_center prev2\n    description: switches to the previous window (if layout is on) else runs the <a href=\"#next-optional\">prev2</a> command\n\n---\n# layout_center\n\nImplements a workspace layout where one window is bigger and centered,\nother windows are tiled as usual in the background.\n\nOn `toggle`, the active window is made floating and centered if the layout wasn't enabled, else reverts the floating status.\n\nWith `next` and `prev` you can cycle the active window, keeping the same layout type.\nIf the layout_center isn't active and `next` or `prev` is used, it will call the [next](#next-optional) and [prev](#prev-optional) configuration options.\n\nTo allow full override of the focus keys, [next2](#next2-optional) and [prev2](#prev2-optional) are provided, they do the same actions as \"next\" and \"prev\" but allow different fallback commands.\n\n<details>\n<summary>Configuration sample</summary>\n\n```toml\n[layout_center]\nmargin = 60\noffset = [0, 30]\nnext = \"movefocus r\"\nprev = \"movefocus l\"\nnext2 = \"movefocus d\"\nprev2 = \"movefocus u\"\n```\n\nusing the following in `hyprland.conf`:\n```sh\nbind = $mainMod, M, exec, pypr layout_center toggle # toggle the layout\n## focus change keys\nbind = $mainMod, left, exec, pypr layout_center prev\nbind = $mainMod, right, exec, pypr layout_center next\nbind = $mainMod, up, exec, pypr layout_center prev2\nbind = $mainMod, down, exec, pypr layout_center next2\n```\n\nYou can completely ignore `next2` and `prev2` if you are allowing focus change in a single direction (when the layout is enabled), eg:\n\n```sh\nbind = $mainMod, up, movefocus, u\nbind = $mainMod, down, movefocus, d\n```\n\n</details>\n\n\n## Commands\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `on_new_client`\n\nDefaults to `\"foreground\"`.\n\nChanges the behavior when a new window opens, possible options are:\n\n- \"foreground\" to make the new window the main window\n- \"background\" to make the new window appear in the background\n- \"close\" to stop the centered layout when a new window opens\n\n### `style`\n\n| Requires Hyprland > 0.40.0\n\nNot set by default.\n\nAllow to set a list of styles to the main (centered) window, eg:\n\n```toml\nstyle = [\"opacity 1\", \"bordercolor rgb(FFFF00)\"]\n```\n\n### `margin`\n\ndefault value is `60`\n\nmargin (in pixels) used when placing the center window, calculated from the border of the screen.\n\nExample to make the main window be 100px far from the monitor's limits:\n```toml\nmargin = 100\n```\n\nYou can also set a different margin for width and height by using a list:\n```toml\nmargin = [10, 60]\n```\n\n### `offset`\n\ndefault value is `[0, 0]`\n\noffset in pixels applied to the main window position\n\nExample shift the main window 20px down:\n```toml\noffset = [0, 20]\n```\n\n### `next`\n### `next2`\n\nnot set by default\n\nWhen the *layout_center* isn't active and the *next* command is triggered, defines the hyprland dispatcher command to run.\n\n`next2` is a similar option, used by the `next2` command, allowing to map \"next\" to both vertical and horizontal focus change.\n\nEg:\n```toml\nnext = \"movefocus r\"\n```\n\n### `prev`\n### `prev2`\n\nSame as `next` but for the `prev` and `prev2` commands.\n\n\n### `captive_focus`\n\ndefault value is `false`\n\n```toml\ncaptive_focus = true\n```\n\nSets the focus on the main window when the focus changes.\nYou may love it or hate it...\n"
  },
  {
    "path": "site/versions/2.4.6/lost_windows.md",
    "content": "---\ncommands:\n    - name: attract_lost\n      description: Bring windows which are not reachable in the currently focused workspace.\n---\n# lost_windows\n\nBring windows which are not reachable in the currently focused workspace.\n*Deprecated:* Was used to work around issues in Hyprland which have been fixed since then.\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n"
  },
  {
    "path": "site/versions/2.4.6/magnify.md",
    "content": "---\ncommands:\n    - name: zoom [value]\n      description: Set the current zoom level (absolute or relative) - toggle zooming if no value is provided\n---\n# magnify\n\nZooms in and out with an optional animation.\n\n\n<details>\n    <summary>Example</summary>\n\n```sh\npypr zoom  # sets zoom to `factor` (2 by default)\npypr zoom +1  # will set zoom to 3x\npypr zoom  # will set zoom to 1x\npypr zoom 1 # will (also) set zoom to 1x - effectively doing nothing\n```\n\nSample `hyprland.conf`:\n\n```sh\nbind = $mainMod , Z, exec, pypr zoom ++0.5\nbind = $mainMod SHIFT, Z, exec, pypr zoom\n```\n\n</details>\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n### `[value]`\n\n#### unset / not specified\n\nWill zoom to [factor](#factor-optional) if not zoomed, else will set the zoom to 1x.\n\n#### floating or integer value\n\nWill set the zoom to the provided value.\n\n#### +value / -value\n\nUpdate (increment or decrement) the current zoom level by the provided value.\n\n#### ++value / --value\n\nUpdate (increment or decrement) the current zoom level by a non-linear scale.\nIt _looks_ more linear changes than using a single + or -.\n\n> [!NOTE]\n>\n> The non-linear scale is calculated as powers of two, eg:\n>\n> - `zoom ++1` → 2x, 4x, 8x, 16x...\n> - `zoom ++0.7` → 1.6x, 2.6x, 4.3x, 7.0x, 11.3x, 18.4x...\n> - `zoom ++0.5` → 1.4x, 2x, 2.8x, 4x, 5.7x, 8x, 11.3x, 16x...\n\n## Configuration\n\n### `factor`\n\ndefault value is `2`\n\nScaling factor to be used when no value is provided.\n\n### `duration`\n\nDefault value is `15`\n\nDuration in tenths of a second for the zoom animation to last, set to `0` to disable animations.\n"
  },
  {
    "path": "site/versions/2.4.6/monitors.md",
    "content": "---\ncommands:\n  - name: relayout\n    description: Apply the configuration and update the layout\n---\n\n# monitors\n\n> First, simpler version of the plugin is still available under the name `monitors_v0`\n\nAllows relative placement of monitors depending on the model (\"description\" returned by `hyprctl monitors`).\nUseful if you have multiple monitors connected to a video signal switch or using a laptop and plugging monitors having different relative positions.\n\nSyntax:\n\n```toml\n[monitors.placement]\n\"description match\".placement = \"other description match\"\n```\n\n<details>\n    <summary>Example to set a Sony monitor on top of a BenQ monitor</summary>\n\n```toml\n[monitors.placement]\nSony.topOf = \"BenQ\"\n\n## Character case is ignored, \"_\" can be added\nSony.Top_Of = [\"BenQ\"]\n\n## Thanks to TOML format, complex configurations can use separate \"sections\" for clarity, eg:\n\n[monitors.placement.\"My monitor brand\"]\n## You can also use \"port\" names such as *HDMI-A-1*, *DP-1*, etc...\nleftOf = \"eDP-1\"\n\n## lists are possible on the right part of the assignment:\nrightOf = [\"Sony\", \"BenQ\"]\n\n## > 2.3.2: you can also set scale, transform & rate for a given monitor\n[monitors.placement.Microstep]\nrate = 100\n```\n\nTry to keep the rules as simple as possible, but relatively complex scenarios are supported.\n\n> [!note]\n> Check [wlr layout UI](https://github.com/fdev31/wlr-layout-ui) which is a nice complement to configure your monitor settings.\n\n</details>\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `placement` (REQUIRED)\n\nSupported placements are:\n\n- leftOf\n- topOf\n- rightOf\n- bottomOf\n- \\<one of the above>(center|middle|end)Of\n\n> [!important]\n> If you don't like the screen to align on the start of the given border,\n> you can use `center` (or `middle`) to center it or `end` to stick it to the opposite border.\n> Eg: \"topCenterOf\", \"leftEndOf\", etc...\n\nYou can separate the terms with \"_\" to improve the readability, as in \"top_center_of\".\n\n#### monitor settings\n\nNot only can you place monitors relatively to each other, but you can also set specific settings for a given monitor.\n\nThe following settings are supported:\n\n- scale\n- transform\n- rate\n- resolution\n\n```toml\n[monitors.placement.\"My monitor brand\"]\nrate = 60\nscale = 1.5\ntransform = 1 # 0: normal, 1: 90°, 2: 180°, 3: 270°, 4: flipped, 5: flipped 90°, 6: flipped 180°, 7: flipped 270°\nresolution = \"1920x1080\"  # can also be expressed as [1920, 1080]\n```\n\n### `startup_relayout`\n\nDefault to `ŧrue`.\n\nWhen set to `false`,\ndo not initialize the monitor layout on startup or when configuration is reloaded.\n\n### `new_monitor_delay`\n\nBy default,\nthe layout computation happens one second after the event is received to let time for things to settle.\n\nYou can change this value using this option.\n\n### `hotplug_command`\n\nNone by default, allows to run a command when any monitor is plugged.\n\n\n```toml\n[monitors]\nhotplug_commands = \"wlrlui -m\"\n```\n\n### `hotplug_commands`\n\nNone by default, allows to run a command when a given monitor is plugged.\n\nExample to load a specific profile using [wlr layout ui](https://github.com/fdev31/wlr-layout-ui):\n\n```toml\n[monitors.hotplug_commands]\n\"DELL P2417H CJFH277Q3HCB\" = \"wlrlui rotated\"\n```\n\n### `unknown`\n\nNone by default,\nallows to run a command when no monitor layout has been changed (no rule applied).\n\n```toml\n[monitors]\nunknown = \"wlrlui\"\n```\n\n### `trim_offset`\n\n`true` by default,\n\nPrevents having arbitrary window offsets or negative values.\n"
  },
  {
    "path": "site/versions/2.4.6/scratchpads.md",
    "content": "---\ncommands:\n  - name: toggle [scratchpad name]\n    description: Toggle the given scratchpad\n  - name: show [scratchpad name]\n    description: Show the given scratchpad\n  - name: hide [scratchpad name]\n    description: Hide the given scratchpad\n  - name: attach\n    description: Toggle attaching/anchoring the currently focused window to the (last used) scratchpad\n\nNote: show and hide can accept '*' as a parameter, applying changes to every scratchpad.\n\n---\n# scratchpads\n\nEasily toggle the visibility of applications you use the most.\n\nConfigurable and flexible, while supporting complex setups it's easy to get started with:\n\n```toml\n[scratchpads.name]\ncommand = \"command to run\"\nclass = \"the window's class\"  # check: hyprctl clients | grep class\nsize = \"[width] [height]\"  # size of the window relative to the screen size\n```\n\n<details>\n<summary>Example</summary>\n\nAs an example, defining two scratchpads:\n\n- _term_ which would be a kitty terminal on upper part of the screen\n- _volume_ which would be a pavucontrol window on the right part of the screen\n\n\n```toml\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\nmax_size = \"1920px 100%\"\nmargin = 50\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nsize = \"40% 90%\"\nunfocus = \"hide\"\nlazy = true\n```\n\nShortcuts are generally needed:\n\n```ini\nbind = $mainMod,V,exec,pypr toggle volume\nbind = $mainMod,A,exec,pypr toggle term\nbind = $mainMod,Y,exec,pypr attach\n```\n</details>\n\n- If you wish to have a more generic space for any application you may run, check [toggle_special](./toggle_special).\n- When you create a scratchpad called \"name\", it will be hidden in `special:scratch_<name>`.\n- Providing `class` allows a glitch free experience, mostly noticeable when using animations\n\n\n## Commands\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n> [!tip]\n> You can use `\"*\"` as a _scratchpad name_ to target every scratchpad when using `show` or `hide`.\n> You'll need to quote or escape the `*` character to avoid interpretation from your shell.\n\n## Configuration\n\n### `command` (REQUIRED)\n\nThis is the command you wish to run in the scratchpad.\n\nIt supports [Variables](./Variables)\n\n### `animation`\n\nType of animation to use, default value is \"fromTop\":\n\n- `null` / `\"\"` (no animation)\n- `fromTop` (stays close to upper screen border)\n- `fromBottom` (stays close to lower screen border)\n- `fromLeft` (stays close to left screen border)\n- `fromRight` (stays close to right screen border)\n\n### `size` (recommended)\n\nNo default value.\n\nEach time scratchpad is shown, window will be resized according to the provided values.\n\nFor example on monitor of size `800x600` and `size= \"80% 80%\"` in config scratchpad always have size `640x480`,\nregardless of which monitor it was first launched on.\n\n> #### Format\n>\n> String with \"x y\" (or \"width height\") values using some units suffix:\n>\n> - **percents** relative to the focused screen size (`%` suffix), eg: `60% 30%`\n> - **pixels** for absolute values (`px` suffix), eg: `800px 600px`\n> - a mix is possible, eg: `800px 40%`\n\n### `class` (recommended)\n\nNo default value.\n\nAllows _Pyprland_ prepare the window for a correct animation and initial positioning.\n\n### `position`\n\nNo default value, overrides the automatic margin-based position.\n\nSets the scratchpad client window position relative to the top-left corner.\n\nSame format as `size` (see above)\n\nExample of scratchpad that always seat on the top-right corner of the screen:\n\n```toml\n[scratchpads.term_quake]\ncommand = \"wezterm start --class term_quake\"\nposition = \"50% 0%\"\nsize = \"50% 50%\"\nclass = \"term_quake\"\n```\n\n> [!note]\n> If `position` is not provided, the window is placed according to `margin` on one axis and centered on the other.\n\n### `multi`\n\nDefaults to `true`.\n\nWhen set to `false`, only one client window is supported for this scratchpad.\nOtherwise other matching windows will be **attach**ed to the scratchpad.\n\n## Advanced configuration\n\nTo go beyond the basic setup and have a look at every configuration item, you can read the following pages:\n\n- [Advanced](./scratchpads_advanced) contains options for fine-tuners or specific tastes (eg: i3 compatibility)\n- [Non-Standard](./scratchpads_nonstandard) contains options for \"broken\" applications\nlike progressive web apps (PWA) or emacsclient, use only if you can't get it to work otherwise\n\n## Monitor specific overrides\n\nYou can use different settings for a specific screen.\nMost attributes related to the display can be changed (not `command`, `class` or `process_tracking` for instance).\n\nUse the `monitor.<monitor name>` configuration item to override values, eg:\n\n```toml\n[scratchpads.music.monitor.eDP-1]\nposition = \"30% 50%\"\nanimation = \"fromBottom\"\n```\n\nYou may want to inline it for simple cases:\n\n```toml\n[scratchpads.music]\nmonitor = {HDMI-A-1={size = \"30% 50%\"}}\n```\n"
  },
  {
    "path": "site/versions/2.4.6/scratchpads_advanced.md",
    "content": "# Fine tuning scratchpads\n\nAdvanced configuration options\n\n## `use`\n\nNo default value.\n\nList of scratchpads (or single string) that will be used for the default values of this scratchpad.\nThink about *templates*:\n\n```toml\n[scratchpads.terminals]\nanimation = \"fromTop\"\nmargin = 50\nsize = \"75% 60%\"\nmax_size = \"1920px 100%\"\n\n[scratchpads.term]\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nuse = \"terminals\"\n```\n\n## `pinned`\n\n`true` by default\n\nMakes the scratchpad \"sticky\" to the monitor, following any workspace change.\n\n## `excludes`\n\nNo default value.\n\nList of scratchpads to hide when this one is displayed, eg: `excludes = [\"term\", \"volume\"]`.\nIf you want to hide every displayed scratch you can set this to the string `\"*\"` instead of a list: `excludes = \"*\"`.\n\n## `restore_excluded`\n\n`false` by default.\n\nWhen enabled, will remember the scratchpads which have been closed due to `excludes` rules, so when the scratchpad is hidden, those previously hidden scratchpads will be shown again.\n\n## `unfocus`\n\nNo default value.\n\nWhen set to `\"hide\"`, allow to hide the window when the focus is lost.\n\nUse `hysteresis` to change the reactivity\n\n## `hysteresis`\n\nDefaults to `0.4` (seconds)\n\nControls how fast a scratchpad hiding on unfocus will react. Check `unfocus` option.\nSet to `0` to disable.\n\n> [!important]\n> Only relevant when `unfocus=\"hide\"` is used.\n\n## `margin`\n\ndefault value is `60`.\n\nnumber of pixels separating the scratchpad from the screen border, depends on the [animation](./scratchpads#animation) set.\n\n> [!tip]\n> It is also possible to set a string to express percentages of the screen (eg: '`3%`').\n\n## `max_size`\n\nNo default value.\n\nSame format as `size` (see above), only used if `size` is also set.\n\nLimits the `size` of the window accordingly.\nTo ensure a window will not be too large on a wide screen for instance:\n\n```toml\nsize = \"60% 30%\"\nmax_size = \"1200px 100%\"\n```\n\n## `lazy`\n\ndefault to `false`.\n\nwhen set to `true`, prevents the command from being started when pypr starts, it will be started when the scratchpad is first used instead.\n\n- Good: saves resources when the scratchpad isn't needed\n- Bad: slows down the first display (app has to launch before showing)\n\n## `preserve_aspect`\n\nNot set by default.\nWhen set to `true`, will preserve the size and position of the scratchpad when called repeatedly from the same monitor and workspace even though an `animation` , `position` or `size` is used (those will be used for the initial setting only).\n\nForces the `lazy` option.\n\n## `offset`\n\nIn pixels, default to `0` (client's window size + margin).\n\nNumber of pixels for the **hide** sliding animation (how far the window will go).\n\n> [!tip]\n> - It is also possible to set a string to express percentages of the client window\n> - `margin` is automatically added to the offset\n> - automatic (value not set) is same as `\"100%\"`\n\n## `hide_delay`\n\nDefaults to `0.2`\n\nDelay (in seconds) after which the hide animation happens, before hiding the scratchpad.\n\nRule of thumb, if you have an animation with speed \"7\", as in:\n```bash\n    animation = windowsOut, 1, 7, easeInOut, popin 80%\n```\nYou can divide the value by two and round to the lowest value, here `3`, then divide by 10, leading to `hide_delay = 0.3`.\n\n## `restore_focus`\n\nEnabled by default, set to `false` if you don't want the focused state to be restored when a scratchpad is hidden.\n\n## `force_monitor`\n\nIf set to some monitor name (eg: `\"DP-1\"`), it will always use this monitor to show the scratchpad.\n\n## `alt_toggle`\n\nDefault value is `false`\n\nWhen enabled, use an alternative `toggle` command logic for multi-screen setups.\nIt applies when the `toggle` command is triggered and the toggled scratchpad is visible on a screen which is not the focused one.\n\nInstead of moving the scratchpad to the focused screen, it will hide the scratchpad.\n\n## `allow_special_workspaces`\n\nDefault value is `false` (can't be enabled when using *Hyprland* < 0.39 where this behavior can't be controlled and is disabled).\n\nWhen enabled, you can toggle a scratchpad over a special workspace.\nIt will always use the \"normal\" workspace otherwise.\n\n## `smart_focus`\n\nDefault value is `true`.\n\nWhen enabled, the focus will be restored in a best effort way as en attempt to improve the user experience.\nIf you face issues such as spontaneous workspace changes, you can disable this feature.\n\n## `close_on_hide`\n\nDefault value is `false`.\n\nWhen enabled, the window in the scratchpad is closed instead of hidden when `pypr hide <name>` is run.\nThis option implies `lazy = true`.\nThis can be useful on laptops where background apps may increase battery power draw.\n\nNote: Currently this option changes the hide animation to use hyprland's close window animation.\n\n"
  },
  {
    "path": "site/versions/2.4.6/scratchpads_nonstandard.md",
    "content": "# Troubleshooting scratchpads\n\nOptions that should only be used for applications that are not behaving in a \"standard\" way, such as `emacsclient` or progressive web apps.\n\n## `match_by`\n\nDefault value is `\"pid\"`\nWhen set to a sensitive client property value (eg: `class`, `initialClass`, `title`, `initialTitle`), will match the client window using the provided property instead of the PID of the process.\n\nThis property must be set accordingly, eg:\n\n```toml\nmatch_by = \"class\"\nclass = \"my-web-app\"\n```\n\nor\n\n```toml\nmatch_by = \"initialClass\"\ninitialClass = \"my-web-app\"\n```\n\nYou can add the \"re:\" prefix to use a regular expression, eg:\n\n```toml\nmatch_by = \"title\"\ntitle = \"re:.*some string.*\"\n```\n\n> [!note]\n> Some apps may open the graphical client window in a \"complicated\" way, to work around this, it is possible to disable the process PID matching algorithm and simply rely on window's class.\n>\n> The `match_by` attribute can be used to achieve this, eg. for emacsclient:\n> ```toml\n> [scratchpads.emacs]\n> command = \"/usr/local/bin/emacsStart.sh\"\n> class = \"Emacs\"\n> match_by = \"class\"\n> ```\n\n## `process_tracking`\n\nDefault value is `true`\n\nAllows disabling the process management. Use only if running a progressive web app (Chrome based apps) or similar.\n\nThis will automatically force `lazy = true` and set `match_by=\"class\"` if no `match_by` rule is provided, to help with the fuzzy client window matching.\n\nIt requires defining a `class` option (or the option matching your `match_by` value).\n\n```toml\n## Chat GPT on Brave\n[scratchpads.gpt]\nanimation = \"fromTop\"\ncommand = \"brave --profile-directory=Default --app=https://chat.openai.com\"\nclass = \"brave-chat.openai.com__-Default\"\nsize = \"75% 60%\"\nprocess_tracking = false\n\n## Some chrome app\n[scratchpads.music]\ncommand = \"google-chrome --profile-directory=Default --app-id=cinhimbnkkaeohfgghhklpknlkffjgod\"\nclass = \"chrome-cinhimbnkkaeohfgghhklpknlkffjgod-Default\"\nsize = \"50% 50%\"\nprocess_tracking = false\n```\n\n> [!tip]\n> To list windows by class and title you can use:\n> - `hyprctl -j clients | jq '.[]|[.class,.title]'`\n> - or if you prefer a graphical tool: `rofi -show window`\n\n> [!note]\n> Progressive web apps will share a single process for every window.\n> On top of requiring the class based window tracking (using `match_by`),\n> the process can not be managed the same way as usual apps and the correlation\n> between the process and the client window isn't as straightforward and can lead to false matches in extreme cases.\n\n## `skip_windowrules`\n\nDefault value is `[]`\nAllows you to skip the window rules for a specific scratchpad.\nAvailable rules are:\n\n- \"aspect\" controlling size and position\n- \"float\" controlling the floating state\n- \"workspace\" which moves the window to its own workspace\n\nIf you are using an application which can spawn multiple windows and you can't see them, you can skip rules made to improve the initial display of the window.\n\n```toml\n[scratchpads.filemanager]\nanimation = \"fromBottom\"\ncommand = \"nemo\"\nclass = \"nemo\"\nsize = \"60% 60%\"\nskip_windowrules = [\"aspect\", \"workspace\"]\n```\n"
  },
  {
    "path": "site/versions/2.4.6/shift_monitors.md",
    "content": "---\ncommands:\n    - name: shift_monitors <direction>\n      description: Swaps the workspaces of every screen in the given direction.\n---\n\n# shift_monitors\n\nSwaps the workspaces of every screen in the given direction.\n\n> [!Note]\n> the behavior can be hard to predict if you have more than 2 monitors (depending on your layout).\n> If you use this plugin with many monitors and have some ideas about a convenient configuration, you are welcome ;)\n\nExample usage in `hyprland.conf`:\n\n```\nbind = $mainMod, O, exec, pypr shift_monitors +1\nbind = $mainMod SHIFT, O, exec, pypr shift_monitors -1\n```\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n"
  },
  {
    "path": "site/versions/2.4.6/shortcuts_menu.md",
    "content": "---\ncommands:\n    - name: menu [name]\n      description: Displays the full menu or part of it if \"name\" is provided\n---\n\n# shortcuts_menu\n\nPresents some menu to run shortcut commands. Supports nested menus (aka categories / sub-menus).\n\n<details>\n   <summary>Configuration examples</summary>\n\n```toml\n[shortcuts_menu.entries]\n\n\"Open Jira ticket\" = 'open-jira-ticket \"$(wl-paste)\"'\nRelayout = \"pypr relayout\"\n\"Fetch window\" = \"pypr fetch_client_menu\"\n\"Hyprland socket\" = 'kitty  socat - \"UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock\"'\n\"Hyprland logs\" = 'kitty tail -f $XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/hyprland.log'\n\n\"Serial USB Term\" = [\n    {name=\"device\", command=\"ls -1 /dev/ttyUSB*; ls -1 /dev/ttyACM*\"},\n    {name=\"speed\", options=[\"115200\", \"9600\", \"38400\", \"115200\", \"256000\", \"512000\"]},\n    \"kitty miniterm --raw --eol LF [device] [speed]\"\n]\n\n\"Color picker\" = [\n    {name=\"format\", options=[\"hex\", \"rgb\", \"hsv\", \"hsl\", \"cmyk\"]},\n    \"sleep 0.2; hyprpicker --format [format] | wl-copy\" # sleep to let the menu close before the picker opens\n]\n\nscreenshot = [\n    {name=\"what\", options=[\"output\", \"window\", \"region\", \"active\"]},\n    \"hyprshot -m [what] -o /tmp -f shot_[what].png\"\n]\n\nannotate = [\n    {name=\"fname\", command=\"ls /tmp/shot_*.png\"},\n    \"satty --filename '[fname]' --output-filename '/tmp/annotated.png'\"\n]\n\n\"Clipboard history\" = [\n    {name=\"entry\", command=\"cliphist list\", filter=\"s/\\t.*//\"},\n    \"cliphist decode '[entry]' | wl-copy\"\n]\n\n\"Copy password\" = [\n    {name=\"what\", command=\"gopass ls --flat\"},\n    \"gopass show -c [what]\"\n]\n\n\"Update/Change password\" = [\n    {name=\"what\", command=\"gopass ls --flat\"},\n    \"kitty -- gopass generate -s --strict -t '[what]' && gopass show -c '[what]'\"\n]\n```\n\n</details>\n\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n> [!tip]\n> - If \"name\" is provided it will show the given sub-menu.\n> - You can use \".\" to reach any level of the configured menus.\n>\n>      Example to reach `shortcuts_menu.entries.utils.\"local commands\"`, use:\n>      ```sh\n>       pypr menu \"utils.local commands\"\n>      ```\n\n## Configuration\n\nAll the [Menu](./Menu) configuration items are also available.\n\n### `entries` (REQUIRED)\n\nDefines the menu entries. Supports [Variables](./Variables)\n\n```toml\n[shortcuts_menu.entries]\n\"entry 1\" = \"command to run\"\n\"entry 2\" = \"command to run\"\n```\nSubmenus can be defined too (there is no depth limit):\n\n```toml\n[shortcuts_menu.entries.\"My submenu\"]\n\"entry X\" = \"command\"\n\n[shortcuts_menu.entries.one.two.three.four.five]\nfoobar = \"ls\"\n```\n\n#### Advanced usage\n\nInstead of navigating a configured list of menu options and running a pre-defined command, you can collect various *variables* (either static list of options selected by the user, or generated from a shell command) and then run a command using those variables. Eg:\n\n```toml\n\"Play Video\" = [\n    {name=\"video_device\", command=\"ls -1 /dev/video*\"},\n    {name=\"player\",\n        options=[\"mpv\", \"guvcview\"]\n    },\n    \"[player] [video_device]\"\n]\n\n\"Ssh\" = [\n    {name=\"action\", options=[\"htop\", \"uptime\", \"sudo halt -p\"]},\n    {name=\"host\", options=[\"gamix\", \"gate\", \"idp\"]},\n    \"kitty --hold ssh [host] [action]\"\n]\n```\n\nYou must define a list of objects, containing:\n- `name`: the variable name\n- then the list of options, must be one of:\n    - `options` for a static list of options\n    - `command` to get the list of options from a shell command's output\n\n> [!tip]\n> You can apply post-filters to the `command` output, eg:\n> ```toml\n> {\n>   name = \"entry\",\n>   command = \"cliphist list\",\n>   filter = \"s/\\t.*//\"  # will remove everything after the TAB character\n> }\n> ```\n> check the [filters](./filters) page for more details\n\nThe last item of the list must be a string which is the command to run. Variables can be used enclosed in `[]`.\n\n### `command_start`\n### `command_end`\n### `submenu_start`\n### `submenu_end`\n\nAllow adding some text (eg: icon) before / after a menu entry.\n\ncommand_* is for final commands, while submenu_* is for entries leading to another menu.\n\nBy default `submenu_end` is set to a right arrow sign, while other attributes are not set.\n\n### `skip_single`\n\nDefaults to `true`.\nWhen disabled, shows the menu even for single options\n\n## Hints\n\n### Multiple menus\n\nTo manage multiple distinct menus, always use a name when using the `pypr menu <name>` command.\n\nExample of a multi-menu configuration:\n\n```toml\n[shortcuts_menu.entries.\"Basic commands\"]\n\"entry X\" = \"command\"\n\"entry Y\" = \"command2\"\n\n[shortcuts_menu.entries.menu2]\n## ...\n```\n\nYou can then show the first menu using `pypr menu \"Basic commands\"`\n"
  },
  {
    "path": "site/versions/2.4.6/sidebar.json",
    "content": "{\n  \"main\": [\n    {\n      \"text\": \"Getting started\",\n      \"link\": \"./Getting-started\"\n    },\n    {\n      \"text\": \"Plugins\",\n      \"link\": \"./Plugins\"\n    },\n    {\n      \"text\": \"Troubleshooting\",\n      \"link\": \"./Troubleshooting\"\n    },\n    {\n      \"text\": \"Development\",\n      \"link\": \"./Development\"\n    }\n  ],\n  \"plugins\": {\n    \"text\": \"Featured plugins\",\n    \"collapsed\": false,\n    \"items\": [\n      {\n        \"text\": \"Expose\",\n        \"link\": \"./expose\"\n      },\n      {\n        \"text\": \"Fcitx5 Switcher\",\n        \"link\": \"./fcitx5_switcher\"\n      },\n      {\n        \"text\": \"Fetch client menu\",\n        \"link\": \"./fetch_client_menu\"\n      },\n      {\n        \"text\": \"Menubar\",\n        \"link\": \"./bar\"\n      },\n      {\n        \"text\": \"Layout center\",\n        \"link\": \"./layout_center\"\n      },\n      {\n        \"text\": \"Lost windows\",\n        \"link\": \"./lost_windows\"\n      },\n      {\n        \"text\": \"Magnify\",\n        \"link\": \"./magnify\"\n      },\n      {\n        \"text\": \"Monitors\",\n        \"link\": \"./monitors\"\n      },\n      {\n        \"text\": \"Scratchpads\",\n        \"link\": \"./scratchpads\",\n        \"items\": [\n          {\n            \"text\": \"Advanced\",\n            \"link\": \"./scratchpads_advanced\"\n          },\n          {\n            \"text\": \"Special cases\",\n            \"link\": \"./scratchpads_nonstandard\"\n          }\n        ]\n      },\n      {\n        \"text\": \"Shift monitors\",\n        \"link\": \"./shift_monitors\"\n      },\n      {\n        \"text\": \"Shortcuts menu\",\n        \"link\": \"./shortcuts_menu\"\n      },\n      {\n        \"text\": \"System notifier\",\n        \"link\": \"./system_notifier\"\n      },\n      {\n        \"text\": \"Toggle dpms\",\n        \"link\": \"./toggle_dpms\"\n      },\n      {\n        \"text\": \"Toggle special\",\n        \"link\": \"./toggle_special\"\n      },\n      {\n        \"text\": \"Wallpapers\",\n        \"link\": \"./wallpapers\"\n      },\n      {\n        \"text\": \"Workspaces follow focus\",\n        \"link\": \"./workspaces_follow_focus\"\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "site/versions/2.4.6/system_notifier.md",
    "content": "# system_notifier\n\nThis plugin adds system notifications based on journal logs (or any program's output).\nIt monitors specified **sources** for log entries matching predefined **patterns** and generates notifications accordingly (after applying an optional **filter**).\n\nSources are commands that return a stream of text (eg: journal, mqtt, tail -f, ...) which is sent to a parser that will use a [regular expression pattern](https://en.wikipedia.org/wiki/Regular_expression) to detect lines of interest and optionally transform them before sending the notification.\n\n<details>\n    <summary>Minimal configuration</summary>\n\n```toml\n[system_notifier.sources]\ncommand = \"sudo journalctl -fx\"\nparser = \"journal\"\n```\n\nIn general you will also need to define some **parsers**.\nBy default a **\"journal\"** parser is provided, otherwise you need to define your own rules.\nThis built-in configuration is close to this one, provided as an example:\n\n```toml\n[system_notifier.parsers.journal]\npattern = \"([a-z0-9]+): Link UP$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is active/\"\ncolor= \"#00aa00\"\n\n[system_notifier.parsers.journal]\npattern = \"([a-z0-9]+): Link DOWN$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is inactive/\"\ncolor= \"#ff8800\"\n\n[system_notifier.parsers.journal]\npattern = \"Process \\d+ \\(.*\\) of .* dumped core.\"\nfilter = \"s/.*Process \\d+ \\((.*)\\) of .* dumped core./\\1 dumped core/\"\ncolor= \"#aa0000\"\n\n[system_notifier.parsers.journal]\npattern = \"usb \\d+-[0-9.]+: Product: \"\nfilter = \"s/.*usb \\d+-[0-9.]+: Product: (.*)/USB plugged: \\1/\"\n```\n</details>\n\n\n## Configuration\n\n### `sources` (recommended)\n\nList of sources to enable (by default nothing is enabled)\n\nEach source must contain a `command` to run and a `parser` to use.\n\nYou can also use a list of parsers, eg:\n\n```toml\n[system_notifier.sources](system_notifier.sources)\ncommand = \"sudo journalctl -fkn\"\nparser = [\"journal\", \"custom_parser\"]\n```\n\n#### command (recommended)\n\nThis is the long-running command (eg: `tail -f <filename>`) returning the stream of text that will be updated. Aa common option is the system journal output (eg: `journalctl -u nginx`)\n\n#### parser\n\nSets the list of rules / parser to be used to extract lines of interest.\nMust match a list of rules defined as `system_notifier.parsers.<parser_name>`.\n\n### `parsers` (recommended)\n\nA list of available parsers that can be used to detect lines of interest in the **sources** and re-format it before issuing a notification.\n\nEach parser definition must contain a **pattern** and optionally a **filter** and a **color**.\n\n#### pattern\n\n```toml\n[system_notifier.parsers.custom_parser](system_notifier.parsers.custom_parser)\n\npattern = 'special value:'\n```\n\nThe pattern is any regular expression.\n\n#### filter\n\nThe [filters](./filters) allows to change the text before the notification, eg:\n`filter=\"s/.*special value: (\\d+)/Value=\\1/\"`\nwill set a filter so a string \"special value: 42\" will lead to the notification \"Value=42\"\n\n#### color\n\nYou can also provide an optional **color** in `\"hex\"` or `\"rgb()\"` format\n\n```toml\ncolor = \"#FF5500\"\n```\n\n### default_color\n\nSets the notification color that will be used when none is provided in a *parser* definition.\n\n```toml\n[system_notifier]\ndefault_color = \"#bbccbb\"\n```\n"
  },
  {
    "path": "site/versions/2.4.6/toggle_dpms.md",
    "content": "---\ncommands:\n    - name: toggle_dpms\n      description: if any screen is powered on, turn them all off, else turn them all on\n---\n\n# toggle_dpms\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n"
  },
  {
    "path": "site/versions/2.4.6/toggle_special.md",
    "content": "---\ncommands:\n    - name: toggle_special [name]\n      description: moves the focused window to the special workspace <code>name</code>, or move it back to the active workspace\n---\n\n# toggle_special\n\nAllows moving the focused window to a special workspace and back (based on the visibility status of that workspace).\n\nIt's a companion of the `togglespecialworkspace` Hyprland's command which toggles a special workspace's visibility.\n\nYou most likely will need to configure the two commands for a complete user experience, eg:\n\n```bash\nbind = $mainMod SHIFT, N, togglespecialworkspace, stash # toggles \"stash\" special workspace visibility\nbind = $mainMod, N, exec, pypr toggle_special stash # moves window to/from the \"stash\" workspace\n```\n\nNo other configuration needed, here `MOD+SHIFT+N` will show every window in \"stash\" while `MOD+N` will move the focused window out of it/ to it.\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n"
  },
  {
    "path": "site/versions/2.4.6/wallpapers.md",
    "content": "---\ncommands:\n    - name: wall next\n      description: Changes the current background image\n    - name: wall clear\n      description: Removes the current background image\n---\n\n# wallpapers\n\nSearch folders for images and sets the background image at a regular interval.\nImages are selected randomly from the full list of images found.\n\nIt serves two purposes:\n\n- adding support for random images to any background setting tool\n- quickly testing different tools with a minimal effort\n\nIt allows \"zapping\" current backgrounds, clearing it to go distraction free and optionally make them different for each screen.\n\n> [!tip]\n> Uses **swaybg** by default, but can be configured to use any other application.\n\n<details>\n    <summary>Minimal example using defaults(requires <b>swaybg</b>)</summary>\n\n```toml\n[wallpapers]\npath = \"~/Images/wallpapers/\" # path to the folder with background images\n```\n\n</details>\n\n<details>\n<summary>More complete, using the custom <b>swww</b> command (not recommended because of its stability)</summary>\n\n```toml\n[wallpapers]\nunique = true # set a different wallpaper for each screen\npath = \"~/Images/wallpapers/\"\ninterval = 60 # change every hour\nextensions = [\"jpg\", \"jpeg\"]\nrecurse = true\n## Using swww\ncommand = 'swww img --transition-type any \"[file]\"'\nclear_command = \"swww clear\"\n```\n\nNote that for applications like `swww`, you'll need to start a daemon separately (eg: from `hyprland.conf`).\n</details>\n\n\n## Commands\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n\n### `path` (REQUIRED)\n\npath to a folder or list of folders that will be searched. Can also be a list, eg:\n\n```toml\npath = [\"~/Images/Portraits/\", \"~/Images/Landscapes/\"]\n```\n\n### `interval`\n\ndefaults to `10`\n\nHow long (in minutes) a background should stay in place\n\n\n### `command`\n\nOverrides the default command to set the background image.\n\n[variables](./Variables) are replaced with the appropriate values, you must use a `\"[file]\"` placeholder for the image path. eg:\n\n```\nswaybg -m fill -i \"[file]\"\n```\n\n### `clear_command`\n\nBy default `clear` command kills the `command` program.\n\nInstead of that, you can provide a command to clear the background. eg:\n\n```\nclear_command = \"swaybg clear\"\n``````\n\n### `extensions`\n\ndefaults to `[\"png\", \"jpg\", \"jpeg\"]`\n\nList of valid wallpaper image extensions.\n\n### `recurse`\n\ndefaults to `false`\n\nWhen enabled, will also search sub-directories recursively.\n\n### `unique`\n\ndefaults to `false`\n\nWhen enabled, will set a different wallpaper for each screen.\n\nIf you are not using the default application, ensure you are using `\"[output]\"` in the [command](#command) template.\n\nExample for swaybg: `swaybg -o \"[output]\" -m fill -i \"[file]\"`\n"
  },
  {
    "path": "site/versions/2.4.6/workspaces_follow_focus.md",
    "content": "---\ncommands:\n    - name: change_workspace [direction]\n      description: Changes the workspace of the focused monitor in the given direction.\n---\n\n# workspaces_follow_focus\n\nMake non-visible workspaces follow the focused monitor.\nAlso provides commands to switch between workspaces while preserving the current monitor assignments:\n\nSyntax:\n```toml\n[workspaces_follow_focus]\nmax_workspaces = 4 # number of workspaces before cycling\n```\nExample usage in `hyprland.conf`:\n\n```ini\nbind = $mainMod, K, exec, pypr change_workspace +1\nbind = $mainMod, J, exec, pypr change_workspace -1\n ```\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `max_workspaces`\n\nLimits the number of workspaces when switching, defaults value is `10`.\n"
  },
  {
    "path": "site/versions/2.4.7/Development.md",
    "content": "# Development\n\nIt's easy to write your own plugin by making a python package and then indicating it's name as the plugin name.\n\n[Contributing guidelines](https://github.com/hyprland-community/pyprland/blob/main/CONTRIBUTING.md)\n\n# Writing plugins\n\nPlugins can be loaded with full python module path, eg: `\"mymodule.pyprlandplugin\"`, the loaded module must provide an `Extension` class.\n\nCheck the `interface.py` file to know the base methods, also have a look at the example below.\n\nTo get more details when an error is occurring, use `pypr --debug <log file path>`, it will also display the log in the console.\n\n> [!note]\n> To quickly get started, you can directly edit the `experimental` built-in plugin.\n> In order to distribute it, make your own Python package or trigger a pull request.\n> If you prefer to make a separate package, check the [examples](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/)'s package\n\nThe `Extension` interface provides a couple of built-in attributes:\n\n- `config` : object exposing the plugin section in `pyprland.toml`\n- `notify` ,`notify_error`, `notify_info` : access to Hyprland's notification system\n- `hyprctl`, `hyprctl_json` : invoke [Hyprland's IPC system](https://wiki.hyprland.org/Configuring/Dispatchers/)\n\n\n> [!important]\n> Contact me to get your extension listed on the home page\n\n> [!tip]\n> You can set a `plugins_paths=[\"/custom/path/example\"]` in the `hyprland` section of the configuration to add extra paths (eg: during development).\n\n> [!Note]\n> If your extension is at the root of the plugin (this is not recommended, preferable add a name space, as in `johns_pyprland.super_feature`, rather than `super_feature`) you can still import it using the `external:` prefix when you refer to it in the `plugins` list.\n\n# API Documentation\n\nRun `tox run -e doc` then visit `http://localhost:8080`\n\nThe most important to know are:\n\n- `hyprctl_json` to get a response from an IPC query\n- `hyprctl` to trigger general IPC commands\n- `on_reload` to be implemented, called when the config is (re)loaded\n- `run_<command_name>` to implement a command\n- `event_<event_name>` called when the given event is emitted by Hyprland\n\nAll those methods are _async_\n\nOn top of that:\n\n- the first line of a `run_*` command's docstring will be used by the `help` command\n- `self.config` in your _Extension_ contains the entry corresponding to your plugin name in the TOML file\n- `state` from `..common` module contains ready to use information\n- there is a `MenuMixin` in `..adapters.menus` to make menu-based plugins easy\n\n# Workflow\n\nJust `^C` when you make a change and repeat:\n\n```sh\npypr exit ; pypr --debug /tmp/output.log\n```\n\n\n## Creating a plugin\n\n```python\nfrom .interface import Plugin\n\n\nclass Extension(Plugin):\n    \" My plugin \"\n\n    async def init(self):\n        await self.notify(\"My plugin loaded\")\n```\n\n## Adding a command\n\nJust add a method called `run_<name of your command>` to your `Extension` class, eg with \"togglezoom\" command:\n\n```python\n    zoomed = False\n\n    async def run_togglezoom(self, args):\n        \"\"\" this doc string will show in `help` to document `togglezoom`\n        But this line will not show in the CLI help\n        \"\"\"\n      if self.zoomed:\n        await self.hyprctl('misc:cursor_zoom_factor 1', 'keyword')\n      else:\n        await self.hyprctl('misc:cursor_zoom_factor 2', 'keyword')\n      self.zoomed = not self.zoomed\n```\n\n## Reacting to an event\n\nSimilar as a command, implement some `async def event_<the event you are interested in>` method.\n\n## Code safety\n\nPypr ensures only one `run_` or `event_` handler runs at a time, allowing the plugins code to stay simple and avoid the need for concurrency handling.\nHowever, each plugin can run its handlers in parallel.\n\n# Reusable code\n\n```py\nfrom ..common import state, CastBoolMixin\n```\n\n- `state` provides a couple of handy variables so you don't have to fetch them, allow optimizing the most common operations\n- `Mixins` are providing common code, for instance the `CastBoolMixin` provides the `cast_bool` method to your `Extension`.\n\nIf you want to use menus, then the `MenuMixin` will provide:\n- `menu` to show a menu\n- `ensure_menu_configured` to call before you require a menu in your plugin\n\n# Example\n\nYou'll find a basic external plugin in the [examples](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/) folder.\n\nIt provides one command: `pypr dummy`.\n\nRead the [plugin code](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/pypr_examples/focus_counter.py)\n\nIt's a simple python package. To install it for development without a need to re-install it for testing, you can use `pip install -e .` in this folder.\nIt's ready to be published using `poetry publish`, don't forget to update the details in the `pyproject.toml` file.\n\n## Usage\n\nEnsure you added `pypr_examples.focus_counter` to your `plugins` list:\n\n```toml\n[pyprland]\nplugins = [\n  \"pypr_examples.focus_counter\"\n]\n```\n\nOptionally you can customize one color:\n\n```toml\n[\"pypr_examples.focus_counter\"]\ncolor = \"FFFF00\"\n```\n"
  },
  {
    "path": "site/versions/2.4.7/Getting-started.md",
    "content": "# Getting started\n\nPypr consists in two things:\n\n- **a tool**: `pypr` which runs the daemon (service), but also allows to interact with it\n  - `pypr-client` is a more limited, faster version, meant to be used in your `hyprland.conf` keyboard bindings **only**\n- **some config file**: `~/.config/hypr/pyprland.toml` (or the path set using `--config`) using the [TOML](https://toml.io/en/) format\n\nThe `pypr` tool only have a few built-in commands:\n\n- `help` lists available commands (including plugins commands)\n- `exit` will terminate the service process\n- `edit` edit the configuration using your `$EDITOR` (or `vi`), reloads on exit\n- `dumpjson` shows a JSON representation of the configuration (after other files have been `include`d)\n- `reload` reads the configuration file and apply some changes:\n  - new plugins will be loaded\n  - configuration items will be updated (most plugins will use the new values on the next usage)\n\nOther commands are implemented by adding [plugins](./Plugins).\n\n> [!important]\n> - with no argument it runs the daemon (doesn't fork in the background)\n>\n> - if you pass parameters, it will interact with the daemon instead.\n\n> [!tip]\n> Pypr *command* names are documented using underscores (`_`) but you can use dashes (`-`) instead.\n> Eg: `pypr shift_monitors` and `pypr shift-monitors` will run the same command\n\n\n## Configuration file\n\nThe configuration file uses a [TOML format](https://toml.io/) with the following as the bare minimum:\n\n```toml\n[pyprland]\nplugins = [\"plugin_name\", \"other_plugin\"]\n```\n\nAdditionally some plugins require **Configuration** options, using the following format:\n\n```toml\n[plugin_name]\nplugin_option = 42\n\n[plugin_name.another_plugin_option]\nsuboption = \"config value\"\n```\n\nYou can also split your configuration into [Multiple configuration files](./MultipleConfigurationFiles).\n\n## Installation\n\nCheck your OS package manager first, eg:\n\n- Archlinux: you can find it on AUR, eg with [yay](https://github.com/Jguer/yay): `yay pyprland`\n- NixOS: Instructions in the [Nix](./Nix) page\n\nOtherwise, use the python package manager (pip) [inside a virtual environment](InstallVirtualEnvironment)\n\n```sh\npip install pyprland\n```\n\n## Running\n\n> [!caution]\n> If you messed with something else than your OS packaging system to get `pypr` installed, use the full path to the `pypr` command.\n\nPreferably start the process with hyprland, adding to `hyprland.conf`:\n\n```ini\nexec-once = /usr/bin/pypr\n```\n\nor if you run into troubles (use the first version once your configuration is stable):\n\n```ini\nexec-once = /usr/bin/pypr --debug /tmp/pypr.log\n```\n\n> [!warning]\n> To avoid issues (eg: you have a complex setup, maybe using a virtual environment), you may want to set the full path (eg: `/home/bob/venv/bin/pypr`).\n> You can get it from `which pypr` in a working terminal\n\nOnce the `pypr` daemon is started (cf `exec-once`), you can list the eventual commands which have been added by the plugins using `pypr -h` or `pypr help`, those commands are generally meant to be use via key bindings, see the `hyprland.conf` part of *Configuring* section below.\n\n## Configuring\n\nCreate a configuration file in `~/.config/hypr/pyprland.toml` enabling a list of plugins, each plugin may have its own configuration needs or don't need any configuration at all.\nMost default values should be acceptable for most users, options which hare not mandatory are marked as such.\n\n> [!important]\n> Provide the values for the configuration options which have no annotation such as \"(optional)\"\n\nCheck the [TOML format](https://toml.io/) for details about the syntax.\n\nSimple example:\n\n```toml\n[pyprland]\nplugins = [\n    \"shift_monitors\",\n    \"workspaces_follow_focus\"\n]\n```\n\n<details>\n  <summary>\nMore complex example\n  </summary>\n\n```toml\n[pyprland]\nplugins = [\n  \"scratchpads\",\n  \"lost_windows\",\n  \"monitors\",\n  \"toggle_dpms\",\n  \"magnify\",\n  \"expose\",\n  \"shift_monitors\",\n  \"workspaces_follow_focus\",\n]\n\n[monitors.placement]\n\"Acer\".top_center_of = \"Sony\"\n\n[workspaces_follow_focus]\nmax_workspaces = 9\n\n[expose]\ninclude_special = false\n\n[scratchpads.stb]\nanimation = \"fromBottom\"\ncommand = \"kitty --class kitty-stb sstb\"\nclass = \"kitty-stb\"\nlazy = true\nsize = \"75% 45%\"\n\n[scratchpads.stb-logs]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-stb-logs stbLog\"\nclass = \"kitty-stb-logs\"\nlazy = true\nsize = \"75% 40%\"\n\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nlazy = true\nsize = \"40% 90%\"\nunfocus = \"hide\"\n```\n\nSome of those plugins may require changes in your `hyprland.conf` to fully operate or to provide a convenient access to a command, eg:\n\n```bash\n\n$pypr = uwsm-app -- /usr/local/bin/pypr-client\n\nbind = $mainMod SHIFT,  Z, exec, $pypr zoom\nbind = $mainMod ALT,    P, exec, $pypr toggle_dpms\nbind = $mainMod SHIFT,  O, exec, $pypr shift_monitors +1\nbind = $mainMod,        B, exec, $pypr expose\nbind = $mainMod,        K, exec, $pypr change_workspace +1\nbind = $mainMod,        J, exec, $pypr change_workspace -1\nbind = $mainMod,        L, exec, $pypr toggle_dpms\nbind = $mainMod  SHIFT, M, exec, $pypr toggle stb stb-logs\nbind = $mainMod,        A, exec, $pypr toggle term\nbind = $mainMod,        V, exec, $pypr toggle volume\n```\n\nThis example makes use of the faster `pypr-client` which may not be available depending on how you installed,\nin case you want to install it manually, just download [the source code](https://github.com/hyprland-community/pyprland/blob/main/client/) and compile it (eg: `gcc -o pypr-client pypr-client.c`).\nRust, Go and C versions are available.\n\n\n</details>\n\n> [!tip]\n> Consult or share [configuration files](https://github.com/hyprland-community/pyprland/tree/main/examples)\n>\n> You might also be interested in [optimizations](./Optimizations).\n"
  },
  {
    "path": "site/versions/2.4.7/InstallVirtualEnvironment.md",
    "content": "# Virtual env\n\nEven though the best way to get Pyprland installed is to use your operating system package manager,\nfor some usages or users it can be convenient to use a virtual environment.\n\nThis is very easy to achieve in a couple of steps:\n\n```shell\npython -m venv ~/pypr-env\n~/pypr-env/bin/pip install pyprland\n```\n\n**That's all folks!**\n\nThe only extra care to take is to use `pypr` from the virtual environment, eg:\n\n- adding the environment's \"bin\" folder to the `PATH` (using `export PATH=\"$PATH:~/pypr-env/bin/\"` in your shell configuration file)\n- always using the full path to the pypr command (in `hyprland.conf`: `exec-once = ~/pypr-env/bin/pypr --debug /tmp/pypr.log`)\n\n# Going bleeding edge!\n\nIf you would rather like to use the latest version available (not released yet), then you can clone the git repository and install from it:\n\n```shell\ncd ~/pypr-env\ngit clone git@github.com:hyprland-community/pyprland.git pyprland-sources\ncd pyprland-sources\n../bin/pip install -e .\n```\n\n## Updating\n\n```shell\ncd ~/pypr-env\ngit pull -r\n```\n\n# Troubelshooting\n\nIf things go wrong, try (eg: after a system upgrade where Python got updated):\n\n```shell\npython -m venv --upgrade ~/pypr-env\ncd  ~/pypr-env\n../bin/pip install -e .\n```\n"
  },
  {
    "path": "site/versions/2.4.7/Menu.md",
    "content": "# Menu capability\n\nMenu based plugins have the following configuration options:\n\n### `engine`\n\nNot set by default, will autodetect the available menu engine.\n\nSupported engines:\n\n- tofi\n- rofi\n- wofi\n- bemenu\n- dmenu\n- anyrun\n\n> [!note]\n> If your menu system isn't supported, you can open a [feature request](https://github.com/hyprland-community/pyprland/issues/new?assignees=fdev31&labels=bug&projects=&template=feature_request.md&title=%5BFEAT%5D+Description+of+the+feature)\n>\n> In case the engine isn't recognized, `engine` + `parameters` configuration options will be used to start the process, it requires a dmenu-like behavior.\n\n### `parameters`\n\nExtra parameters added to the engine command, the default value is specific to each engine.\n\n> [!important]\n> Setting this will override the default value!\n>\n> In general, *rofi*-like programs will require at least `-dmenu` option.\n\n> [!tip]\n> You can use '[prompt]' in the parameters, it will be replaced by the prompt, eg:\n> ```sh\n> -dmenu -matching fuzzy -i -p '[prompt]'\n> ```\n"
  },
  {
    "path": "site/versions/2.4.7/MultipleConfigurationFiles.md",
    "content": "### Multiple configuration files\n\nYou can also split your configuration into multiple files that will be loaded in the provided order after the main file:\n```toml\n[pyprland]\ninclude = [\"/shared/pyprland.toml\", \"~/pypr_extra_config.toml\"]\n```\nYou can also load folders, in which case TOML files in the folder will be loaded in alphabetical order:\n```toml\n[pyprland]\ninclude = [\"~/.config/pypr.d/\"]\n```\n\nAnd then add a `~/.config/pypr.d/monitors.toml` file:\n```toml\npyprland.plugins = [ \"monitors\" ]\n\n[monitors.placement]\nBenQ.Top_Center_Of = \"DP-1\" # projo\n\"CJFH277Q3HCB\".top_of = \"eDP-1\" # work\n```\n\n> [!tip]\n> To check the final merged configuration, you can use the `dumpjson` command.\n\n"
  },
  {
    "path": "site/versions/2.4.7/Nix.md",
    "content": "# Nix\n\nYou are recommended to get the latest pyprland package from the [flake.nix](https://github.com/hyprland-community/pyprland/blob/main/flake.nix)\nprovided within this repository. To use it in your system, you may add pyprland\nto your flake inputs.\n\n## Flake\n\n```nix\n## flake.nix\n{\n  inputs = {\n    nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n    pyprland.url = \"github:hyprland-community/pyprland\";\n  };\n\n  outputs = { self, nixpkgs, pyprland, ...}: let\n  in {\n    nixosConfigurations.<yourHostname> = nixpkgs.lib.nixosSystem {\n      #         remember to replace this with your system arch ↓\n      environment.systemPackages = [ pyprland.packages.\"x86_64-linux\".pyprland ];\n      # ...\n    };\n  };\n}\n```\n\nAlternatively, if you are using the Nix package manager but not NixOS as your\nmain distribution, you may use `nix profile` tool to install pyprland from this\nrepository using the following command.\n\n```bash\nnix profile install github:nix-community/pyprland\n```\n\nThe package will now be in your latest profile. You may use `nix profile list`\nto verify your installation.\n\n## Nixpkgs\n\nPyprland is available under nixpkgs, and can be installed by adding\n`pkgs.pyprland` to either `environment.systemPackages` or `home.packages`\ndepending on whether you want it available system-wide or to only a single\nuser using home-manager. If the derivation available in nixpkgs is out-of-date\nthen you may consider using `overrideAttrs` to update the source locally.\n\n```nix\nlet\n  pyprland = pkgs.pyprland.overrideAttrs {\n    version = \"your-version-here\";\n    src = fetchFromGitHub {\n      owner = \"hyprland-community\";\n      repo = \"pyprland\";\n      rev = \"tag-or-revision\";\n      # leave empty for the first time, add the new hash from the error message\n      hash = \"\";\n    };\n  };\nin {\n  # add the overridden package to systemPackages\n  environment.systemPackages = [pyprland];\n}\n```\n"
  },
  {
    "path": "site/versions/2.4.7/Optimizations.md",
    "content": "## Optimizing\n\n### Plugins\n\n- Only enable the plugins you are using in the `plugins` array (in `[pyprland]` section).\n- Leaving the configuration for plugins which are not enabled will have no impact.\n- Using multiple configuration files only have a small impact on the startup time.\n\n### Pypr\n\nYou can run `pypr` using `pypy` (version 3) for a more snappy experience.\nOne way is to use a `pypy3` virtual environment:\n\n```bash\npypy3 -m venv pypr-venv\nsource ./pypr-venv/bin/activate\ncd <pypr source folder>\npip install -e .\n```\n\n### Pypr command\n\nIn case you want to save some time when interacting with the daemon, the simplest is to use one of the `pypr-client` implementations as mentioned in the [getting started page](./Getting-started.md). If for some reason `pypr-client` isn't made available by the package of your OS and can not compile code,\nyou can use `socat` instead (needs to be installed).\n\nExample of a `pypr-cli` command (should be reachable from your environment's `PATH`):\n```sh\n#!/bin/sh\nsocat - \"UNIX-CONNECT:${XDG_RUNTIME_DIR}/hypr/${HYPRLAND_INSTANCE_SIGNATURE}/.pyprland.sock\" <<< $@\n```\nOn slow systems this may make a difference.\nNote that the \"help\" command will require usage of the standard `pypr` command.\n"
  },
  {
    "path": "site/versions/2.4.7/Plugins.md",
    "content": "<script setup>\nimport PluginList from './components/PluginList.vue'\n</script>\n\nThis page lists every plugin provided by Pyprland out of the box, more can be enabled if you install the matching package.\n\n\"🌟\" indicates some maturity & reliability level of the plugin, considering age, attention paid and complexity - from 0 to 3.\n\n<PluginList/>\n"
  },
  {
    "path": "site/versions/2.4.7/Troubleshooting.md",
    "content": "# Troubleshooting\n\n## General\n\nIn case of trouble running a `pypr` command:\n- kill the existing pypr if any (try `pypr exit` first)\n- run from the terminal adding `--debug /dev/null` to the arguments to get more information\n\nIf the client says it can't connect, then there is a high chance pypr daemon didn't start, check if it's running using `ps axuw |grep pypr`. You can try to run it from a terminal with the same technique: `pypr --debug /dev/null` and see if any error occurs.\n\n## Force hyprland version\n\nIn case your `hyprctl version -j` command isn't returning an accurate version, you can make Pyprland ignore it and use a provided value instead:\n\n```toml\n[pyprland]\nhyprland_version = \"0.41.0\"\n```\n\n## Unresponsive scratchpads\n\nScratchpads aren't responding for few seconds after trying to show one (which didn't show!)\n\nThis may happen if an application is very slow to start.\nIn that case pypr will wait for a window blocking other scratchpad's operation, before giving up after a few seconds.\n\nNote that other plugins shouldn't be blocked by it.\n\nMore scratchpads troubleshooting can be found [here](./scratchpads_nonstandard).\n"
  },
  {
    "path": "site/versions/2.4.7/Variables.md",
    "content": "# Variables & substitutions\n\nSome commands support shared global variables, they must be defined in the *pyprland* section of the configuration:\n```toml\n[pyprland.variables]\nterm = \"foot\"\nterm_classed = \"foot -a\" # kitty uses --class\n```\n\nIf a plugin supports it, you can then use the variables in the attribute that supports it, eg:\n\n```toml\n[myplugin]\nsome_variable = \"the terminal is [term]\"\n```\n"
  },
  {
    "path": "site/versions/2.4.7/bar.md",
    "content": "---\ncommands:\n  - name: bar restart\n    description: Restart/refresh Menu Bar on the \"best\" monitor.\n---\n\n# menubar\n\nRuns your favorite bar app (gbar, ags / hyprpanel, waybar, ...) with option to pass the \"best\" monitor from a list of monitors.\n\n- Will take care of starting the command on startup (you must not run it from another source like `hyprland.conf`).\n- Automatically restarts the menu bar on crash\n- Checks which monitors are on and take the best one from a provided list\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n\n## Configuration\n\n### `command` (REQUIRED)\n\nThe command which runs the menu bar. The string `[monitor]` will be replaced by the best monitor.\n\n### `monitors`\n\nList of monitors to chose from, the first have higher priority over the second one etc...\n\n\n## Example\n\n```sh\n[gbar]\nmonitors = [\"DP-1\", \"HDMI-1\", \"HDMI-1-A\"]\n```\n"
  },
  {
    "path": "site/versions/2.4.7/components/CommandList.vue",
    "content": "<template>\n    <dl>\n    </dl>\n    <ul v-for=\"command in commands\" :key=\"command.name\">\n        <li><code v-html=\"command.name.replace(/[   ]*$/, '').replace(/ /g, '&ensp;')\" /> <span\n                v-html=\"command.description\" /></li>\n    </ul>\n</template>\n\n<script>\nexport default {\n    props: {\n        commands: {\n            type: Array,\n            required: true\n        }\n    }\n}\n</script>\n"
  },
  {
    "path": "site/versions/2.4.7/components/PluginList.vue",
    "content": "<template>\n    <div>\n        <h1>Built-in plugins</h1>\n        <div v-for=\"plugin in sortedPlugins\" :key=\"plugin.name\" class=\"plugin-item\">\n            <div class=\"plugin-info\">\n                <h3>\n                    <a :href=\"plugin.name + '.html'\">{{ plugin.name }}</a>\n                    <span>&nbsp;{{ '🌟'.repeat(plugin.stars) }}</span>\n                    <span v-if=\"plugin.multimon\">\n                        <Badge type=\"tip\" text=\"multi-monitor\" />\n                    </span>\n                </h3>\n                <p v-html=\"plugin.description\" />\n            </div>\n            <a v-if=\"plugin.demoVideoId\" :href=\"'https://www.youtube.com/watch?v=' + plugin.demoVideoId\"\n                class=\"plugin-video\">\n                <img :src=\"'https://img.youtube.com/vi/' + plugin.demoVideoId + '/1.jpg'\" alt=\"Demo video thumbnail\" />\n            </a>\n        </div>\n    </div>\n</template>\n\n<style scoped>\n.plugin-item {\n    display: flex;\n    align-items: flex-start;\n    margin-bottom: 1.5rem;\n}\n\n.plugin-info {\n    flex: 1;\n}\n\n.plugin-video {\n    margin-left: 1rem;\n}\n\n.plugin-video img {\n    max-width: 200px;\n    /* Adjust as needed */\n    height: auto;\n}\n</style>\n\n\n<script>\n\nexport default {\n    computed: {\n        sortedPlugins() {\n            if (this.sortByStars) {\n                return this.plugins.slice().sort((a, b) => {\n                    if (b.stars === a.stars) {\n                        return a.name.localeCompare(b.name);\n                    }\n                    return b.stars - a.stars;\n                });\n            } else {\n                return this.plugins.slice().sort((a, b) => a.name.localeCompare(b.name));\n            }\n        }\n    },\n    data() {\n        return {\n            sortByStars: false,\n            plugins: [\n                {\n                    name: 'scratchpads',\n                    stars: 3,\n                    description: 'makes your applications dropdowns & togglable poppups',\n                    demoVideoId: 'ZOhv59VYqkc'\n                },\n                {\n                    name: 'magnify',\n                    stars: 3,\n                    description: 'toggles zooming of viewport or sets a specific scaling factor',\n                    demoVideoId: 'yN-mhh9aDuo'\n\n                },\n                {\n                    name: 'toggle_special',\n                    stars: 3,\n                    description: 'moves windows from/to special workspaces',\n                    demoVideoId: 'BNZCMqkwTOo'\n                },\n                {\n                    name: 'shortcuts_menu',\n                    stars: 3,\n                    description: 'a flexible way to make your own shortcuts menus & launchers',\n                    demoVideoId: 'UCuS417BZK8'\n                },\n                {\n                    name: 'fetch_client_menu',\n                    stars: 3,\n                    description: 'select a window to be moved to your active workspace (using rofi, dmenu, etc...)',\n                },\n                {\n                    name: 'layout_center',\n                    stars: 3,\n                    description: 'a workspace layout where one client window is almost maximized and others seat in the background',\n                    demoVideoId: 'vEr9eeSJYDc'\n                },\n                {\n                    name: 'system_notifier',\n                    stars: 3,\n                    description: 'open streams (eg: journal logs) and trigger notifications',\n                },\n                {\n                    name: 'wallpapers',\n                    stars: 3,\n                    description: 'handles random wallpapers at regular interval (from a folder). Supports rounded corners and color scheme generation.'\n                },\n                {\n                    name: 'bar',\n                    stars: 3,\n                    description: 'improves multi-monitor handling of the status bar - restarts it on crashes'\n                },\n                {\n                    name: 'expose',\n                    stars: 2,\n                    description: 'exposes all the windows for a quick \"jump to\" feature',\n                    demoVideoId: 'ce5HQZ3na8M'\n                },\n                {\n                    name: 'lost_windows',\n                    stars: 1,\n                    description: 'brings lost floating windows (which are out of reach) to the current workspace'\n                },\n                {\n                    name: 'toggle_dpms',\n                    stars: 0,\n                    description: 'toggles the DPMS status of every plugged monitor'\n                },\n                {\n                    name: 'workspaces_follow_focus',\n                    multimon: true,\n                    stars: 3,\n                    description: 'makes non visible workspaces available on the currently focused screen.<br/> If you think the multi-screen behavior of Hyprland is not usable or broken/unexpected, this is probably for you.'\n\n                },\n                {\n                    name: 'shift_monitors',\n                    multimon: true,\n                    stars: 3,\n                    description: 'moves workspaces from monitor to monitor (caroussel)'\n                },\n                {\n                    name: 'monitors',\n                    stars: 3,\n                    description: 'allows relative placement and configuration of monitors'\n                },\n                {\n                    name: 'fcitx5_switcher',\n                    stars: 0,\n                    description: 'Automatically switch fcitx5 input method status based on window class and title.'\n                }\n            ]\n        }\n    }\n}\n</script>\n"
  },
  {
    "path": "site/versions/2.4.7/expose.md",
    "content": "---\ncommands:\n  - name: expose\n    description: Expose every client on the active workspace. If expose is already active, then restores everything and move to the focused window.\n---\n\n# expose\n\nImplements the \"expose\" effect, showing every client window on the focused screen.\n\nFor a similar feature using a menu, try the [fetch_client_menu](./fetch_client_menu) plugin (less intrusive).\n\nSample `hyprland.conf`:\n\n```bash\n# Setup the key binding\nbind = $mainMod, B, exec, pypr expose\n\n# Add some style to the \"exposed\" workspace\nworkspace = special:exposed,gapsout:60,gapsin:30,bordersize:5,border:true,shadow:false\n```\n\n`MOD+B` will bring every client to the focused workspace, pressed again it will go to this workspace.\n\nCheck [workspace rules](https://wiki.hyprland.org/Configuring/Workspace-Rules/#rules) for styling options.\n\n> [!note]\n> If you are looking for `toggle_minimized`, check the [toggle_special](./toggle_special) plugin\n\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n\n### `include_special`\n\ndefault value is `false`\n\nAlso include windows in the special workspaces during the expose.\n\n"
  },
  {
    "path": "site/versions/2.4.7/fcitx5_switcher.md",
    "content": "---\ncommand:\n  - name: fcitx5_switcher\n    description: Automatically switch fcitx5 input method status based on window class and title.\n---\n\n# fcitx5_switcher\n\nThis is a useful tool for CJK input method users.\n\nIt can automatically switch fcitx5 input method status based on window class and title.\n\nExample:\n```toml\n[fcitx5_switcher]\nactive_classes = [\"wechat\", \"QQ\", \"zoom\"]\ninactive_classes = [\n    \"code\",\n    \"kitty\",\n    \"google-chrome\",\n]\nactive_titles = []\ninactive_titles = []\n```\n\nIn this example, if the window class is \"wechat\" or \"QQ\" or \"zoom\", the input method will be activated. If the window class is \"code\" or \"kitty\" or \"google-chrome\", the input method will be inactivated."
  },
  {
    "path": "site/versions/2.4.7/fetch_client_menu.md",
    "content": "---\ncommands:\n  - name: fetch_client_menu\n    description: Bring any window to the active workspace using a menu.\n---\n\n# fetch_client_menu\n\nBring any window to the active workspace using a menu.\n\nA bit like the [expose](./expose) plugin but using a menu instead (less intrusive).\n\nIt brings the window to the current workspace, while [expose](./expose) moves the currently focused screen to the application workspace.\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\nAll the [Menu](Menu) configuration items are also available.\n\n### `separator`\n\ndefault value is `\"|\"`\n\nChanges the character (or string) used to separate a menu entry from its entry number.\n\n"
  },
  {
    "path": "site/versions/2.4.7/filters.md",
    "content": "# Text filters\n\nAt the moment there is only one filter, close match of [sed's \"s\" command](https://www.gnu.org/software/sed/manual/html_node/The-_0022s_0022-Command.html).\n\nThe only supported flag is \"g\"\n\n## Example\n\n```toml\nfilter = 's/foo/bar/'\nfilter = 's/.*started (.*)/\\1 has started/'\nfilter = 's#</?div>##g'\n```\n"
  },
  {
    "path": "site/versions/2.4.7/index.md",
    "content": "---\n# https://vitepress.dev/reference/default-theme-home-page\nlayout: home\n\nhero:\n  text: \"Hyprland extensions\"\n  tagline: Enhance your desktop capabilities with Pyprland\n  image:\n    src: /logo.svg\n    alt: logo\n  actions:\n    - theme: brand\n      text: Getting started\n      link: ./Getting-started\n    - theme: alt\n      text: Plugins\n      link: ./Plugins\n    - theme: alt\n      text: Report something\n      link: https://github.com/hyprland-community/pyprland/issues/new/choose\n\nfeatures:\n  - title: TOML format\n    details: Simple and flexible configuration file(s)\n  - title: Customizable\n    details: Create your own Hyprland experience\n  - title: Fast and easy\n    details: Designed for performance and simplicity\n---\n\n## Version 2.4.7 archive\n"
  },
  {
    "path": "site/versions/2.4.7/layout_center.md",
    "content": "---\ncommands:\n  - name: layout_center toggle\n    description: toggles the layout on and off\n  - name: layout_center next\n    description: switches to the next window (if layout is on) else runs the <a href=\"#next-optional\">next</a> command\n  - name: layout_center prev\n    description: switches to the previous window (if layout is on) else runs the <a href=\"#next-optional\">prev</a> command\n  - name: layout_center next2\n    description: switches to the next window (if layout is on) else runs the <a href=\"#next-optional\">next2</a> command\n  - name: layout_center prev2\n    description: switches to the previous window (if layout is on) else runs the <a href=\"#next-optional\">prev2</a> command\n\n---\n# layout_center\n\nImplements a workspace layout where one window is bigger and centered,\nother windows are tiled as usual in the background.\n\nOn `toggle`, the active window is made floating and centered if the layout wasn't enabled, else reverts the floating status.\n\nWith `next` and `prev` you can cycle the active window, keeping the same layout type.\nIf the layout_center isn't active and `next` or `prev` is used, it will call the [next](#next-optional) and [prev](#prev-optional) configuration options.\n\nTo allow full override of the focus keys, [next2](#next2-optional) and [prev2](#prev2-optional) are provided, they do the same actions as \"next\" and \"prev\" but allow different fallback commands.\n\n<details>\n<summary>Configuration sample</summary>\n\n```toml\n[layout_center]\nmargin = 60\noffset = [0, 30]\nnext = \"movefocus r\"\nprev = \"movefocus l\"\nnext2 = \"movefocus d\"\nprev2 = \"movefocus u\"\n```\n\nusing the following in `hyprland.conf`:\n```sh\nbind = $mainMod, M, exec, pypr layout_center toggle # toggle the layout\n## focus change keys\nbind = $mainMod, left, exec, pypr layout_center prev\nbind = $mainMod, right, exec, pypr layout_center next\nbind = $mainMod, up, exec, pypr layout_center prev2\nbind = $mainMod, down, exec, pypr layout_center next2\n```\n\nYou can completely ignore `next2` and `prev2` if you are allowing focus change in a single direction (when the layout is enabled), eg:\n\n```sh\nbind = $mainMod, up, movefocus, u\nbind = $mainMod, down, movefocus, d\n```\n\n</details>\n\n\n## Commands\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `on_new_client`\n\nDefaults to `\"foreground\"`.\n\nChanges the behavior when a new window opens, possible options are:\n\n- \"foreground\" to make the new window the main window\n- \"background\" to make the new window appear in the background\n- \"close\" to stop the centered layout when a new window opens\n\n### `style`\n\n| Requires Hyprland > 0.40.0\n\nNot set by default.\n\nAllow to set a list of styles to the main (centered) window, eg:\n\n```toml\nstyle = [\"opacity 1\", \"bordercolor rgb(FFFF00)\"]\n```\n\n### `margin`\n\ndefault value is `60`\n\nmargin (in pixels) used when placing the center window, calculated from the border of the screen.\n\nExample to make the main window be 100px far from the monitor's limits:\n```toml\nmargin = 100\n```\n\nYou can also set a different margin for width and height by using a list:\n```toml\nmargin = [10, 60]\n```\n\n### `offset`\n\ndefault value is `[0, 0]`\n\noffset in pixels applied to the main window position\n\nExample shift the main window 20px down:\n```toml\noffset = [0, 20]\n```\n\n### `next`\n### `next2`\n\nnot set by default\n\nWhen the *layout_center* isn't active and the *next* command is triggered, defines the hyprland dispatcher command to run.\n\n`next2` is a similar option, used by the `next2` command, allowing to map \"next\" to both vertical and horizontal focus change.\n\nEg:\n```toml\nnext = \"movefocus r\"\n```\n\n### `prev`\n### `prev2`\n\nSame as `next` but for the `prev` and `prev2` commands.\n\n\n### `captive_focus`\n\ndefault value is `false`\n\n```toml\ncaptive_focus = true\n```\n\nSets the focus on the main window when the focus changes.\nYou may love it or hate it...\n"
  },
  {
    "path": "site/versions/2.4.7/lost_windows.md",
    "content": "---\ncommands:\n    - name: attract_lost\n      description: Bring windows which are not reachable in the currently focused workspace.\n---\n# lost_windows\n\nBring windows which are not reachable in the currently focused workspace.\n*Deprecated:* Was used to work around issues in Hyprland which have been fixed since then.\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n"
  },
  {
    "path": "site/versions/2.4.7/magnify.md",
    "content": "---\ncommands:\n    - name: zoom [value]\n      description: Set the current zoom level (absolute or relative) - toggle zooming if no value is provided\n---\n# magnify\n\nZooms in and out with an optional animation.\n\n\n<details>\n    <summary>Example</summary>\n\n```sh\npypr zoom  # sets zoom to `factor` (2 by default)\npypr zoom +1  # will set zoom to 3x\npypr zoom  # will set zoom to 1x\npypr zoom 1 # will (also) set zoom to 1x - effectively doing nothing\n```\n\nSample `hyprland.conf`:\n\n```sh\nbind = $mainMod , Z, exec, pypr zoom ++0.5\nbind = $mainMod SHIFT, Z, exec, pypr zoom\n```\n\n</details>\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n### `[value]`\n\n#### unset / not specified\n\nWill zoom to [factor](#factor-optional) if not zoomed, else will set the zoom to 1x.\n\n#### floating or integer value\n\nWill set the zoom to the provided value.\n\n#### +value / -value\n\nUpdate (increment or decrement) the current zoom level by the provided value.\n\n#### ++value / --value\n\nUpdate (increment or decrement) the current zoom level by a non-linear scale.\nIt _looks_ more linear changes than using a single + or -.\n\n> [!NOTE]\n>\n> The non-linear scale is calculated as powers of two, eg:\n>\n> - `zoom ++1` → 2x, 4x, 8x, 16x...\n> - `zoom ++0.7` → 1.6x, 2.6x, 4.3x, 7.0x, 11.3x, 18.4x...\n> - `zoom ++0.5` → 1.4x, 2x, 2.8x, 4x, 5.7x, 8x, 11.3x, 16x...\n\n## Configuration\n\n### `factor`\n\ndefault value is `2`\n\nScaling factor to be used when no value is provided.\n\n### `duration`\n\nDefault value is `15`\n\nDuration in tenths of a second for the zoom animation to last, set to `0` to disable animations.\n"
  },
  {
    "path": "site/versions/2.4.7/monitors.md",
    "content": "---\ncommands:\n  - name: relayout\n    description: Apply the configuration and update the layout\n---\n\n# monitors\n\n> First, simpler version of the plugin is still available under the name `monitors_v0`\n\nAllows relative placement of monitors depending on the model (\"description\" returned by `hyprctl monitors`).\nUseful if you have multiple monitors connected to a video signal switch or using a laptop and plugging monitors having different relative positions.\n\nSyntax:\n\n```toml\n[monitors.placement]\n\"description match\".placement = \"other description match\"\n```\n\n<details>\n    <summary>Example to set a Sony monitor on top of a BenQ monitor</summary>\n\n```toml\n[monitors.placement]\nSony.topOf = \"BenQ\"\n\n## Character case is ignored, \"_\" can be added\nSony.Top_Of = [\"BenQ\"]\n\n## Thanks to TOML format, complex configurations can use separate \"sections\" for clarity, eg:\n\n[monitors.placement.\"My monitor brand\"]\n## You can also use \"port\" names such as *HDMI-A-1*, *DP-1*, etc...\nleftOf = \"eDP-1\"\n\n## lists are possible on the right part of the assignment:\nrightOf = [\"Sony\", \"BenQ\"]\n\n## > 2.3.2: you can also set scale, transform & rate for a given monitor\n[monitors.placement.Microstep]\nrate = 100\n```\n\nTry to keep the rules as simple as possible, but relatively complex scenarios are supported.\n\n> [!note]\n> Check [wlr layout UI](https://github.com/fdev31/wlr-layout-ui) which is a nice complement to configure your monitor settings.\n\n</details>\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `placement` (REQUIRED)\n\nSupported placements are:\n\n- leftOf\n- topOf\n- rightOf\n- bottomOf\n- \\<one of the above>(center|middle|end)Of\n\n> [!important]\n> If you don't like the screen to align on the start of the given border,\n> you can use `center` (or `middle`) to center it or `end` to stick it to the opposite border.\n> Eg: \"topCenterOf\", \"leftEndOf\", etc...\n\nYou can separate the terms with \"_\" to improve the readability, as in \"top_center_of\".\n\n#### monitor settings\n\nNot only can you place monitors relatively to each other, but you can also set specific settings for a given monitor.\n\nThe following settings are supported:\n\n- scale\n- transform\n- rate\n- resolution\n\n```toml\n[monitors.placement.\"My monitor brand\"]\nrate = 60\nscale = 1.5\ntransform = 1 # 0: normal, 1: 90°, 2: 180°, 3: 270°, 4: flipped, 5: flipped 90°, 6: flipped 180°, 7: flipped 270°\nresolution = \"1920x1080\"  # can also be expressed as [1920, 1080]\n```\n\n### `startup_relayout`\n\nDefault to `ŧrue`.\n\nWhen set to `false`,\ndo not initialize the monitor layout on startup or when configuration is reloaded.\n\n### `new_monitor_delay`\n\nBy default,\nthe layout computation happens one second after the event is received to let time for things to settle.\n\nYou can change this value using this option.\n\n### `hotplug_command`\n\nNone by default, allows to run a command when any monitor is plugged.\n\n\n```toml\n[monitors]\nhotplug_commands = \"wlrlui -m\"\n```\n\n### `hotplug_commands`\n\nNone by default, allows to run a command when a given monitor is plugged.\n\nExample to load a specific profile using [wlr layout ui](https://github.com/fdev31/wlr-layout-ui):\n\n```toml\n[monitors.hotplug_commands]\n\"DELL P2417H CJFH277Q3HCB\" = \"wlrlui rotated\"\n```\n\n### `unknown`\n\nNone by default,\nallows to run a command when no monitor layout has been changed (no rule applied).\n\n```toml\n[monitors]\nunknown = \"wlrlui\"\n```\n\n### `trim_offset`\n\n`true` by default,\n\nPrevents having arbitrary window offsets or negative values.\n"
  },
  {
    "path": "site/versions/2.4.7/scratchpads.md",
    "content": "---\ncommands:\n  - name: toggle [scratchpad name]\n    description: Toggle the given scratchpad\n  - name: show [scratchpad name]\n    description: Show the given scratchpad\n  - name: hide [scratchpad name]\n    description: Hide the given scratchpad\n  - name: attach\n    description: Toggle attaching/anchoring the currently focused window to the (last used) scratchpad\n\nNote: show and hide can accept '*' as a parameter, applying changes to every scratchpad.\n\n---\n# scratchpads\n\nEasily toggle the visibility of applications you use the most.\n\nConfigurable and flexible, while supporting complex setups it's easy to get started with:\n\n```toml\n[scratchpads.name]\ncommand = \"command to run\"\nclass = \"the window's class\"  # check: hyprctl clients | grep class\nsize = \"[width] [height]\"  # size of the window relative to the screen size\n```\n\n<details>\n<summary>Example</summary>\n\nAs an example, defining two scratchpads:\n\n- _term_ which would be a kitty terminal on upper part of the screen\n- _volume_ which would be a pavucontrol window on the right part of the screen\n\n\n```toml\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\nmax_size = \"1920px 100%\"\nmargin = 50\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nsize = \"40% 90%\"\nunfocus = \"hide\"\nlazy = true\n```\n\nShortcuts are generally needed:\n\n```ini\nbind = $mainMod,V,exec,pypr toggle volume\nbind = $mainMod,A,exec,pypr toggle term\nbind = $mainMod,Y,exec,pypr attach\n```\n</details>\n\n- If you wish to have a more generic space for any application you may run, check [toggle_special](./toggle_special).\n- When you create a scratchpad called \"name\", it will be hidden in `special:scratch_<name>`.\n- Providing `class` allows a glitch free experience, mostly noticeable when using animations\n\n\n## Commands\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n> [!tip]\n> You can use `\"*\"` as a _scratchpad name_ to target every scratchpad when using `show` or `hide`.\n> You'll need to quote or escape the `*` character to avoid interpretation from your shell.\n\n## Configuration\n\n### `command` (REQUIRED)\n\nThis is the command you wish to run in the scratchpad.\n\nIt supports [Variables](./Variables)\n\n### `animation`\n\nType of animation to use, default value is \"fromTop\":\n\n- `null` / `\"\"` (no animation)\n- `fromTop` (stays close to upper screen border)\n- `fromBottom` (stays close to lower screen border)\n- `fromLeft` (stays close to left screen border)\n- `fromRight` (stays close to right screen border)\n\n### `size` (recommended)\n\nNo default value.\n\nEach time scratchpad is shown, window will be resized according to the provided values.\n\nFor example on monitor of size `800x600` and `size= \"80% 80%\"` in config scratchpad always have size `640x480`,\nregardless of which monitor it was first launched on.\n\n> #### Format\n>\n> String with \"x y\" (or \"width height\") values using some units suffix:\n>\n> - **percents** relative to the focused screen size (`%` suffix), eg: `60% 30%`\n> - **pixels** for absolute values (`px` suffix), eg: `800px 600px`\n> - a mix is possible, eg: `800px 40%`\n\n### `class` (recommended)\n\nNo default value.\n\nAllows _Pyprland_ prepare the window for a correct animation and initial positioning.\n\n### `position`\n\nNo default value, overrides the automatic margin-based position.\n\nSets the scratchpad client window position relative to the top-left corner.\n\nSame format as `size` (see above)\n\nExample of scratchpad that always seat on the top-right corner of the screen:\n\n```toml\n[scratchpads.term_quake]\ncommand = \"wezterm start --class term_quake\"\nposition = \"50% 0%\"\nsize = \"50% 50%\"\nclass = \"term_quake\"\n```\n\n> [!note]\n> If `position` is not provided, the window is placed according to `margin` on one axis and centered on the other.\n\n### `multi`\n\nDefaults to `true`.\n\nWhen set to `false`, only one client window is supported for this scratchpad.\nOtherwise other matching windows will be **attach**ed to the scratchpad.\n\n## Advanced configuration\n\nTo go beyond the basic setup and have a look at every configuration item, you can read the following pages:\n\n- [Advanced](./scratchpads_advanced) contains options for fine-tuners or specific tastes (eg: i3 compatibility)\n- [Non-Standard](./scratchpads_nonstandard) contains options for \"broken\" applications\nlike progressive web apps (PWA) or emacsclient, use only if you can't get it to work otherwise\n\n## Monitor specific overrides\n\nYou can use different settings for a specific screen.\nMost attributes related to the display can be changed (not `command`, `class` or `process_tracking` for instance).\n\nUse the `monitor.<monitor name>` configuration item to override values, eg:\n\n```toml\n[scratchpads.music.monitor.eDP-1]\nposition = \"30% 50%\"\nanimation = \"fromBottom\"\n```\n\nYou may want to inline it for simple cases:\n\n```toml\n[scratchpads.music]\nmonitor = {HDMI-A-1={size = \"30% 50%\"}}\n```\n"
  },
  {
    "path": "site/versions/2.4.7/scratchpads_advanced.md",
    "content": "# Fine tuning scratchpads\n\nAdvanced configuration options\n\n## `use`\n\nNo default value.\n\nList of scratchpads (or single string) that will be used for the default values of this scratchpad.\nThink about *templates*:\n\n```toml\n[scratchpads.terminals]\nanimation = \"fromTop\"\nmargin = 50\nsize = \"75% 60%\"\nmax_size = \"1920px 100%\"\n\n[scratchpads.term]\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nuse = \"terminals\"\n```\n\n## `pinned`\n\n`true` by default\n\nMakes the scratchpad \"sticky\" to the monitor, following any workspace change.\n\n## `excludes`\n\nNo default value.\n\nList of scratchpads to hide when this one is displayed, eg: `excludes = [\"term\", \"volume\"]`.\nIf you want to hide every displayed scratch you can set this to the string `\"*\"` instead of a list: `excludes = \"*\"`.\n\n## `restore_excluded`\n\n`false` by default.\n\nWhen enabled, will remember the scratchpads which have been closed due to `excludes` rules, so when the scratchpad is hidden, those previously hidden scratchpads will be shown again.\n\n## `unfocus`\n\nNo default value.\n\nWhen set to `\"hide\"`, allow to hide the window when the focus is lost.\n\nUse `hysteresis` to change the reactivity\n\n## `hysteresis`\n\nDefaults to `0.4` (seconds)\n\nControls how fast a scratchpad hiding on unfocus will react. Check `unfocus` option.\nSet to `0` to disable.\n\n> [!important]\n> Only relevant when `unfocus=\"hide\"` is used.\n\n## `margin`\n\ndefault value is `60`.\n\nnumber of pixels separating the scratchpad from the screen border, depends on the [animation](./scratchpads#animation) set.\n\n> [!tip]\n> It is also possible to set a string to express percentages of the screen (eg: '`3%`').\n\n## `max_size`\n\nNo default value.\n\nSame format as `size` (see above), only used if `size` is also set.\n\nLimits the `size` of the window accordingly.\nTo ensure a window will not be too large on a wide screen for instance:\n\n```toml\nsize = \"60% 30%\"\nmax_size = \"1200px 100%\"\n```\n\n## `lazy`\n\ndefault to `false`.\n\nwhen set to `true`, prevents the command from being started when pypr starts, it will be started when the scratchpad is first used instead.\n\n- Good: saves resources when the scratchpad isn't needed\n- Bad: slows down the first display (app has to launch before showing)\n\n## `preserve_aspect`\n\nNot set by default.\nWhen set to `true`, will preserve the size and position of the scratchpad when called repeatedly from the same monitor and workspace even though an `animation` , `position` or `size` is used (those will be used for the initial setting only).\n\nForces the `lazy` option.\n\n## `offset`\n\nIn pixels, default to `0` (client's window size + margin).\n\nNumber of pixels for the **hide** sliding animation (how far the window will go).\n\n> [!tip]\n> - It is also possible to set a string to express percentages of the client window\n> - `margin` is automatically added to the offset\n> - automatic (value not set) is same as `\"100%\"`\n\n## `hide_delay`\n\nDefaults to `0.2`\n\nDelay (in seconds) after which the hide animation happens, before hiding the scratchpad.\n\nRule of thumb, if you have an animation with speed \"7\", as in:\n```bash\n    animation = windowsOut, 1, 7, easeInOut, popin 80%\n```\nYou can divide the value by two and round to the lowest value, here `3`, then divide by 10, leading to `hide_delay = 0.3`.\n\n## `restore_focus`\n\nEnabled by default, set to `false` if you don't want the focused state to be restored when a scratchpad is hidden.\n\n## `force_monitor`\n\nIf set to some monitor name (eg: `\"DP-1\"`), it will always use this monitor to show the scratchpad.\n\n## `alt_toggle`\n\nDefault value is `false`\n\nWhen enabled, use an alternative `toggle` command logic for multi-screen setups.\nIt applies when the `toggle` command is triggered and the toggled scratchpad is visible on a screen which is not the focused one.\n\nInstead of moving the scratchpad to the focused screen, it will hide the scratchpad.\n\n## `allow_special_workspaces`\n\nDefault value is `false` (can't be enabled when using *Hyprland* < 0.39 where this behavior can't be controlled and is disabled).\n\nWhen enabled, you can toggle a scratchpad over a special workspace.\nIt will always use the \"normal\" workspace otherwise.\n\n## `smart_focus`\n\nDefault value is `true`.\n\nWhen enabled, the focus will be restored in a best effort way as en attempt to improve the user experience.\nIf you face issues such as spontaneous workspace changes, you can disable this feature.\n\n## `close_on_hide`\n\nDefault value is `false`.\n\nWhen enabled, the window in the scratchpad is closed instead of hidden when `pypr hide <name>` is run.\nThis option implies `lazy = true`.\nThis can be useful on laptops where background apps may increase battery power draw.\n\nNote: Currently this option changes the hide animation to use hyprland's close window animation.\n\n"
  },
  {
    "path": "site/versions/2.4.7/scratchpads_nonstandard.md",
    "content": "# Troubleshooting scratchpads\n\nOptions that should only be used for applications that are not behaving in a \"standard\" way, such as `emacsclient` or progressive web apps.\n\n## `match_by`\n\nDefault value is `\"pid\"`\nWhen set to a sensitive client property value (eg: `class`, `initialClass`, `title`, `initialTitle`), will match the client window using the provided property instead of the PID of the process.\n\nThis property must be set accordingly, eg:\n\n```toml\nmatch_by = \"class\"\nclass = \"my-web-app\"\n```\n\nor\n\n```toml\nmatch_by = \"initialClass\"\ninitialClass = \"my-web-app\"\n```\n\nYou can add the \"re:\" prefix to use a regular expression, eg:\n\n```toml\nmatch_by = \"title\"\ntitle = \"re:.*some string.*\"\n```\n\n> [!note]\n> Some apps may open the graphical client window in a \"complicated\" way, to work around this, it is possible to disable the process PID matching algorithm and simply rely on window's class.\n>\n> The `match_by` attribute can be used to achieve this, eg. for emacsclient:\n> ```toml\n> [scratchpads.emacs]\n> command = \"/usr/local/bin/emacsStart.sh\"\n> class = \"Emacs\"\n> match_by = \"class\"\n> ```\n\n## `process_tracking`\n\nDefault value is `true`\n\nAllows disabling the process management. Use only if running a progressive web app (Chrome based apps) or similar.\n\nThis will automatically force `lazy = true` and set `match_by=\"class\"` if no `match_by` rule is provided, to help with the fuzzy client window matching.\n\nIt requires defining a `class` option (or the option matching your `match_by` value).\n\n```toml\n## Chat GPT on Brave\n[scratchpads.gpt]\nanimation = \"fromTop\"\ncommand = \"brave --profile-directory=Default --app=https://chat.openai.com\"\nclass = \"brave-chat.openai.com__-Default\"\nsize = \"75% 60%\"\nprocess_tracking = false\n\n## Some chrome app\n[scratchpads.music]\ncommand = \"google-chrome --profile-directory=Default --app-id=cinhimbnkkaeohfgghhklpknlkffjgod\"\nclass = \"chrome-cinhimbnkkaeohfgghhklpknlkffjgod-Default\"\nsize = \"50% 50%\"\nprocess_tracking = false\n```\n\n> [!tip]\n> To list windows by class and title you can use:\n> - `hyprctl -j clients | jq '.[]|[.class,.title]'`\n> - or if you prefer a graphical tool: `rofi -show window`\n\n> [!note]\n> Progressive web apps will share a single process for every window.\n> On top of requiring the class based window tracking (using `match_by`),\n> the process can not be managed the same way as usual apps and the correlation\n> between the process and the client window isn't as straightforward and can lead to false matches in extreme cases.\n\n## `skip_windowrules`\n\nDefault value is `[]`\nAllows you to skip the window rules for a specific scratchpad.\nAvailable rules are:\n\n- \"aspect\" controlling size and position\n- \"float\" controlling the floating state\n- \"workspace\" which moves the window to its own workspace\n\nIf you are using an application which can spawn multiple windows and you can't see them, you can skip rules made to improve the initial display of the window.\n\n```toml\n[scratchpads.filemanager]\nanimation = \"fromBottom\"\ncommand = \"nemo\"\nclass = \"nemo\"\nsize = \"60% 60%\"\nskip_windowrules = [\"aspect\", \"workspace\"]\n```\n"
  },
  {
    "path": "site/versions/2.4.7/shift_monitors.md",
    "content": "---\ncommands:\n    - name: shift_monitors <direction>\n      description: Swaps the workspaces of every screen in the given direction.\n---\n\n# shift_monitors\n\nSwaps the workspaces of every screen in the given direction.\n\n> [!Note]\n> the behavior can be hard to predict if you have more than 2 monitors (depending on your layout).\n> If you use this plugin with many monitors and have some ideas about a convenient configuration, you are welcome ;)\n\nExample usage in `hyprland.conf`:\n\n```\nbind = $mainMod, O, exec, pypr shift_monitors +1\nbind = $mainMod SHIFT, O, exec, pypr shift_monitors -1\n```\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n"
  },
  {
    "path": "site/versions/2.4.7/shortcuts_menu.md",
    "content": "---\ncommands:\n    - name: menu [name]\n      description: Displays the full menu or part of it if \"name\" is provided\n---\n\n# shortcuts_menu\n\nPresents some menu to run shortcut commands. Supports nested menus (aka categories / sub-menus).\n\n<details>\n   <summary>Configuration examples</summary>\n\n```toml\n[shortcuts_menu.entries]\n\n\"Open Jira ticket\" = 'open-jira-ticket \"$(wl-paste)\"'\nRelayout = \"pypr relayout\"\n\"Fetch window\" = \"pypr fetch_client_menu\"\n\"Hyprland socket\" = 'kitty  socat - \"UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock\"'\n\"Hyprland logs\" = 'kitty tail -f $XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/hyprland.log'\n\n\"Serial USB Term\" = [\n    {name=\"device\", command=\"ls -1 /dev/ttyUSB*; ls -1 /dev/ttyACM*\"},\n    {name=\"speed\", options=[\"115200\", \"9600\", \"38400\", \"115200\", \"256000\", \"512000\"]},\n    \"kitty miniterm --raw --eol LF [device] [speed]\"\n]\n\n\"Color picker\" = [\n    {name=\"format\", options=[\"hex\", \"rgb\", \"hsv\", \"hsl\", \"cmyk\"]},\n    \"sleep 0.2; hyprpicker --format [format] | wl-copy\" # sleep to let the menu close before the picker opens\n]\n\nscreenshot = [\n    {name=\"what\", options=[\"output\", \"window\", \"region\", \"active\"]},\n    \"hyprshot -m [what] -o /tmp -f shot_[what].png\"\n]\n\nannotate = [\n    {name=\"fname\", command=\"ls /tmp/shot_*.png\"},\n    \"satty --filename '[fname]' --output-filename '/tmp/annotated.png'\"\n]\n\n\"Clipboard history\" = [\n    {name=\"entry\", command=\"cliphist list\", filter=\"s/\\t.*//\"},\n    \"cliphist decode '[entry]' | wl-copy\"\n]\n\n\"Copy password\" = [\n    {name=\"what\", command=\"gopass ls --flat\"},\n    \"gopass show -c [what]\"\n]\n\n\"Update/Change password\" = [\n    {name=\"what\", command=\"gopass ls --flat\"},\n    \"kitty -- gopass generate -s --strict -t '[what]' && gopass show -c '[what]'\"\n]\n```\n\n</details>\n\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n> [!tip]\n> - If \"name\" is provided it will show the given sub-menu.\n> - You can use \".\" to reach any level of the configured menus.\n>\n>      Example to reach `shortcuts_menu.entries.utils.\"local commands\"`, use:\n>      ```sh\n>       pypr menu \"utils.local commands\"\n>      ```\n\n## Configuration\n\nAll the [Menu](./Menu) configuration items are also available.\n\n### `entries` (REQUIRED)\n\nDefines the menu entries. Supports [Variables](./Variables)\n\n```toml\n[shortcuts_menu.entries]\n\"entry 1\" = \"command to run\"\n\"entry 2\" = \"command to run\"\n```\nSubmenus can be defined too (there is no depth limit):\n\n```toml\n[shortcuts_menu.entries.\"My submenu\"]\n\"entry X\" = \"command\"\n\n[shortcuts_menu.entries.one.two.three.four.five]\nfoobar = \"ls\"\n```\n\n#### Advanced usage\n\nInstead of navigating a configured list of menu options and running a pre-defined command, you can collect various *variables* (either static list of options selected by the user, or generated from a shell command) and then run a command using those variables. Eg:\n\n```toml\n\"Play Video\" = [\n    {name=\"video_device\", command=\"ls -1 /dev/video*\"},\n    {name=\"player\",\n        options=[\"mpv\", \"guvcview\"]\n    },\n    \"[player] [video_device]\"\n]\n\n\"Ssh\" = [\n    {name=\"action\", options=[\"htop\", \"uptime\", \"sudo halt -p\"]},\n    {name=\"host\", options=[\"gamix\", \"gate\", \"idp\"]},\n    \"kitty --hold ssh [host] [action]\"\n]\n```\n\nYou must define a list of objects, containing:\n- `name`: the variable name\n- then the list of options, must be one of:\n    - `options` for a static list of options\n    - `command` to get the list of options from a shell command's output\n\n> [!tip]\n> You can apply post-filters to the `command` output, eg:\n> ```toml\n> {\n>   name = \"entry\",\n>   command = \"cliphist list\",\n>   filter = \"s/\\t.*//\"  # will remove everything after the TAB character\n> }\n> ```\n> check the [filters](./filters) page for more details\n\nThe last item of the list must be a string which is the command to run. Variables can be used enclosed in `[]`.\n\n### `command_start`\n### `command_end`\n### `submenu_start`\n### `submenu_end`\n\nAllow adding some text (eg: icon) before / after a menu entry.\n\ncommand_* is for final commands, while submenu_* is for entries leading to another menu.\n\nBy default `submenu_end` is set to a right arrow sign, while other attributes are not set.\n\n### `skip_single`\n\nDefaults to `true`.\nWhen disabled, shows the menu even for single options\n\n## Hints\n\n### Multiple menus\n\nTo manage multiple distinct menus, always use a name when using the `pypr menu <name>` command.\n\nExample of a multi-menu configuration:\n\n```toml\n[shortcuts_menu.entries.\"Basic commands\"]\n\"entry X\" = \"command\"\n\"entry Y\" = \"command2\"\n\n[shortcuts_menu.entries.menu2]\n## ...\n```\n\nYou can then show the first menu using `pypr menu \"Basic commands\"`\n"
  },
  {
    "path": "site/versions/2.4.7/sidebar.json",
    "content": "{\n  \"main\": [\n    {\n      \"text\": \"Getting started\",\n      \"link\": \"./Getting-started\"\n    },\n    {\n      \"text\": \"Plugins\",\n      \"link\": \"./Plugins\"\n    },\n    {\n      \"text\": \"Troubleshooting\",\n      \"link\": \"./Troubleshooting\"\n    },\n    {\n      \"text\": \"Development\",\n      \"link\": \"./Development\"\n    }\n  ],\n  \"plugins\": {\n    \"text\": \"Featured plugins\",\n    \"collapsed\": false,\n    \"items\": [\n      {\n        \"text\": \"Expose\",\n        \"link\": \"./expose\"\n      },\n      {\n        \"text\": \"Fcitx5 Switcher\",\n        \"link\": \"./fcitx5_switcher\"\n      },\n      {\n        \"text\": \"Fetch client menu\",\n        \"link\": \"./fetch_client_menu\"\n      },\n      {\n        \"text\": \"Menubar\",\n        \"link\": \"./bar\"\n      },\n      {\n        \"text\": \"Layout center\",\n        \"link\": \"./layout_center\"\n      },\n      {\n        \"text\": \"Lost windows\",\n        \"link\": \"./lost_windows\"\n      },\n      {\n        \"text\": \"Magnify\",\n        \"link\": \"./magnify\"\n      },\n      {\n        \"text\": \"Monitors\",\n        \"link\": \"./monitors\"\n      },\n      {\n        \"text\": \"Scratchpads\",\n        \"link\": \"./scratchpads\",\n        \"items\": [\n          {\n            \"text\": \"Advanced\",\n            \"link\": \"./scratchpads_advanced\"\n          },\n          {\n            \"text\": \"Special cases\",\n            \"link\": \"./scratchpads_nonstandard\"\n          }\n        ]\n      },\n      {\n        \"text\": \"Shift monitors\",\n        \"link\": \"./shift_monitors\"\n      },\n      {\n        \"text\": \"Shortcuts menu\",\n        \"link\": \"./shortcuts_menu\"\n      },\n      {\n        \"text\": \"System notifier\",\n        \"link\": \"./system_notifier\"\n      },\n      {\n        \"text\": \"Toggle dpms\",\n        \"link\": \"./toggle_dpms\"\n      },\n      {\n        \"text\": \"Toggle special\",\n        \"link\": \"./toggle_special\"\n      },\n      {\n        \"text\": \"Wallpapers\",\n        \"link\": \"./wallpapers\"\n      },\n      {\n        \"text\": \"Workspaces follow focus\",\n        \"link\": \"./workspaces_follow_focus\"\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "site/versions/2.4.7/system_notifier.md",
    "content": "# system_notifier\n\nThis plugin adds system notifications based on journal logs (or any program's output).\nIt monitors specified **sources** for log entries matching predefined **patterns** and generates notifications accordingly (after applying an optional **filter**).\n\nSources are commands that return a stream of text (eg: journal, mqtt, tail -f, ...) which is sent to a parser that will use a [regular expression pattern](https://en.wikipedia.org/wiki/Regular_expression) to detect lines of interest and optionally transform them before sending the notification.\n\n<details>\n    <summary>Minimal configuration</summary>\n\n```toml\n[system_notifier.sources]\ncommand = \"sudo journalctl -fx\"\nparser = \"journal\"\n```\n\nIn general you will also need to define some **parsers**.\nBy default a **\"journal\"** parser is provided, otherwise you need to define your own rules.\nThis built-in configuration is close to this one, provided as an example:\n\n```toml\n[system_notifier.parsers.journal]\npattern = \"([a-z0-9]+): Link UP$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is active/\"\ncolor= \"#00aa00\"\n\n[system_notifier.parsers.journal]\npattern = \"([a-z0-9]+): Link DOWN$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is inactive/\"\ncolor= \"#ff8800\"\n\n[system_notifier.parsers.journal]\npattern = \"Process \\d+ \\(.*\\) of .* dumped core.\"\nfilter = \"s/.*Process \\d+ \\((.*)\\) of .* dumped core./\\1 dumped core/\"\ncolor= \"#aa0000\"\n\n[system_notifier.parsers.journal]\npattern = \"usb \\d+-[0-9.]+: Product: \"\nfilter = \"s/.*usb \\d+-[0-9.]+: Product: (.*)/USB plugged: \\1/\"\n```\n</details>\n\n\n## Configuration\n\n### `sources` (recommended)\n\nList of sources to enable (by default nothing is enabled)\n\nEach source must contain a `command` to run and a `parser` to use.\n\nYou can also use a list of parsers, eg:\n\n```toml\n[system_notifier.sources](system_notifier.sources)\ncommand = \"sudo journalctl -fkn\"\nparser = [\"journal\", \"custom_parser\"]\n```\n\n#### command (recommended)\n\nThis is the long-running command (eg: `tail -f <filename>`) returning the stream of text that will be updated. Aa common option is the system journal output (eg: `journalctl -u nginx`)\n\n#### parser\n\nSets the list of rules / parser to be used to extract lines of interest.\nMust match a list of rules defined as `system_notifier.parsers.<parser_name>`.\n\n### `parsers` (recommended)\n\nA list of available parsers that can be used to detect lines of interest in the **sources** and re-format it before issuing a notification.\n\nEach parser definition must contain a **pattern** and optionally a **filter** and a **color**.\n\n#### pattern\n\n```toml\n[system_notifier.parsers.custom_parser](system_notifier.parsers.custom_parser)\n\npattern = 'special value:'\n```\n\nThe pattern is any regular expression.\n\n#### filter\n\nThe [filters](./filters) allows to change the text before the notification, eg:\n`filter=\"s/.*special value: (\\d+)/Value=\\1/\"`\nwill set a filter so a string \"special value: 42\" will lead to the notification \"Value=42\"\n\n#### color\n\nYou can also provide an optional **color** in `\"hex\"` or `\"rgb()\"` format\n\n```toml\ncolor = \"#FF5500\"\n```\n\n### default_color\n\nSets the notification color that will be used when none is provided in a *parser* definition.\n\n```toml\n[system_notifier]\ndefault_color = \"#bbccbb\"\n```\n"
  },
  {
    "path": "site/versions/2.4.7/toggle_dpms.md",
    "content": "---\ncommands:\n    - name: toggle_dpms\n      description: if any screen is powered on, turn them all off, else turn them all on\n---\n\n# toggle_dpms\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n"
  },
  {
    "path": "site/versions/2.4.7/toggle_special.md",
    "content": "---\ncommands:\n    - name: toggle_special [name]\n      description: moves the focused window to the special workspace <code>name</code>, or move it back to the active workspace\n---\n\n# toggle_special\n\nAllows moving the focused window to a special workspace and back (based on the visibility status of that workspace).\n\nIt's a companion of the `togglespecialworkspace` Hyprland's command which toggles a special workspace's visibility.\n\nYou most likely will need to configure the two commands for a complete user experience, eg:\n\n```bash\nbind = $mainMod SHIFT, N, togglespecialworkspace, stash # toggles \"stash\" special workspace visibility\nbind = $mainMod, N, exec, pypr toggle_special stash # moves window to/from the \"stash\" workspace\n```\n\nNo other configuration needed, here `MOD+SHIFT+N` will show every window in \"stash\" while `MOD+N` will move the focused window out of it/ to it.\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n"
  },
  {
    "path": "site/versions/2.4.7/wallpapers.md",
    "content": "---\ncommands:\n    - name: wall next\n      description: Changes the current background image\n    - name: wall clear\n      description: Removes the current background image\n---\n\n# wallpapers\n\nSearch folders for images and sets the background image at a regular interval.\nImages are selected randomly from the full list of images found.\n\nIt serves two purposes:\n\n- adding support for random images to any background setting tool\n- quickly testing different tools with a minimal effort\n\nIt allows \"zapping\" current backgrounds, clearing it to go distraction free and optionally make them different for each screen.\n\n> [!tip]\n> Uses **swaybg** by default, but can be configured to use any other application.\n\n<details>\n    <summary>Minimal example using defaults(requires <b>swaybg</b>)</summary>\n\n```toml\n[wallpapers]\npath = \"~/Images/wallpapers/\" # path to the folder with background images\n```\n\n</details>\n\n<details>\n<summary>More complete, using the custom <b>swww</b> command (not recommended because of its stability)</summary>\n\n```toml\n[wallpapers]\nunique = true # set a different wallpaper for each screen\npath = \"~/Images/wallpapers/\"\ninterval = 60 # change every hour\nextensions = [\"jpg\", \"jpeg\"]\nrecurse = true\n## Using swww\ncommand = 'swww img --transition-type any \"[file]\"'\nclear_command = \"swww clear\"\n```\n\nNote that for applications like `swww`, you'll need to start a daemon separately (eg: from `hyprland.conf`).\n</details>\n\n\n## Commands\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n\n### `path` (REQUIRED)\n\npath to a folder or list of folders that will be searched. Can also be a list, eg:\n\n```toml\npath = [\"~/Images/Portraits/\", \"~/Images/Landscapes/\"]\n```\n\n### `interval`\n\ndefaults to `10`\n\nHow long (in minutes) a background should stay in place\n\n\n### `command`\n\nOverrides the default command to set the background image.\n\n[variables](./Variables) are replaced with the appropriate values, you must use a `\"[file]\"` placeholder for the image path. eg:\n\n```\nswaybg -m fill -i \"[file]\"\n```\n\n### `clear_command`\n\nBy default `clear` command kills the `command` program.\n\nInstead of that, you can provide a command to clear the background. eg:\n\n```\nclear_command = \"swaybg clear\"\n``````\n\n### `extensions`\n\ndefaults to `[\"png\", \"jpg\", \"jpeg\"]`\n\nList of valid wallpaper image extensions.\n\n### `recurse`\n\ndefaults to `false`\n\nWhen enabled, will also search sub-directories recursively.\n\n### `unique`\n\ndefaults to `false`\n\nWhen enabled, will set a different wallpaper for each screen.\n\nIf you are not using the default application, ensure you are using `\"[output]\"` in the [command](#command) template.\n\nExample for swaybg: `swaybg -o \"[output]\" -m fill -i \"[file]\"`\n"
  },
  {
    "path": "site/versions/2.4.7/workspaces_follow_focus.md",
    "content": "---\ncommands:\n    - name: change_workspace [direction]\n      description: Changes the workspace of the focused monitor in the given direction.\n---\n\n# workspaces_follow_focus\n\nMake non-visible workspaces follow the focused monitor.\nAlso provides commands to switch between workspaces while preserving the current monitor assignments:\n\nSyntax:\n```toml\n[workspaces_follow_focus]\nmax_workspaces = 4 # number of workspaces before cycling\n```\nExample usage in `hyprland.conf`:\n\n```ini\nbind = $mainMod, K, exec, pypr change_workspace +1\nbind = $mainMod, J, exec, pypr change_workspace -1\n ```\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `max_workspaces`\n\nLimits the number of workspaces when switching, defaults value is `10`.\n"
  },
  {
    "path": "site/versions/2.5.x/Development.md",
    "content": "# Development\n\nIt's easy to write your own plugin by making a python package and then indicating it's name as the plugin name.\n\n[Contributing guidelines](https://github.com/hyprland-community/pyprland/blob/main/CONTRIBUTING.md)\n\n# Writing plugins\n\nPlugins can be loaded with full python module path, eg: `\"mymodule.pyprlandplugin\"`, the loaded module must provide an `Extension` class.\n\nCheck the `interface.py` file to know the base methods, also have a look at the example below.\n\nTo get more details when an error is occurring, use `pypr --debug <log file path>`, it will also display the log in the console.\n\n> [!note]\n> To quickly get started, you can directly edit the `experimental` built-in plugin.\n> In order to distribute it, make your own Python package or trigger a pull request.\n> If you prefer to make a separate package, check the [examples](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/)'s package\n\nThe `Extension` interface provides a couple of built-in attributes:\n\n- `config` : object exposing the plugin section in `pyprland.toml`\n- `notify` ,`notify_error`, `notify_info` : access to Hyprland's notification system\n- `hyprctl`, `hyprctl_json` : invoke [Hyprland's IPC system](https://wiki.hyprland.org/Configuring/Dispatchers/)\n\n\n> [!important]\n> Contact me to get your extension listed on the home page\n\n> [!tip]\n> You can set a `plugins_paths=[\"/custom/path/example\"]` in the `hyprland` section of the configuration to add extra paths (eg: during development).\n\n> [!Note]\n> If your extension is at the root of the plugin (this is not recommended, preferable add a name space, as in `johns_pyprland.super_feature`, rather than `super_feature`) you can still import it using the `external:` prefix when you refer to it in the `plugins` list.\n\n# API Documentation\n\nRun `tox run -e doc` then visit `http://localhost:8080`\n\nThe most important to know are:\n\n- `hyprctl_json` to get a response from an IPC query\n- `hyprctl` to trigger general IPC commands\n- `on_reload` to be implemented, called when the config is (re)loaded\n- `run_<command_name>` to implement a command\n- `event_<event_name>` called when the given event is emitted by Hyprland\n\nAll those methods are _async_\n\nOn top of that:\n\n- the first line of a `run_*` command's docstring will be used by the `help` command\n- `self.config` in your _Extension_ contains the entry corresponding to your plugin name in the TOML file\n- `state` from `..common` module contains ready to use information\n- there is a `MenuMixin` in `..adapters.menus` to make menu-based plugins easy\n\n# Workflow\n\nJust `^C` when you make a change and repeat:\n\n```sh\npypr exit ; pypr --debug /tmp/output.log\n```\n\n\n## Creating a plugin\n\n```python\nfrom .interface import Plugin\n\n\nclass Extension(Plugin):\n    \" My plugin \"\n\n    async def init(self):\n        await self.notify(\"My plugin loaded\")\n```\n\n## Adding a command\n\nJust add a method called `run_<name of your command>` to your `Extension` class, eg with \"togglezoom\" command:\n\n```python\n    zoomed = False\n\n    async def run_togglezoom(self, args):\n        \"\"\" this doc string will show in `help` to document `togglezoom`\n        But this line will not show in the CLI help\n        \"\"\"\n      if self.zoomed:\n        await self.hyprctl('misc:cursor_zoom_factor 1', 'keyword')\n      else:\n        await self.hyprctl('misc:cursor_zoom_factor 2', 'keyword')\n      self.zoomed = not self.zoomed\n```\n\n## Reacting to an event\n\nSimilar as a command, implement some `async def event_<the event you are interested in>` method.\n\n## Code safety\n\nPypr ensures only one `run_` or `event_` handler runs at a time, allowing the plugins code to stay simple and avoid the need for concurrency handling.\nHowever, each plugin can run its handlers in parallel.\n\n# Reusable code\n\n```py\nfrom ..common import state, CastBoolMixin\n```\n\n- `state` provides a couple of handy variables so you don't have to fetch them, allow optimizing the most common operations\n- `Mixins` are providing common code, for instance the `CastBoolMixin` provides the `cast_bool` method to your `Extension`.\n\nIf you want to use menus, then the `MenuMixin` will provide:\n- `menu` to show a menu\n- `ensure_menu_configured` to call before you require a menu in your plugin\n\n# Example\n\nYou'll find a basic external plugin in the [examples](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/) folder.\n\nIt provides one command: `pypr dummy`.\n\nRead the [plugin code](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/pypr_examples/focus_counter.py)\n\nIt's a simple python package. To install it for development without a need to re-install it for testing, you can use `pip install -e .` in this folder.\nIt's ready to be published using `poetry publish`, don't forget to update the details in the `pyproject.toml` file.\n\n## Usage\n\nEnsure you added `pypr_examples.focus_counter` to your `plugins` list:\n\n```toml\n[pyprland]\nplugins = [\n  \"pypr_examples.focus_counter\"\n]\n```\n\nOptionally you can customize one color:\n\n```toml\n[\"pypr_examples.focus_counter\"]\ncolor = \"FFFF00\"\n```\n"
  },
  {
    "path": "site/versions/2.5.x/Getting-started.md",
    "content": "# Getting started\n\nPypr consists in two things:\n\n- **a tool**: `pypr` which runs the daemon (service), but also allows to interact with it\n  - `pypr-client` is a more limited, faster version, meant to be used in your `hyprland.conf` keyboard bindings **only**\n- **some config file**: `~/.config/hypr/pyprland.toml` (or the path set using `--config`) using the [TOML](https://toml.io/en/) format\n\nThe `pypr` tool only have a few built-in commands:\n\n- `help` lists available commands (including plugins commands)\n- `exit` will terminate the service process\n- `edit` edit the configuration using your `$EDITOR` (or `vi`), reloads on exit\n- `dumpjson` shows a JSON representation of the configuration (after other files have been `include`d)\n- `reload` reads the configuration file and apply some changes:\n  - new plugins will be loaded\n  - configuration items will be updated (most plugins will use the new values on the next usage)\n\nOther commands are implemented by adding [plugins](./Plugins).\n\n> [!important]\n> - with no argument it runs the daemon (doesn't fork in the background)\n>\n> - if you pass parameters, it will interact with the daemon instead.\n\n> [!tip]\n> Pypr *command* names are documented using underscores (`_`) but you can use dashes (`-`) instead.\n> Eg: `pypr shift_monitors` and `pypr shift-monitors` will run the same command\n\n\n## Configuration file\n\nThe configuration file uses a [TOML format](https://toml.io/) with the following as the bare minimum:\n\n```toml\n[pyprland]\nplugins = [\"plugin_name\", \"other_plugin\"]\n```\n\nAdditionally some plugins require **Configuration** options, using the following format:\n\n```toml\n[plugin_name]\nplugin_option = 42\n\n[plugin_name.another_plugin_option]\nsuboption = \"config value\"\n```\n\nYou can also split your configuration into [Multiple configuration files](./MultipleConfigurationFiles).\n\n## Installation\n\nCheck your OS package manager first, eg:\n\n- Archlinux: you can find it on AUR, eg with [yay](https://github.com/Jguer/yay): `yay pyprland`\n- NixOS: Instructions in the [Nix](./Nix) page\n\nOtherwise, use the python package manager (pip) [inside a virtual environment](InstallVirtualEnvironment)\n\n```sh\npip install pyprland\n```\n\n## Running\n\n> [!caution]\n> If you messed with something else than your OS packaging system to get `pypr` installed, use the full path to the `pypr` command.\n\nPreferably start the process with hyprland, adding to `hyprland.conf`:\n\n```ini\nexec-once = /usr/bin/pypr\n```\n\nor if you run into troubles (use the first version once your configuration is stable):\n\n```ini\nexec-once = /usr/bin/pypr --debug /tmp/pypr.log\n```\n\n> [!warning]\n> To avoid issues (eg: you have a complex setup, maybe using a virtual environment), you may want to set the full path (eg: `/home/bob/venv/bin/pypr`).\n> You can get it from `which pypr` in a working terminal\n\nOnce the `pypr` daemon is started (cf `exec-once`), you can list the eventual commands which have been added by the plugins using `pypr -h` or `pypr help`, those commands are generally meant to be use via key bindings, see the `hyprland.conf` part of *Configuring* section below.\n\n## Configuring\n\nCreate a configuration file in `~/.config/hypr/pyprland.toml` enabling a list of plugins, each plugin may have its own configuration needs or don't need any configuration at all.\nMost default values should be acceptable for most users, options which hare not mandatory are marked as such.\n\n> [!important]\n> Provide the values for the configuration options which have no annotation such as \"(optional)\"\n\nCheck the [TOML format](https://toml.io/) for details about the syntax.\n\nSimple example:\n\n```toml\n[pyprland]\nplugins = [\n    \"shift_monitors\",\n    \"workspaces_follow_focus\"\n]\n```\n\n<details>\n  <summary>\nMore complex example\n  </summary>\n\n```toml\n[pyprland]\nplugins = [\n  \"scratchpads\",\n  \"lost_windows\",\n  \"monitors\",\n  \"toggle_dpms\",\n  \"magnify\",\n  \"expose\",\n  \"shift_monitors\",\n  \"workspaces_follow_focus\",\n]\n\n[monitors.placement]\n\"Acer\".top_center_of = \"Sony\"\n\n[workspaces_follow_focus]\nmax_workspaces = 9\n\n[expose]\ninclude_special = false\n\n[scratchpads.stb]\nanimation = \"fromBottom\"\ncommand = \"kitty --class kitty-stb sstb\"\nclass = \"kitty-stb\"\nlazy = true\nsize = \"75% 45%\"\n\n[scratchpads.stb-logs]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-stb-logs stbLog\"\nclass = \"kitty-stb-logs\"\nlazy = true\nsize = \"75% 40%\"\n\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nlazy = true\nsize = \"40% 90%\"\nunfocus = \"hide\"\n```\n\nSome of those plugins may require changes in your `hyprland.conf` to fully operate or to provide a convenient access to a command, eg:\n\n```bash\n\n$pypr = uwsm-app -- /usr/local/bin/pypr-client\n\nbind = $mainMod SHIFT,  Z, exec, $pypr zoom\nbind = $mainMod ALT,    P, exec, $pypr toggle_dpms\nbind = $mainMod SHIFT,  O, exec, $pypr shift_monitors +1\nbind = $mainMod,        B, exec, $pypr expose\nbind = $mainMod,        K, exec, $pypr change_workspace +1\nbind = $mainMod,        J, exec, $pypr change_workspace -1\nbind = $mainMod,        L, exec, $pypr toggle_dpms\nbind = $mainMod  SHIFT, M, exec, $pypr toggle stb stb-logs\nbind = $mainMod,        A, exec, $pypr toggle term\nbind = $mainMod,        V, exec, $pypr toggle volume\n```\n\nThis example makes use of the faster `pypr-client` which may not be available depending on how you installed,\nin case you want to install it manually, just download [the source code](https://github.com/hyprland-community/pyprland/blob/main/client/) and compile it (eg: `gcc -o pypr-client pypr-client.c`).\nRust, Go and C versions are available.\n\n\n</details>\n\n> [!tip]\n> Consult or share [configuration files](https://github.com/hyprland-community/pyprland/tree/main/examples)\n>\n> You might also be interested in [optimizations](./Optimizations).\n"
  },
  {
    "path": "site/versions/2.5.x/InstallVirtualEnvironment.md",
    "content": "# Virtual env\n\nEven though the best way to get Pyprland installed is to use your operating system package manager,\nfor some usages or users it can be convenient to use a virtual environment.\n\nThis is very easy to achieve in a couple of steps:\n\n```shell\npython -m venv ~/pypr-env\n~/pypr-env/bin/pip install pyprland\n```\n\n**That's all folks!**\n\nThe only extra care to take is to use `pypr` from the virtual environment, eg:\n\n- adding the environment's \"bin\" folder to the `PATH` (using `export PATH=\"$PATH:~/pypr-env/bin/\"` in your shell configuration file)\n- always using the full path to the pypr command (in `hyprland.conf`: `exec-once = ~/pypr-env/bin/pypr --debug /tmp/pypr.log`)\n\n# Going bleeding edge!\n\nIf you would rather like to use the latest version available (not released yet), then you can clone the git repository and install from it:\n\n```shell\ncd ~/pypr-env\ngit clone git@github.com:hyprland-community/pyprland.git pyprland-sources\ncd pyprland-sources\n../bin/pip install -e .\n```\n\n## Updating\n\n```shell\ncd ~/pypr-env\ngit pull -r\n```\n\n# Troubelshooting\n\nIf things go wrong, try (eg: after a system upgrade where Python got updated):\n\n```shell\npython -m venv --upgrade ~/pypr-env\ncd  ~/pypr-env\n../bin/pip install -e .\n```\n"
  },
  {
    "path": "site/versions/2.5.x/Menu.md",
    "content": "# Menu capability\n\nMenu based plugins have the following configuration options:\n\n### `engine`\n\nNot set by default, will autodetect the available menu engine.\n\nSupported engines:\n\n- tofi\n- rofi\n- wofi\n- bemenu\n- dmenu\n- anyrun\n\n> [!note]\n> If your menu system isn't supported, you can open a [feature request](https://github.com/hyprland-community/pyprland/issues/new?assignees=fdev31&labels=bug&projects=&template=feature_request.md&title=%5BFEAT%5D+Description+of+the+feature)\n>\n> In case the engine isn't recognized, `engine` + `parameters` configuration options will be used to start the process, it requires a dmenu-like behavior.\n\n### `parameters`\n\nExtra parameters added to the engine command, the default value is specific to each engine.\n\n> [!important]\n> Setting this will override the default value!\n>\n> In general, *rofi*-like programs will require at least `-dmenu` option.\n\n> [!tip]\n> You can use '[prompt]' in the parameters, it will be replaced by the prompt, eg:\n> ```sh\n> -dmenu -matching fuzzy -i -p '[prompt]'\n> ```\n"
  },
  {
    "path": "site/versions/2.5.x/MultipleConfigurationFiles.md",
    "content": "### Multiple configuration files\n\nYou can also split your configuration into multiple files that will be loaded in the provided order after the main file:\n```toml\n[pyprland]\ninclude = [\"/shared/pyprland.toml\", \"~/pypr_extra_config.toml\"]\n```\nYou can also load folders, in which case TOML files in the folder will be loaded in alphabetical order:\n```toml\n[pyprland]\ninclude = [\"~/.config/pypr.d/\"]\n```\n\nAnd then add a `~/.config/pypr.d/monitors.toml` file:\n```toml\npyprland.plugins = [ \"monitors\" ]\n\n[monitors.placement]\nBenQ.Top_Center_Of = \"DP-1\" # projo\n\"CJFH277Q3HCB\".top_of = \"eDP-1\" # work\n```\n\n> [!tip]\n> To check the final merged configuration, you can use the `dumpjson` command.\n\n"
  },
  {
    "path": "site/versions/2.5.x/Nix.md",
    "content": "# Nix\n\nYou are recommended to get the latest pyprland package from the [flake.nix](https://github.com/hyprland-community/pyprland/blob/main/flake.nix)\nprovided within this repository. To use it in your system, you may add pyprland\nto your flake inputs.\n\n## Flake\n\n```nix\n## flake.nix\n{\n  inputs = {\n    nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n    pyprland.url = \"github:hyprland-community/pyprland\";\n  };\n\n  outputs = { self, nixpkgs, pyprland, ...}: let\n  in {\n    nixosConfigurations.<yourHostname> = nixpkgs.lib.nixosSystem {\n      #         remember to replace this with your system arch ↓\n      environment.systemPackages = [ pyprland.packages.\"x86_64-linux\".pyprland ];\n      # ...\n    };\n  };\n}\n```\n\nAlternatively, if you are using the Nix package manager but not NixOS as your\nmain distribution, you may use `nix profile` tool to install pyprland from this\nrepository using the following command.\n\n```bash\nnix profile install github:nix-community/pyprland\n```\n\nThe package will now be in your latest profile. You may use `nix profile list`\nto verify your installation.\n\n## Nixpkgs\n\nPyprland is available under nixpkgs, and can be installed by adding\n`pkgs.pyprland` to either `environment.systemPackages` or `home.packages`\ndepending on whether you want it available system-wide or to only a single\nuser using home-manager. If the derivation available in nixpkgs is out-of-date\nthen you may consider using `overrideAttrs` to update the source locally.\n\n```nix\nlet\n  pyprland = pkgs.pyprland.overrideAttrs {\n    version = \"your-version-here\";\n    src = fetchFromGitHub {\n      owner = \"hyprland-community\";\n      repo = \"pyprland\";\n      rev = \"tag-or-revision\";\n      # leave empty for the first time, add the new hash from the error message\n      hash = \"\";\n    };\n  };\nin {\n  # add the overridden package to systemPackages\n  environment.systemPackages = [pyprland];\n}\n```\n"
  },
  {
    "path": "site/versions/2.5.x/Optimizations.md",
    "content": "## Optimizing\n\n### Plugins\n\n- Only enable the plugins you are using in the `plugins` array (in `[pyprland]` section).\n- Leaving the configuration for plugins which are not enabled will have no impact.\n- Using multiple configuration files only have a small impact on the startup time.\n\n### Pypr\n\nYou can run `pypr` using `pypy` (version 3) for a more snappy experience.\nOne way is to use a `pypy3` virtual environment:\n\n```bash\npypy3 -m venv pypr-venv\nsource ./pypr-venv/bin/activate\ncd <pypr source folder>\npip install -e .\n```\n\n### Pypr command\n\nIn case you want to save some time when interacting with the daemon, the simplest is to use one of the `pypr-client` implementations as mentioned in the [getting started page](./Getting-started.md). If for some reason `pypr-client` isn't made available by the package of your OS and can not compile code,\nyou can use `socat` instead (needs to be installed).\n\nExample of a `pypr-cli` command (should be reachable from your environment's `PATH`):\n```sh\n#!/bin/sh\nsocat - \"UNIX-CONNECT:${XDG_RUNTIME_DIR}/hypr/${HYPRLAND_INSTANCE_SIGNATURE}/.pyprland.sock\" <<< $@\n```\nOn slow systems this may make a difference.\nNote that the \"help\" command will require usage of the standard `pypr` command.\n"
  },
  {
    "path": "site/versions/2.5.x/Plugins.md",
    "content": "<script setup>\nimport PluginList from './components/PluginList.vue'\n</script>\n\nThis page lists every plugin provided by Pyprland out of the box, more can be enabled if you install the matching package.\n\n\"🌟\" indicates some maturity & reliability level of the plugin, considering age, attention paid and complexity - from 0 to 3.\n\n<PluginList/>\n"
  },
  {
    "path": "site/versions/2.5.x/Troubleshooting.md",
    "content": "# Troubleshooting\n\n## General\n\nIn case of trouble running a `pypr` command:\n- kill the existing pypr if any (try `pypr exit` first)\n- run from the terminal adding `--debug /dev/null` to the arguments to get more information\n\nIf the client says it can't connect, then there is a high chance pypr daemon didn't start, check if it's running using `ps axuw |grep pypr`. You can try to run it from a terminal with the same technique: `pypr --debug /dev/null` and see if any error occurs.\n\n## Force hyprland version\n\nIn case your `hyprctl version -j` command isn't returning an accurate version, you can make Pyprland ignore it and use a provided value instead:\n\n```toml\n[pyprland]\nhyprland_version = \"0.41.0\"\n```\n\n## Unresponsive scratchpads\n\nScratchpads aren't responding for few seconds after trying to show one (which didn't show!)\n\nThis may happen if an application is very slow to start.\nIn that case pypr will wait for a window blocking other scratchpad's operation, before giving up after a few seconds.\n\nNote that other plugins shouldn't be blocked by it.\n\nMore scratchpads troubleshooting can be found [here](./scratchpads_nonstandard).\n"
  },
  {
    "path": "site/versions/2.5.x/Variables.md",
    "content": "# Variables & substitutions\n\nSome commands support shared global variables, they must be defined in the *pyprland* section of the configuration:\n```toml\n[pyprland.variables]\nterm = \"foot\"\nterm_classed = \"foot -a\" # kitty uses --class\n```\n\nIf a plugin supports it, you can then use the variables in the attribute that supports it, eg:\n\n```toml\n[myplugin]\nsome_variable = \"the terminal is [term]\"\n```\n"
  },
  {
    "path": "site/versions/2.5.x/bar.md",
    "content": "---\ncommands:\n  - name: bar restart\n    description: Restart/refresh Menu Bar on the \"best\" monitor.\n---\n\n# menubar\n\nRuns your favorite bar app (gbar, ags / hyprpanel, waybar, ...) with option to pass the \"best\" monitor from a list of monitors.\n\n- Will take care of starting the command on startup (you must not run it from another source like `hyprland.conf`).\n- Automatically restarts the menu bar on crash\n- Checks which monitors are on and take the best one from a provided list\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n\n## Configuration\n\n### `command` (REQUIRED)\n\nThe command which runs the menu bar. The string `[monitor]` will be replaced by the best monitor.\n\n### `monitors`\n\nList of monitors to chose from, the first have higher priority over the second one etc...\n\n\n## Example\n\n```sh\n[gbar]\nmonitors = [\"DP-1\", \"HDMI-1\", \"HDMI-1-A\"]\n```\n"
  },
  {
    "path": "site/versions/2.5.x/components/CommandList.vue",
    "content": "<template>\n    <dl>\n    </dl>\n    <ul v-for=\"command in commands\" :key=\"command.name\">\n        <li><code v-html=\"command.name.replace(/[   ]*$/, '').replace(/ /g, '&ensp;')\" /> <span\n                v-html=\"command.description\" /></li>\n    </ul>\n</template>\n\n<script>\nexport default {\n    props: {\n        commands: {\n            type: Array,\n            required: true\n        }\n    }\n}\n</script>\n"
  },
  {
    "path": "site/versions/2.5.x/components/PluginList.vue",
    "content": "<template>\n    <div>\n        <h1>Built-in plugins</h1>\n        <div v-for=\"plugin in sortedPlugins\" :key=\"plugin.name\" class=\"plugin-item\">\n            <div class=\"plugin-info\">\n                <h3>\n                    <a :href=\"plugin.name + '.html'\">{{ plugin.name }}</a>\n                    <span>&nbsp;{{ '🌟'.repeat(plugin.stars) }}</span>\n                    <span v-if=\"plugin.multimon\">\n                        <Badge type=\"tip\" text=\"multi-monitor\" />\n                    </span>\n                </h3>\n                <p v-html=\"plugin.description\" />\n            </div>\n            <a v-if=\"plugin.demoVideoId\" :href=\"'https://www.youtube.com/watch?v=' + plugin.demoVideoId\"\n                class=\"plugin-video\">\n                <img :src=\"'https://img.youtube.com/vi/' + plugin.demoVideoId + '/1.jpg'\" alt=\"Demo video thumbnail\" />\n            </a>\n        </div>\n    </div>\n</template>\n\n<style scoped>\n.plugin-item {\n    display: flex;\n    align-items: flex-start;\n    margin-bottom: 1.5rem;\n}\n\n.plugin-info {\n    flex: 1;\n}\n\n.plugin-video {\n    margin-left: 1rem;\n}\n\n.plugin-video img {\n    max-width: 200px;\n    /* Adjust as needed */\n    height: auto;\n}\n</style>\n\n\n<script>\n\nexport default {\n    computed: {\n        sortedPlugins() {\n            if (this.sortByStars) {\n                return this.plugins.slice().sort((a, b) => {\n                    if (b.stars === a.stars) {\n                        return a.name.localeCompare(b.name);\n                    }\n                    return b.stars - a.stars;\n                });\n            } else {\n                return this.plugins.slice().sort((a, b) => a.name.localeCompare(b.name));\n            }\n        }\n    },\n    data() {\n        return {\n            sortByStars: false,\n            plugins: [\n                {\n                    name: 'scratchpads',\n                    stars: 3,\n                    description: 'makes your applications dropdowns & togglable poppups',\n                    demoVideoId: 'ZOhv59VYqkc'\n                },\n                {\n                    name: 'magnify',\n                    stars: 3,\n                    description: 'toggles zooming of viewport or sets a specific scaling factor',\n                    demoVideoId: 'yN-mhh9aDuo'\n\n                },\n                {\n                    name: 'toggle_special',\n                    stars: 3,\n                    description: 'moves windows from/to special workspaces',\n                    demoVideoId: 'BNZCMqkwTOo'\n                },\n                {\n                    name: 'shortcuts_menu',\n                    stars: 3,\n                    description: 'a flexible way to make your own shortcuts menus & launchers',\n                    demoVideoId: 'UCuS417BZK8'\n                },\n                {\n                    name: 'fetch_client_menu',\n                    stars: 3,\n                    description: 'select a window to be moved to your active workspace (using rofi, dmenu, etc...)',\n                },\n                {\n                    name: 'layout_center',\n                    stars: 3,\n                    description: 'a workspace layout where one client window is almost maximized and others seat in the background',\n                    demoVideoId: 'vEr9eeSJYDc'\n                },\n                {\n                    name: 'system_notifier',\n                    stars: 3,\n                    description: 'open streams (eg: journal logs) and trigger notifications',\n                },\n                {\n                    name: 'wallpapers',\n                    stars: 3,\n                    description: 'handles random wallpapers at regular interval (from a folder). Supports rounded corners and color scheme generation.'\n                },\n                {\n                    name: 'bar',\n                    stars: 3,\n                    description: 'improves multi-monitor handling of the status bar - restarts it on crashes'\n                },\n                {\n                    name: 'expose',\n                    stars: 2,\n                    description: 'exposes all the windows for a quick \"jump to\" feature',\n                    demoVideoId: 'ce5HQZ3na8M'\n                },\n                {\n                    name: 'lost_windows',\n                    stars: 1,\n                    description: 'brings lost floating windows (which are out of reach) to the current workspace'\n                },\n                {\n                    name: 'toggle_dpms',\n                    stars: 0,\n                    description: 'toggles the DPMS status of every plugged monitor'\n                },\n                {\n                    name: 'workspaces_follow_focus',\n                    multimon: true,\n                    stars: 3,\n                    description: 'makes non visible workspaces available on the currently focused screen.<br/> If you think the multi-screen behavior of Hyprland is not usable or broken/unexpected, this is probably for you.'\n\n                },\n                {\n                    name: 'shift_monitors',\n                    multimon: true,\n                    stars: 3,\n                    description: 'moves workspaces from monitor to monitor (caroussel)'\n                },\n                {\n                    name: 'monitors',\n                    stars: 3,\n                    description: 'allows relative placement and configuration of monitors'\n                },\n                {\n                    name: 'fcitx5_switcher',\n                    stars: 0,\n                    description: 'Automatically switch fcitx5 input method status based on window class and title.'\n                }\n            ]\n        }\n    }\n}\n</script>\n"
  },
  {
    "path": "site/versions/2.5.x/expose.md",
    "content": "---\ncommands:\n  - name: expose\n    description: Expose every client on the active workspace. If expose is already active, then restores everything and move to the focused window.\n---\n\n# expose\n\nImplements the \"expose\" effect, showing every client window on the focused screen.\n\nFor a similar feature using a menu, try the [fetch_client_menu](./fetch_client_menu) plugin (less intrusive).\n\nSample `hyprland.conf`:\n\n```bash\n# Setup the key binding\nbind = $mainMod, B, exec, pypr expose\n\n# Add some style to the \"exposed\" workspace\nworkspace = special:exposed,gapsout:60,gapsin:30,bordersize:5,border:true,shadow:false\n```\n\n`MOD+B` will bring every client to the focused workspace, pressed again it will go to this workspace.\n\nCheck [workspace rules](https://wiki.hyprland.org/Configuring/Workspace-Rules/#rules) for styling options.\n\n> [!note]\n> If you are looking for `toggle_minimized`, check the [toggle_special](./toggle_special) plugin\n\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n\n### `include_special`\n\ndefault value is `false`\n\nAlso include windows in the special workspaces during the expose.\n\n"
  },
  {
    "path": "site/versions/2.5.x/fcitx5_switcher.md",
    "content": "---\ncommand:\n  - name: fcitx5_switcher\n    description: Automatically switch fcitx5 input method status based on window class and title.\n---\n\n# fcitx5_switcher\n\nThis is a useful tool for CJK input method users.\n\nIt can automatically switch fcitx5 input method status based on window class and title.\n\nExample:\n```toml\n[fcitx5_switcher]\nactive_classes = [\"wechat\", \"QQ\", \"zoom\"]\ninactive_classes = [\n    \"code\",\n    \"kitty\",\n    \"google-chrome\",\n]\nactive_titles = []\ninactive_titles = []\n```\n\nIn this example, if the window class is \"wechat\" or \"QQ\" or \"zoom\", the input method will be activated. If the window class is \"code\" or \"kitty\" or \"google-chrome\", the input method will be inactivated."
  },
  {
    "path": "site/versions/2.5.x/fetch_client_menu.md",
    "content": "---\ncommands:\n  - name: fetch_client_menu\n    description: Bring any window to the active workspace using a menu.\n---\n\n# fetch_client_menu\n\nBring any window to the active workspace using a menu.\n\nA bit like the [expose](./expose) plugin but using a menu instead (less intrusive).\n\nIt brings the window to the current workspace, while [expose](./expose) moves the currently focused screen to the application workspace.\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\nAll the [Menu](Menu) configuration items are also available.\n\n### `separator`\n\ndefault value is `\"|\"`\n\nChanges the character (or string) used to separate a menu entry from its entry number.\n\n"
  },
  {
    "path": "site/versions/2.5.x/filters.md",
    "content": "# Text filters\n\nAt the moment there is only one filter, close match of [sed's \"s\" command](https://www.gnu.org/software/sed/manual/html_node/The-_0022s_0022-Command.html).\n\nThe only supported flag is \"g\"\n\n## Example\n\n```toml\nfilter = 's/foo/bar/'\nfilter = 's/.*started (.*)/\\1 has started/'\nfilter = 's#</?div>##g'\n```\n"
  },
  {
    "path": "site/versions/2.5.x/index.md",
    "content": "---\n# https://vitepress.dev/reference/default-theme-home-page\nlayout: home\n\nhero:\n  text: \"Hyprland extensions\"\n  tagline: Enhance your desktop capabilities with Pyprland\n  image:\n    src: /logo.svg\n    alt: logo\n  actions:\n    - theme: brand\n      text: Getting started\n      link: ./Getting-started\n    - theme: alt\n      text: Plugins\n      link: ./Plugins\n    - theme: alt\n      text: Report something\n      link: https://github.com/hyprland-community/pyprland/issues/new/choose\n\nfeatures:\n  - title: TOML format\n    details: Simple and flexible configuration file(s)\n  - title: Customizable\n    details: Create your own Hyprland experience\n  - title: Fast and easy\n    details: Designed for performance and simplicity\n---\n\n## Version 2.5.x archive\n"
  },
  {
    "path": "site/versions/2.5.x/layout_center.md",
    "content": "---\ncommands:\n  - name: layout_center toggle\n    description: toggles the layout on and off\n  - name: layout_center next\n    description: switches to the next window (if layout is on) else runs the <a href=\"#next-optional\">next</a> command\n  - name: layout_center prev\n    description: switches to the previous window (if layout is on) else runs the <a href=\"#next-optional\">prev</a> command\n  - name: layout_center next2\n    description: switches to the next window (if layout is on) else runs the <a href=\"#next-optional\">next2</a> command\n  - name: layout_center prev2\n    description: switches to the previous window (if layout is on) else runs the <a href=\"#next-optional\">prev2</a> command\n\n---\n# layout_center\n\nImplements a workspace layout where one window is bigger and centered,\nother windows are tiled as usual in the background.\n\nOn `toggle`, the active window is made floating and centered if the layout wasn't enabled, else reverts the floating status.\n\nWith `next` and `prev` you can cycle the active window, keeping the same layout type.\nIf the layout_center isn't active and `next` or `prev` is used, it will call the [next](#next-optional) and [prev](#prev-optional) configuration options.\n\nTo allow full override of the focus keys, [next2](#next2-optional) and [prev2](#prev2-optional) are provided, they do the same actions as \"next\" and \"prev\" but allow different fallback commands.\n\n<details>\n<summary>Configuration sample</summary>\n\n```toml\n[layout_center]\nmargin = 60\noffset = [0, 30]\nnext = \"movefocus r\"\nprev = \"movefocus l\"\nnext2 = \"movefocus d\"\nprev2 = \"movefocus u\"\n```\n\nusing the following in `hyprland.conf`:\n```sh\nbind = $mainMod, M, exec, pypr layout_center toggle # toggle the layout\n## focus change keys\nbind = $mainMod, left, exec, pypr layout_center prev\nbind = $mainMod, right, exec, pypr layout_center next\nbind = $mainMod, up, exec, pypr layout_center prev2\nbind = $mainMod, down, exec, pypr layout_center next2\n```\n\nYou can completely ignore `next2` and `prev2` if you are allowing focus change in a single direction (when the layout is enabled), eg:\n\n```sh\nbind = $mainMod, up, movefocus, u\nbind = $mainMod, down, movefocus, d\n```\n\n</details>\n\n\n## Commands\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `on_new_client`\n\nDefaults to `\"foreground\"`.\n\nChanges the behavior when a new window opens, possible options are:\n\n- \"foreground\" to make the new window the main window\n- \"background\" to make the new window appear in the background\n- \"close\" to stop the centered layout when a new window opens\n\n### `style`\n\n| Requires Hyprland > 0.40.0\n\nNot set by default.\n\nAllow to set a list of styles to the main (centered) window, eg:\n\n```toml\nstyle = [\"opacity 1\", \"bordercolor rgb(FFFF00)\"]\n```\n\n### `margin`\n\ndefault value is `60`\n\nmargin (in pixels) used when placing the center window, calculated from the border of the screen.\n\nExample to make the main window be 100px far from the monitor's limits:\n```toml\nmargin = 100\n```\n\nYou can also set a different margin for width and height by using a list:\n```toml\nmargin = [10, 60]\n```\n\n### `offset`\n\ndefault value is `[0, 0]`\n\noffset in pixels applied to the main window position\n\nExample shift the main window 20px down:\n```toml\noffset = [0, 20]\n```\n\n### `next`\n### `next2`\n\nnot set by default\n\nWhen the *layout_center* isn't active and the *next* command is triggered, defines the hyprland dispatcher command to run.\n\n`next2` is a similar option, used by the `next2` command, allowing to map \"next\" to both vertical and horizontal focus change.\n\nEg:\n```toml\nnext = \"movefocus r\"\n```\n\n### `prev`\n### `prev2`\n\nSame as `next` but for the `prev` and `prev2` commands.\n\n\n### `captive_focus`\n\ndefault value is `false`\n\n```toml\ncaptive_focus = true\n```\n\nSets the focus on the main window when the focus changes.\nYou may love it or hate it...\n"
  },
  {
    "path": "site/versions/2.5.x/lost_windows.md",
    "content": "---\ncommands:\n    - name: attract_lost\n      description: Bring windows which are not reachable in the currently focused workspace.\n---\n# lost_windows\n\nBring windows which are not reachable in the currently focused workspace.\n*Deprecated:* Was used to work around issues in Hyprland which have been fixed since then.\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n"
  },
  {
    "path": "site/versions/2.5.x/magnify.md",
    "content": "---\ncommands:\n    - name: zoom [value]\n      description: Set the current zoom level (absolute or relative) - toggle zooming if no value is provided\n---\n# magnify\n\nZooms in and out with an optional animation.\n\n\n<details>\n    <summary>Example</summary>\n\n```sh\npypr zoom  # sets zoom to `factor` (2 by default)\npypr zoom +1  # will set zoom to 3x\npypr zoom  # will set zoom to 1x\npypr zoom 1 # will (also) set zoom to 1x - effectively doing nothing\n```\n\nSample `hyprland.conf`:\n\n```sh\nbind = $mainMod , Z, exec, pypr zoom ++0.5\nbind = $mainMod SHIFT, Z, exec, pypr zoom\n```\n\n</details>\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n### `[value]`\n\n#### unset / not specified\n\nWill zoom to [factor](#factor-optional) if not zoomed, else will set the zoom to 1x.\n\n#### floating or integer value\n\nWill set the zoom to the provided value.\n\n#### +value / -value\n\nUpdate (increment or decrement) the current zoom level by the provided value.\n\n#### ++value / --value\n\nUpdate (increment or decrement) the current zoom level by a non-linear scale.\nIt _looks_ more linear changes than using a single + or -.\n\n> [!NOTE]\n>\n> The non-linear scale is calculated as powers of two, eg:\n>\n> - `zoom ++1` → 2x, 4x, 8x, 16x...\n> - `zoom ++0.7` → 1.6x, 2.6x, 4.3x, 7.0x, 11.3x, 18.4x...\n> - `zoom ++0.5` → 1.4x, 2x, 2.8x, 4x, 5.7x, 8x, 11.3x, 16x...\n\n## Configuration\n\n### `factor`\n\ndefault value is `2`\n\nScaling factor to be used when no value is provided.\n\n### `duration`\n\nDefault value is `15`\n\nDuration in tenths of a second for the zoom animation to last, set to `0` to disable animations.\n"
  },
  {
    "path": "site/versions/2.5.x/monitors.md",
    "content": "---\ncommands:\n  - name: relayout\n    description: Apply the configuration and update the layout\n---\n\n# monitors\n\n> First, simpler version of the plugin is still available under the name `monitors_v0`\n\nAllows relative placement of monitors depending on the model (\"description\" returned by `hyprctl monitors`).\nUseful if you have multiple monitors connected to a video signal switch or using a laptop and plugging monitors having different relative positions.\n\nSyntax:\n\n```toml\n[monitors.placement]\n\"description match\".placement = \"other description match\"\n```\n\n<details>\n    <summary>Example to set a Sony monitor on top of a BenQ monitor</summary>\n\n```toml\n[monitors.placement]\nSony.topOf = \"BenQ\"\n\n## Character case is ignored, \"_\" can be added\nSony.Top_Of = [\"BenQ\"]\n\n## Thanks to TOML format, complex configurations can use separate \"sections\" for clarity, eg:\n\n[monitors.placement.\"My monitor brand\"]\n## You can also use \"port\" names such as *HDMI-A-1*, *DP-1*, etc...\nleftOf = \"eDP-1\"\n\n## lists are possible on the right part of the assignment:\nrightOf = [\"Sony\", \"BenQ\"]\n\n## > 2.3.2: you can also set scale, transform & rate for a given monitor\n[monitors.placement.Microstep]\nrate = 100\n```\n\nTry to keep the rules as simple as possible, but relatively complex scenarios are supported.\n\n> [!note]\n> Check [wlr layout UI](https://github.com/fdev31/wlr-layout-ui) which is a nice complement to configure your monitor settings.\n\n</details>\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `placement` (REQUIRED)\n\nSupported placements are:\n\n- leftOf\n- topOf\n- rightOf\n- bottomOf\n- \\<one of the above>(center|middle|end)Of\n\n> [!important]\n> If you don't like the screen to align on the start of the given border,\n> you can use `center` (or `middle`) to center it or `end` to stick it to the opposite border.\n> Eg: \"topCenterOf\", \"leftEndOf\", etc...\n\nYou can separate the terms with \"_\" to improve the readability, as in \"top_center_of\".\n\n#### monitor settings\n\nNot only can you place monitors relatively to each other, but you can also set specific settings for a given monitor.\n\nThe following settings are supported:\n\n- scale\n- transform\n- rate\n- resolution\n\n```toml\n[monitors.placement.\"My monitor brand\"]\nrate = 60\nscale = 1.5\ntransform = 1 # 0: normal, 1: 90°, 2: 180°, 3: 270°, 4: flipped, 5: flipped 90°, 6: flipped 180°, 7: flipped 270°\nresolution = \"1920x1080\"  # can also be expressed as [1920, 1080]\n```\n\n### `startup_relayout`\n\nDefault to `ŧrue`.\n\nWhen set to `false`,\ndo not initialize the monitor layout on startup or when configuration is reloaded.\n\n### `new_monitor_delay`\n\nBy default,\nthe layout computation happens one second after the event is received to let time for things to settle.\n\nYou can change this value using this option.\n\n### `hotplug_command`\n\nNone by default, allows to run a command when any monitor is plugged.\n\n\n```toml\n[monitors]\nhotplug_commands = \"wlrlui -m\"\n```\n\n### `hotplug_commands`\n\nNone by default, allows to run a command when a given monitor is plugged.\n\nExample to load a specific profile using [wlr layout ui](https://github.com/fdev31/wlr-layout-ui):\n\n```toml\n[monitors.hotplug_commands]\n\"DELL P2417H CJFH277Q3HCB\" = \"wlrlui rotated\"\n```\n\n### `unknown`\n\nNone by default,\nallows to run a command when no monitor layout has been changed (no rule applied).\n\n```toml\n[monitors]\nunknown = \"wlrlui\"\n```\n\n### `trim_offset`\n\n`true` by default,\n\nPrevents having arbitrary window offsets or negative values.\n"
  },
  {
    "path": "site/versions/2.5.x/scratchpads.md",
    "content": "---\ncommands:\n  - name: toggle [scratchpad name]\n    description: Toggle the given scratchpad\n  - name: show [scratchpad name]\n    description: Show the given scratchpad\n  - name: hide [scratchpad name]\n    description: Hide the given scratchpad\n  - name: attach\n    description: Toggle attaching/anchoring the currently focused window to the (last used) scratchpad\n\nNote: show and hide can accept '*' as a parameter, applying changes to every scratchpad.\n\n---\n# scratchpads\n\nEasily toggle the visibility of applications you use the most.\n\nConfigurable and flexible, while supporting complex setups it's easy to get started with:\n\n```toml\n[scratchpads.name]\ncommand = \"command to run\"\nclass = \"the window's class\"  # check: hyprctl clients | grep class\nsize = \"[width] [height]\"  # size of the window relative to the screen size\n```\n\n<details>\n<summary>Example</summary>\n\nAs an example, defining two scratchpads:\n\n- _term_ which would be a kitty terminal on upper part of the screen\n- _volume_ which would be a pavucontrol window on the right part of the screen\n\n\n```toml\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\nmax_size = \"1920px 100%\"\nmargin = 50\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nsize = \"40% 90%\"\nunfocus = \"hide\"\nlazy = true\n```\n\nShortcuts are generally needed:\n\n```ini\nbind = $mainMod,V,exec,pypr toggle volume\nbind = $mainMod,A,exec,pypr toggle term\nbind = $mainMod,Y,exec,pypr attach\n```\n</details>\n\n- If you wish to have a more generic space for any application you may run, check [toggle_special](./toggle_special).\n- When you create a scratchpad called \"name\", it will be hidden in `special:scratch_<name>`.\n- Providing `class` allows a glitch free experience, mostly noticeable when using animations\n\n\n## Commands\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n> [!tip]\n> You can use `\"*\"` as a _scratchpad name_ to target every scratchpad when using `show` or `hide`.\n> You'll need to quote or escape the `*` character to avoid interpretation from your shell.\n\n## Configuration\n\n### `command` (REQUIRED)\n\nThis is the command you wish to run in the scratchpad.\n\nIt supports [Variables](./Variables)\n\n### `animation`\n\nType of animation to use, default value is \"fromTop\":\n\n- `null` / `\"\"` (no animation)\n- `fromTop` (stays close to upper screen border)\n- `fromBottom` (stays close to lower screen border)\n- `fromLeft` (stays close to left screen border)\n- `fromRight` (stays close to right screen border)\n\n### `size` (recommended)\n\nNo default value.\n\nEach time scratchpad is shown, window will be resized according to the provided values.\n\nFor example on monitor of size `800x600` and `size= \"80% 80%\"` in config scratchpad always have size `640x480`,\nregardless of which monitor it was first launched on.\n\n> #### Format\n>\n> String with \"x y\" (or \"width height\") values using some units suffix:\n>\n> - **percents** relative to the focused screen size (`%` suffix), eg: `60% 30%`\n> - **pixels** for absolute values (`px` suffix), eg: `800px 600px`\n> - a mix is possible, eg: `800px 40%`\n\n### `class` (recommended)\n\nNo default value.\n\nAllows _Pyprland_ prepare the window for a correct animation and initial positioning.\n\n### `position`\n\nNo default value, overrides the automatic margin-based position.\n\nSets the scratchpad client window position relative to the top-left corner.\n\nSame format as `size` (see above)\n\nExample of scratchpad that always seat on the top-right corner of the screen:\n\n```toml\n[scratchpads.term_quake]\ncommand = \"wezterm start --class term_quake\"\nposition = \"50% 0%\"\nsize = \"50% 50%\"\nclass = \"term_quake\"\n```\n\n> [!note]\n> If `position` is not provided, the window is placed according to `margin` on one axis and centered on the other.\n\n### `multi`\n\nDefaults to `true`.\n\nWhen set to `false`, only one client window is supported for this scratchpad.\nOtherwise other matching windows will be **attach**ed to the scratchpad.\n\n## Advanced configuration\n\nTo go beyond the basic setup and have a look at every configuration item, you can read the following pages:\n\n- [Advanced](./scratchpads_advanced) contains options for fine-tuners or specific tastes (eg: i3 compatibility)\n- [Non-Standard](./scratchpads_nonstandard) contains options for \"broken\" applications\nlike progressive web apps (PWA) or emacsclient, use only if you can't get it to work otherwise\n\n## Monitor specific overrides\n\nYou can use different settings for a specific screen.\nMost attributes related to the display can be changed (not `command`, `class` or `process_tracking` for instance).\n\nUse the `monitor.<monitor name>` configuration item to override values, eg:\n\n```toml\n[scratchpads.music.monitor.eDP-1]\nposition = \"30% 50%\"\nanimation = \"fromBottom\"\n```\n\nYou may want to inline it for simple cases:\n\n```toml\n[scratchpads.music]\nmonitor = {HDMI-A-1={size = \"30% 50%\"}}\n```\n"
  },
  {
    "path": "site/versions/2.5.x/scratchpads_advanced.md",
    "content": "# Fine tuning scratchpads\n\nAdvanced configuration options\n\n## `use`\n\nNo default value.\n\nList of scratchpads (or single string) that will be used for the default values of this scratchpad.\nThink about *templates*:\n\n```toml\n[scratchpads.terminals]\nanimation = \"fromTop\"\nmargin = 50\nsize = \"75% 60%\"\nmax_size = \"1920px 100%\"\n\n[scratchpads.term]\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nuse = \"terminals\"\n```\n\n## `pinned`\n\n`true` by default\n\nMakes the scratchpad \"sticky\" to the monitor, following any workspace change.\n\n## `excludes`\n\nNo default value.\n\nList of scratchpads to hide when this one is displayed, eg: `excludes = [\"term\", \"volume\"]`.\nIf you want to hide every displayed scratch you can set this to the string `\"*\"` instead of a list: `excludes = \"*\"`.\n\n## `restore_excluded`\n\n`false` by default.\n\nWhen enabled, will remember the scratchpads which have been closed due to `excludes` rules, so when the scratchpad is hidden, those previously hidden scratchpads will be shown again.\n\n## `unfocus`\n\nNo default value.\n\nWhen set to `\"hide\"`, allow to hide the window when the focus is lost.\n\nUse `hysteresis` to change the reactivity\n\n## `hysteresis`\n\nDefaults to `0.4` (seconds)\n\nControls how fast a scratchpad hiding on unfocus will react. Check `unfocus` option.\nSet to `0` to disable.\n\n> [!important]\n> Only relevant when `unfocus=\"hide\"` is used.\n\n## `margin`\n\ndefault value is `60`.\n\nnumber of pixels separating the scratchpad from the screen border, depends on the [animation](./scratchpads#animation) set.\n\n> [!tip]\n> It is also possible to set a string to express percentages of the screen (eg: '`3%`').\n\n## `max_size`\n\nNo default value.\n\nSame format as `size` (see above), only used if `size` is also set.\n\nLimits the `size` of the window accordingly.\nTo ensure a window will not be too large on a wide screen for instance:\n\n```toml\nsize = \"60% 30%\"\nmax_size = \"1200px 100%\"\n```\n\n## `lazy`\n\ndefault to `false`.\n\nwhen set to `true`, prevents the command from being started when pypr starts, it will be started when the scratchpad is first used instead.\n\n- Good: saves resources when the scratchpad isn't needed\n- Bad: slows down the first display (app has to launch before showing)\n\n## `preserve_aspect`\n\nNot set by default.\nWhen set to `true`, will preserve the size and position of the scratchpad when called repeatedly from the same monitor and workspace even though an `animation` , `position` or `size` is used (those will be used for the initial setting only).\n\nForces the `lazy` option.\n\n## `offset`\n\nIn pixels, default to `0` (client's window size + margin).\n\nNumber of pixels for the **hide** sliding animation (how far the window will go).\n\n> [!tip]\n> - It is also possible to set a string to express percentages of the client window\n> - `margin` is automatically added to the offset\n> - automatic (value not set) is same as `\"100%\"`\n\n## `hide_delay`\n\nDefaults to `0.2`\n\nDelay (in seconds) after which the hide animation happens, before hiding the scratchpad.\n\nRule of thumb, if you have an animation with speed \"7\", as in:\n```bash\n    animation = windowsOut, 1, 7, easeInOut, popin 80%\n```\nYou can divide the value by two and round to the lowest value, here `3`, then divide by 10, leading to `hide_delay = 0.3`.\n\n## `restore_focus`\n\nEnabled by default, set to `false` if you don't want the focused state to be restored when a scratchpad is hidden.\n\n## `force_monitor`\n\nIf set to some monitor name (eg: `\"DP-1\"`), it will always use this monitor to show the scratchpad.\n\n## `alt_toggle`\n\nDefault value is `false`\n\nWhen enabled, use an alternative `toggle` command logic for multi-screen setups.\nIt applies when the `toggle` command is triggered and the toggled scratchpad is visible on a screen which is not the focused one.\n\nInstead of moving the scratchpad to the focused screen, it will hide the scratchpad.\n\n## `allow_special_workspaces`\n\nDefault value is `false` (can't be enabled when using *Hyprland* < 0.39 where this behavior can't be controlled and is disabled).\n\nWhen enabled, you can toggle a scratchpad over a special workspace.\nIt will always use the \"normal\" workspace otherwise.\n\n## `smart_focus`\n\nDefault value is `true`.\n\nWhen enabled, the focus will be restored in a best effort way as en attempt to improve the user experience.\nIf you face issues such as spontaneous workspace changes, you can disable this feature.\n\n## `close_on_hide`\n\nDefault value is `false`.\n\nWhen enabled, the window in the scratchpad is closed instead of hidden when `pypr hide <name>` is run.\nThis option implies `lazy = true`.\nThis can be useful on laptops where background apps may increase battery power draw.\n\nNote: Currently this option changes the hide animation to use hyprland's close window animation.\n\n"
  },
  {
    "path": "site/versions/2.5.x/scratchpads_nonstandard.md",
    "content": "# Troubleshooting scratchpads\n\nOptions that should only be used for applications that are not behaving in a \"standard\" way, such as `emacsclient` or progressive web apps.\n\n## `match_by`\n\nDefault value is `\"pid\"`\nWhen set to a sensitive client property value (eg: `class`, `initialClass`, `title`, `initialTitle`), will match the client window using the provided property instead of the PID of the process.\n\nThis property must be set accordingly, eg:\n\n```toml\nmatch_by = \"class\"\nclass = \"my-web-app\"\n```\n\nor\n\n```toml\nmatch_by = \"initialClass\"\ninitialClass = \"my-web-app\"\n```\n\nYou can add the \"re:\" prefix to use a regular expression, eg:\n\n```toml\nmatch_by = \"title\"\ntitle = \"re:.*some string.*\"\n```\n\n> [!note]\n> Some apps may open the graphical client window in a \"complicated\" way, to work around this, it is possible to disable the process PID matching algorithm and simply rely on window's class.\n>\n> The `match_by` attribute can be used to achieve this, eg. for emacsclient:\n> ```toml\n> [scratchpads.emacs]\n> command = \"/usr/local/bin/emacsStart.sh\"\n> class = \"Emacs\"\n> match_by = \"class\"\n> ```\n\n## `process_tracking`\n\nDefault value is `true`\n\nAllows disabling the process management. Use only if running a progressive web app (Chrome based apps) or similar.\n\nThis will automatically force `lazy = true` and set `match_by=\"class\"` if no `match_by` rule is provided, to help with the fuzzy client window matching.\n\nIt requires defining a `class` option (or the option matching your `match_by` value).\n\n```toml\n## Chat GPT on Brave\n[scratchpads.gpt]\nanimation = \"fromTop\"\ncommand = \"brave --profile-directory=Default --app=https://chat.openai.com\"\nclass = \"brave-chat.openai.com__-Default\"\nsize = \"75% 60%\"\nprocess_tracking = false\n\n## Some chrome app\n[scratchpads.music]\ncommand = \"google-chrome --profile-directory=Default --app-id=cinhimbnkkaeohfgghhklpknlkffjgod\"\nclass = \"chrome-cinhimbnkkaeohfgghhklpknlkffjgod-Default\"\nsize = \"50% 50%\"\nprocess_tracking = false\n```\n\n> [!tip]\n> To list windows by class and title you can use:\n> - `hyprctl -j clients | jq '.[]|[.class,.title]'`\n> - or if you prefer a graphical tool: `rofi -show window`\n\n> [!note]\n> Progressive web apps will share a single process for every window.\n> On top of requiring the class based window tracking (using `match_by`),\n> the process can not be managed the same way as usual apps and the correlation\n> between the process and the client window isn't as straightforward and can lead to false matches in extreme cases.\n\n## `skip_windowrules`\n\nDefault value is `[]`\nAllows you to skip the window rules for a specific scratchpad.\nAvailable rules are:\n\n- \"aspect\" controlling size and position\n- \"float\" controlling the floating state\n- \"workspace\" which moves the window to its own workspace\n\nIf you are using an application which can spawn multiple windows and you can't see them, you can skip rules made to improve the initial display of the window.\n\n```toml\n[scratchpads.filemanager]\nanimation = \"fromBottom\"\ncommand = \"nemo\"\nclass = \"nemo\"\nsize = \"60% 60%\"\nskip_windowrules = [\"aspect\", \"workspace\"]\n```\n"
  },
  {
    "path": "site/versions/2.5.x/shift_monitors.md",
    "content": "---\ncommands:\n    - name: shift_monitors <direction>\n      description: Swaps the workspaces of every screen in the given direction.\n---\n\n# shift_monitors\n\nSwaps the workspaces of every screen in the given direction.\n\n> [!Note]\n> the behavior can be hard to predict if you have more than 2 monitors (depending on your layout).\n> If you use this plugin with many monitors and have some ideas about a convenient configuration, you are welcome ;)\n\nExample usage in `hyprland.conf`:\n\n```\nbind = $mainMod, O, exec, pypr shift_monitors +1\nbind = $mainMod SHIFT, O, exec, pypr shift_monitors -1\n```\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n"
  },
  {
    "path": "site/versions/2.5.x/shortcuts_menu.md",
    "content": "---\ncommands:\n    - name: menu [name]\n      description: Displays the full menu or part of it if \"name\" is provided\n---\n\n# shortcuts_menu\n\nPresents some menu to run shortcut commands. Supports nested menus (aka categories / sub-menus).\n\n<details>\n   <summary>Configuration examples</summary>\n\n```toml\n[shortcuts_menu.entries]\n\n\"Open Jira ticket\" = 'open-jira-ticket \"$(wl-paste)\"'\nRelayout = \"pypr relayout\"\n\"Fetch window\" = \"pypr fetch_client_menu\"\n\"Hyprland socket\" = 'kitty  socat - \"UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock\"'\n\"Hyprland logs\" = 'kitty tail -f $XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/hyprland.log'\n\n\"Serial USB Term\" = [\n    {name=\"device\", command=\"ls -1 /dev/ttyUSB*; ls -1 /dev/ttyACM*\"},\n    {name=\"speed\", options=[\"115200\", \"9600\", \"38400\", \"115200\", \"256000\", \"512000\"]},\n    \"kitty miniterm --raw --eol LF [device] [speed]\"\n]\n\n\"Color picker\" = [\n    {name=\"format\", options=[\"hex\", \"rgb\", \"hsv\", \"hsl\", \"cmyk\"]},\n    \"sleep 0.2; hyprpicker --format [format] | wl-copy\" # sleep to let the menu close before the picker opens\n]\n\nscreenshot = [\n    {name=\"what\", options=[\"output\", \"window\", \"region\", \"active\"]},\n    \"hyprshot -m [what] -o /tmp -f shot_[what].png\"\n]\n\nannotate = [\n    {name=\"fname\", command=\"ls /tmp/shot_*.png\"},\n    \"satty --filename '[fname]' --output-filename '/tmp/annotated.png'\"\n]\n\n\"Clipboard history\" = [\n    {name=\"entry\", command=\"cliphist list\", filter=\"s/\\t.*//\"},\n    \"cliphist decode '[entry]' | wl-copy\"\n]\n\n\"Copy password\" = [\n    {name=\"what\", command=\"gopass ls --flat\"},\n    \"gopass show -c [what]\"\n]\n\n\"Update/Change password\" = [\n    {name=\"what\", command=\"gopass ls --flat\"},\n    \"kitty -- gopass generate -s --strict -t '[what]' && gopass show -c '[what]'\"\n]\n```\n\n</details>\n\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n> [!tip]\n> - If \"name\" is provided it will show the given sub-menu.\n> - You can use \".\" to reach any level of the configured menus.\n>\n>      Example to reach `shortcuts_menu.entries.utils.\"local commands\"`, use:\n>      ```sh\n>       pypr menu \"utils.local commands\"\n>      ```\n\n## Configuration\n\nAll the [Menu](./Menu) configuration items are also available.\n\n### `entries` (REQUIRED)\n\nDefines the menu entries. Supports [Variables](./Variables)\n\n```toml\n[shortcuts_menu.entries]\n\"entry 1\" = \"command to run\"\n\"entry 2\" = \"command to run\"\n```\nSubmenus can be defined too (there is no depth limit):\n\n```toml\n[shortcuts_menu.entries.\"My submenu\"]\n\"entry X\" = \"command\"\n\n[shortcuts_menu.entries.one.two.three.four.five]\nfoobar = \"ls\"\n```\n\n#### Advanced usage\n\nInstead of navigating a configured list of menu options and running a pre-defined command, you can collect various *variables* (either static list of options selected by the user, or generated from a shell command) and then run a command using those variables. Eg:\n\n```toml\n\"Play Video\" = [\n    {name=\"video_device\", command=\"ls -1 /dev/video*\"},\n    {name=\"player\",\n        options=[\"mpv\", \"guvcview\"]\n    },\n    \"[player] [video_device]\"\n]\n\n\"Ssh\" = [\n    {name=\"action\", options=[\"htop\", \"uptime\", \"sudo halt -p\"]},\n    {name=\"host\", options=[\"gamix\", \"gate\", \"idp\"]},\n    \"kitty --hold ssh [host] [action]\"\n]\n```\n\nYou must define a list of objects, containing:\n- `name`: the variable name\n- then the list of options, must be one of:\n    - `options` for a static list of options\n    - `command` to get the list of options from a shell command's output\n\n> [!tip]\n> You can apply post-filters to the `command` output, eg:\n> ```toml\n> {\n>   name = \"entry\",\n>   command = \"cliphist list\",\n>   filter = \"s/\\t.*//\"  # will remove everything after the TAB character\n> }\n> ```\n> check the [filters](./filters) page for more details\n\nThe last item of the list must be a string which is the command to run. Variables can be used enclosed in `[]`.\n\n### `command_start`\n### `command_end`\n### `submenu_start`\n### `submenu_end`\n\nAllow adding some text (eg: icon) before / after a menu entry.\n\ncommand_* is for final commands, while submenu_* is for entries leading to another menu.\n\nBy default `submenu_end` is set to a right arrow sign, while other attributes are not set.\n\n### `skip_single`\n\nDefaults to `true`.\nWhen disabled, shows the menu even for single options\n\n## Hints\n\n### Multiple menus\n\nTo manage multiple distinct menus, always use a name when using the `pypr menu <name>` command.\n\nExample of a multi-menu configuration:\n\n```toml\n[shortcuts_menu.entries.\"Basic commands\"]\n\"entry X\" = \"command\"\n\"entry Y\" = \"command2\"\n\n[shortcuts_menu.entries.menu2]\n## ...\n```\n\nYou can then show the first menu using `pypr menu \"Basic commands\"`\n"
  },
  {
    "path": "site/versions/2.5.x/sidebar.json",
    "content": "{\n  \"main\": [\n    {\n      \"text\": \"Getting started\",\n      \"link\": \"./Getting-started\"\n    },\n    {\n      \"text\": \"Plugins\",\n      \"link\": \"./Plugins\"\n    },\n    {\n      \"text\": \"Troubleshooting\",\n      \"link\": \"./Troubleshooting\"\n    },\n    {\n      \"text\": \"Development\",\n      \"link\": \"./Development\"\n    }\n  ],\n  \"plugins\": {\n    \"text\": \"Featured plugins\",\n    \"collapsed\": false,\n    \"items\": [\n      {\n        \"text\": \"Expose\",\n        \"link\": \"./expose\"\n      },\n      {\n        \"text\": \"Fcitx5 Switcher\",\n        \"link\": \"./fcitx5_switcher\"\n      },\n      {\n        \"text\": \"Fetch client menu\",\n        \"link\": \"./fetch_client_menu\"\n      },\n      {\n        \"text\": \"Menubar\",\n        \"link\": \"./bar\"\n      },\n      {\n        \"text\": \"Layout center\",\n        \"link\": \"./layout_center\"\n      },\n      {\n        \"text\": \"Lost windows\",\n        \"link\": \"./lost_windows\"\n      },\n      {\n        \"text\": \"Magnify\",\n        \"link\": \"./magnify\"\n      },\n      {\n        \"text\": \"Monitors\",\n        \"link\": \"./monitors\"\n      },\n      {\n        \"text\": \"Scratchpads\",\n        \"link\": \"./scratchpads\",\n        \"items\": [\n          {\n            \"text\": \"Advanced\",\n            \"link\": \"./scratchpads_advanced\"\n          },\n          {\n            \"text\": \"Special cases\",\n            \"link\": \"./scratchpads_nonstandard\"\n          }\n        ]\n      },\n      {\n        \"text\": \"Shift monitors\",\n        \"link\": \"./shift_monitors\"\n      },\n      {\n        \"text\": \"Shortcuts menu\",\n        \"link\": \"./shortcuts_menu\"\n      },\n      {\n        \"text\": \"System notifier\",\n        \"link\": \"./system_notifier\"\n      },\n      {\n        \"text\": \"Toggle dpms\",\n        \"link\": \"./toggle_dpms\"\n      },\n      {\n        \"text\": \"Toggle special\",\n        \"link\": \"./toggle_special\"\n      },\n      {\n        \"text\": \"Wallpapers\",\n        \"link\": \"./wallpapers\"\n      },\n      {\n        \"text\": \"Workspaces follow focus\",\n        \"link\": \"./workspaces_follow_focus\"\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "site/versions/2.5.x/system_notifier.md",
    "content": "# system_notifier\n\nThis plugin adds system notifications based on journal logs (or any program's output).\nIt monitors specified **sources** for log entries matching predefined **patterns** and generates notifications accordingly (after applying an optional **filter**).\n\nSources are commands that return a stream of text (eg: journal, mqtt, tail -f, ...) which is sent to a parser that will use a [regular expression pattern](https://en.wikipedia.org/wiki/Regular_expression) to detect lines of interest and optionally transform them before sending the notification.\n\n<details>\n    <summary>Minimal configuration</summary>\n\n```toml\n[system_notifier.sources]\ncommand = \"sudo journalctl -fx\"\nparser = \"journal\"\n```\n\nIn general you will also need to define some **parsers**.\nBy default a **\"journal\"** parser is provided, otherwise you need to define your own rules.\nThis built-in configuration is close to this one, provided as an example:\n\n```toml\n[system_notifier.parsers.journal]\npattern = \"([a-z0-9]+): Link UP$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is active/\"\ncolor= \"#00aa00\"\n\n[system_notifier.parsers.journal]\npattern = \"([a-z0-9]+): Link DOWN$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is inactive/\"\ncolor= \"#ff8800\"\n\n[system_notifier.parsers.journal]\npattern = \"Process \\d+ \\(.*\\) of .* dumped core.\"\nfilter = \"s/.*Process \\d+ \\((.*)\\) of .* dumped core./\\1 dumped core/\"\ncolor= \"#aa0000\"\n\n[system_notifier.parsers.journal]\npattern = \"usb \\d+-[0-9.]+: Product: \"\nfilter = \"s/.*usb \\d+-[0-9.]+: Product: (.*)/USB plugged: \\1/\"\n```\n</details>\n\n\n## Configuration\n\n### `sources` (recommended)\n\nList of sources to enable (by default nothing is enabled)\n\nEach source must contain a `command` to run and a `parser` to use.\n\nYou can also use a list of parsers, eg:\n\n```toml\n[system_notifier.sources](system_notifier.sources)\ncommand = \"sudo journalctl -fkn\"\nparser = [\"journal\", \"custom_parser\"]\n```\n\n#### command (recommended)\n\nThis is the long-running command (eg: `tail -f <filename>`) returning the stream of text that will be updated. Aa common option is the system journal output (eg: `journalctl -u nginx`)\n\n#### parser\n\nSets the list of rules / parser to be used to extract lines of interest.\nMust match a list of rules defined as `system_notifier.parsers.<parser_name>`.\n\n### `parsers` (recommended)\n\nA list of available parsers that can be used to detect lines of interest in the **sources** and re-format it before issuing a notification.\n\nEach parser definition must contain a **pattern** and optionally a **filter** and a **color**.\n\n#### pattern\n\n```toml\n[system_notifier.parsers.custom_parser](system_notifier.parsers.custom_parser)\n\npattern = 'special value:'\n```\n\nThe pattern is any regular expression.\n\n#### filter\n\nThe [filters](./filters) allows to change the text before the notification, eg:\n`filter=\"s/.*special value: (\\d+)/Value=\\1/\"`\nwill set a filter so a string \"special value: 42\" will lead to the notification \"Value=42\"\n\n#### color\n\nYou can also provide an optional **color** in `\"hex\"` or `\"rgb()\"` format\n\n```toml\ncolor = \"#FF5500\"\n```\n\n### default_color\n\nSets the notification color that will be used when none is provided in a *parser* definition.\n\n```toml\n[system_notifier]\ndefault_color = \"#bbccbb\"\n```\n"
  },
  {
    "path": "site/versions/2.5.x/toggle_dpms.md",
    "content": "---\ncommands:\n    - name: toggle_dpms\n      description: if any screen is powered on, turn them all off, else turn them all on\n---\n\n# toggle_dpms\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n"
  },
  {
    "path": "site/versions/2.5.x/toggle_special.md",
    "content": "---\ncommands:\n    - name: toggle_special [name]\n      description: moves the focused window to the special workspace <code>name</code>, or move it back to the active workspace\n---\n\n# toggle_special\n\nAllows moving the focused window to a special workspace and back (based on the visibility status of that workspace).\n\nIt's a companion of the `togglespecialworkspace` Hyprland's command which toggles a special workspace's visibility.\n\nYou most likely will need to configure the two commands for a complete user experience, eg:\n\n```bash\nbind = $mainMod SHIFT, N, togglespecialworkspace, stash # toggles \"stash\" special workspace visibility\nbind = $mainMod, N, exec, pypr toggle_special stash # moves window to/from the \"stash\" workspace\n```\n\nNo other configuration needed, here `MOD+SHIFT+N` will show every window in \"stash\" while `MOD+N` will move the focused window out of it/ to it.\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n"
  },
  {
    "path": "site/versions/2.5.x/wallpapers.md",
    "content": "---\ncommands:\n    - name: wall next\n      description: Changes the current background image, resume cycling if paused\n    - name: wall clear\n      description: Removes the current background image and pause cycling\n    - name: wall pause\n      description: Stop cycling the wallpaper after a delay\n---\n\n# wallpapers\n\nSearch folders for images and sets the background image at a regular interval.\nPictures are selected randomly from the full list of images found.\n\nIt serves two purposes:\n\n- adding support for random images to any background setting tool\n- quickly testing different tools with a minimal effort\n\nIt allows \"zapping\" current backgrounds, clearing it to go distraction free and optionally make them different for each screen.\n\n> [!tip]\n> Uses **swaybg** by default, but can be configured to use any other application.\n\n<details>\n    <summary>Minimal example using defaults(requires <b>swaybg</b>)</summary>\n\n```toml\n[wallpapers]\npath = \"~/Pictures/wallpapers/\" # path to the folder with background images\n```\n\n</details>\n\n<details>\n<summary>More complete, using the custom <b>swww</b> command (not recommended because of its stability)</summary>\n\n```toml\n[wallpapers]\nunique = true # set a different wallpaper for each screen\npath = \"~/Pictures/wallpapers/\"\ninterval = 60 # change every hour\nextensions = [\"jpg\", \"jpeg\"]\nrecurse = true\nclear_command = \"swww clear\"\n```\n\nNote that for applications like `swww`, you'll need to start a daemon separately (eg: from `hyprland.conf`).\n</details>\n\n\n## Commands\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n\n### `path` (REQUIRED)\n\npath to a folder or list of folders that will be searched. Can also be a list, eg:\n\n```toml\npath = [\"~/Pictures/Portraits/\", \"~/Pictures/Landscapes/\"]\n```\n\n### `interval`\n\ndefaults to `10`\n\nHow long (in minutes) a background should stay in place\n\n\n### `command`\n\nOverrides the default command to set the background image.\n\n> [!note]\n> Will use an optimized **hyprpaper** usage if no command is provided on > 2.5.1\n\n[variables](./Variables) are replaced with the appropriate values, you must use a `\"[file]\"` placeholder for the image path and `\"[output]\"` for the screen. eg:\n\n```\nswaybg -i '[file]' -o '[output]'\n```\nor\n```\nswww img --outputs [output]  [file]\n```\n\n### `clear_command`\n\nBy default `clear` command kills the `command` program.\n\nInstead of that, you can provide a command to clear the background. eg:\n\n```\nclear_command = \"swaybg clear\"\n```\n\n### `post_command`\n\nExecutes a command after a wallpaper change. Can use `[file]`, eg:\n\n```\npost_command = \"matugen image '[file]'\"\n```\n\n### `radius`\n\nWhen set, adds rounded borders to the wallpapers. Expressed in pixels. Disabled by default.\n\nFor this feature to work, you must use '[output]' in your `command` to specify the screen port name to use in the command.\n\neg:\n```\nradius = 16\n```\n\n### `extensions`\n\ndefaults to `[\"png\", \"jpg\", \"jpeg\"]`\n\nList of valid wallpaper image extensions.\n\n### `recurse`\n\ndefaults to `false`\n\nWhen enabled, will also search sub-directories recursively.\n\n### `unique`\n\ndefaults to `false`\n\nWhen enabled, will set a different wallpaper for each screen.\n\nIf you are not using the default application, ensure you are using `\"[output]\"` in the [command](#command) template.\n\nExample for swaybg: `swaybg -o \"[output]\" -m fill -i \"[file]\"`\n"
  },
  {
    "path": "site/versions/2.5.x/workspaces_follow_focus.md",
    "content": "---\ncommands:\n    - name: change_workspace [direction]\n      description: Changes the workspace of the focused monitor in the given direction.\n---\n\n# workspaces_follow_focus\n\nMake non-visible workspaces follow the focused monitor.\nAlso provides commands to switch between workspaces while preserving the current monitor assignments:\n\nSyntax:\n```toml\n[workspaces_follow_focus]\nmax_workspaces = 4 # number of workspaces before cycling\n```\nExample usage in `hyprland.conf`:\n\n```ini\nbind = $mainMod, K, exec, pypr change_workspace +1\nbind = $mainMod, J, exec, pypr change_workspace -1\n ```\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `max_workspaces`\n\nLimits the number of workspaces when switching, defaults value is `10`.\n"
  },
  {
    "path": "site/versions/2.6.2/Development.md",
    "content": "# Development\n\nIt's easy to write your own plugin by making a python package and then indicating it's name as the plugin name.\n\n[Contributing guidelines](https://github.com/hyprland-community/pyprland/blob/main/CONTRIBUTING.md)\n\n# Writing plugins\n\nPlugins can be loaded with full python module path, eg: `\"mymodule.pyprlandplugin\"`, the loaded module must provide an `Extension` class.\n\nCheck the `interface.py` file to know the base methods, also have a look at the example below.\n\nTo get more details when an error is occurring, use `pypr --debug <log file path>`, it will also display the log in the console.\n\n> [!note]\n> To quickly get started, you can directly edit the `experimental` built-in plugin.\n> In order to distribute it, make your own Python package or trigger a pull request.\n> If you prefer to make a separate package, check the [examples](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/)'s package\n\nThe `Extension` interface provides a couple of built-in attributes:\n\n- `config` : object exposing the plugin section in `pyprland.toml`\n- `notify` ,`notify_error`, `notify_info` : access to Hyprland's notification system\n- `hyprctl`, `hyprctl_json` : invoke [Hyprland's IPC system](https://wiki.hyprland.org/Configuring/Dispatchers/)\n\n\n> [!important]\n> Contact me to get your extension listed on the home page\n\n> [!tip]\n> You can set a `plugins_paths=[\"/custom/path/example\"]` in the `hyprland` section of the configuration to add extra paths (eg: during development).\n\n> [!Note]\n> If your extension is at the root of the plugin (this is not recommended, preferable add a name space, as in `johns_pyprland.super_feature`, rather than `super_feature`) you can still import it using the `external:` prefix when you refer to it in the `plugins` list.\n\n# API Documentation\n\nRun `tox run -e doc` then visit `http://localhost:8080`\n\nThe most important to know are:\n\n- `hyprctl_json` to get a response from an IPC query\n- `hyprctl` to trigger general IPC commands\n- `on_reload` to be implemented, called when the config is (re)loaded\n- `run_<command_name>` to implement a command\n- `event_<event_name>` called when the given event is emitted by Hyprland\n\nAll those methods are _async_\n\nOn top of that:\n\n- the first line of a `run_*` command's docstring will be used by the `help` command\n- `self.config` in your _Extension_ contains the entry corresponding to your plugin name in the TOML file\n- `state` from `..common` module contains ready to use information\n- there is a `MenuMixin` in `..adapters.menus` to make menu-based plugins easy\n\n# Workflow\n\nJust `^C` when you make a change and repeat:\n\n```sh\npypr exit ; pypr --debug /tmp/output.log\n```\n\n\n## Creating a plugin\n\n```python\nfrom .interface import Plugin\n\n\nclass Extension(Plugin):\n    \" My plugin \"\n\n    async def init(self):\n        await self.notify(\"My plugin loaded\")\n```\n\n## Adding a command\n\nJust add a method called `run_<name of your command>` to your `Extension` class, eg with \"togglezoom\" command:\n\n```python\n    zoomed = False\n\n    async def run_togglezoom(self, args):\n        \"\"\" this doc string will show in `help` to document `togglezoom`\n        But this line will not show in the CLI help\n        \"\"\"\n      if self.zoomed:\n        await self.hyprctl('misc:cursor_zoom_factor 1', 'keyword')\n      else:\n        await self.hyprctl('misc:cursor_zoom_factor 2', 'keyword')\n      self.zoomed = not self.zoomed\n```\n\n## Reacting to an event\n\nSimilar as a command, implement some `async def event_<the event you are interested in>` method.\n\n## Code safety\n\nPypr ensures only one `run_` or `event_` handler runs at a time, allowing the plugins code to stay simple and avoid the need for concurrency handling.\nHowever, each plugin can run its handlers in parallel.\n\n# Reusable code\n\n```py\nfrom ..common import state, CastBoolMixin\n```\n\n- `state` provides a couple of handy variables so you don't have to fetch them, allow optimizing the most common operations\n- `Mixins` are providing common code, for instance the `CastBoolMixin` provides the `cast_bool` method to your `Extension`.\n\nIf you want to use menus, then the `MenuMixin` will provide:\n- `menu` to show a menu\n- `ensure_menu_configured` to call before you require a menu in your plugin\n\n# Example\n\nYou'll find a basic external plugin in the [examples](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/) folder.\n\nIt provides one command: `pypr dummy`.\n\nRead the [plugin code](https://github.com/hyprland-community/pyprland/blob/main/sample_extension/pypr_examples/focus_counter.py)\n\nIt's a simple python package. To install it for development without a need to re-install it for testing, you can use `pip install -e .` in this folder.\nIt's ready to be published using `poetry publish`, don't forget to update the details in the `pyproject.toml` file.\n\n## Usage\n\nEnsure you added `pypr_examples.focus_counter` to your `plugins` list:\n\n```toml\n[pyprland]\nplugins = [\n  \"pypr_examples.focus_counter\"\n]\n```\n\nOptionally you can customize one color:\n\n```toml\n[\"pypr_examples.focus_counter\"]\ncolor = \"FFFF00\"\n```\n"
  },
  {
    "path": "site/versions/2.6.2/Getting-started.md",
    "content": "# Getting started\n\nPypr consists in two things:\n\n- **a tool**: `pypr` which runs the daemon (service), but also allows to interact with it\n  - `pypr-client` is a more limited, faster version, meant to be used in your `hyprland.conf` keyboard bindings **only**\n- **some config file**: `~/.config/hypr/pyprland.toml` (or the path set using `--config`) using the [TOML](https://toml.io/en/) format\n\nThe `pypr` tool only have a few built-in commands:\n\n- `help` lists available commands (including plugins commands)\n- `exit` will terminate the service process\n- `edit` edit the configuration using your `$EDITOR` (or `vi`), reloads on exit\n- `dumpjson` shows a JSON representation of the configuration (after other files have been `include`d)\n- `reload` reads the configuration file and apply some changes:\n  - new plugins will be loaded\n  - configuration items will be updated (most plugins will use the new values on the next usage)\n\nOther commands are implemented by adding [plugins](./Plugins).\n\n> [!important]\n> - with no argument it runs the daemon (doesn't fork in the background)\n>\n> - if you pass parameters, it will interact with the daemon instead.\n\n> [!tip]\n> Pypr *command* names are documented using underscores (`_`) but you can use dashes (`-`) instead.\n> Eg: `pypr shift_monitors` and `pypr shift-monitors` will run the same command\n\n\n## Configuration file\n\nThe configuration file uses a [TOML format](https://toml.io/) with the following as the bare minimum:\n\n```toml\n[pyprland]\nplugins = [\"plugin_name\", \"other_plugin\"]\n```\n\nAdditionally some plugins require **Configuration** options, using the following format:\n\n```toml\n[plugin_name]\nplugin_option = 42\n\n[plugin_name.another_plugin_option]\nsuboption = \"config value\"\n```\n\nYou can also split your configuration into [Multiple configuration files](./MultipleConfigurationFiles).\n\n## Installation\n\nCheck your OS package manager first, eg:\n\n- Archlinux: you can find it on AUR, eg with [yay](https://github.com/Jguer/yay): `yay pyprland`\n- NixOS: Instructions in the [Nix](./Nix) page\n\nOtherwise, use the python package manager (pip) [inside a virtual environment](InstallVirtualEnvironment)\n\n```sh\npip install pyprland\n```\n\n## Running\n\n> [!caution]\n> If you messed with something else than your OS packaging system to get `pypr` installed, use the full path to the `pypr` command.\n\nPreferably start the process with hyprland, adding to `hyprland.conf`:\n\n```ini\nexec-once = /usr/bin/pypr\n```\n\nor if you run into troubles (use the first version once your configuration is stable):\n\n```ini\nexec-once = /usr/bin/pypr --debug /tmp/pypr.log\n```\n\n> [!warning]\n> To avoid issues (eg: you have a complex setup, maybe using a virtual environment), you may want to set the full path (eg: `/home/bob/venv/bin/pypr`).\n> You can get it from `which pypr` in a working terminal\n\nOnce the `pypr` daemon is started (cf `exec-once`), you can list the eventual commands which have been added by the plugins using `pypr -h` or `pypr help`, those commands are generally meant to be use via key bindings, see the `hyprland.conf` part of *Configuring* section below.\n\n## Configuring\n\nCreate a configuration file in `~/.config/hypr/pyprland.toml` enabling a list of plugins, each plugin may have its own configuration needs or don't need any configuration at all.\nMost default values should be acceptable for most users, options which hare not mandatory are marked as such.\n\n> [!important]\n> Provide the values for the configuration options which have no annotation such as \"(optional)\"\n\nCheck the [TOML format](https://toml.io/) for details about the syntax.\n\nSimple example:\n\n```toml\n[pyprland]\nplugins = [\n    \"shift_monitors\",\n    \"workspaces_follow_focus\"\n]\n```\n\n<details>\n  <summary>\nMore complex example\n  </summary>\n\n```toml\n[pyprland]\nplugins = [\n  \"scratchpads\",\n  \"lost_windows\",\n  \"monitors\",\n  \"toggle_dpms\",\n  \"magnify\",\n  \"expose\",\n  \"shift_monitors\",\n  \"workspaces_follow_focus\",\n]\n\n[monitors.placement]\n\"Acer\".top_center_of = \"Sony\"\n\n[workspaces_follow_focus]\nmax_workspaces = 9\n\n[expose]\ninclude_special = false\n\n[scratchpads.stb]\nanimation = \"fromBottom\"\ncommand = \"kitty --class kitty-stb sstb\"\nclass = \"kitty-stb\"\nlazy = true\nsize = \"75% 45%\"\n\n[scratchpads.stb-logs]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-stb-logs stbLog\"\nclass = \"kitty-stb-logs\"\nlazy = true\nsize = \"75% 40%\"\n\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nlazy = true\nsize = \"40% 90%\"\nunfocus = \"hide\"\n```\n\nSome of those plugins may require changes in your `hyprland.conf` to fully operate or to provide a convenient access to a command, eg:\n\n```bash\n\n$pypr = uwsm-app -- /usr/local/bin/pypr-client\n\nbind = $mainMod SHIFT,  Z, exec, $pypr zoom\nbind = $mainMod ALT,    P, exec, $pypr toggle_dpms\nbind = $mainMod SHIFT,  O, exec, $pypr shift_monitors +1\nbind = $mainMod,        B, exec, $pypr expose\nbind = $mainMod,        K, exec, $pypr change_workspace +1\nbind = $mainMod,        J, exec, $pypr change_workspace -1\nbind = $mainMod,        L, exec, $pypr toggle_dpms\nbind = $mainMod  SHIFT, M, exec, $pypr toggle stb stb-logs\nbind = $mainMod,        A, exec, $pypr toggle term\nbind = $mainMod,        V, exec, $pypr toggle volume\n```\n\nThis example makes use of the faster `pypr-client` which may not be available depending on how you installed,\nin case you want to install it manually, just download [the source code](https://github.com/hyprland-community/pyprland/blob/main/client/) and compile it (eg: `gcc -o pypr-client pypr-client.c`).\nRust, Go and C versions are available.\n\n\n</details>\n\n> [!tip]\n> Consult or share [configuration files](https://github.com/hyprland-community/pyprland/tree/main/examples)\n>\n> You might also be interested in [optimizations](./Optimizations).\n"
  },
  {
    "path": "site/versions/2.6.2/InstallVirtualEnvironment.md",
    "content": "# Virtual env\n\nEven though the best way to get Pyprland installed is to use your operating system package manager,\nfor some usages or users it can be convenient to use a virtual environment.\n\nThis is very easy to achieve in a couple of steps:\n\n```shell\npython -m venv ~/pypr-env\n~/pypr-env/bin/pip install pyprland\n```\n\n**That's all folks!**\n\nThe only extra care to take is to use `pypr` from the virtual environment, eg:\n\n- adding the environment's \"bin\" folder to the `PATH` (using `export PATH=\"$PATH:~/pypr-env/bin/\"` in your shell configuration file)\n- always using the full path to the pypr command (in `hyprland.conf`: `exec-once = ~/pypr-env/bin/pypr --debug /tmp/pypr.log`)\n\n# Going bleeding edge!\n\nIf you would rather like to use the latest version available (not released yet), then you can clone the git repository and install from it:\n\n```shell\ncd ~/pypr-env\ngit clone git@github.com:hyprland-community/pyprland.git pyprland-sources\ncd pyprland-sources\n../bin/pip install -e .\n```\n\n## Updating\n\n```shell\ncd ~/pypr-env\ngit pull -r\n```\n\n# Troubelshooting\n\nIf things go wrong, try (eg: after a system upgrade where Python got updated):\n\n```shell\npython -m venv --upgrade ~/pypr-env\ncd  ~/pypr-env\n../bin/pip install -e .\n```\n"
  },
  {
    "path": "site/versions/2.6.2/Menu.md",
    "content": "# Menu capability\n\nMenu based plugins have the following configuration options:\n\n### `engine`\n\nNot set by default, will autodetect the available menu engine.\n\nSupported engines (will be tested in order):\n\n- fuzzel\n- tofi\n- rofi\n- wofi\n- bemenu\n- dmenu\n- anyrun\n- walker\n\n> [!note]\n> If your menu system isn't supported, you can open a [feature request](https://github.com/hyprland-community/pyprland/issues/new?assignees=fdev31&labels=bug&projects=&template=feature_request.md&title=%5BFEAT%5D+Description+of+the+feature)\n>\n> In case the engine isn't recognized, `engine` + `parameters` configuration options will be used to start the process, it requires a dmenu-like behavior.\n\n### `parameters`\n\nExtra parameters added to the engine command, the default value is specific to each engine.\n\n> [!important]\n> Setting this will override the default value!\n>\n> In general, *rofi*-like programs will require at least `-dmenu` option.\n\n> [!tip]\n> You can use '[prompt]' in the parameters, it will be replaced by the prompt, eg:\n> ```sh\n> -dmenu -matching fuzzy -i -p '[prompt]'\n> ```\n"
  },
  {
    "path": "site/versions/2.6.2/MultipleConfigurationFiles.md",
    "content": "### Multiple configuration files\n\nYou can also split your configuration into multiple files that will be loaded in the provided order after the main file:\n```toml\n[pyprland]\ninclude = [\"/shared/pyprland.toml\", \"~/pypr_extra_config.toml\"]\n```\nYou can also load folders, in which case TOML files in the folder will be loaded in alphabetical order:\n```toml\n[pyprland]\ninclude = [\"~/.config/pypr.d/\"]\n```\n\nAnd then add a `~/.config/pypr.d/monitors.toml` file:\n```toml\npyprland.plugins = [ \"monitors\" ]\n\n[monitors.placement]\nBenQ.Top_Center_Of = \"DP-1\" # projo\n\"CJFH277Q3HCB\".top_of = \"eDP-1\" # work\n```\n\n> [!tip]\n> To check the final merged configuration, you can use the `dumpjson` command.\n\n"
  },
  {
    "path": "site/versions/2.6.2/Nix.md",
    "content": "# Nix\n\nYou are recommended to get the latest pyprland package from the [flake.nix](https://github.com/hyprland-community/pyprland/blob/main/flake.nix)\nprovided within this repository. To use it in your system, you may add pyprland\nto your flake inputs.\n\n## Flake\n\n```nix\n## flake.nix\n{\n  inputs = {\n    nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n    pyprland.url = \"github:hyprland-community/pyprland\";\n  };\n\n  outputs = { self, nixpkgs, pyprland, ...}: let\n  in {\n    nixosConfigurations.<yourHostname> = nixpkgs.lib.nixosSystem {\n      #         remember to replace this with your system arch ↓\n      environment.systemPackages = [ pyprland.packages.\"x86_64-linux\".pyprland ];\n      # ...\n    };\n  };\n}\n```\n\nAlternatively, if you are using the Nix package manager but not NixOS as your\nmain distribution, you may use `nix profile` tool to install pyprland from this\nrepository using the following command.\n\n```bash\nnix profile install github:nix-community/pyprland\n```\n\nThe package will now be in your latest profile. You may use `nix profile list`\nto verify your installation.\n\n## Nixpkgs\n\nPyprland is available under nixpkgs, and can be installed by adding\n`pkgs.pyprland` to either `environment.systemPackages` or `home.packages`\ndepending on whether you want it available system-wide or to only a single\nuser using home-manager. If the derivation available in nixpkgs is out-of-date\nthen you may consider using `overrideAttrs` to update the source locally.\n\n```nix\nlet\n  pyprland = pkgs.pyprland.overrideAttrs {\n    version = \"your-version-here\";\n    src = fetchFromGitHub {\n      owner = \"hyprland-community\";\n      repo = \"pyprland\";\n      rev = \"tag-or-revision\";\n      # leave empty for the first time, add the new hash from the error message\n      hash = \"\";\n    };\n  };\nin {\n  # add the overridden package to systemPackages\n  environment.systemPackages = [pyprland];\n}\n```\n"
  },
  {
    "path": "site/versions/2.6.2/Optimizations.md",
    "content": "## Optimizing\n\n### Plugins\n\n- Only enable the plugins you are using in the `plugins` array (in `[pyprland]` section).\n- Leaving the configuration for plugins which are not enabled will have no impact.\n- Using multiple configuration files only have a small impact on the startup time.\n\n### Pypr\n\nYou can run `pypr` using `pypy` (version 3) for a more snappy experience.\nOne way is to use a `pypy3` virtual environment:\n\n```bash\npypy3 -m venv pypr-venv\nsource ./pypr-venv/bin/activate\ncd <pypr source folder>\npip install -e .\n```\n\n### Pypr command\n\nIn case you want to save some time when interacting with the daemon, the simplest is to use one of the `pypr-client` implementations as mentioned in the [getting started page](./Getting-started.md). If for some reason `pypr-client` isn't made available by the package of your OS and can not compile code,\nyou can use `socat` instead (needs to be installed).\n\nExample of a `pypr-cli` command (should be reachable from your environment's `PATH`):\n```sh\n#!/bin/sh\nsocat - \"UNIX-CONNECT:${XDG_RUNTIME_DIR}/hypr/${HYPRLAND_INSTANCE_SIGNATURE}/.pyprland.sock\" <<< $@\n```\nOn slow systems this may make a difference.\nNote that the \"help\" command will require usage of the standard `pypr` command.\n"
  },
  {
    "path": "site/versions/2.6.2/Plugins.md",
    "content": "<script setup>\nimport PluginList from './components/PluginList.vue'\n</script>\n\nThis page lists every plugin provided by Pyprland out of the box, more can be enabled if you install the matching package.\n\n\"🌟\" indicates some maturity & reliability level of the plugin, considering age, attention paid and complexity - from 0 to 3.\n\nSome plugins require an external **graphical menu system**, such as *rofi*.\nEach plugin can use a different menu system but the [configuration is unified](Menu). In case no [engine](Menu#engine) is provided some auto-detection of installed applications will happen.\n\n<PluginList/>\n"
  },
  {
    "path": "site/versions/2.6.2/Troubleshooting.md",
    "content": "# Troubleshooting\n\n## General\n\nIn case of trouble running a `pypr` command:\n- kill the existing pypr if any (try `pypr exit` first)\n- run from the terminal adding `--debug /dev/null` to the arguments to get more information\n\nIf the client says it can't connect, then there is a high chance pypr daemon didn't start, check if it's running using `ps axuw |grep pypr`. You can try to run it from a terminal with the same technique: `pypr --debug /dev/null` and see if any error occurs.\n\n## Force hyprland version\n\nIn case your `hyprctl version -j` command isn't returning an accurate version, you can make Pyprland ignore it and use a provided value instead:\n\n```toml\n[pyprland]\nhyprland_version = \"0.41.0\"\n```\n\n## Unresponsive scratchpads\n\nScratchpads aren't responding for few seconds after trying to show one (which didn't show!)\n\nThis may happen if an application is very slow to start.\nIn that case pypr will wait for a window blocking other scratchpad's operation, before giving up after a few seconds.\n\nNote that other plugins shouldn't be blocked by it.\n\nMore scratchpads troubleshooting can be found [here](./scratchpads_nonstandard).\n"
  },
  {
    "path": "site/versions/2.6.2/Variables.md",
    "content": "# Variables & substitutions\n\nSome commands support shared global variables, they must be defined in the *pyprland* section of the configuration:\n```toml\n[pyprland.variables]\nterm = \"foot\"\nterm_classed = \"foot -a\" # kitty uses --class\n```\n\nIf a plugin supports it, you can then use the variables in the attribute that supports it, eg:\n\n```toml\n[myplugin]\nsome_variable = \"the terminal is [term]\"\n```\n"
  },
  {
    "path": "site/versions/2.6.2/components/CommandList.vue",
    "content": "<template>\n  <ul v-for=\"command in commands\" :key=\"command.name\">\n    <li>\n      <code v-html=\"command.name.replace(/[ ]*$/, '').replace(/ +/g, '&ensp;')\" />&ensp;\n      <span v-html=\"renderDescription(command.description)\" />\n    </li>\n  </ul>\n</template>\n\n<script>\nimport MarkdownIt from 'markdown-it'\n\nconst md = new MarkdownIt({ html: true, linkify: true })\n\nexport default {\n  props: {\n    commands: {\n      type: Array,\n      required: true\n    }\n  },\n  methods: {\n    renderDescription(text) {\n      return md.renderInline(text)\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "site/versions/2.6.2/components/PluginList.vue",
    "content": "<template>\n    <div>\n        <h1>Built-in plugins</h1>\n        <div v-for=\"plugin in sortedPlugins\" :key=\"plugin.name\" class=\"plugin-item\">\n            <div class=\"plugin-info\">\n                <h3>\n                    <a :href=\"plugin.name + '.html'\">{{ plugin.name }}</a>\n                    <span>&nbsp;{{ '🌟'.repeat(plugin.stars) }}</span>\n                    <span v-if=\"plugin.multimon\">\n                        <Badge type=\"tip\" text=\"multi-monitor\" />\n                    </span>\n                </h3>\n                <p v-html=\"plugin.description\" />\n            </div>\n            <a v-if=\"plugin.demoVideoId\" :href=\"'https://www.youtube.com/watch?v=' + plugin.demoVideoId\"\n                class=\"plugin-video\">\n                <img :src=\"'https://img.youtube.com/vi/' + plugin.demoVideoId + '/1.jpg'\" alt=\"Demo video thumbnail\" />\n            </a>\n        </div>\n    </div>\n</template>\n\n<style scoped>\n.plugin-item {\n    display: flex;\n    align-items: flex-start;\n    margin-bottom: 1.5rem;\n}\n\n.plugin-info {\n    flex: 1;\n}\n\n.plugin-video {\n    margin-left: 1rem;\n}\n\n.plugin-video img {\n    max-width: 200px;\n    /* Adjust as needed */\n    height: auto;\n}\n</style>\n\n\n<script>\n\nexport default {\n    computed: {\n        sortedPlugins() {\n            if (this.sortByStars) {\n                return this.plugins.slice().sort((a, b) => {\n                    if (b.stars === a.stars) {\n                        return a.name.localeCompare(b.name);\n                    }\n                    return b.stars - a.stars;\n                });\n            } else {\n                return this.plugins.slice().sort((a, b) => a.name.localeCompare(b.name));\n            }\n        }\n    },\n    data() {\n        return {\n            sortByStars: false,\n            plugins: [\n                {\n                    name: 'scratchpads',\n                    stars: 3,\n                    description: 'makes your applications dropdowns & togglable poppups',\n                    demoVideoId: 'ZOhv59VYqkc'\n                },\n                {\n                    name: 'magnify',\n                    stars: 3,\n                    description: 'toggles zooming of viewport or sets a specific scaling factor',\n                    demoVideoId: 'yN-mhh9aDuo'\n\n                },\n                {\n                    name: 'toggle_special',\n                    stars: 3,\n                    description: 'moves windows from/to special workspaces',\n                    demoVideoId: 'BNZCMqkwTOo'\n                },\n                {\n                    name: 'shortcuts_menu',\n                    stars: 3,\n                    description: 'a flexible way to make your own shortcuts menus & launchers',\n                    demoVideoId: 'UCuS417BZK8'\n                },\n                {\n                    name: 'fetch_client_menu',\n                    stars: 3,\n                    description: 'select a window to be moved to your active workspace (using rofi, dmenu, etc...)',\n                },\n                {\n                    name: 'layout_center',\n                    stars: 3,\n                    description: 'a workspace layout where one client window is almost maximized and others seat in the background',\n                    demoVideoId: 'vEr9eeSJYDc'\n                },\n                {\n                    name: 'system_notifier',\n                    stars: 3,\n                    description: 'open streams (eg: journal logs) and trigger notifications',\n                },\n                {\n                    name: 'wallpapers',\n                    stars: 3,\n                    description: 'handles random wallpapers at regular interval (from a folder). Supports rounded corners and color scheme generation.'\n                },\n                {\n                    name: 'bar',\n                    stars: 3,\n                    description: 'improves multi-monitor handling of the status bar - restarts it on crashes'\n                },\n                {\n                    name: 'expose',\n                    stars: 2,\n                    description: 'exposes all the windows for a quick \"jump to\" feature',\n                    demoVideoId: 'ce5HQZ3na8M'\n                },\n                {\n                    name: 'lost_windows',\n                    stars: 1,\n                    description: 'brings lost floating windows (which are out of reach) to the current workspace'\n                },\n                {\n                    name: 'toggle_dpms',\n                    stars: 0,\n                    description: 'toggles the DPMS status of every plugged monitor'\n                },\n                {\n                    name: 'workspaces_follow_focus',\n                    multimon: true,\n                    stars: 3,\n                    description: 'makes non visible workspaces available on the currently focused screen.<br/> If you think the multi-screen behavior of Hyprland is not usable or broken/unexpected, this is probably for you.'\n\n                },\n                {\n                    name: 'shift_monitors',\n                    multimon: true,\n                    stars: 3,\n                    description: 'moves workspaces from monitor to monitor (caroussel)'\n                },\n                {\n                    name: 'monitors',\n                    stars: 3,\n                    description: 'allows relative placement and configuration of monitors'\n                },\n                {\n                    name: 'fcitx5_switcher',\n                    stars: 0,\n                    description: 'Automatically switch fcitx5 input method status based on window class and title.'\n                }\n            ]\n        }\n    }\n}\n</script>\n"
  },
  {
    "path": "site/versions/2.6.2/expose.md",
    "content": "---\ncommands:\n  - name: expose\n    description: Expose every client on the active workspace. If expose is already active, then restores everything and move to the focused window.\n---\n\n# expose\n\nImplements the \"expose\" effect, showing every client window on the focused screen.\n\nFor a similar feature using a menu, try the [fetch_client_menu](./fetch_client_menu) plugin (less intrusive).\n\nSample `hyprland.conf`:\n\n```bash\n# Setup the key binding\nbind = $mainMod, B, exec, pypr expose\n\n# Add some style to the \"exposed\" workspace\nworkspace = special:exposed,gapsout:60,gapsin:30,bordersize:5,border:true,shadow:false\n```\n\n`MOD+B` will bring every client to the focused workspace, pressed again it will go to this workspace.\n\nCheck [workspace rules](https://wiki.hyprland.org/Configuring/Workspace-Rules/#rules) for styling options.\n\n> [!note]\n> If you are looking for `toggle_minimized`, check the [toggle_special](./toggle_special) plugin\n\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n\n### `include_special`\n\ndefault value is `false`\n\nAlso include windows in the special workspaces during the expose.\n\n"
  },
  {
    "path": "site/versions/2.6.2/fcitx5_switcher.md",
    "content": "---\ncommand:\n  - name: fcitx5_switcher\n    description: Automatically switch fcitx5 input method status based on window class and title.\n---\n\n# fcitx5_switcher\n\nThis is a useful tool for CJK input method users.\n\nIt can automatically switch fcitx5 input method status based on window class and title.\n\nExample:\n```toml\n[fcitx5_switcher]\nactive_classes = [\"wechat\", \"QQ\", \"zoom\"]\ninactive_classes = [\n    \"code\",\n    \"kitty\",\n    \"google-chrome\",\n]\nactive_titles = []\ninactive_titles = []\n```\n\nIn this example, if the window class is \"wechat\" or \"QQ\" or \"zoom\", the input method will be activated. If the window class is \"code\" or \"kitty\" or \"google-chrome\", the input method will be inactivated."
  },
  {
    "path": "site/versions/2.6.2/fetch_client_menu.md",
    "content": "---\ncommands:\n  - name: fetch_client_menu\n    description: Bring any window to the active workspace using a menu.\n---\n\n# fetch_client_menu\n\nBring any window to the active workspace using a menu.\n\nA bit like the [expose](./expose) plugin but using a menu instead (less intrusive).\n\nIt brings the window to the current workspace, while [expose](./expose) moves the currently focused screen to the application workspace.\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\nAll the [Menu](Menu) configuration items are also available.\n\n### `separator`\n\ndefault value is `\"|\"`\n\nChanges the character (or string) used to separate a menu entry from its entry number.\n\n"
  },
  {
    "path": "site/versions/2.6.2/filters.md",
    "content": "# Text filters\n\nAt the moment there is only one filter, close match of [sed's \"s\" command](https://www.gnu.org/software/sed/manual/html_node/The-_0022s_0022-Command.html).\n\nThe only supported flag is \"g\"\n\n## Example\n\n```toml\nfilter = 's/foo/bar/'\nfilter = 's/.*started (.*)/\\1 has started/'\nfilter = 's#</?div>##g'\n```\n"
  },
  {
    "path": "site/versions/2.6.2/index.md",
    "content": "---\n# https://vitepress.dev/reference/default-theme-home-page\nlayout: home\n\nhero:\n  text: \"Hyprland extensions\"\n  tagline: Enhance your desktop capabilities with Pyprland\n  image:\n    src: /logo.svg\n    alt: logo\n  actions:\n    - theme: brand\n      text: Getting started\n      link: ./Getting-started\n    - theme: alt\n      text: Plugins\n      link: ./Plugins\n    - theme: alt\n      text: Report something\n      link: https://github.com/hyprland-community/pyprland/issues/new/choose\n\nfeatures:\n  - title: TOML format\n    details: Simple and flexible configuration file(s)\n  - title: Customizable\n    details: Create your own Hyprland experience\n  - title: Fast and easy\n    details: Designed for performance and simplicity\n---\n\n# What is Pyprland?\n\nIt's a software that extends the functionality of the great [Hyprland](https://hyprland.org/) window manager, adding new features and improving the existing ones.\n\nIt also enables a high degree of customization and automation, making it easier to adapt to your workflow.\n\nTo understand the potential of Pyprland, you can check the [plugins](./Plugins) page.\n\n# Major recent changes\n\n- Major rewrite of the [Monitors plugin](/monitors) delivers improved stability and functionality.\n- The [Wallpapers plugin](/wallpapers) now applies [rounded corners](/wallpapers#radius) per display and derives cohesive [color schemes from the background](/wallpapers#templates) (Matugen/Pywal-inspired).\n\n"
  },
  {
    "path": "site/versions/2.6.2/layout_center.md",
    "content": "---\ncommands:\n  - name: layout_center toggle\n    description: toggles the layout on and off\n  - name: layout_center next\n    description: switches to the next window (if layout is on) else runs the [next](#next) command\n  - name: layout_center prev\n    description: switches to the previous window (if layout is on) else runs the [prev](#prev) command\n  - name: layout_center next2\n    description: switches to the next window (if layout is on) else runs the [prev](#prev2) command\n  - name: layout_center prev2\n    description: switches to the previous window (if layout is on) else runs the [prev2](#prev2) command\n\n---\n# layout_center\n\nImplements a workspace layout where one window is bigger and centered,\nother windows are tiled as usual in the background.\n\nOn `toggle`, the active window is made floating and centered if the layout wasn't enabled, else reverts the floating status.\n\nWith `next` and `prev` you can cycle the active window, keeping the same layout type.\nIf the layout_center isn't active and `next` or `prev` is used, it will call the [next](#next) and [prev](#prev) configuration options.\n\nTo allow full override of the focus keys, [next2](#next2) and [prev2](#prev2) are provided, they do the same actions as \"next\" and \"prev\" but allow different fallback commands.\n\n<details>\n<summary>Configuration sample</summary>\n\n```toml\n[layout_center]\nmargin = 60\noffset = [0, 30]\nnext = \"movefocus r\"\nprev = \"movefocus l\"\nnext2 = \"movefocus d\"\nprev2 = \"movefocus u\"\n```\n\nusing the following in `hyprland.conf`:\n```sh\nbind = $mainMod, M, exec, pypr layout_center toggle # toggle the layout\n## focus change keys\nbind = $mainMod, left, exec, pypr layout_center prev\nbind = $mainMod, right, exec, pypr layout_center next\nbind = $mainMod, up, exec, pypr layout_center prev2\nbind = $mainMod, down, exec, pypr layout_center next2\n```\n\nYou can completely ignore `next2` and `prev2` if you are allowing focus change in a single direction (when the layout is enabled), eg:\n\n```sh\nbind = $mainMod, up, movefocus, u\nbind = $mainMod, down, movefocus, d\n```\n\n</details>\n\n\n## Commands\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `on_new_client`\n\nDefaults to `\"foreground\"`.\n\nChanges the behavior when a new window opens, possible options are:\n\n- \"foreground\" to make the new window the main window\n- \"background\" to make the new window appear in the background\n- \"close\" to stop the centered layout when a new window opens\n\n### `style`\n\n| Requires Hyprland > 0.40.0\n\nNot set by default.\n\nAllow to set a list of styles to the main (centered) window, eg:\n\n```toml\nstyle = [\"opacity 1\", \"bordercolor rgb(FFFF00)\"]\n```\n\n### `margin`\n\ndefault value is `60`\n\nmargin (in pixels) used when placing the center window, calculated from the border of the screen.\n\nExample to make the main window be 100px far from the monitor's limits:\n```toml\nmargin = 100\n```\n\nYou can also set a different margin for width and height by using a list:\n```toml\nmargin = [10, 60]\n```\n\n### `offset`\n\ndefault value is `[0, 0]`\n\noffset in pixels applied to the main window position\n\nExample shift the main window 20px down:\n```toml\noffset = [0, 20]\n```\n\n### `next`\n### `next2`\n\nnot set by default\n\nWhen the *layout_center* isn't active and the *next* command is triggered, defines the hyprland dispatcher command to run.\n\n`next2` is a similar option, used by the `next2` command, allowing to map \"next\" to both vertical and horizontal focus change.\n\nEg:\n```toml\nnext = \"movefocus r\"\n```\n\n### `prev`\n### `prev2`\n\nSame as `next` but for the `prev` and `prev2` commands.\n\n\n### `captive_focus`\n\ndefault value is `false`\n\n```toml\ncaptive_focus = true\n```\n\nSets the focus on the main window when the focus changes.\nYou may love it or hate it...\n"
  },
  {
    "path": "site/versions/2.6.2/lost_windows.md",
    "content": "---\ncommands:\n    - name: attract_lost\n      description: Bring windows which are not reachable in the currently focused workspace.\n---\n# lost_windows\n\nBring windows which are not reachable in the currently focused workspace.\n*Deprecated:* Was used to work around issues in Hyprland which have been fixed since then.\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n"
  },
  {
    "path": "site/versions/2.6.2/magnify.md",
    "content": "---\ncommands:\n    - name: zoom [value]\n      description: Set the current zoom level (absolute or relative) - toggle zooming if no value is provided\n---\n# magnify\n\nZooms in and out with an optional animation.\n\n\n<details>\n    <summary>Example</summary>\n\n```sh\npypr zoom  # sets zoom to `factor` (2 by default)\npypr zoom +1  # will set zoom to 3x\npypr zoom  # will set zoom to 1x\npypr zoom 1 # will (also) set zoom to 1x - effectively doing nothing\n```\n\nSample `hyprland.conf`:\n\n```sh\nbind = $mainMod , Z, exec, pypr zoom ++0.5\nbind = $mainMod SHIFT, Z, exec, pypr zoom\n```\n\n</details>\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n### `[value]`\n\n#### unset / not specified\n\nWill zoom to [factor](#factor-optional) if not zoomed, else will set the zoom to 1x.\n\n#### floating or integer value\n\nWill set the zoom to the provided value.\n\n#### +value / -value\n\nUpdate (increment or decrement) the current zoom level by the provided value.\n\n#### ++value / --value\n\nUpdate (increment or decrement) the current zoom level by a non-linear scale.\nIt _looks_ more linear changes than using a single + or -.\n\n> [!NOTE]\n>\n> The non-linear scale is calculated as powers of two, eg:\n>\n> - `zoom ++1` → 2x, 4x, 8x, 16x...\n> - `zoom ++0.7` → 1.6x, 2.6x, 4.3x, 7.0x, 11.3x, 18.4x...\n> - `zoom ++0.5` → 1.4x, 2x, 2.8x, 4x, 5.7x, 8x, 11.3x, 16x...\n\n## Configuration\n\n### `factor`\n\ndefault value is `2`\n\nScaling factor to be used when no value is provided.\n\n### `duration`\n\nDefault value is `0`\n\nDuration in tenths of a second for the zoom animation to last, set to `15` for the former behavior.\nIt is not needed anymore with recent Hyprland versions, you can even customize the animation in use:\n\nin *Hyprland* config:\n```\nanimations {\n    bezier = easeInOut,0.65, 0, 0.35, 1\n    animation = zoomFactor, 1, 4, easeInOut\n}\n```\n"
  },
  {
    "path": "site/versions/2.6.2/menubar.md",
    "content": "---\ncommands:\n  - name: bar restart\n    description: Restart/refresh Menu Bar on the \"best\" monitor.\n  - name: bar stop\n    description: Stop the Menu Bar process\n---\n\n# menubar\n\nRuns your favorite bar app (gbar, ags / hyprpanel, waybar, ...) with option to pass the \"best\" monitor from a list of monitors.\n\n- Will take care of starting the command on startup (you must not run it from another source like `hyprland.conf`).\n- Automatically restarts the menu bar on crash\n- Checks which monitors are on and take the best one from a provided list\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `command` (REQUIRED)\n\nThe command which runs the menu bar. The string `[monitor]` will be replaced by the best monitor.\n\n### `monitors`\n\nList of monitors to chose from, the first have higher priority over the second one etc...\n\n\n## Example\n\n```sh\n[gbar]\nmonitors = [\"DP-1\", \"HDMI-1\", \"HDMI-1-A\"]\n```\n"
  },
  {
    "path": "site/versions/2.6.2/monitors.md",
    "content": "---\ncommands:\n  - name: relayout\n    description: Apply the configuration and update the layout\n---\n\n# monitors\n\n> First, simpler version of the plugin is still available under the name `monitors_v0`\n\nAllows relative placement of monitors depending on the model (\"description\" returned by `hyprctl monitors`).\nUseful if you have multiple monitors connected to a video signal switch or using a laptop and plugging monitors having different relative positions.\n\nSyntax:\n\n```toml\n[monitors.placement]\n\"description match\".placement = \"other description match\"\n```\n\n<details>\n    <summary>Example to set a Sony monitor on top of a BenQ monitor</summary>\n\n```toml\n[monitors.placement]\nSony.topOf = \"BenQ\"\n\n## Character case is ignored, \"_\" can be added\nSony.Top_Of = [\"BenQ\"]\n\n## Thanks to TOML format, complex configurations can use separate \"sections\" for clarity, eg:\n\n[monitors.placement.\"My monitor brand\"]\n## You can also use \"port\" names such as *HDMI-A-1*, *DP-1*, etc...\nleftOf = \"eDP-1\"\n\n## lists are possible on the right part of the assignment:\nrightOf = [\"Sony\", \"BenQ\"]\n\n## > 2.3.2: you can also set scale, transform & rate for a given monitor\n[monitors.placement.Microstep]\nrate = 100\n```\n\nTry to keep the rules as simple as possible, but relatively complex scenarios are supported.\n\n> [!note]\n> Check [wlr layout UI](https://github.com/fdev31/wlr-layout-ui) which is a nice complement to configure your monitor settings.\n\n</details>\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `placement` (REQUIRED)\n\nSupported placements are:\n\n- leftOf\n- topOf\n- rightOf\n- bottomOf\n- \\<one of the above>(center|middle|end)Of\n\n> [!important]\n> If you don't like the screen to align on the start of the given border,\n> you can use `center` (or `middle`) to center it or `end` to stick it to the opposite border.\n> Eg: \"topCenterOf\", \"leftEndOf\", etc...\n\nYou can separate the terms with \"_\" to improve the readability, as in \"top_center_of\".\n\n#### monitor settings\n\nNot only can you place monitors relatively to each other, but you can also set specific settings for a given monitor.\n\nThe following settings are supported:\n\n- scale\n- transform\n- rate\n- resolution\n\n```toml\n[monitors.placement.\"My monitor brand\"]\nrate = 60\nscale = 1.5\ntransform = 1 # 0: normal, 1: 90°, 2: 180°, 3: 270°, 4: flipped, 5: flipped 90°, 6: flipped 180°, 7: flipped 270°\nresolution = \"1920x1080\"  # can also be expressed as [1920, 1080]\n```\n\n### `startup_relayout`\n\nDefault to `ŧrue`.\n\nWhen set to `false`,\ndo not initialize the monitor layout on startup or when configuration is reloaded.\n\n### `new_monitor_delay`\n\nBy default,\nthe layout computation happens one second after the event is received to let time for things to settle.\n\nYou can change this value using this option.\n\n### `hotplug_command`\n\nNone by default, allows to run a command when any monitor is plugged.\n\n\n```toml\n[monitors]\nhotplug_commands = \"wlrlui -m\"\n```\n\n### `hotplug_commands`\n\nNone by default, allows to run a command when a given monitor is plugged.\n\nExample to load a specific profile using [wlr layout ui](https://github.com/fdev31/wlr-layout-ui):\n\n```toml\n[monitors.hotplug_commands]\n\"DELL P2417H CJFH277Q3HCB\" = \"wlrlui rotated\"\n```\n\n### `unknown`\n\nNone by default,\nallows to run a command when no monitor layout has been changed (no rule applied).\n\n```toml\n[monitors]\nunknown = \"wlrlui\"\n```\n\n### `trim_offset`\n\n`true` by default,\n\nPrevents having arbitrary window offsets or negative values.\n"
  },
  {
    "path": "site/versions/2.6.2/scratchpads.md",
    "content": "---\ncommands:\n  - name: toggle [scratchpad name]\n    description: Toggle the given scratchpad\n  - name: show [scratchpad name]\n    description: Show the given scratchpad\n  - name: hide [scratchpad name]\n    description: Hide the given scratchpad\n  - name: attach\n    description: Toggle attaching/anchoring the currently focused window to the (last used) scratchpad. (see also [multi](#multi))\n\nNote: show and hide can accept '*' as a parameter, applying changes to every scratchpad.\n\n---\n# scratchpads\n\nEasily toggle the visibility of applications you use the most.\n\nConfigurable and flexible, while supporting complex setups it's easy to get started with:\n\n```toml\n[scratchpads.name]\ncommand = \"command to run\"\nclass = \"the window's class\"  # check: hyprctl clients | grep class\nsize = \"[width] [height]\"  # size of the window relative to the screen size\n```\n\n<details>\n<summary>Example</summary>\n\nAs an example, defining two scratchpads:\n\n- _term_ which would be a kitty terminal on upper part of the screen\n- _volume_ which would be a pavucontrol window on the right part of the screen\n\n\n```toml\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\nmax_size = \"1920px 100%\"\nmargin = 50\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nsize = \"40% 90%\"\nunfocus = \"hide\"\nlazy = true\n```\n\nShortcuts are generally needed:\n\n```ini\nbind = $mainMod,V,exec,pypr toggle volume\nbind = $mainMod,A,exec,pypr toggle term\nbind = $mainMod,Y,exec,pypr attach\n```\n</details>\n\n- If you wish to have a more generic space for any application you may run, check [toggle_special](./toggle_special).\n- When you create a scratchpad called \"name\", it will be hidden in `special:scratch_<name>`.\n- Providing `class` allows a glitch free experience, mostly noticeable when using animations\n\n\n## Commands\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n> [!tip]\n> You can use `\"*\"` as a _scratchpad name_ to target every scratchpad when using `show` or `hide`.\n> You'll need to quote or escape the `*` character to avoid interpretation from your shell.\n\n## Configuration\n\n### `command` (REQUIRED)\n\nThis is the command you wish to run in the scratchpad.\n\nIt supports [Variables](./Variables)\n\n### `animation`\n\nType of animation to use, default value is \"fromTop\":\n\n- `null` / `\"\"` (no animation)\n- `fromTop` (stays close to upper screen border)\n- `fromBottom` (stays close to lower screen border)\n- `fromLeft` (stays close to left screen border)\n- `fromRight` (stays close to right screen border)\n\n### `size` (recommended)\n\nNo default value.\n\nEach time scratchpad is shown, window will be resized according to the provided values.\n\nFor example on monitor of size `800x600` and `size= \"80% 80%\"` in config scratchpad always have size `640x480`,\nregardless of which monitor it was first launched on.\n\n> #### Format\n>\n> String with \"x y\" (or \"width height\") values using some units suffix:\n>\n> - **percents** relative to the focused screen size (`%` suffix), eg: `60% 30%`\n> - **pixels** for absolute values (`px` suffix), eg: `800px 600px`\n> - a mix is possible, eg: `800px 40%`\n\n### `class` (recommended)\n\nNo default value.\n\nAllows _Pyprland_ prepare the window for a correct animation and initial positioning.\n\n### `position`\n\nNo default value, overrides the automatic margin-based position.\n\nSets the scratchpad client window position relative to the top-left corner.\n\nSame format as `size` (see above)\n\nExample of scratchpad that always seat on the top-right corner of the screen:\n\n```toml\n[scratchpads.term_quake]\ncommand = \"wezterm start --class term_quake\"\nposition = \"50% 0%\"\nsize = \"50% 50%\"\nclass = \"term_quake\"\n```\n\n> [!note]\n> If `position` is not provided, the window is placed according to `margin` on one axis and centered on the other.\n\n### `multi`\n\nDefaults to `true`.\n\nWhen set to `false`, only one client window is supported for this scratchpad.\nOtherwise other matching windows will be **attach**ed to the scratchpad.\n\n## Advanced configuration\n\nTo go beyond the basic setup and have a look at every configuration item, you can read the following pages:\n\n- [Advanced](./scratchpads_advanced) contains options for fine-tuners or specific tastes (eg: i3 compatibility)\n- [Non-Standard](./scratchpads_nonstandard) contains options for \"broken\" applications\nlike progressive web apps (PWA) or emacsclient, use only if you can't get it to work otherwise\n\n## Monitor specific overrides\n\nYou can use different settings for a specific screen.\nMost attributes related to the display can be changed (not `command`, `class` or `process_tracking` for instance).\n\nUse the `monitor.<monitor name>` configuration item to override values, eg:\n\n```toml\n[scratchpads.music.monitor.eDP-1]\nposition = \"30% 50%\"\nanimation = \"fromBottom\"\n```\n\nYou may want to inline it for simple cases:\n\n```toml\n[scratchpads.music]\nmonitor = {HDMI-A-1={size = \"30% 50%\"}}\n```\n"
  },
  {
    "path": "site/versions/2.6.2/scratchpads_advanced.md",
    "content": "# Fine tuning scratchpads\n\nAdvanced configuration options\n\n## `use`\n\nNo default value.\n\nList of scratchpads (or single string) that will be used for the default values of this scratchpad.\nThink about *templates*:\n\n```toml\n[scratchpads.terminals]\nanimation = \"fromTop\"\nmargin = 50\nsize = \"75% 60%\"\nmax_size = \"1920px 100%\"\n\n[scratchpads.term]\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nuse = \"terminals\"\n```\n\n## `pinned`\n\n`true` by default\n\nMakes the scratchpad \"sticky\" to the monitor, following any workspace change.\n\n## `excludes`\n\nNo default value.\n\nList of scratchpads to hide when this one is displayed, eg: `excludes = [\"term\", \"volume\"]`.\nIf you want to hide every displayed scratch you can set this to the string `\"*\"` instead of a list: `excludes = \"*\"`.\n\n## `restore_excluded`\n\n`false` by default.\n\nWhen enabled, will remember the scratchpads which have been closed due to `excludes` rules, so when the scratchpad is hidden, those previously hidden scratchpads will be shown again.\n\n## `unfocus`\n\nNo default value.\n\nWhen set to `\"hide\"`, allow to hide the window when the focus is lost.\n\nUse `hysteresis` to change the reactivity\n\n## `hysteresis`\n\nDefaults to `0.4` (seconds)\n\nControls how fast a scratchpad hiding on unfocus will react. Check `unfocus` option.\nSet to `0` to disable.\n\n> [!important]\n> Only relevant when `unfocus=\"hide\"` is used.\n\n## `margin`\n\ndefault value is `60`.\n\nnumber of pixels separating the scratchpad from the screen border, depends on the [animation](./scratchpads#animation) set.\n\n> [!tip]\n> It is also possible to set a string to express percentages of the screen (eg: '`3%`').\n\n## `max_size`\n\nNo default value.\n\nSame format as `size` (see above), only used if `size` is also set.\n\nLimits the `size` of the window accordingly.\nTo ensure a window will not be too large on a wide screen for instance:\n\n```toml\nsize = \"60% 30%\"\nmax_size = \"1200px 100%\"\n```\n\n## `lazy`\n\ndefault to `false`.\n\nwhen set to `true`, prevents the command from being started when pypr starts, it will be started when the scratchpad is first used instead.\n\n- Good: saves resources when the scratchpad isn't needed\n- Bad: slows down the first display (app has to launch before showing)\n\n## `preserve_aspect`\n\nNot set by default.\nWhen set to `true`, will preserve the size and position of the scratchpad when called repeatedly from the same monitor and workspace even though an `animation` , `position` or `size` is used (those will be used for the initial setting only).\n\nForces the `lazy` option.\n\n## `offset`\n\nIn pixels, default to `0` (client's window size + margin).\n\nNumber of pixels for the **hide** sliding animation (how far the window will go).\n\n> [!tip]\n> - It is also possible to set a string to express percentages of the client window\n> - `margin` is automatically added to the offset\n> - automatic (value not set) is same as `\"100%\"`\n\n## `hide_delay`\n\nDefaults to `0.2`\n\nDelay (in seconds) after which the hide animation happens, before hiding the scratchpad.\n\nRule of thumb, if you have an animation with speed \"7\", as in:\n```bash\n    animation = windowsOut, 1, 7, easeInOut, popin 80%\n```\nYou can divide the value by two and round to the lowest value, here `3`, then divide by 10, leading to `hide_delay = 0.3`.\n\n## `restore_focus`\n\nEnabled by default, set to `false` if you don't want the focused state to be restored when a scratchpad is hidden.\n\n## `force_monitor`\n\nIf set to some monitor name (eg: `\"DP-1\"`), it will always use this monitor to show the scratchpad.\n\n## `alt_toggle`\n\nDefault value is `false`\n\nWhen enabled, use an alternative `toggle` command logic for multi-screen setups.\nIt applies when the `toggle` command is triggered and the toggled scratchpad is visible on a screen which is not the focused one.\n\nInstead of moving the scratchpad to the focused screen, it will hide the scratchpad.\n\n## `allow_special_workspaces`\n\nDefault value is `false` (can't be enabled when using *Hyprland* < 0.39 where this behavior can't be controlled and is disabled).\n\nWhen enabled, you can toggle a scratchpad over a special workspace.\nIt will always use the \"normal\" workspace otherwise.\n\n## `smart_focus`\n\nDefault value is `true`.\n\nWhen enabled, the focus will be restored in a best effort way as en attempt to improve the user experience.\nIf you face issues such as spontaneous workspace changes, you can disable this feature.\n\n## `close_on_hide`\n\nDefault value is `false`.\n\nWhen enabled, the window in the scratchpad is closed instead of hidden when `pypr hide <name>` is run.\nThis option implies `lazy = true`.\nThis can be useful on laptops where background apps may increase battery power draw.\n\nNote: Currently this option changes the hide animation to use hyprland's close window animation.\n\n"
  },
  {
    "path": "site/versions/2.6.2/scratchpads_nonstandard.md",
    "content": "# Troubleshooting scratchpads\n\nOptions that should only be used for applications that are not behaving in a \"standard\" way, such as `emacsclient` or progressive web apps.\n\n## `match_by`\n\nDefault value is `\"pid\"`\nWhen set to a sensitive client property value (eg: `class`, `initialClass`, `title`, `initialTitle`), will match the client window using the provided property instead of the PID of the process.\n\nThis property must be set accordingly, eg:\n\n```toml\nmatch_by = \"class\"\nclass = \"my-web-app\"\n```\n\nor\n\n```toml\nmatch_by = \"initialClass\"\ninitialClass = \"my-web-app\"\n```\n\nYou can add the \"re:\" prefix to use a regular expression, eg:\n\n```toml\nmatch_by = \"title\"\ntitle = \"re:.*some string.*\"\n```\n\n> [!note]\n> Some apps may open the graphical client window in a \"complicated\" way, to work around this, it is possible to disable the process PID matching algorithm and simply rely on window's class.\n>\n> The `match_by` attribute can be used to achieve this, eg. for emacsclient:\n> ```toml\n> [scratchpads.emacs]\n> command = \"/usr/local/bin/emacsStart.sh\"\n> class = \"Emacs\"\n> match_by = \"class\"\n> ```\n\n## `process_tracking`\n\nDefault value is `true`\n\nAllows disabling the process management. Use only if running a progressive web app (Chrome based apps) or similar.\n\nThis will automatically force `lazy = true` and set `match_by=\"class\"` if no `match_by` rule is provided, to help with the fuzzy client window matching.\n\nIt requires defining a `class` option (or the option matching your `match_by` value).\n\n```toml\n## Chat GPT on Brave\n[scratchpads.gpt]\nanimation = \"fromTop\"\ncommand = \"brave --profile-directory=Default --app=https://chat.openai.com\"\nclass = \"brave-chat.openai.com__-Default\"\nsize = \"75% 60%\"\nprocess_tracking = false\n\n## Some chrome app\n[scratchpads.music]\ncommand = \"google-chrome --profile-directory=Default --app-id=cinhimbnkkaeohfgghhklpknlkffjgod\"\nclass = \"chrome-cinhimbnkkaeohfgghhklpknlkffjgod-Default\"\nsize = \"50% 50%\"\nprocess_tracking = false\n```\n\n> [!tip]\n> To list windows by class and title you can use:\n> - `hyprctl -j clients | jq '.[]|[.class,.title]'`\n> - or if you prefer a graphical tool: `rofi -show window`\n\n> [!note]\n> Progressive web apps will share a single process for every window.\n> On top of requiring the class based window tracking (using `match_by`),\n> the process can not be managed the same way as usual apps and the correlation\n> between the process and the client window isn't as straightforward and can lead to false matches in extreme cases.\n\n## `skip_windowrules`\n\nDefault value is `[]`\nAllows you to skip the window rules for a specific scratchpad.\nAvailable rules are:\n\n- \"aspect\" controlling size and position\n- \"float\" controlling the floating state\n- \"workspace\" which moves the window to its own workspace\n\nIf you are using an application which can spawn multiple windows and you can't see them, you can skip rules made to improve the initial display of the window.\n\n```toml\n[scratchpads.filemanager]\nanimation = \"fromBottom\"\ncommand = \"nemo\"\nclass = \"nemo\"\nsize = \"60% 60%\"\nskip_windowrules = [\"aspect\", \"workspace\"]\n```\n"
  },
  {
    "path": "site/versions/2.6.2/shift_monitors.md",
    "content": "---\ncommands:\n    - name: shift_monitors <direction>\n      description: Swaps the workspaces of every screen in the given direction.\n---\n\n# shift_monitors\n\nSwaps the workspaces of every screen in the given direction.\n\n> [!Note]\n> the behavior can be hard to predict if you have more than 2 monitors (depending on your layout).\n> If you use this plugin with many monitors and have some ideas about a convenient configuration, you are welcome ;)\n\nExample usage in `hyprland.conf`:\n\n```\nbind = $mainMod, O, exec, pypr shift_monitors +1\nbind = $mainMod SHIFT, O, exec, pypr shift_monitors -1\n```\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n"
  },
  {
    "path": "site/versions/2.6.2/shortcuts_menu.md",
    "content": "---\ncommands:\n    - name: menu [name]\n      description: Displays the full menu or part of it if \"name\" is provided\n---\n\n# shortcuts_menu\n\nPresents some menu to run shortcut commands. Supports nested menus (aka categories / sub-menus).\n\n<details>\n   <summary>Configuration examples</summary>\n\n```toml\n[shortcuts_menu.entries]\n\n\"Open Jira ticket\" = 'open-jira-ticket \"$(wl-paste)\"'\nRelayout = \"pypr relayout\"\n\"Fetch window\" = \"pypr fetch_client_menu\"\n\"Hyprland socket\" = 'kitty  socat - \"UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock\"'\n\"Hyprland logs\" = 'kitty tail -f $XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/hyprland.log'\n\n\"Serial USB Term\" = [\n    {name=\"device\", command=\"ls -1 /dev/ttyUSB*; ls -1 /dev/ttyACM*\"},\n    {name=\"speed\", options=[\"115200\", \"9600\", \"38400\", \"115200\", \"256000\", \"512000\"]},\n    \"kitty miniterm --raw --eol LF [device] [speed]\"\n]\n\n\"Color picker\" = [\n    {name=\"format\", options=[\"hex\", \"rgb\", \"hsv\", \"hsl\", \"cmyk\"]},\n    \"sleep 0.2; hyprpicker --format [format] | wl-copy\" # sleep to let the menu close before the picker opens\n]\n\nscreenshot = [\n    {name=\"what\", options=[\"output\", \"window\", \"region\", \"active\"]},\n    \"hyprshot -m [what] -o /tmp -f shot_[what].png\"\n]\n\nannotate = [\n    {name=\"fname\", command=\"ls /tmp/shot_*.png\"},\n    \"satty --filename '[fname]' --output-filename '/tmp/annotated.png'\"\n]\n\n\"Clipboard history\" = [\n    {name=\"entry\", command=\"cliphist list\", filter=\"s/\\t.*//\"},\n    \"cliphist decode '[entry]' | wl-copy\"\n]\n\n\"Copy password\" = [\n    {name=\"what\", command=\"gopass ls --flat\"},\n    \"gopass show -c [what]\"\n]\n\n\"Update/Change password\" = [\n    {name=\"what\", command=\"gopass ls --flat\"},\n    \"kitty -- gopass generate -s --strict -t '[what]' && gopass show -c '[what]'\"\n]\n```\n\n</details>\n\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n> [!tip]\n> - If \"name\" is provided it will show the given sub-menu.\n> - You can use \".\" to reach any level of the configured menus.\n>\n>      Example to reach `shortcuts_menu.entries.utils.\"local commands\"`, use:\n>      ```sh\n>       pypr menu \"utils.local commands\"\n>      ```\n\n## Configuration\n\nAll the [Menu](./Menu) configuration items are also available.\n\n### `entries` (REQUIRED)\n\nDefines the menu entries. Supports [Variables](./Variables)\n\n```toml\n[shortcuts_menu.entries]\n\"entry 1\" = \"command to run\"\n\"entry 2\" = \"command to run\"\n```\nSubmenus can be defined too (there is no depth limit):\n\n```toml\n[shortcuts_menu.entries.\"My submenu\"]\n\"entry X\" = \"command\"\n\n[shortcuts_menu.entries.one.two.three.four.five]\nfoobar = \"ls\"\n```\n\n#### Advanced usage\n\nInstead of navigating a configured list of menu options and running a pre-defined command, you can collect various *variables* (either static list of options selected by the user, or generated from a shell command) and then run a command using those variables. Eg:\n\n```toml\n\"Play Video\" = [\n    {name=\"video_device\", command=\"ls -1 /dev/video*\"},\n    {name=\"player\",\n        options=[\"mpv\", \"guvcview\"]\n    },\n    \"[player] [video_device]\"\n]\n\n\"Ssh\" = [\n    {name=\"action\", options=[\"htop\", \"uptime\", \"sudo halt -p\"]},\n    {name=\"host\", options=[\"gamix\", \"gate\", \"idp\"]},\n    \"kitty --hold ssh [host] [action]\"\n]\n```\n\nYou must define a list of objects, containing:\n- `name`: the variable name\n- then the list of options, must be one of:\n    - `options` for a static list of options\n    - `command` to get the list of options from a shell command's output\n\n> [!tip]\n> You can apply post-filters to the `command` output, eg:\n> ```toml\n> {\n>   name = \"entry\",\n>   command = \"cliphist list\",\n>   filter = \"s/\\t.*//\"  # will remove everything after the TAB character\n> }\n> ```\n> check the [filters](./filters) page for more details\n\nThe last item of the list must be a string which is the command to run. Variables can be used enclosed in `[]`.\n\n### `command_start`\n### `command_end`\n### `submenu_start`\n### `submenu_end`\n\nAllow adding some text (eg: icon) before / after a menu entry.\n\ncommand_* is for final commands, while submenu_* is for entries leading to another menu.\n\nBy default `submenu_end` is set to a right arrow sign, while other attributes are not set.\n\n### `skip_single`\n\nDefaults to `true`.\nWhen disabled, shows the menu even for single options\n\n## Hints\n\n### Multiple menus\n\nTo manage multiple distinct menus, always use a name when using the `pypr menu <name>` command.\n\nExample of a multi-menu configuration:\n\n```toml\n[shortcuts_menu.entries.\"Basic commands\"]\n\"entry X\" = \"command\"\n\"entry Y\" = \"command2\"\n\n[shortcuts_menu.entries.menu2]\n## ...\n```\n\nYou can then show the first menu using `pypr menu \"Basic commands\"`\n"
  },
  {
    "path": "site/versions/2.6.2/sidebar.json",
    "content": "{\n  \"main\": [\n    {\n      \"text\": \"Getting started\",\n      \"link\": \"./Getting-started\"\n    },\n    {\n      \"text\": \"Plugins\",\n      \"link\": \"./Plugins\"\n    },\n    {\n      \"text\": \"Troubleshooting\",\n      \"link\": \"./Troubleshooting\"\n    },\n    {\n      \"text\": \"Development\",\n      \"link\": \"./Development\"\n    }\n  ],\n  \"plugins\": {\n    \"text\": \"Featured plugins\",\n    \"collapsed\": false,\n    \"items\": [\n      {\n        \"text\": \"Expose\",\n        \"link\": \"./expose\"\n      },\n      {\n        \"text\": \"Fcitx5 Switcher\",\n        \"link\": \"./fcitx5_switcher\"\n      },\n      {\n        \"text\": \"Fetch client menu\",\n        \"link\": \"./fetch_client_menu\"\n      },\n      {\n        \"text\": \"Menubar\",\n        \"link\": \"./menubar\"\n      },\n      {\n        \"text\": \"Layout center\",\n        \"link\": \"./layout_center\"\n      },\n      {\n        \"text\": \"Lost windows\",\n        \"link\": \"./lost_windows\"\n      },\n      {\n        \"text\": \"Magnify\",\n        \"link\": \"./magnify\"\n      },\n      {\n        \"text\": \"Monitors\",\n        \"link\": \"./monitors\"\n      },\n      {\n        \"text\": \"Scratchpads\",\n        \"link\": \"./scratchpads\",\n        \"items\": [\n          {\n            \"text\": \"Advanced\",\n            \"link\": \"./scratchpads_advanced\"\n          },\n          {\n            \"text\": \"Special cases\",\n            \"link\": \"./scratchpads_nonstandard\"\n          }\n        ]\n      },\n      {\n        \"text\": \"Shift monitors\",\n        \"link\": \"./shift_monitors\"\n      },\n      {\n        \"text\": \"Shortcuts menu\",\n        \"link\": \"./shortcuts_menu\"\n      },\n      {\n        \"text\": \"System notifier\",\n        \"link\": \"./system_notifier\"\n      },\n      {\n        \"text\": \"Toggle dpms\",\n        \"link\": \"./toggle_dpms\"\n      },\n      {\n        \"text\": \"Toggle special\",\n        \"link\": \"./toggle_special\"\n      },\n      {\n        \"text\": \"Wallpapers\",\n        \"link\": \"./wallpapers\"\n      },\n      {\n        \"text\": \"Workspaces follow focus\",\n        \"link\": \"./workspaces_follow_focus\"\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "site/versions/2.6.2/system_notifier.md",
    "content": "# system_notifier\n\nThis plugin adds system notifications based on journal logs (or any program's output).\nIt monitors specified **sources** for log entries matching predefined **patterns** and generates notifications accordingly (after applying an optional **filter**).\n\nSources are commands that return a stream of text (eg: journal, mqtt, `tail -f`, ...) which is sent to a parser that will use a [regular expression pattern](https://en.wikipedia.org/wiki/Regular_expression) to detect lines of interest and optionally transform them before sending the notification.\n\n<details>\n    <summary>Minimal configuration</summary>\n\n```toml\n[[system_notifier.sources]]\ncommand = \"journalctl -fx\"\nparser = \"journal\"\n```\n\nNo **sources** are defined by default, so you will need to define at least one.\n\nIn general you will also need to define some **parsers**.\nBy default a **\"journal\"** parser is provided, otherwise you need to define your own rules.\nThis built-in configuration is close to this one, provided as an example:\n\n```toml\n[[system_notifier.parsers.journal]]\npattern = \"([a-z0-9]+): Link UP$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is active/\"\ncolor = \"#00aa00\"\n\n[[system_notifier.parsers.journal]]\npattern = \"([a-z0-9]+): Link DOWN$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is inactive/\"\ncolor = \"#ff8800\"\nduration = 15\n\n[[system_notifier.parsers.journal]]\npattern = \"Process \\d+ \\(.*\\) of .* dumped core.\"\nfilter = \"s/.*Process \\d+ \\((.*)\\) of .* dumped core./\\1 dumped core/\"\ncolor = \"#aa0000\"\n\n[[system_notifier.parsers.journal]]\npattern = \"usb \\d+-[0-9.]+: Product: \"\nfilter = \"s/.*usb \\d+-[0-9.]+: Product: (.*)/USB plugged: \\1/\"\n```\n</details>\n\n\n## Configuration\n\n### `sources` (recommended)\n\nList of sources to enable (by default nothing is enabled)\n\nEach source must contain a `command` to run and a `parser` to use.\n\nYou can also use a list of parsers, eg:\n\n```toml\n[[system_notifier.sources]]\ncommand = \"sudo journalctl -fkn\"\nparser = [\"journal\", \"custom_parser\"]\n```\n\n#### command (recommended)\n\nThis is the long-running command (eg: `tail -f <filename>`) returning the stream of text that will be updated. Aa common option is the system journal output (eg: `journalctl -u nginx`)\n\n#### parser\n\nSets the list of rules / parser to be used to extract lines of interest.\nMust match a list of rules defined as `system_notifier.parsers.<parser_name>`.\n\n### `parsers` (recommended)\n\nA list of available parsers that can be used to detect lines of interest in the **sources** and re-format it before issuing a notification.\n\nEach parser definition must contain a **pattern** and optionally a **filter**, **color** and **duration**.\n\n#### pattern\n\n```toml\n[[system_notifier.parsers.custom_parser]]\npattern = 'special value:'\n```\n\nThe pattern is any regular expression.\n\n#### filter\n\nThe [filters](./filters) allows to change the text before the notification, eg:\n`filter=\"s/.*special value: (\\d+)/Value=\\1/\"`\nwill set a filter so a string \"special value: 42\" will lead to the notification \"Value=42\"\n\n#### color\n\nYou can also provide an optional **color** in `\"hex\"` or `\"rgb()\"` format\n\n```toml\ncolor = \"#FF5500\"\n```\n\n#### duration\n\nNotifications display for 3 seconds by default. To change how long they display, use `duration`, which is expressed in seconds.\n\n```toml\n[[system_notifier.parsers.custom_parser]]\npattern = 'special value:'\nduration = 10\n```\n\n### use_notify_send\n\nIf you want your notifications to display in your desktop environment's preferred notification UI rather than Hyprland's native notifications, you can set `use_notify_send` to `true`. This will send them via [libnotify](https://gitlab.gnome.org/GNOME/libnotify) using the [`notify-send`](https://man.archlinux.org/man/notify-send.1) command.\n\n```toml\n[system_notifier]\nuse_notify_send = true\n```\n\n### default_color\n\nSets the notification color that will be used when none is provided in a *parser* definition.\n\n```toml\n[system_notifier]\ndefault_color = \"#bbccbb\"\n```\n"
  },
  {
    "path": "site/versions/2.6.2/toggle_dpms.md",
    "content": "---\ncommands:\n    - name: toggle_dpms\n      description: if any screen is powered on, turn them all off, else turn them all on\n---\n\n# toggle_dpms\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n"
  },
  {
    "path": "site/versions/2.6.2/toggle_special.md",
    "content": "---\ncommands:\n    - name: toggle_special [name]\n      description: moves the focused window to the special workspace <code>name</code>, or move it back to the active workspace\n---\n\n# toggle_special\n\nAllows moving the focused window to a special workspace and back (based on the visibility status of that workspace).\n\nIt's a companion of the `togglespecialworkspace` Hyprland's command which toggles a special workspace's visibility.\n\nYou most likely will need to configure the two commands for a complete user experience, eg:\n\n```bash\nbind = $mainMod SHIFT, N, togglespecialworkspace, stash # toggles \"stash\" special workspace visibility\nbind = $mainMod, N, exec, pypr toggle_special stash # moves window to/from the \"stash\" workspace\n```\n\nNo other configuration needed, here `MOD+SHIFT+N` will show every window in \"stash\" while `MOD+N` will move the focused window out of it/ to it.\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n"
  },
  {
    "path": "site/versions/2.6.2/wallpapers.md",
    "content": "---\ncommands:\n    - name: wall next\n      description: Changes the current background image, resume activity if paused\n    - name: wall clear\n      description: Removes the current background image and pause cycling\n    - name: wall pause\n      description: Stops updating the wallpaper automatically\n    - name: wall color \"#ff0000\"\n      description: Re-generate the [templates](#templates) with the given color\n    - name: wall color \"#ff0000\" neutral\n      description: Re-generate the templates with the given color and [color scheme](#color-scheme) (color filter)\n\n\n---\n\n\n# wallpapers\n\nSearch folders for images and sets the background image at a regular interval.\nPictures are selected randomly from the full list of images found.\n\nIt serves two purposes:\n\n- adding support for random images to any background setting tool\n- quickly testing different tools with a minimal effort\n\nIt allows \"zapping\" current backgrounds, clearing it to go distraction free and optionally make them different for each screen.\n\n> [!tip]\n> Uses **hyprpaper** by default, but can be configured to use any other application.\n> You'll need to run hyprpaper separately for now. (eg: `uwsm app -- hyprpaper`)\n\n<details>\n    <summary>Minimal example using defaults (requires <b>hyprpaper</b>)</summary>\n\n```toml\n[wallpapers]\npath = \"~/Pictures/wallpapers/\" # path to the folder with background images\n```\n\n</details>\n\n<details>\n<summary>More complete, using the custom <b>swww</b> command (not recommended because of its stability)</summary>\n\n```toml\n[wallpapers]\nunique = true # set a different wallpaper for each screen\npath = \"~/Pictures/wallpapers/\"\ninterval = 60 # change every hour\nextensions = [\"jpg\", \"jpeg\"]\nrecurse = true\nclear_command = \"swww clear\"\ncommand = \"swww img --outputs '[output]'  '[file]'\"\n\n```\n\nNote that for applications like `swww`, you'll need to start a daemon separately (eg: from `hyprland.conf`).\n</details>\n\n\n## Commands\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `path` (REQUIRED)\n\npath to a folder or list of folders that will be searched. Can also be a list, eg:\n\n```toml\npath = [\"~/Pictures/Portraits/\", \"~/Pictures/Landscapes/\"]\n```\n\n### `interval`\n\ndefaults to `10`\n\nHow long (in minutes) a background should stay in place\n\n\n### `command`\n\nOverrides the default command to set the background image.\n\n> [!note]\n> Uses an optimized **hyprpaper** usage if *no command* is provided on version > 2.5.1\n\n[variables](./Variables) are replaced with the appropriate values, you must use a `\"[file]\"` placeholder for the image path and `\"[output]\"` for the screen. eg:\n\n```sh\nswaybg -i '[file]' -o '[output]'\n```\nor\n```sh\nswww img --outputs [output]  [file]\n```\n\n### `clear_command`\n\nBy default `clear` command kills the `command` program.\n\nInstead of that, you can provide a command to clear the background. eg:\n\n```toml\nclear_command = \"swaybg clear\"\n```\n\n### `post_command`\n\nExecutes a command after a wallpaper change. Can use `[file]`, eg:\n\n```toml\npost_command = \"matugen image '[file]'\"\n```\n\n### `radius`\n\nWhen set, adds rounded borders to the wallpapers. Expressed in pixels. Disabled by default.\n\nFor this feature to work, you must use '[output]' in your `command` to specify the screen port name to use in the command.\n\neg:\n```toml\nradius = 16\n```\n\n### `extensions`\n\ndefaults to `[\"png\", \"jpg\", \"jpeg\"]`\n\nList of valid wallpaper image extensions.\n\n### `recurse`\n\ndefaults to `false`\n\nWhen enabled, will also search sub-directories recursively.\n\n### `unique`\n\ndefaults to `false`\n\nWhen enabled, will set a different wallpaper for each screen (Usage with [templates](#templates) is not recommended).\n\nIf you are not using the default application, ensure you are using `\"[output]\"` in the [command](#command) template.\n\nExample for swaybg: `swaybg -o \"[output]\" -m fill -i \"[file]\"`\n\n### `templates`\n\nMinimal *matugen* or *pywal* feature, mostly compatible with *matugen* syntax.\n\nOpen a ticket if misses a feature you are used to.\n\nExample:\n``` toml\n[wallpapers.templates.hyprland]\ninput_path = \"~/color_configs/hyprlandcolors.sh\"\noutput_path = \"/tmp/hyprlandcolors.sh\"\npost_hook = \"sh /tmp/hyprlandcolors.sh\"\n```\n\nWhere the input_path would contain\n```sh\nhyprctl keyword general:col.active_border \"rgb({{colors.primary.default.hex_stripped}}) rgb({{colors.tertiary.default.hex_stripped}}) 30deg\"\nhyprctl keyword decoration:shadow:color \"rgb({{colors.primary.default.hex_stripped}})\"\n```\n\n#### Supported transformations:\n\n- set_lightness\n- set_alpha\n\n#### Supported color formats:\n\n- hex\n- hex_stripped\n- rgb\n- rgba\n\n#### Supported colors:\n\n- source\n- primary\n- on_primary\n- primary_container\n- on_primary_container\n- secondary\n- on_secondary\n- secondary_container\n- on_secondary_container\n- tertiary\n- on_tertiary\n- tertiary_container\n- on_tertiary_container\n- error\n- on_error\n- error_container\n- on_error_container\n- surface\n- surface_bright\n- surface_dim\n- surface_container_lowest\n- surface_container_low\n- surface_container\n- surface_container_high\n- surface_container_highest\n- on_surface\n- surface_variant\n- on_surface_variant\n- background\n- on_background\n- outline\n- outline_variant\n- inverse_primary\n- inverse_surface\n- inverse_on_surface\n- surface_tint\n- scrim\n- shadow\n- white\n- primary_fixed\n- primary_fixed_dim\n- on_primary_fixed\n- on_primary_fixed_variant\n- secondary_fixed\n- secondary_fixed_dim\n- on_secondary_fixed\n- on_secondary_fixed_variant\n- tertiary_fixed\n- tertiary_fixed_dim\n- on_tertiary_fixed\n- on_tertiary_fixed_variant\n- red\n- green\n- yellow\n- blue\n- magenta\n- cyan\n\n### `color_scheme`\n\nOptional modification of the base color used in the [templates](#templates). One of:\n\n- **pastel** a bit more washed colors\n- **fluo** or **fluorescent** for high color saturation\n- **neutral** for low color saturation\n- **earth** a bit more dark, a bit less blue\n- **vibrant** for moderate to high saturation\n- **mellow** for lower saturation\n\n### `variant`\n\nChanges the algorithm in use to pick the primary, secondary and tertiary colors.\n\n- \"islands\" will use the 3 most popular colors of the wallpaper image\n\notherwise it will only pick the \"main\" color and shift the hue to get the secondary and tertiary colors.\n"
  },
  {
    "path": "site/versions/2.6.2/workspaces_follow_focus.md",
    "content": "---\ncommands:\n    - name: change_workspace [direction]\n      description: Changes the workspace of the focused monitor in the given direction.\n---\n\n# workspaces_follow_focus\n\nMake non-visible workspaces follow the focused monitor.\nAlso provides commands to switch between workspaces while preserving the current monitor assignments:\n\nSyntax:\n```toml\n[workspaces_follow_focus]\nmax_workspaces = 4 # number of workspaces before cycling\n```\nExample usage in `hyprland.conf`:\n\n```ini\nbind = $mainMod, K, exec, pypr change_workspace +1\nbind = $mainMod, J, exec, pypr change_workspace -1\n ```\n\n## Command\n\n<CommandList :commands=\"$frontmatter.commands\" />\n\n## Configuration\n\n### `max_workspaces`\n\nLimits the number of workspaces when switching, defaults value is `10`.\n"
  },
  {
    "path": "site/versions/3.0.0/Architecture.md",
    "content": "# Architecture\n\nThis section provides a comprehensive overview of Pyprland's internal architecture, designed for developers who want to understand, extend, or contribute to the project.\n\n> [!tip]\n> For a practical guide to writing plugins, see the [Development](./Development) document.\n\n## Sections\n\n| Section | Description |\n|---------|-------------|\n| [Overview](./Architecture_overview) | High-level architecture, executive summary, data flow, directory structure, design patterns |\n| [Core Components](./Architecture_core) | Manager, plugins, adapters, IPC layer, socket protocol, C client, configuration, data models |\n\n## Quick Links\n\n### Overview\n\n- [Executive Summary](./Architecture_overview#executive-summary) - What Pyprland is and how it works\n- [High-Level Architecture](./Architecture_overview#high-level-architecture) - Visual overview of all components\n- [Data Flow](./Architecture_overview#data-flow) - Event processing and command processing sequences\n- [Directory Structure](./Architecture_overview#directory-structure) - Source code organization\n- [Design Patterns](./Architecture_overview#design-patterns) - Patterns used throughout the codebase\n\n### Core Components\n\n- [Entry Points](./Architecture_core#entry-points) - Daemon vs client mode\n- [Manager](./Architecture_core#manager) - The core orchestrator\n- [Plugin System](./Architecture_core#plugin-system) - Base class, lifecycle, built-in plugins\n- [Backend Adapter Layer](./Architecture_core#backend-adapter-layer) - Hyprland and Niri abstractions\n- [IPC Layer](./Architecture_core#ipc-layer) - Window manager communication\n- [Socket Protocol](./Architecture_core#pyprland-socket-protocol) - Client-daemon protocol specification\n- [pypr-client](./Architecture_core#pypr-client) - Lightweight alternative for keybindings\n- [Configuration System](./Architecture_core#configuration-system) - TOML config system\n- [Data Models](./Architecture_core#data-models) - TypedDict definitions\n\n## Further Reading\n\n- [Development Guide](./Development) - How to write plugins\n- [Plugin Documentation](./Plugins) - List of available plugins\n- [Sample Extension](https://github.com/fdev31/pyprland/tree/main/sample_extension) - Example external plugin package\n- [Hyprland IPC](https://wiki.hyprland.org/IPC/) - Hyprland's IPC documentation\n"
  },
  {
    "path": "site/versions/3.0.0/Architecture_core.md",
    "content": "# Core Components\n\nThis document details the core components of Pyprland's architecture.\n\n## Entry Points\n\nThe application can run in two modes: **daemon** (background service) or **client** (send commands to running daemon).\n\n```mermaid\nflowchart LR\n    main([\"🚀 main()\"]) --> detect{\"❓ Args?\"}\n    detect -->|No arguments| daemon[\"🔧 run_daemon()\"]\n    detect -->|Command given| client[\"📤 run_client()\"]\n    daemon --> Pyprland([\"⚙️ Pyprland().run()\"])\n    client --> socket([\"📡 Send via socket\"])\n    Pyprland --> events[\"📨 Listen for events\"]\n    socket --> response([\"✅ Receive response\"])\n\n    style main fill:#7fb3d3,stroke:#5a8fa8,color:#000\n    style detect fill:#d4c875,stroke:#a89a50,color:#000\n    style daemon fill:#8fbc8f,stroke:#6a9a6a,color:#000\n    style client fill:#8fbc8f,stroke:#6a9a6a,color:#000\n    style Pyprland fill:#d4a574,stroke:#a67c50,color:#000\n    style socket fill:#d4a574,stroke:#a67c50,color:#000\n```\n\n| Entry Point | File | Purpose |\n|-------------|------|---------|\n| `pypr` | [`command.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/command.py) | Main CLI entry (daemon or client mode) |\n| Daemon mode | [`pypr_daemon.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/pypr_daemon.py) | Start the background daemon |\n| Client mode | [`client.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/client.py) | Send command to running daemon |\n\n## Manager\n\nThe [`Pyprland`](https://github.com/fdev31/pyprland/blob/main/pyprland/manager.py) class is the core orchestrator, responsible for:\n\n| Responsibility | Method/Attribute |\n|----------------|------------------|\n| Plugin loading | `_load_plugins()` |\n| Event dispatching | `_run_event()` |\n| Command handling | `handle_command()` |\n| Server lifecycle | `run()`, `serve()` |\n| Configuration | `load_config()`, `config` |\n| Shared state | `state: SharedState` |\n\n**Key Design Patterns:**\n\n- **Per-plugin async task queues** (`queues: dict[str, asyncio.Queue]`) - ensures plugin isolation\n- **Deduplication** via `@remove_duplicate` decorator - prevents rapid duplicate events\n- **Plugin isolation** - each plugin processes events independently\n\n## Plugin System\n\n### Base Class\n\nAll plugins inherit from the [`Plugin`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/interface.py) base class:\n\n```python\nclass Plugin:\n    name: str                    # Plugin identifier\n    config: Configuration        # Plugin-specific config section\n    state: SharedState           # Shared application state\n    backend: EnvironmentBackend  # WM abstraction layer\n    log: Logger                  # Plugin-specific logger\n    \n    # Lifecycle hooks\n    async def init() -> None           # Called once at startup\n    async def on_reload() -> None      # Called on init and config reload\n    async def exit() -> None           # Called on shutdown\n    \n    # Config validation\n    config_schema: ClassVar[list[ConfigField]]\n    def validate_config() -> list[str]\n```\n\n### Event Handler Protocol\n\nPlugins implement handlers by naming convention. See [`protocols.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/protocols.py) for the full protocol definitions:\n\n```python\n# Hyprland events: event_<eventname>\nasync def event_openwindow(self, params: str) -> None: ...\nasync def event_closewindow(self, addr: str) -> None: ...\nasync def event_workspace(self, workspace: str) -> None: ...\n\n# Commands: run_<command>\nasync def run_toggle(self, name: str) -> str | None: ...\n\n# Niri events: niri_<eventtype>\nasync def niri_outputschanged(self, data: dict) -> None: ...\n```\n\n### Plugin Lifecycle\n\n```mermaid\nsequenceDiagram\n    autonumber\n    participant M as ⚙️ Manager\n    participant P as 🔌 Plugin\n    participant C as 📄 Config\n\n    rect rgba(143, 188, 143, 0.2)\n        Note over M,C: Initialization Phase\n        M->>P: __init__(name)\n        M->>P: init()\n        M->>C: load TOML config\n        C-->>M: config data\n        M->>P: load_config(config)\n        M->>P: validate_config()\n        P-->>M: validation errors (if any)\n        M->>P: on_reload()\n    end\n    \n    rect rgba(127, 179, 211, 0.2)\n        Note over M,P: Runtime Phase\n        loop Events from WM\n            M->>P: event_*(data)\n            P-->>M: (async processing)\n        end\n        \n        loop Commands from User\n            M->>P: run_*(args)\n            P-->>M: result\n        end\n    end\n    \n    rect rgba(212, 165, 116, 0.2)\n        Note over M,P: Shutdown Phase\n        M->>P: exit()\n        P-->>M: cleanup complete\n    end\n```\n\n### Built-in Plugins\n\n| Plugin | Source | Description |\n|--------|--------|-------------|\n| `pyprland` (core) | [`plugins/pyprland/`](https://github.com/fdev31/pyprland/tree/main/pyprland/plugins/pyprland) | Internal state management |\n| `scratchpads` | [`plugins/scratchpads/`](https://github.com/fdev31/pyprland/tree/main/pyprland/plugins/scratchpads) | Dropdown/scratchpad windows |\n| `monitors` | [`plugins/monitors/`](https://github.com/fdev31/pyprland/tree/main/pyprland/plugins/monitors) | Monitor layout management |\n| `wallpapers` | [`plugins/wallpapers/`](https://github.com/fdev31/pyprland/tree/main/pyprland/plugins/wallpapers) | Wallpaper cycling, color schemes |\n| `expose` | [`plugins/expose.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/expose.py) | Window overview |\n| `magnify` | [`plugins/magnify.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/magnify.py) | Zoom functionality |\n| `layout_center` | [`plugins/layout_center.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/layout_center.py) | Centered layout mode |\n| `fetch_client_menu` | [`plugins/fetch_client_menu.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/fetch_client_menu.py) | Menu-based window switching |\n| `shortcuts_menu` | [`plugins/shortcuts_menu.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/shortcuts_menu.py) | Shortcut launcher |\n| `toggle_dpms` | [`plugins/toggle_dpms.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/toggle_dpms.py) | Screen power toggle |\n| `toggle_special` | [`plugins/toggle_special.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/toggle_special.py) | Special workspace toggle |\n| `system_notifier` | [`plugins/system_notifier.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/system_notifier.py) | System notifications |\n| `lost_windows` | [`plugins/lost_windows.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/lost_windows.py) | Recover lost windows |\n| `shift_monitors` | [`plugins/shift_monitors.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/shift_monitors.py) | Shift windows between monitors |\n| `workspaces_follow_focus` | [`plugins/workspaces_follow_focus.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/workspaces_follow_focus.py) | Workspace follows focus |\n| `fcitx5_switcher` | [`plugins/fcitx5_switcher.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/fcitx5_switcher.py) | Input method switching |\n| `menubar` | [`plugins/menubar.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/menubar.py) | Menu bar integration |\n\n## Backend Adapter Layer\n\nThe adapter layer abstracts differences between window managers. See [`adapters/`](https://github.com/fdev31/pyprland/tree/main/pyprland/adapters) for the full implementation.\n\n```mermaid\nclassDiagram\n    class EnvironmentBackend {\n        <<abstract>>\n        #state: SharedState\n        #log: Logger\n        +get_clients() list~ClientInfo~\n        +get_monitors() list~MonitorInfo~\n        +execute(command) bool\n        +execute_json(command) Any\n        +execute_batch(commands) None\n        +notify(message, duration, color) None\n        +parse_event(raw_data) tuple\n    }\n    \n    class HyprlandBackend {\n        +get_clients()\n        +get_monitors()\n        +execute()\n        +parse_event()\n    }\n    note for HyprlandBackend \"Communicates via<br/>HYPRLAND_INSTANCE_SIGNATURE<br/>socket paths\"\n    \n    class NiriBackend {\n        +get_clients()\n        +get_monitors()\n        +execute()\n        +parse_event()\n    }\n    note for NiriBackend \"Communicates via<br/>NIRI_SOCKET<br/>JSON protocol\"\n    \n    EnvironmentBackend <|-- HyprlandBackend : implements\n    EnvironmentBackend <|-- NiriBackend : implements\n```\n\n| Class | Source |\n|-------|--------|\n| `EnvironmentBackend` | [`adapters/backend.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/backend.py) |\n| `HyprlandBackend` | [`adapters/hyprland.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/hyprland.py) |\n| `NiriBackend` | [`adapters/niri.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/niri.py) |\n\nThe backend is selected automatically based on environment:\n- If `NIRI_SOCKET` is set -> `NiriBackend`\n- Otherwise -> `HyprlandBackend`\n\n## IPC Layer\n\nLow-level socket communication with the window manager is handled in [`ipc.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/ipc.py):\n\n| Function | Purpose |\n|----------|---------|\n| `hyprctl_connection()` | Context manager for Hyprland command socket |\n| `niri_connection()` | Context manager for Niri socket |\n| `get_response()` | Send command, receive JSON response |\n| `get_event_stream()` | Subscribe to WM event stream |\n| `niri_request()` | Send Niri-specific request |\n| `@retry_on_reset` | Decorator for automatic connection retry |\n\n**Socket Paths:**\n\n| Socket | Path |\n|--------|------|\n| Hyprland commands | `$XDG_RUNTIME_DIR/hypr/$SIGNATURE/.socket.sock` |\n| Hyprland events | `$XDG_RUNTIME_DIR/hypr/$SIGNATURE/.socket2.sock` |\n| Niri | `$NIRI_SOCKET` |\n| Pyprland (Hyprland) | `$XDG_RUNTIME_DIR/hypr/$SIGNATURE/.pyprland.sock` |\n| Pyprland (Niri) | `dirname($NIRI_SOCKET)/.pyprland.sock` |\n| Pyprland (standalone) | `$XDG_DATA_HOME/.pyprland.sock` |\n\n## Pyprland Socket Protocol\n\nThe daemon exposes a Unix domain socket for client-daemon communication. This simple text-based protocol allows any language to implement a client.\n\n### Socket Path\n\nThe socket location depends on the environment:\n\n| Environment | Socket Path |\n|-------------|-------------|\n| Hyprland | `$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.pyprland.sock` |\n| Niri | `dirname($NIRI_SOCKET)/.pyprland.sock` |\n| Standalone | `$XDG_DATA_HOME/.pyprland.sock` (defaults to `~/.local/share/.pyprland.sock`) |\n\nIf the Hyprland path exceeds 107 characters, a shortened path is used:\n\n```\n/tmp/.pypr-$HYPRLAND_INSTANCE_SIGNATURE/.pyprland.sock\n```\n\n### Protocol\n\n```mermaid\nsequenceDiagram\n    autonumber\n    participant Client as 💻 Client\n    participant Socket as 📡 Unix Socket\n    participant Daemon as ⚙️ Daemon\n\n    rect rgba(127, 179, 211, 0.2)\n        Note over Client,Socket: Request\n        Client->>Socket: Connect\n        Client->>Socket: \"command args\\n\"\n        Client->>Socket: EOF (shutdown write)\n    end\n\n    rect rgba(143, 188, 143, 0.2)\n        Note over Socket,Daemon: Processing\n        Socket->>Daemon: read_command()\n        Daemon->>Daemon: Execute command\n    end\n\n    rect rgba(212, 165, 116, 0.2)\n        Note over Daemon,Client: Response\n        Daemon->>Socket: \"OK\\n\" or \"ERROR: msg\\n\"\n        Socket->>Client: Read until EOF\n        Client->>Client: Parse & exit\n    end\n```\n\n| Direction | Format |\n|-----------|--------|\n| **Request** | `<command> [args...]\\n` (newline-terminated, then EOF) |\n| **Response** | `OK [output]` or `ERROR: <message>` or raw text (legacy) |\n\n**Response Prefixes:**\n\n| Prefix | Meaning | Exit Code |\n|--------|---------|-----------|\n| `OK` | Command succeeded | 0 |\n| `OK <output>` | Command succeeded with output | 0 |\n| `ERROR: <msg>` | Command failed | 4 |\n| *(raw text)* | Legacy response (help, version, dumpjson) | 0 |\n\n**Exit Codes:**\n\n| Code | Name | Description |\n|------|------|-------------|\n| 0 | SUCCESS | Command completed successfully |\n| 1 | USAGE_ERROR | No command provided or invalid arguments |\n| 2 | ENV_ERROR | Missing environment variables |\n| 3 | CONNECTION_ERROR | Cannot connect to daemon |\n| 4 | COMMAND_ERROR | Command execution failed |\n\nSee [`models.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/models.py) for `ExitCode` and `ResponsePrefix` definitions.\n\n## pypr-client {#pypr-client}\n\nFor performance-critical use cases (e.g., keybindings), `pypr-client` is a lightweight C client available as an alternative to `pypr`. It supports all commands except `validate` and `edit` (which require Python).\n\n| File | Description |\n|------|-------------|\n| [`client/pypr-client.c`](https://github.com/fdev31/pyprland/blob/main/client/pypr-client.c) | C implementation of the pypr client |\n\n**Build:**\n\n```bash\ncd client\ngcc -O2 -o pypr-client pypr-client.c\n```\n\n**Features:**\n\n- Minimal dependencies (libc only)\n- Fast startup (~1ms vs ~50ms for Python)\n- Same protocol as Python client\n- Proper exit codes for scripting\n\n**Comparison:**\n\n| Aspect | `pypr` | `pypr-client` |\n|--------|--------|---------------|\n| Startup time | ~50ms | ~1ms |\n| Dependencies | Python 3.11+ | libc |\n| Daemon mode | Yes | No |\n| Commands | All | All except `validate`, `edit` |\n| Best for | Interactive use, daemon | Keybindings |\n| Source | [`client.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/client.py) | [`pypr-client.c`](https://github.com/fdev31/pyprland/blob/main/client/pypr-client.c) |\n\n## Configuration System\n\nConfiguration is stored in TOML format at `~/.config/hypr/pyprland.toml`:\n\n```toml\n[pyprland]\nplugins = [\"scratchpads\", \"monitors\", \"magnify\"]\n\n[scratchpads.term]\ncommand = \"kitty --class scratchpad\"\nposition = \"50% 50%\"\nsize = \"80% 80%\"\n\n[monitors]\nunknown = \"extend\"\n```\n\n| Component | Source | Description |\n|-----------|--------|-------------|\n| `Configuration` | [`config.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/config.py) | Dict wrapper with typed accessors |\n| `ConfigValidator` | [`validation.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/validation.py) | Schema-based validation |\n| `ConfigField` | [`validation.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/validation.py) | Field definition (name, type, required, default) |\n\n## Shared State\n\nThe [`SharedState`](https://github.com/fdev31/pyprland/blob/main/pyprland/common.py) dataclass maintains commonly needed information:\n\n```python\n@dataclass\nclass SharedState:\n    active_workspace: str    # Current workspace name\n    active_monitor: str      # Current monitor name  \n    active_window: str       # Current window address\n    environment: str         # \"hyprland\" or \"niri\"\n    variables: dict          # User-defined variables\n    monitors: list[str]      # All monitor names\n    hyprland_version: VersionInfo\n```\n\n## Data Models\n\nTypedDict definitions in [`models.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/models.py) ensure type safety:\n\n```python\nclass ClientInfo(TypedDict):\n    address: str\n    mapped: bool\n    hidden: bool\n    workspace: WorkspaceInfo\n    class_: str  # aliased from \"class\"\n    title: str\n    # ... more fields\n\nclass MonitorInfo(TypedDict):\n    name: str\n    width: int\n    height: int\n    x: int\n    y: int\n    focused: bool\n    transform: int\n    # ... more fields\n```\n"
  },
  {
    "path": "site/versions/3.0.0/Architecture_overview.md",
    "content": "# Architecture Overview\n\nThis document provides a high-level overview of Pyprland's architecture, data flow, and design patterns.\n\n> [!tip]\n> For a practical guide to writing plugins, see the [Development](./Development) document.\n\n## Executive Summary\n\n**Pyprland** is a plugin-based companion application for tiling window managers (Hyprland, Niri). It operates as a daemon that extends the window manager's capabilities through a modular plugin system, communicating via Unix domain sockets (IPC).\n\n| Attribute | Value |\n|-----------|-------|\n| Language | Python 3.11+ |\n| License | MIT |\n| Architecture | Daemon/Client, Plugin-based |\n| Async Framework | asyncio |\n\n## High-Level Architecture\n\n```mermaid\nflowchart TB\n    subgraph User[\"👤 User Layer\"]\n        KB([\"⌨️ Keyboard Bindings\"])\n        CLI([\"💻 pypr / pypr-client\"])\n    end\n\n    subgraph Pyprland[\"🔶 Pyprland Daemon\"]\n        direction TB\n        CMD[\"🎯 Command Handler\"]\n        EVT[\"📨 Event Listener\"]\n        \n        subgraph Plugins[\"🔌 Plugin Registry\"]\n            P1[\"scratchpads\"]\n            P2[\"monitors\"]\n            P3[\"wallpapers\"]\n            P4[\"expose\"]\n            P5[\"...\"]\n        end\n        \n        subgraph Adapters[\"🔄 Backend Adapters\"]\n            HB[\"HyprlandBackend\"]\n            NB[\"NiriBackend\"]\n        end\n        \n        MGR[\"⚙️ Manager<br/>Orchestrator\"]\n        STATE[\"📦 SharedState\"]\n    end\n\n    subgraph WM[\"🪟 Window Manager\"]\n        HYPR([\"Hyprland\"])\n        NIRI([\"Niri\"])\n    end\n\n    KB --> CLI\n    CLI -->|Unix Socket| CMD\n    CMD --> MGR\n    MGR --> Plugins\n    Plugins --> Adapters\n    EVT -->|Event Stream| MGR\n    Adapters <-->|IPC Socket| WM\n    WM -->|Events| EVT\n    MGR --> STATE\n    Plugins --> STATE\n\n    style User fill:#7fb3d3,stroke:#5a8fa8,color:#000\n    style Pyprland fill:#d4a574,stroke:#a67c50,color:#000\n    style WM fill:#8fbc8f,stroke:#6a9a6a,color:#000\n    style Plugins fill:#c9a86c,stroke:#9a7a4a,color:#000\n    style Adapters fill:#c9a86c,stroke:#9a7a4a,color:#000\n```\n\n## Data Flow\n\n### Event Processing\n\nWhen the window manager emits an event (window opened, workspace changed, etc.):\n\n```mermaid\nsequenceDiagram\n    autonumber\n    participant WM as 🪟 Window Manager\n    participant IPC as 📡 IPC Layer\n    participant MGR as ⚙️ Manager\n    participant Q1 as 📥 Plugin A Queue\n    participant Q2 as 📥 Plugin B Queue\n    participant P1 as 🔌 Plugin A\n    participant P2 as 🔌 Plugin B\n\n    rect rgba(143, 188, 143, 0.2)\n        Note over WM,IPC: Event Reception\n        WM->>+IPC: Event stream (async)\n        IPC->>-MGR: Parse event (name, params)\n    end\n\n    rect rgba(127, 179, 211, 0.2)\n        Note over MGR,Q2: Event Distribution\n        par Parallel queuing\n            MGR->>Q1: Queue event\n            MGR->>Q2: Queue event\n        end\n    end\n\n    rect rgba(212, 165, 116, 0.2)\n        Note over Q1,WM: Plugin Execution\n        par Parallel processing\n            Q1->>P1: event_openwindow()\n            P1->>WM: Execute commands\n        and\n            Q2->>P2: event_openwindow()\n            P2->>WM: Execute commands\n        end\n    end\n```\n\n### Command Processing\n\nWhen the user runs `pypr <command>`:\n\n```mermaid\nsequenceDiagram\n    autonumber\n    participant User as 👤 User\n    participant CLI as 💻 pypr / pypr-client\n    participant Socket as 📡 Unix Socket\n    participant MGR as ⚙️ Manager\n    participant Plugin as 🔌 Plugin\n    participant Backend as 🔄 Backend\n    participant WM as 🪟 Window Manager\n\n    rect rgba(127, 179, 211, 0.2)\n        Note over User,Socket: Request Phase\n        User->>CLI: pypr toggle term\n        CLI->>Socket: Connect & send command\n        Socket->>MGR: handle_command()\n    end\n\n    rect rgba(212, 165, 116, 0.2)\n        Note over MGR,Plugin: Routing Phase\n        MGR->>MGR: Find plugin with run_toggle\n        MGR->>Plugin: run_toggle(\"term\")\n    end\n\n    rect rgba(143, 188, 143, 0.2)\n        Note over Plugin,WM: Execution Phase\n        Plugin->>Backend: execute(command)\n        Backend->>WM: IPC call\n        WM-->>Backend: Response\n        Backend-->>Plugin: Result\n    end\n\n    rect rgba(150, 120, 160, 0.2)\n        Note over Plugin,User: Response Phase\n        Plugin-->>MGR: Return value\n        MGR-->>Socket: Response\n        Socket-->>CLI: Display result\n    end\n```\n\n## Directory Structure\n\nAll source files are in the [`pyprland/`](https://github.com/fdev31/pyprland/tree/main/pyprland) directory:\n\n```\npyprland/\n├── command.py           # CLI entry point, argument parsing\n├── pypr_daemon.py       # Daemon startup logic\n├── manager.py           # Core Pyprland class (orchestrator)\n├── client.py            # Client mode implementation\n├── ipc.py               # Socket communication with WM\n├── config.py            # Configuration wrapper\n├── validation.py        # Config validation framework\n├── common.py            # Shared utilities, SharedState, logging\n├── constants.py         # Global constants\n├── models.py            # TypedDict definitions\n├── version.py           # Version string\n├── aioops.py            # Async file ops, DebouncedTask\n├── completions.py       # Shell completion generators\n├── help.py              # Help system\n├── ansi.py              # Terminal colors/styling\n├── debug.py             # Debug utilities\n│\n├── adapters/            # Window manager abstraction\n│   ├── backend.py       # Abstract EnvironmentBackend\n│   ├── hyprland.py      # Hyprland implementation\n│   ├── niri.py          # Niri implementation\n│   ├── menus.py         # Menu engine abstraction (rofi, wofi, etc.)\n│   └── units.py         # Unit conversion utilities\n│\n└── plugins/             # Plugin implementations\n    ├── interface.py     # Plugin base class\n    ├── protocols.py     # Event handler protocols\n    │\n    ├── pyprland/        # Core internal plugin\n    ├── scratchpads/     # Scratchpad plugin (complex, multi-file)\n    ├── monitors/        # Monitor management\n    ├── wallpapers/      # Wallpaper management\n    │\n    └── *.py             # Simple single-file plugins\n```\n\n## Design Patterns\n\n| Pattern | Usage |\n|---------|-------|\n| **Plugin Architecture** | Extensibility via [`Plugin`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/interface.py) base class |\n| **Adapter Pattern** | [`EnvironmentBackend`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/backend.py) abstracts WM differences |\n| **Strategy Pattern** | Menu engines in [`menus.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/menus.py) (rofi, wofi, tofi, etc.) |\n| **Observer Pattern** | Event handlers subscribe to WM events |\n| **Async Task Queues** | Per-plugin isolation, prevents blocking |\n| **Decorator Pattern** | `@retry_on_reset` in [`ipc.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/ipc.py), `@remove_duplicate` in [`manager.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/manager.py) |\n| **Template Method** | Plugin lifecycle hooks (`init`, `on_reload`, `exit`) |\n"
  },
  {
    "path": "site/versions/3.0.0/Commands.md",
    "content": "# Commands\n\n<script setup>\nimport PluginCommands from './components/PluginCommands.vue'\n</script>\n\nThis page covers the `pypr` command-line interface and available commands.\n\n## Overview\n\nThe `pypr` command operates in two modes:\n\n| Usage | Mode | Description |\n|-------|------|-------------|\n| `pypr` | Daemon | Starts the Pyprland daemon (foreground) |\n| `pypr <command>` | Client | Sends a command to the running daemon |\n\n\nThere is also an optional `pypr-client` command which is designed for running in keyboard-bindings since it starts faster but doesn't support every built-in command (eg: `validate`, `edit`).\n\n> [!tip]\n> Command names can use dashes or underscores interchangeably.\n> E.g., `pypr shift_monitors` and `pypr shift-monitors` are equivalent.\n\n## Built-in Commands\n\nThese commands are always available, regardless of which plugins are loaded:\n\n<PluginCommands plugin=\"pyprland\" />\n\n## Plugin Commands\n\nEach plugin can add its own commands. Use `pypr help` to see the commands made available by the list of plugins you set in your configuration file.\n\nExamples:\n- `scratchpads` plugin adds: `toggle`, `show`, `hide`\n- `magnify` plugin adds: `zoom`\n- `expose` plugin adds: `expose`\n\nSee individual [plugin documentation](./Plugins) for command details.\n\n## Shell Completions {#command-compgen}\n\nPyprland can generate shell completions dynamically based on your loaded plugins and configuration.\n\n### Generating Completions\n\nWith the daemon running:\n\n```sh\n# Output to stdout (redirect to file)\npypr compgen zsh > ~/.zsh/completions/_pypr\n\n# Install to default user path\npypr compgen bash default\npypr compgen zsh default\npypr compgen fish default\n\n# Install to custom path (absolute or ~/)\npypr compgen bash ~/custom/path/pypr\npypr compgen zsh /etc/zsh/completions/_pypr\n```\n\n> [!warning]\n> Relative paths may not do what you expect. Use `default`, an absolute path, or a `~/` path.\n\n### Default Installation Paths\n\n| Shell | Default Path |\n|-------|--------------|\n| Bash | `~/.local/share/bash-completion/completions/pypr` |\n| Zsh | `~/.zsh/completions/_pypr` |\n| Fish | `~/.config/fish/completions/pypr.fish` |\n\n> [!tip]\n> For Zsh, the default path may not be in your `$fpath`. Pypr will show instructions to add it.\n\n> [!note]\n> Regenerate completions after adding new plugins or scratchpads to keep them up to date.\n\n## pypr-client {#pypr-client}\n\n`pypr-client` is a lightweight, compiled alternative to `pypr` for sending commands to the daemon. It's significantly faster and ideal for key bindings.\n\n### When to Use It\n\n- In `hyprland.conf` key bindings where startup time matters\n- When you need minimal latency (e.g., toggling scratchpads)\n\n### Limitations\n\n- Cannot run the daemon (use `pypr` for that)\n- Does not support `validate` or `edit` commands (these require Python)\n\n### Installation\n\nDepending on your installation method, `pypr-client` may already be available. If not:\n\n1. Download the [source code](https://github.com/hyprland-community/pyprland/tree/main/client/)\n2. Compile it: `gcc -o pypr-client pypr-client.c`\n\nRust and Go versions are also available in the same directory.\n\n### Usage in hyprland.conf\n\n```ini\n# Use pypr-client for faster key bindings\n$pypr = /usr/bin/pypr-client\n\nbind = $mainMod, A, exec, $pypr toggle term\nbind = $mainMod, B, exec, $pypr expose\nbind = $mainMod SHIFT, Z, exec, $pypr zoom\n```\n\n> [!tip]\n> If using [uwsm](https://github.com/Vladimir-csp/uwsm), wrap the command:\n> ```ini\n> $pypr = uwsm-app -- /usr/bin/pypr-client\n> ```\n\nFor technical details about the client-daemon protocol, see [Architecture: Socket Protocol](./Architecture_core#pyprland-socket-protocol).\n\n## Debugging\n\nTo run the daemon with debug logging:\n\n```sh\npypr --debug\n```\n\nTo also save logs to a file:\n\n```sh\npypr --debug $HOME/pypr.log\n```\n\nOr in `hyprland.conf`:\n\n```ini\nexec-once = /usr/bin/pypr --debug $HOME/pypr.log\n```\n\nThe log file will contain detailed information useful for troubleshooting.\n"
  },
  {
    "path": "site/versions/3.0.0/Configuration.md",
    "content": "# Configuration\n\nThis page covers the configuration file format and available options.\n\n## File Location\n\nThe default configuration file is:\n\n```\n~/.config/hypr/pyprland.toml\n```\n\nYou can specify a different path using the `--config` flag:\n\n```sh\npypr --config /path/to/config.toml\n```\n\n## Format\n\nPyprland uses the [TOML format](https://toml.io/). The basic structure is:\n\n```toml\n[pyprland]\nplugins = [\"plugin_name\", \"other_plugin\"]\n\n[plugin_name]\noption = \"value\"\n\n[plugin_name.nested_option]\nsuboption = 42\n```\n\n## [pyprland] Section\n\nThe main section configures the Pyprland daemon itself.\n\n<PluginConfig plugin=\"pyprland\" linkPrefix=\"config-\" />\n\n### `include` <ConfigBadges plugin=\"pyprland\" option=\"include\" /> {#config-include}\n\nList of additional configuration files to include. See [Multiple Configuration Files](./MultipleConfigurationFiles) for details.\n\n### `notification_type` <ConfigBadges plugin=\"pyprland\" option=\"notification_type\" /> {#config-notification-type}\n\nControls how notifications are displayed:\n\n| Value | Behavior |\n|-------|----------|\n| `\"auto\"` | Adapts to environment (Niri uses `notify-send`, Hyprland uses `hyprctl notify`) |\n| `\"notify-send\"` | Forces use of `notify-send` command |\n| `\"native\"` | Forces use of compositor's native notification system |\n\n### `variables` <ConfigBadges plugin=\"pyprland\" option=\"variables\" /> {#config-variables}\n\nCustom variables that can be used in plugin configurations. See [Variables](./Variables) for usage details.\n\n## Examples\n\n```toml\n[pyprland]\nplugins = [\n    \"scratchpads\",\n    \"magnify\",\n    \"expose\",\n]\nnotification_type = \"notify-send\"\n```\n\n### Plugin Configuration\n\nEach plugin can have its own configuration section. The format depends on the plugin:\n\n```toml\n# Simple options\n[magnify]\nfactor = 2\n\n# Nested options (e.g., scratchpads)\n[scratchpads.term]\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\n```\n\nSee individual [plugin documentation](./Plugins) for available options.\n\n### Multiple Configuration Files\n\nYou can split your configuration across multiple files using `include`:\n\n```toml\n[pyprland]\ninclude = [\n    \"~/.config/hypr/scratchpads.toml\",\n    \"~/.config/hypr/monitors.toml\",\n]\nplugins = [\"scratchpads\", \"monitors\"]\n```\n\nSee [Multiple Configuration Files](./MultipleConfigurationFiles) for details.\n\n## Hyprland Integration\n\nMost plugins provide commands that you'll want to bind to keys. Add bindings to your `hyprland.conf`:\n\n```ini\n# Define pypr command (adjust path as needed)\n$pypr = /usr/bin/pypr\n\n# Example bindings\nbind = $mainMod, A, exec, $pypr toggle term\nbind = $mainMod, B, exec, $pypr expose\nbind = $mainMod SHIFT, Z, exec, $pypr zoom\n```\n\n> [!tip]\n> For faster key bindings, use `pypr-client` instead of `pypr`. See [Commands](./Commands#pypr-client) for details.\n\n> [!tip]\n> Command names can use dashes or underscores interchangeably.\n> E.g., `pypr shift_monitors` and `pypr shift-monitors` are equivalent.\n\n## Validation\n\nYou can validate your configuration without running the daemon:\n\n```sh\npypr validate\n```\n\nThis checks your config against plugin schemas and reports any errors.\n\n## Tips\n\n- See [Examples](./Examples) for complete configuration samples\n- See [Optimizations](./Optimizations) for performance tips\n- Only enable plugins you actually use in the `plugins` array\n"
  },
  {
    "path": "site/versions/3.0.0/Development.md",
    "content": "# Development\n\nIt's easy to write your own plugin by making a Python package and then indicating its name as the plugin name.\n\n> [!tip]\n> For details on internal architecture, data flows, and design patterns, see the [Architecture](./Architecture) document.\n\n[Contributing guidelines](https://github.com/fdev31/pyprland/blob/main/CONTRIBUTING.md)\n\n## Development Setup\n\n### Prerequisites\n\n- Python 3.11+\n- [Poetry](https://python-poetry.org/) for dependency management\n- [pre-commit](https://pre-commit.com/) for Git hooks\n\n### Initial Setup\n\n```sh\n# Clone the repository\ngit clone https://github.com/fdev31/pyprland.git\ncd pyprland\n\n# Install dependencies\npoetry install\n\n# Install dev and lint dependencies\npoetry install --with dev,lint\n\n# Install pre-commit hooks\npip install pre-commit\npre-commit install\npre-commit install --hook-type pre-push\n```\n\n## Quick Start\n\n### Debugging\n\nTo get detailed logs when an error occurs, use:\n\n```sh\npypr --debug\n```\n\nThis displays logs in the console. To also save logs to a file:\n\n```sh\npypr --debug $HOME/pypr.log\n```\n\n### Quick Experimentation\n\n> [!note]\n> To quickly get started, you can directly edit the built-in [`experimental`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/experimental.py) plugin.\n> To distribute your plugin, create your own Python package or submit a pull request.\n\n### Custom Plugin Paths\n\n> [!tip]\n> Set `plugins_paths = [\"/custom/path\"]` in the `[pyprland]` section of your config to add extra plugin search paths during development.\n\n## Writing Plugins\n\n### Plugin Loading\n\nPlugins are loaded by their full Python module path:\n\n```toml\n[pyprland]\nplugins = [\"mypackage.myplugin\"]\n```\n\nThe module must provide an `Extension` class inheriting from [`Plugin`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/interface.py).\n\n> [!note]\n> If your extension is at the root level (not recommended), you can import it using the `external:` prefix:\n> ```toml\n> plugins = [\"external:myplugin\"]\n> ```\n> Prefer namespaced packages like `johns_pyprland.super_feature` instead.\n\n### Plugin Attributes\n\nYour `Extension` class has access to these attributes:\n\n| Attribute | Type | Description |\n|-----------|------|-------------|\n| `self.name` | `str` | Plugin identifier |\n| `self.config` | [`Configuration`](https://github.com/fdev31/pyprland/blob/main/pyprland/config.py) | Plugin's TOML config section |\n| `self.state` | [`SharedState`](https://github.com/fdev31/pyprland/blob/main/pyprland/common.py) | Shared application state (active workspace, monitor, etc.) |\n| `self.backend` | [`EnvironmentBackend`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/backend.py) | WM interaction: commands, queries, notifications |\n| `self.log` | `Logger` | Plugin-specific logger |\n\n### Creating Your First Plugin\n\n```python\nfrom pyprland.plugins.interface import Plugin\n\n\nclass Extension(Plugin):\n    \"\"\"My custom plugin.\"\"\"\n\n    async def init(self) -> None:\n        \"\"\"Called once at startup.\"\"\"\n        self.log.info(\"My plugin initialized\")\n\n    async def on_reload(self) -> None:\n        \"\"\"Called on init and config reload.\"\"\"\n        self.log.info(f\"Config: {self.config}\")\n\n    async def exit(self) -> None:\n        \"\"\"Cleanup on shutdown.\"\"\"\n        pass\n```\n\n### Adding Commands\n\nAdd `run_<commandname>` methods to handle `pypr <commandname>` calls.\n\nThe **first line** of the docstring appears in `pypr help`:\n\n```python\nclass Extension(Plugin):\n    zoomed = False\n\n    async def run_togglezoom(self, args: str) -> str | None:\n        \"\"\"Toggle zoom level.\n\n        This second line won't appear in CLI help.\n        \"\"\"\n        if self.zoomed:\n            await self.backend.execute(\"keyword misc:cursor_zoom_factor 1\")\n        else:\n            await self.backend.execute(\"keyword misc:cursor_zoom_factor 2\")\n        self.zoomed = not self.zoomed\n```\n\n### Reacting to Events\n\nAdd `event_<eventname>` methods to react to [Hyprland events](https://wiki.hyprland.org/IPC/):\n\n```python\nasync def event_openwindow(self, params: str) -> None:\n    \"\"\"React to window open events.\"\"\"\n    addr, workspace, cls, title = params.split(\",\", 3)\n    self.log.debug(f\"Window opened: {title}\")\n\nasync def event_workspace(self, workspace: str) -> None:\n    \"\"\"React to workspace changes.\"\"\"\n    self.log.info(f\"Switched to workspace: {workspace}\")\n```\n\n> [!note]\n> **Code Safety:** Pypr ensures only one handler runs at a time per plugin, so you don't need concurrency handling. Each plugin runs independently in parallel. See [Architecture - Manager](./Architecture#manager) for details.\n\n### Configuration Schema\n\nDefine expected config fields for automatic validation using [`ConfigField`](https://github.com/fdev31/pyprland/blob/main/pyprland/validation.py):\n\n```python\nfrom pyprland.plugins.interface import Plugin\nfrom pyprland.validation import ConfigField\n\n\nclass Extension(Plugin):\n    config_schema = [\n        ConfigField(\"enabled\", bool, required=False, default=True),\n        ConfigField(\"timeout\", int, required=False, default=5000),\n        ConfigField(\"command\", str, required=True),\n    ]\n\n    async def on_reload(self) -> None:\n        # Config is validated before on_reload is called\n        cmd = self.config[\"command\"]  # Guaranteed to exist\n```\n\n### Using Menus\n\nFor plugins that need menu interaction (rofi, wofi, tofi, etc.), use [`MenuMixin`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/menus.py):\n\n```python\nfrom pyprland.adapters.menus import MenuMixin\nfrom pyprland.plugins.interface import Plugin\n\n\nclass Extension(MenuMixin, Plugin):\n    async def run_select(self, args: str) -> None:\n        \"\"\"Show a selection menu.\"\"\"\n        await self.ensure_menu_configured()\n\n        options = [\"Option 1\", \"Option 2\", \"Option 3\"]\n        selected = await self.menu(options, \"Choose an option:\")\n\n        if selected:\n            await self.backend.notify_info(f\"Selected: {selected}\")\n```\n\n## Reusable Code\n\n### Shared State\n\nAccess commonly needed information without fetching it:\n\n```python\n# Current workspace, monitor, window\nworkspace = self.state.active_workspace\nmonitor = self.state.active_monitor\nwindow_addr = self.state.active_window\n\n# Environment detection\nif self.state.environment == \"niri\":\n    # Niri-specific logic\n    pass\n```\n\nSee [Architecture - Shared State](./Architecture#shared-state) for all available fields.\n\n### Mixins\n\nUse mixins for common functionality:\n\n```python\nfrom pyprland.common import CastBoolMixin\nfrom pyprland.plugins.interface import Plugin\n\n\nclass Extension(CastBoolMixin, Plugin):\n    async def on_reload(self) -> None:\n        # Safely cast config values to bool\n        enabled = self.cast_bool(self.config.get(\"enabled\", True))\n```\n\n## Development Workflow\n\nRestart the daemon after making changes:\n\n```sh\npypr exit ; pypr --debug\n```\n\n### API Documentation\n\nGenerate and browse the full API documentation:\n\n```sh\ntox run -e doc\n# Then visit http://localhost:8080\n```\n\n## Testing & Quality Assurance\n\n### Running All Checks\n\nBefore submitting a PR, run the full test suite:\n\n```sh\ntox\n```\n\nThis runs unit tests across Python versions and linting checks.\n\n### Tox Environments\n\n| Environment | Command | Description |\n|-------------|---------|-------------|\n| `py314-unit` | `tox run -e py314-unit` | Unit tests (Python 3.14) |\n| `py311-unit` | `tox run -e py311-unit` | Unit tests (Python 3.11) |\n| `py312-unit` | `tox run -e py312-unit` | Unit tests (Python 3.12) |\n| `py314-linting` | `tox run -e py314-linting` | Full linting suite (mypy, ruff, pylint, flake8) |\n| `py314-wiki` | `tox run -e py314-wiki` | Check plugin documentation coverage |\n| `doc` | `tox run -e doc` | Generate API docs with pdoc |\n| `coverage` | `tox run -e coverage` | Run tests with coverage report |\n| `deadcode` | `tox run -e deadcode` | Detect dead code with vulture |\n\n### Quick Test Commands\n\n```sh\n# Run unit tests only\ntox run -e py314-unit\n\n# Run linting only\ntox run -e py314-linting\n\n# Check documentation coverage\ntox run -e py314-wiki\n\n# Run tests with coverage\ntox run -e coverage\n```\n\n## Pre-commit Hooks\n\nPre-commit hooks ensure code quality before commits and pushes.\n\n### Installation\n\n```sh\npip install pre-commit\npre-commit install\npre-commit install --hook-type pre-push\n```\n\n### What Runs Automatically\n\n**On every commit:**\n\n| Hook | Purpose |\n|------|---------|\n| `versionMgmt` | Auto-increment version number |\n| `wikiDocGen` | Regenerate plugin documentation JSON |\n| `wikiDocCheck` | Verify documentation coverage |\n| `ruff-check` | Lint Python code |\n| `ruff-format` | Format Python code |\n| `flake8` | Additional Python linting |\n| `check-yaml` | Validate YAML files |\n| `check-json` | Validate JSON files |\n| `pretty-format-json` | Auto-format JSON files |\n| `beautysh` | Format shell scripts |\n| `yamllint` | Lint YAML files |\n\n**On push:**\n\n| Hook | Purpose |\n|------|---------|\n| `runtests` | Run full pytest suite |\n\n### Manual Execution\n\nRun all hooks manually:\n\n```sh\npre-commit run --all-files\n```\n\nRun a specific hook:\n\n```sh\npre-commit run ruff-check --all-files\n```\n\n## Packaging & Distribution\n\n### Creating an External Plugin Package\n\nSee the [sample extension](https://github.com/fdev31/pyprland/tree/main/sample_extension) for a complete example with:\n- Proper package structure\n- `pyproject.toml` configuration\n- Example plugin code: [`focus_counter.py`](https://github.com/fdev31/pyprland/blob/main/sample_extension/pypr_examples/focus_counter.py)\n\n### Development Installation\n\nInstall your package in editable mode for testing:\n\n```sh\ncd your-plugin-package/\npip install -e .\n```\n\n### Publishing\n\nWhen ready to distribute:\n\n```sh\npoetry publish\n```\n\nDon't forget to update the details in your `pyproject.toml` file first.\n\n### Example Usage\n\nAdd your plugin to the config:\n\n```toml\n[pyprland]\nplugins = [\"pypr_examples.focus_counter\"]\n\n[\"pypr_examples.focus_counter\"]\nmultiplier = 2\n```\n\n> [!important]\n> Contact the maintainer to get your extension listed on the home page.\n\n## Further Reading\n\n- [Architecture](./Architecture) - Internal system design, data flows, and design patterns\n- [Plugins](./Plugins) - List of available built-in plugins\n- [Sample Extension](https://github.com/fdev31/pyprland/tree/main/sample_extension) - Complete example plugin package\n- [Hyprland IPC](https://wiki.hyprland.org/IPC/) - Hyprland's IPC documentation\n"
  },
  {
    "path": "site/versions/3.0.0/Examples.md",
    "content": "# Examples\n\nThis page provides complete configuration examples to help you get started.\n\n## Basic Setup\n\nA minimal configuration with a few popular plugins:\n\n### pyprland.toml\n\n```toml\n[pyprland]\nplugins = [\n    \"scratchpads\",\n    \"magnify\",\n    \"expose\",\n]\n\n[scratchpads.term]\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\nanimation = \"fromTop\"\n\n[scratchpads.volume]\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nsize = \"40% 90%\"\nanimation = \"fromRight\"\nlazy = true\n```\n\n### hyprland.conf\n\n```ini\n$pypr = /usr/bin/pypr\n\nbind = $mainMod, A, exec, $pypr toggle term\nbind = $mainMod, V, exec, $pypr toggle volume\nbind = $mainMod, B, exec, $pypr expose\nbind = $mainMod SHIFT, Z, exec, $pypr zoom\n```\n\n## Full-Featured Setup\n\nA comprehensive configuration demonstrating multiple plugins and features:\n\n### pyprland.toml\n\n```toml\n[pyprland]\nplugins = [\n  \"scratchpads\",\n  \"lost_windows\",\n  \"monitors\",\n  \"toggle_dpms\",\n  \"magnify\",\n  \"expose\",\n  \"shift_monitors\",\n  \"workspaces_follow_focus\",\n]\n\n[monitors.placement]\n\"Acer\".top_center_of = \"Sony\"\n\n[workspaces_follow_focus]\nmax_workspaces = 9\n\n[expose]\ninclude_special = false\n\n[scratchpads.stb]\nanimation = \"fromBottom\"\ncommand = \"kitty --class kitty-stb sstb\"\nclass = \"kitty-stb\"\nlazy = true\nsize = \"75% 45%\"\n\n[scratchpads.stb-logs]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-stb-logs stbLog\"\nclass = \"kitty-stb-logs\"\nlazy = true\nsize = \"75% 40%\"\n\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nlazy = true\nsize = \"40% 90%\"\nunfocus = \"hide\"\n```\n\n### hyprland.conf\n\n```ini\n# Use pypr-client for faster response in key bindings\n$pypr = uwsm-app -- /usr/local/bin/pypr-client\n\nbind = $mainMod SHIFT,  Z, exec, $pypr zoom\nbind = $mainMod ALT,    P, exec, $pypr toggle_dpms\nbind = $mainMod SHIFT,  O, exec, $pypr shift_monitors +1\nbind = $mainMod,        B, exec, $pypr expose\nbind = $mainMod,        K, exec, $pypr change_workspace +1\nbind = $mainMod,        J, exec, $pypr change_workspace -1\nbind = $mainMod,        L, exec, $pypr toggle_dpms\nbind = $mainMod  SHIFT, M, exec, $pypr toggle stb stb-logs\nbind = $mainMod,        A, exec, $pypr toggle term\nbind = $mainMod,        V, exec, $pypr toggle volume\n```\n\n> [!note]\n> This example uses `pypr-client` for faster key binding response. See [Commands: pypr-client](./Commands#pypr-client) for details.\n\n## Advanced Features\n\n### Variables\n\nYou can define reusable variables in your configuration to avoid repetition and make it easier to switch terminals or other tools.\n\nDefine variables in the `[pyprland.variables]` section:\n\n```toml\n[pyprland.variables]\nterm = \"foot\"\nterm_classed = \"foot -a\"  # For kitty, use \"kitty --class\"\n```\n\nThen use them in plugin configurations that support variable substitution:\n\n```toml\n[scratchpads.term]\ncommand = \"[term_classed] scratchterm\"\nclass = \"scratchterm\"\n```\n\nThis way, switching from `foot` to `kitty` only requires changing the variables, not every scratchpad definition.\n\nSee [Variables](./Variables) for more details.\n\n### Text Filters\n\nSome plugins support text filters for transforming output. Filters use a syntax similar to sed's `s` command:\n\n```toml\nfilter = 's/foo/bar/'           # Replace first \"foo\" with \"bar\"\nfilter = 's/foo/bar/g'          # Replace all occurrences\nfilter = 's/.*started (.*)/\\1 has started/'  # Regex with capture groups\nfilter = 's#</?div>##g'         # Use different delimiter\n```\n\nSee [Filters](./filters) for more details.\n\n## Community Examples\n\nBrowse community-contributed configuration files:\n\n- [GitHub examples folder](https://github.com/hyprland-community/pyprland/tree/main/examples)\n\nFeel free to share your own configurations by contributing to the repository.\n\n## Tips\n\n- [Optimizations](./Optimizations) - Performance tuning tips\n- [Troubleshooting](./Troubleshooting) - Common issues and solutions\n- [Multiple Configuration Files](./MultipleConfigurationFiles) - Split your config for better organization\n"
  },
  {
    "path": "site/versions/3.0.0/Getting-started.md",
    "content": "# Getting Started\n\nPypr consists of two things:\n\n- **A tool**: `pypr` which runs the daemon (service) and allows you to interact with it\n- **A config file**: `~/.config/hypr/pyprland.toml` using the [TOML](https://toml.io/en/) format\n\n> [!important]\n> - With no arguments, `pypr` runs the daemon (doesn't fork to background)\n> - With arguments, it sends commands to the running daemon\n\n> [!tip]\n> For keybindings, use `pypr-client` instead of `pypr` for faster response (~1ms vs ~50ms). See [Commands: pypr-client](./Commands#pypr-client) for details.\n\n## Installation\n\nCheck your OS package manager first:\n\n- **Arch Linux**: Available on AUR, e.g., with [yay](https://github.com/Jguer/yay): `yay pyprland`\n- **NixOS**: See the [Nix](./Nix) page for instructions\n\nOtherwise, install via pip (preferably in a [virtual environment](./InstallVirtualEnvironment)):\n\n```sh\npip install pyprland\n```\n\n## Minimal Configuration\n\nCreate `~/.config/hypr/pyprland.toml` with:\n\n```toml\n[pyprland]\nplugins = [\n    \"scratchpads\",\n    \"magnify\",\n]\n```\n\nThis enables two popular plugins. See the [Plugins](./Plugins) page for the full list.\n\n## Running the Daemon\n\n> [!caution]\n> If you installed pypr outside your OS package manager (e.g., pip, virtual environment), use the full path to the `pypr` command. Get it with `which pypr` in a working terminal.\n\n### Option 1: Hyprland exec-once\n\nAdd to your `hyprland.conf`:\n\n```ini\nexec-once = /usr/bin/pypr\n```\n\nFor debugging, use:\n\n```ini\nexec-once = /usr/bin/pypr --debug\n```\n\nOr to also save logs to a file:\n\n```ini\nexec-once = /usr/bin/pypr --debug $HOME/pypr.log\n```\n\n### Option 2: Systemd User Service\n\nCreate `~/.config/systemd/user/pyprland.service`:\n\n```ini\n[Unit]\nDescription=Starts pyprland daemon\nAfter=graphical-session.target\nWants=graphical-session.target\n# Optional: wait for other services to start first\n# Wants=hyprpaper.service\nStartLimitIntervalSec=600\nStartLimitBurst=5\n\n[Service]\nType=simple\n# Optional: only start on specific compositor\n# For Hyprland:\n# ExecStartPre=/bin/sh -c '[ \"$XDG_CURRENT_DESKTOP\" = \"Hyprland\" ] || exit 0'\n# For Niri:\n# ExecStartPre=/bin/sh -c '[ \"$XDG_CURRENT_DESKTOP\" = \"niri\" ] || exit 0'\nExecStart=pypr\nRestart=always\n\n[Install]\nWantedBy=graphical-session.target\n```\n\nThen enable and start the service:\n\n```sh\nsystemctl enable --user --now pyprland.service\n```\n\n## Verifying It Works\n\nOnce the daemon is running, check available commands:\n\n```sh\npypr help\n```\n\nIf something isn't working, check the [Troubleshooting](./Troubleshooting) page.\n\n## Next Steps\n\n- [Configuration](./Configuration) - Full configuration reference\n- [Commands](./Commands) - CLI commands and shell completions\n- [Plugins](./Plugins) - Browse available plugins\n- [Examples](./Examples) - Complete configuration examples\n"
  },
  {
    "path": "site/versions/3.0.0/InstallVirtualEnvironment.md",
    "content": "# Virtual env\n\nEven though the best way to get Pyprland installed is to use your operating system package manager,\nfor some usages or users it can be convenient to use a virtual environment.\n\nThis is very easy to achieve in a couple of steps:\n\n```shell\npython -m venv ~/pypr-env\n~/pypr-env/bin/pip install pyprland\n```\n\n**That's all folks!**\n\nThe only extra care to take is to use `pypr` from the virtual environment, eg:\n\n- adding the environment's \"bin\" folder to the `PATH` (using `export PATH=\"$PATH:~/pypr-env/bin/\"` in your shell configuration file)\n- always using the full path to the pypr command (in `hyprland.conf`: `exec-once = ~/pypr-env/bin/pypr` or with debug logging: `exec-once = ~/pypr-env/bin/pypr --debug $HOME/pypr.log`)\n\n# Going bleeding edge!\n\nIf you would rather like to use the latest version available (not released yet), then you can clone the git repository and install from it:\n\n```shell\ncd ~/pypr-env\ngit clone git@github.com:hyprland-community/pyprland.git pyprland-sources\ncd pyprland-sources\n../bin/pip install -e .\n```\n\n## Updating\n\n```shell\ncd ~/pypr-env\ngit pull -r\n```\n\n# Troubelshooting\n\nIf things go wrong, try (eg: after a system upgrade where Python got updated):\n\n```shell\npython -m venv --upgrade ~/pypr-env\ncd  ~/pypr-env\n../bin/pip install -e .\n```\n"
  },
  {
    "path": "site/versions/3.0.0/Menu.md",
    "content": "# Menu capability\n\nMenu based plugins have the following configuration options:\n\n<PluginConfig plugin=\"menu\" linkPrefix=\"config-\" />\n\n### `engine` <ConfigBadges plugin=\"menu\" option=\"engine\" /> {#config-engine}\n\nAuto-detects the available menu engine if not set.\n\nSupported engines (tested in order):\n\n- fuzzel\n- tofi\n- rofi\n- wofi\n- bemenu\n- dmenu\n- anyrun\n- walker\n\n> [!note]\n> If your menu system isn't supported, you can open a [feature request](https://github.com/hyprland-community/pyprland/issues/new?assignees=fdev31&labels=bug&projects=&template=feature_request.md&title=%5BFEAT%5D+Description+of+the+feature)\n>\n> In case the engine isn't recognized, `engine` + `parameters` configuration options will be used to start the process, it requires a dmenu-like behavior.\n\n### `parameters` <ConfigBadges plugin=\"menu\" option=\"parameters\" /> {#config-parameters}\n\nExtra parameters added to the engine command. Setting this will override the engine's default value.\n\n> [!tip]\n> You can use `[prompt]` in the parameters, it will be replaced by the prompt, eg for rofi/wofi:\n> ```sh\n> -dmenu -matching fuzzy -i -p '[prompt]'\n> ```\n\n#### Default parameters per engine\n\n<EngineDefaults />\n"
  },
  {
    "path": "site/versions/3.0.0/MultipleConfigurationFiles.md",
    "content": "### Multiple configuration files\n\nYou can also split your configuration into multiple files that will be loaded in the provided order after the main file:\n```toml\n[pyprland]\ninclude = [\"/shared/pyprland.toml\", \"~/pypr_extra_config.toml\"]\n```\nYou can also load folders, in which case TOML files in the folder will be loaded in alphabetical order:\n```toml\n[pyprland]\ninclude = [\"~/.config/pypr.d/\"]\n```\n\nAnd then add a `~/.config/pypr.d/monitors.toml` file:\n```toml\npyprland.plugins = [ \"monitors\" ]\n\n[monitors.placement]\nBenQ.Top_Center_Of = \"DP-1\" # projo\n\"CJFH277Q3HCB\".top_of = \"eDP-1\" # work\n```\n\n> [!tip]\n> To check the final merged configuration, you can use the `dumpjson` command.\n\n"
  },
  {
    "path": "site/versions/3.0.0/Nix.md",
    "content": "# Nix\n\nYou are recommended to get the latest pyprland package from the [flake.nix](https://github.com/hyprland-community/pyprland/blob/main/flake.nix)\nprovided within this repository. To use it in your system, you may add pyprland\nto your flake inputs.\n\n## Flake\n\n```nix\n## flake.nix\n{\n  inputs = {\n    nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n    pyprland.url = \"github:hyprland-community/pyprland\";\n  };\n\n  outputs = { self, nixpkgs, pyprland, ...}: let\n  in {\n    nixosConfigurations.<yourHostname> = nixpkgs.lib.nixosSystem {\n      #         remember to replace this with your system arch ↓\n      environment.systemPackages = [ pyprland.packages.\"x86_64-linux\".pyprland ];\n      # ...\n    };\n  };\n}\n```\n\nAlternatively, if you are using the Nix package manager but not NixOS as your\nmain distribution, you may use `nix profile` tool to install pyprland from this\nrepository using the following command.\n\n```bash\nnix profile install github:nix-community/pyprland\n```\n\nThe package will now be in your latest profile. You may use `nix profile list`\nto verify your installation.\n\n## Nixpkgs\n\nPyprland is available under nixpkgs, and can be installed by adding\n`pkgs.pyprland` to either `environment.systemPackages` or `home.packages`\ndepending on whether you want it available system-wide or to only a single\nuser using home-manager. If the derivation available in nixpkgs is out-of-date\nthen you may consider using `overrideAttrs` to update the source locally.\n\n```nix\nlet\n  pyprland = pkgs.pyprland.overrideAttrs {\n    version = \"your-version-here\";\n    src = fetchFromGitHub {\n      owner = \"hyprland-community\";\n      repo = \"pyprland\";\n      rev = \"tag-or-revision\";\n      # leave empty for the first time, add the new hash from the error message\n      hash = \"\";\n    };\n  };\nin {\n  # add the overridden package to systemPackages\n  environment.systemPackages = [pyprland];\n}\n```\n"
  },
  {
    "path": "site/versions/3.0.0/Optimizations.md",
    "content": "## Optimizing\n\n### Plugins\n\n- Only enable the plugins you are using in the `plugins` array (in `[pyprland]` section).\n- Leaving the configuration for plugins which are not enabled will have no impact.\n- Using multiple configuration files only have a small impact on the startup time.\n\n### Pypr\n\nYou can run `pypr` using `pypy` (version 3) for a more snappy experience.\nOne way is to use a `pypy3` virtual environment:\n\n```bash\npypy3 -m venv pypr-venv\nsource ./pypr-venv/bin/activate\ncd <pypr source folder>\npip install -e .\n```\n\n### Pypr command\n\nIn case you want to save some time when interacting with the daemon, the simplest is to use `pypr-client`. See [Commands: pypr-client](./Commands#pypr-client) for details. If `pypr-client` isn't available from your OS package and you cannot compile code,\nyou can use `socat` instead (needs to be installed).\n\nExample of a `pypr-cli` command (should be reachable from your environment's `PATH`):\n\n#### Hyprland\n```sh\n#!/bin/sh\nsocat - \"UNIX-CONNECT:${XDG_RUNTIME_DIR}/hypr/${HYPRLAND_INSTANCE_SIGNATURE}/.pyprland.sock\" <<< $@\n```\n\n#### Niri\n```sh\n#!/bin/sh\nsocat - \"UNIX-CONNECT:$(dirname ${NIRI_SOCKET})/.pyprland.sock\" <<< $@\n```\n\n#### Standalone (other window manager)\n```sh\n#!/bin/sh\nsocat - \"UNIX-CONNECT:${XDG_DATA_HOME:-$HOME/.local/share}/.pyprland.sock\" <<< $@\n```\n\nOn slow systems this may make a difference.\nNote that `validate` and `edit` commands require the standard `pypr` command.\n"
  },
  {
    "path": "site/versions/3.0.0/Plugins.md",
    "content": "<script setup>\nimport PluginList from '/components/PluginList.vue'\n</script>\n\nThis page lists every plugin provided by Pyprland out of the box, more can be enabled if you install the matching package.\n\n\"🌟\" indicates some maturity & reliability level of the plugin, considering age, attention paid and complexity - from 0 to 3.\n\nA badge such as <Badge type=\"tip\">multi-monitor</Badge> indicates a requirement.\n\nSome plugins require an external **graphical menu system**, such as *rofi*.\nEach plugin can use a different menu system but the [configuration is unified](Menu). In case no [engine](Menu#engine) is provided some auto-detection of installed applications will happen.\n\n<PluginList/>\n"
  },
  {
    "path": "site/versions/3.0.0/Troubleshooting.md",
    "content": "# Troubleshooting\n\n## Checking Logs\n\nHow you access logs depends on how you run pyprland.\n\n### Systemd Service\n\nIf you run pyprland as a [systemd user service](./Getting-started#option-2-systemd-user-service):\n\n```sh\njournalctl --user -u pyprland -f\n```\n\n### exec-once (Hyprland)\n\nIf you run pyprland via [exec-once](./Getting-started#option-1-hyprland-exec-once), logs go to stderr by default and are typically lost.\n\nTo enable debug logging, add `--debug` to your exec-once command. Optionally specify a file path to also save logs to a file:\n\n```ini\nexec-once = /usr/bin/pypr --debug $HOME/pypr.log\n```\n\nThen check the log file:\n\n```sh\ntail -f ~/pypr.log\n```\n\n> [!tip]\n> Use a path like `$HOME/pypr.log` or `/tmp/pypr.log` to avoid cluttering your home directory.\n\n### Running from Terminal\n\nFor quick debugging, run pypr directly in a terminal:\n\n```sh\npypr --debug\n```\n\nThis shows debug output directly in the terminal. Optionally add a file path to also save logs to a file.\n\n## General Issues\n\nIn case of trouble running a `pypr` command:\n\n1. Kill the existing pypr daemon if running (try `pypr exit` first)\n2. Run from a terminal with `--debug` to see error messages\n\nIf the client says it can't connect, the daemon likely didn't start. Check if it's running:\n\n```sh\nps aux | grep pypr\n```\n\nYou can try starting it manually from a terminal:\n\n```sh\npypr --debug\n```\n\nThis will show any startup errors directly in the terminal.\n\n## Force Hyprland Version\n\nIn case your `hyprctl version -j` command isn't returning an accurate version, you can make Pyprland ignore it and use a provided value instead:\n\n```toml\n[pyprland]\nhyprland_version = \"0.41.0\"\n```\n\n## Unresponsive Scratchpads\n\nScratchpads aren't responding for a few seconds after trying to show one (which didn't show!)\n\nThis may happen if an application is very slow to start.\nIn that case pypr will wait for a window, blocking other scratchpad operations, before giving up after a few seconds.\n\nNote that other plugins shouldn't be blocked by this.\n\nMore scratchpads troubleshooting can be found [here](./scratchpads_nonstandard).\n\n## See Also\n\n- [Getting Started: Running the Daemon](./Getting-started#running-the-daemon) - Setup options\n- [Commands: Debugging](./Commands#debugging) - Debug flag reference\n"
  },
  {
    "path": "site/versions/3.0.0/Variables.md",
    "content": "# Variables & substitutions\n\nSome commands support shared global variables, they must be defined in the *pyprland* section of the configuration:\n```toml\n[pyprland.variables]\nterm = \"foot\"\nterm_classed = \"foot -a\" # kitty uses --class\n```\n\nIf a plugin supports it, you can then use the variables in the attribute that supports it, eg:\n\n```toml\n[myplugin]\nsome_variable = \"the terminal is [term]\"\n```\n"
  },
  {
    "path": "site/versions/3.0.0/components/CommandList.vue",
    "content": "<template>\n  <ul v-for=\"command in commands\" :key=\"command.name\">\n    <li>\n      <code v-html=\"command.name.replace(/[ ]*$/, '').replace(/ +/g, '&ensp;')\" />&ensp;\n      <span v-html=\"renderDescription(command.description)\" />\n    </li>\n  </ul>\n</template>\n\n<script>\nimport { renderDescription } from './configHelpers.js'\n\nexport default {\n  props: {\n    commands: {\n      type: Array,\n      required: true\n    }\n  },\n  methods: {\n    renderDescription\n  }\n}\n</script>\n"
  },
  {
    "path": "site/versions/3.0.0/components/ConfigBadges.vue",
    "content": "<template>\n    <span v-if=\"loaded && item\" class=\"config-badges\">\n        <Badge type=\"info\">{{ typeIcon }}{{ item.type }}</Badge>\n        <Badge v-if=\"hasDefault\" type=\"tip\">=<code>{{ formattedDefault }}</code></Badge>\n        <Badge v-if=\"item.required\" type=\"danger\">required</Badge>\n        <Badge v-else-if=\"item.recommended\" type=\"warning\">recommended</Badge>\n    </span>\n</template>\n\n<script>\nexport default {\n    props: {\n        plugin: {\n            type: String,\n            required: true\n        },\n        option: {\n            type: String,\n            required: true\n        }\n    },\n    data() {\n        return {\n            item: null,\n            loaded: false\n        }\n    },\n    async mounted() {\n        try {\n            const data = await import(`../generated/${this.plugin}.json`)\n            const config = data.config || []\n            // Find the option - handle both \"option\" and \"[prefix].option\" formats\n            this.item = config.find(c => {\n                const baseName = c.name.replace(/^\\[.*?\\]\\./, '')\n                return baseName === this.option || c.name === this.option\n            })\n        } catch (e) {\n            console.error(`Failed to load config for plugin: ${this.plugin}`, e)\n        } finally {\n            this.loaded = true\n        }\n    },\n    computed: {\n        typeIcon() {\n            if (!this.item) return ''\n            const type = this.item.type || ''\n            if (type.includes('Path')) {\n                return this.item.is_directory ? '\\u{1F4C1} ' : '\\u{1F4C4} '\n            }\n            return ''\n        },\n        hasDefault() {\n            if (!this.item) return false\n            const value = this.item.default\n            if (value === null || value === undefined) return false\n            if (value === '') return false\n            if (Array.isArray(value) && value.length === 0) return false\n            if (typeof value === 'object' && Object.keys(value).length === 0) return false\n            return true\n        },\n        formattedDefault() {\n            if (!this.item) return ''\n            const value = this.item.default\n            if (typeof value === 'boolean') {\n                return value ? 'true' : 'false'\n            }\n            if (typeof value === 'string') {\n                return `\"${value}\"`\n            }\n            if (Array.isArray(value)) {\n                return JSON.stringify(value)\n            }\n            return String(value)\n        }\n    }\n}\n</script>\n\n<style scoped>\n.config-badges {\n    margin-left: 0.5em;\n}\n\n.config-badges code {\n    background: transparent;\n    font-size: 0.9em;\n}\n</style>\n"
  },
  {
    "path": "site/versions/3.0.0/components/ConfigTable.vue",
    "content": "<template>\n  <!-- Grouped by category (only at top level, when categories exist) -->\n  <div v-if=\"hasCategories && !isNested\" class=\"config-categories\">\n    <details\n      v-for=\"group in groupedItems\"\n      :key=\"group.category\"\n      :open=\"group.category === 'basic'\"\n      class=\"config-category\"\n    >\n      <summary class=\"config-category-header\">\n        {{ getCategoryDisplayName(group.category) }}\n        <span class=\"config-category-count\">({{ group.items.length }})</span>\n        <a v-if=\"group.category === 'menu'\" href=\"./Menu\" class=\"config-category-link\">See full documentation</a>\n      </summary>\n      <table class=\"config-table\">\n        <thead>\n          <tr>\n            <th>Option</th>\n            <th>Description</th>\n          </tr>\n        </thead>\n        <tbody>\n          <template v-for=\"item in group.items\" :key=\"item.name\">\n            <tr>\n              <td class=\"config-option-cell\">\n                <a v-if=\"isDocumented(item.name)\" :href=\"'#' + getAnchor(item.name)\" class=\"config-link\" title=\"More details below\">\n                  <span v-if=\"hasChildren(item)\" class=\"config-has-children\" title=\"Has child options\">+</span>\n                  <code>{{ item.name }}</code>\n                  <span class=\"config-info-icon\">i</span>\n                </a>\n                <template v-else>\n                  <span v-if=\"hasChildren(item)\" class=\"config-has-children\" title=\"Has child options\">+</span>\n                  <code>{{ item.name }}</code>\n                </template>\n                <Badge type=\"info\">{{ getTypeIcon(item) }}{{ item.type }}</Badge>\n                <Badge v-if=\"item.required\" type=\"danger\">required</Badge>\n                <Badge v-else-if=\"item.recommended\" type=\"warning\">recommended</Badge>\n                <div v-if=\"hasDefault(item.default)\" type=\"tip\">=<code>{{ formatDefault(item.default) }}</code></div>\n              </td>\n              <td class=\"config-description\" v-html=\"renderDescription(item.description)\" />\n            </tr>\n            <!-- Children row (recursive) -->\n            <tr v-if=\"hasChildren(item)\" class=\"config-children-row\">\n              <td colspan=\"2\" class=\"config-children-cell\">\n                <details class=\"config-children-details\">\n                  <summary><code>{{ item.name }}</code> options</summary>\n                  <config-table\n                    :items=\"item.children\"\n                    :is-nested=\"true\"\n                    :option-to-anchor=\"optionToAnchor\"\n                    :parent-name=\"getQualifiedName(item.name)\"\n                  />\n                </details>\n              </td>\n            </tr>\n          </template>\n        </tbody>\n      </table>\n    </details>\n  </div>\n\n  <!-- Flat table (for nested tables or when no categories) -->\n  <table v-else :class=\"['config-table', { 'config-nested': isNested }]\">\n    <thead>\n      <tr>\n        <th>Option</th>\n        <th>Description</th>\n      </tr>\n    </thead>\n    <tbody>\n      <template v-for=\"item in items\" :key=\"item.name\">\n        <tr>\n          <td class=\"config-option-cell\">\n            <a v-if=\"isDocumented(item.name)\" :href=\"'#' + getAnchor(item.name)\" class=\"config-link\" title=\"More details below\">\n              <span v-if=\"hasChildren(item)\" class=\"config-has-children\" title=\"Has child options\">+</span>\n              <code>{{ item.name }}</code>\n              <span class=\"config-info-icon\">i</span>\n            </a>\n            <template v-else>\n              <span v-if=\"hasChildren(item)\" class=\"config-has-children\" title=\"Has child options\">+</span>\n              <code>{{ item.name }}</code>\n            </template>\n            <Badge type=\"info\">{{ item.type }}</Badge>\n            <Badge v-if=\"item.required\" type=\"danger\">required</Badge>\n            <Badge v-else-if=\"item.recommended\" type=\"warning\">recommended</Badge>\n            <div v-if=\"hasDefault(item.default)\" type=\"tip\">=<code>{{ formatDefault(item.default) }}</code></div>\n          </td>\n          <td class=\"config-description\" v-html=\"renderDescription(item.description)\" />\n        </tr>\n        <!-- Children row (recursive) -->\n        <tr v-if=\"hasChildren(item)\" class=\"config-children-row\">\n          <td colspan=\"2\" class=\"config-children-cell\">\n            <details class=\"config-children-details\">\n              <summary><code>{{ item.name }}</code> options</summary>\n              <config-table\n                :items=\"item.children\"\n                :is-nested=\"true\"\n                :option-to-anchor=\"optionToAnchor\"\n                :parent-name=\"getQualifiedName(item.name)\"\n              />\n            </details>\n          </td>\n        </tr>\n      </template>\n    </tbody>\n  </table>\n</template>\n\n<script>\nimport { hasChildren, hasDefault, formatDefault, renderDescription } from './configHelpers.js'\n\n// Category display order and names\nconst CATEGORY_ORDER = ['basic', 'menu', 'appearance', 'positioning', 'behavior', 'external_commands', 'templating', 'placement', 'advanced', 'overrides', '']\nconst CATEGORY_NAMES = {\n  'basic': 'Basic',\n  'menu': 'Menu',\n  'appearance': 'Appearance',\n  'positioning': 'Positioning',\n  'behavior': 'Behavior',\n  'external_commands': 'External commands',\n  'templating': 'Templating',\n  'placement': 'Placement',\n  'advanced': 'Advanced',\n  'overrides': 'Overrides',\n  '': 'Other'\n}\n\nexport default {\n  name: 'ConfigTable',\n  props: {\n    items: { type: Array, required: true },\n    isNested: { type: Boolean, default: false },\n    optionToAnchor: { type: Object, default: () => ({}) },\n    parentName: { type: String, default: '' }\n  },\n  computed: {\n    hasCategories() {\n      // Only group if there are multiple distinct categories\n      const categories = new Set(this.items.map(item => item.category || ''))\n      return categories.size > 1\n    },\n    groupedItems() {\n      // Group items by category\n      const groups = {}\n      for (const item of this.items) {\n        const category = item.category || ''\n        if (!groups[category]) {\n          groups[category] = []\n        }\n        groups[category].push(item)\n      }\n\n      // Sort groups by CATEGORY_ORDER\n      const result = []\n      for (const cat of CATEGORY_ORDER) {\n        if (groups[cat]) {\n          result.push({ category: cat, items: groups[cat] })\n          delete groups[cat]\n        }\n      }\n      // Add any remaining categories not in the order list\n      for (const cat of Object.keys(groups).sort()) {\n        result.push({ category: cat, items: groups[cat] })\n      }\n\n      return result\n    }\n  },\n  methods: {\n    hasChildren,\n    hasDefault,\n    formatDefault,\n    renderDescription,\n    getTypeIcon(item) {\n      const type = item.type || ''\n      if (type.includes('Path')) {\n        return item.is_directory ? '\\u{1F4C1} ' : '\\u{1F4C4} '\n      }\n      return ''\n    },\n    getCategoryDisplayName(category) {\n      return CATEGORY_NAMES[category] || category.charAt(0).toUpperCase() + category.slice(1)\n    },\n    getQualifiedName(name) {\n      const baseName = name.replace(/^\\[.*?\\]\\./, '')\n      return this.parentName ? `${this.parentName}.${baseName}` : baseName\n    },\n    isDocumented(name) {\n      if (Object.keys(this.optionToAnchor).length === 0) return false\n      const qualifiedName = this.getQualifiedName(name)\n      const anchorKey = qualifiedName.replace(/\\./g, '-')\n      return anchorKey in this.optionToAnchor || qualifiedName in this.optionToAnchor\n    },\n    getAnchor(name) {\n      const qualifiedName = this.getQualifiedName(name)\n      const anchorKey = qualifiedName.replace(/\\./g, '-')\n      return this.optionToAnchor[anchorKey] || this.optionToAnchor[qualifiedName] || ''\n    }\n  }\n}\n</script>\n\n<style scoped>\n.config-categories {\n  display: flex;\n  flex-direction: column;\n  gap: 0.5rem;\n}\n\n.config-category {\n  border: 1px solid var(--vp-c-divider);\n  border-radius: 8px;\n  overflow: hidden;\n}\n\n.config-category[open] {\n  border-color: var(--vp-c-brand);\n}\n\n.config-category-header {\n  display: flex;\n  align-items: center;\n  gap: 0.5rem;\n  padding: 0.75rem 1rem;\n  background: var(--vp-c-bg-soft);\n  font-weight: 600;\n  cursor: pointer;\n  user-select: none;\n}\n\n.config-category-header:hover {\n  background: var(--vp-c-bg-mute);\n}\n\n.config-category-count {\n  font-weight: 400;\n  color: var(--vp-c-text-2);\n  font-size: 0.875em;\n}\n\n.config-category-link {\n  margin-left: auto;\n  font-weight: 400;\n  font-size: 0.875em;\n  color: var(--vp-c-brand);\n  text-decoration: none;\n}\n\n.config-category-link:hover {\n  text-decoration: underline;\n}\n\n.config-category .config-table {\n  margin: 0;\n  border: none;\n  border-radius: 0;\n}\n\n.config-category .config-table thead {\n  background: var(--vp-c-bg-alt);\n}\n</style>\n"
  },
  {
    "path": "site/versions/3.0.0/components/EngineDefaults.vue",
    "content": "<template>\n  <div v-if=\"loading\" class=\"data-loading\">Loading engine defaults...</div>\n  <div v-else-if=\"error\" class=\"data-error\">{{ error }}</div>\n  <table v-else class=\"data-table\">\n    <thead>\n      <tr>\n        <th>Engine</th>\n        <th>Default Parameters</th>\n      </tr>\n    </thead>\n    <tbody>\n      <tr v-for=\"(params, engine) in engineDefaults\" :key=\"engine\">\n        <td><code>{{ engine }}</code></td>\n        <td><code>{{ params || '-' }}</code></td>\n      </tr>\n    </tbody>\n  </table>\n</template>\n\n<script setup>\nimport { usePluginData } from './usePluginData.js'\n\nconst { data: engineDefaults, loading, error } = usePluginData(async () => {\n  const module = await import('../generated/menu.json')\n  return module.engine_defaults || {}\n})\n</script>\n"
  },
  {
    "path": "site/versions/3.0.0/components/PluginCommands.vue",
    "content": "<template>\n  <div v-if=\"loading\" class=\"command-loading\">Loading commands...</div>\n  <div v-else-if=\"error\" class=\"command-error\">{{ error }}</div>\n  <div v-else-if=\"filteredCommands.length === 0\" class=\"command-empty\">\n    No commands are provided by this plugin.\n  </div>\n  <div v-else class=\"command-box\">\n    <ul class=\"command-list\">\n      <li v-for=\"command in filteredCommands\" :key=\"command.name\" class=\"command-item\">\n        <a v-if=\"isDocumented(command.name)\" :href=\"'#' + getAnchor(command.name)\" class=\"command-link\" title=\"More details below\">\n          <code class=\"command-name\">{{ command.name }}</code>\n          <span class=\"command-info-icon\">i</span>\n        </a>\n        <code v-else class=\"command-name\">{{ command.name }}</code>\n        <template v-for=\"(arg, idx) in command.args\" :key=\"idx\">\n          <code class=\"command-arg\">{{ formatArg(arg) }}</code>\n        </template>\n        <span class=\"command-desc\" v-html=\"renderDescription(command.short_description)\" />\n      </li>\n    </ul>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted } from 'vue'\nimport { renderDescription } from './configHelpers.js'\nimport { usePluginData } from './usePluginData.js'\n\nconst props = defineProps({\n  plugin: {\n    type: String,\n    required: true\n  },\n  filter: {\n    type: Array,\n    default: null\n  },\n  linkPrefix: {\n    type: String,\n    default: ''\n  }\n})\n\nconst commandToAnchor = ref({})\n\nconst { data: commands, loading, error } = usePluginData(async () => {\n  const module = await import(`../generated/${props.plugin}.json`)\n  return module.commands || []\n})\n\nconst filteredCommands = computed(() => {\n  if (!props.filter || props.filter.length === 0) {\n    return commands.value\n  }\n  return commands.value.filter(cmd => props.filter.includes(cmd.name))\n})\n\nonMounted(() => {\n  if (props.linkPrefix) {\n    const anchors = document.querySelectorAll('h3[id], h4[id], h5[id]')\n    const mapping = {}\n    anchors.forEach(heading => {\n      mapping[heading.id] = heading.id\n      // Also extract command names from <code> elements\n      const codes = heading.querySelectorAll('code')\n      codes.forEach(code => {\n        mapping[code.textContent] = heading.id\n      })\n    })\n    commandToAnchor.value = mapping\n  }\n})\n\nfunction isDocumented(name) {\n  if (Object.keys(commandToAnchor.value).length === 0) return false\n  const anchorKey = `${props.linkPrefix}${name}`\n  return anchorKey in commandToAnchor.value || name in commandToAnchor.value\n}\n\nfunction getAnchor(name) {\n  const anchorKey = `${props.linkPrefix}${name}`\n  return commandToAnchor.value[anchorKey] || commandToAnchor.value[name] || ''\n}\n\nfunction formatArg(arg) {\n  return arg.required ? arg.value : `[${arg.value}]`\n}\n</script>\n"
  },
  {
    "path": "site/versions/3.0.0/components/PluginConfig.vue",
    "content": "<template>\n  <div v-if=\"loading\" class=\"config-loading\">Loading configuration...</div>\n  <div v-else-if=\"error\" class=\"config-error\">{{ error }}</div>\n  <div v-else-if=\"filteredConfig.length === 0\" class=\"config-empty\">No configuration options available.</div>\n  <config-table\n    v-else\n    :items=\"filteredConfig\"\n    :option-to-anchor=\"optionToAnchor\"\n  />\n</template>\n\n<script>\nimport ConfigTable from './ConfigTable.vue'\n\nexport default {\n  components: {\n    ConfigTable\n  },\n  props: {\n    plugin: {\n      type: String,\n      required: true\n    },\n    linkPrefix: {\n      type: String,\n      default: ''\n    },\n    filter: {\n      type: Array,\n      default: null\n    }\n  },\n  data() {\n    return {\n      config: [],\n      loading: true,\n      error: null,\n      optionToAnchor: {}\n    }\n  },\n  computed: {\n    filteredConfig() {\n      if (!this.filter || this.filter.length === 0) {\n        return this.config\n      }\n      return this.config.filter(item => {\n        const baseName = item.name.replace(/^\\[.*?\\]\\./, '')\n        return this.filter.includes(baseName)\n      })\n    }\n  },\n  async mounted() {\n    try {\n      const data = await import(`../generated/${this.plugin}.json`)\n      this.config = data.config || []\n    } catch (e) {\n      this.error = `Failed to load configuration for plugin: ${this.plugin}`\n      console.error(e)\n    } finally {\n      this.loading = false\n    }\n    \n    // Scan page for documented option anchors (h3, h4, h5) and build option->anchor mapping\n    if (this.linkPrefix) {\n      const anchors = document.querySelectorAll('h3[id], h4[id], h5[id]')\n      const mapping = {}\n      anchors.forEach(heading => {\n        // Map by anchor ID directly (e.g., \"placement-scale\" -> \"placement-scale\")\n        // This allows qualified lookups like \"placement.scale\" -> \"placement-scale\"\n        mapping[heading.id] = heading.id\n        // Also extract option names from <code> elements for top-level matching\n        const codes = heading.querySelectorAll('code')\n        codes.forEach(code => {\n          mapping[code.textContent] = heading.id\n        })\n      })\n      this.optionToAnchor = mapping\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "site/versions/3.0.0/components/PluginList.vue",
    "content": "<template>\n    <div>\n        <h1>Built-in plugins</h1>\n        <div v-if=\"loading\">Loading plugins...</div>\n        <div v-else-if=\"error\">{{ error }}</div>\n        <div v-else v-for=\"plugin in sortedPlugins\" :key=\"plugin.name\" class=\"plugin-item\">\n            <div class=\"plugin-info\">\n                <h3>\n                    <a :href=\"plugin.name + '.html'\">{{ plugin.name }}</a>\n                    <span v-html=\"'&nbsp;' + getStars(plugin.stars)\"></span>\n                    <span v-if=\"plugin.multimon\">\n                        <Badge type=\"tip\" text=\"multi-monitor\" />\n                    </span>\n                    <span v-if=\"plugin.environments && plugin.environments.length\">\n                        <Badge v-for=\"env in plugin.environments\" :key=\"env\" type=\"tip\" :text=\"env\" style=\"margin-left: 5px;\" />\n                    </span>\n                </h3>\n                <p v-html=\"plugin.description\" />\n            </div>\n            <a v-if=\"plugin.demoVideoId\" :href=\"'https://www.youtube.com/watch?v=' + plugin.demoVideoId\"\n                class=\"plugin-video\">\n                <img :src=\"'https://img.youtube.com/vi/' + plugin.demoVideoId + '/1.jpg'\" alt=\"Demo video thumbnail\" />\n            </a>\n        </div>\n    </div>\n</template>\n\n<style scoped>\n.plugin-item {\n    display: flex;\n    align-items: flex-start;\n    margin-bottom: 1.5rem;\n}\n\n.plugin-info {\n    flex: 1;\n}\n\n.plugin-video {\n    margin-left: 1rem;\n}\n\n.plugin-video img {\n    max-width: 200px;\n    /* Adjust as needed */\n    height: auto;\n}\n</style>\n\n\n<script setup>\nimport { computed } from 'vue'\nimport { usePluginData } from './usePluginData.js'\n\nconst { data: plugins, loading, error } = usePluginData(async () => {\n    const module = await import('../generated/index.json')\n    // Filter out internal plugins like 'pyprland'\n    return (module.plugins || []).filter(p => p.name !== 'pyprland')\n})\n\nconst sortedPlugins = computed(() => {\n    if (!plugins.value?.length) return []\n    return plugins.value.slice().sort((a, b) => a.name.localeCompare(b.name))\n})\n\nfunction getStars(count) {\n    return count > 0 ? '&#11088;'.repeat(count) : ''\n}\n</script>\n"
  },
  {
    "path": "site/versions/3.0.0/components/configHelpers.js",
    "content": "/**\n * Shared helper functions for config table components.\n */\n\nimport MarkdownIt from 'markdown-it'\n\nconst md = new MarkdownIt({ html: true, linkify: true })\n\n/**\n * Check if a config item has children.\n * @param {Object} item - Config item\n * @returns {boolean}\n */\nexport function hasChildren(item) {\n  return item.children && item.children.length > 0\n}\n\n/**\n * Check if a value represents a meaningful default (not empty/null).\n * @param {*} value - Default value to check\n * @returns {boolean}\n */\nexport function hasDefault(value) {\n  if (value === null || value === undefined) return false\n  if (value === '') return false\n  if (Array.isArray(value) && value.length === 0) return false\n  if (typeof value === 'object' && Object.keys(value).length === 0) return false\n  return true\n}\n\n/**\n * Format a default value for display.\n * @param {*} value - Value to format\n * @returns {string}\n */\nexport function formatDefault(value) {\n  if (typeof value === 'boolean') {\n    return value ? 'true' : 'false'\n  }\n  if (typeof value === 'string') {\n    return `\"${value}\"`\n  }\n  if (Array.isArray(value)) {\n    return JSON.stringify(value)\n  }\n  return String(value)\n}\n\n/**\n * Render description text with markdown support.\n * Transforms <opt1|opt2|...> patterns to styled inline code blocks.\n * @param {string} text - Description text\n * @returns {string} - HTML string\n */\nexport function renderDescription(text) {\n  if (!text) return ''\n  // Transform <opt1|opt2|...> patterns to styled inline code blocks\n  text = text.replace(/<([^>|]+(?:\\|[^>|]+)+)>/g, (match, choices) => {\n    return choices.split('|').map(c => `\\`${c}\\``).join(' | ')\n  })\n  // Use render() to support links, then strip wrapping <p> tags\n  const html = md.render(text)\n  return html.replace(/^<p>/, '').replace(/<\\/p>\\n?$/, '')\n}\n"
  },
  {
    "path": "site/versions/3.0.0/components/usePluginData.js",
    "content": "/**\n * Composable for loading plugin data with loading/error states.\n *\n * Provides a standardized pattern for async data loading in Vue components.\n */\n\nimport { ref, onMounted } from 'vue'\n\n/**\n * Load data asynchronously with loading and error state management.\n *\n * @param {Function} loader - Async function that returns the data\n * @returns {Object} - { data, loading, error } refs\n *\n * @example\n * // Load commands from a plugin JSON file\n * const { data: commands, loading, error } = usePluginData(async () => {\n *   const module = await import(`../generated/${props.plugin}.json`)\n *   return module.commands || []\n * })\n *\n * @example\n * // Load with default value\n * const { data: config, loading, error } = usePluginData(\n *   async () => {\n *     const module = await import('../generated/menu.json')\n *     return module.engine_defaults || {}\n *   }\n * )\n */\nexport function usePluginData(loader) {\n  const data = ref(null)\n  const loading = ref(true)\n  const error = ref(null)\n\n  onMounted(async () => {\n    try {\n      data.value = await loader()\n    } catch (e) {\n      error.value = e.message || 'Failed to load data'\n      console.error(e)\n    } finally {\n      loading.value = false\n    }\n  })\n\n  return { data, loading, error }\n}\n"
  },
  {
    "path": "site/versions/3.0.0/expose.md",
    "content": "---\n---\n\n# expose\n\nImplements the \"expose\" effect, showing every client window on the focused screen.\n\nFor a similar feature using a menu, try the [fetch_client_menu](./fetch_client_menu) plugin (less intrusive).\n\nSample `hyprland.conf`:\n\n```bash\n# Setup the key binding\nbind = $mainMod, B, exec, pypr expose\n\n# Add some style to the \"exposed\" workspace\nworkspace = special:exposed,gapsout:60,gapsin:30,bordersize:5,border:true,shadow:false\n```\n\n`MOD+B` will bring every client to the focused workspace, pressed again it will go to this workspace.\n\nCheck [workspace rules](https://wiki.hyprland.org/Configuring/Workspace-Rules/#rules) for styling options.\n\n> [!note]\n> If you are looking for `toggle_minimized`, check the [toggle_special](./toggle_special) plugin\n\n\n## Commands\n\n<PluginCommands plugin=\"expose\" />\n\n## Configuration\n\n<PluginConfig plugin=\"expose\" linkPrefix=\"config-\" />\n\n"
  },
  {
    "path": "site/versions/3.0.0/fcitx5_switcher.md",
    "content": "---\n---\n\n# fcitx5_switcher\n\nThis is a useful tool for CJK input method users.\n\nIt can automatically switch fcitx5 input method status based on window class and title.\n\n<details>\n<summary>Example</summary>\n\n```toml\n[fcitx5_switcher]\nactive_classes = [\"wechat\", \"QQ\", \"zoom\"]\ninactive_classes = [\n    \"code\",\n    \"kitty\",\n    \"google-chrome\",\n]\nactive_titles = []\ninactive_titles = []\n```\n\nIn this example, if the window class is \"wechat\" or \"QQ\" or \"zoom\", the input method will be activated. If the window class is \"code\" or \"kitty\" or \"google-chrome\", the input method will be inactivated.\n\n</details>\n\n## Commands\n\n<PluginCommands plugin=\"fcitx5_switcher\" />\n\n## Configuration\n\n<PluginConfig plugin=\"fcitx5_switcher\" linkPrefix=\"config-\" />\n\n"
  },
  {
    "path": "site/versions/3.0.0/fetch_client_menu.md",
    "content": "---\n---\n\n# fetch_client_menu\n\nBring any window to the active workspace using a menu.\n\nA bit like the [expose](./expose) plugin but using a menu instead (less intrusive).\n\nIt brings the window to the current workspace, while [expose](./expose) moves the currently focused screen to the application workspace.\n\n## Commands\n\n<PluginCommands plugin=\"fetch_client_menu\" />\n\n## Configuration\n\n<PluginConfig plugin=\"fetch_client_menu\" linkPrefix=\"config-\" />"
  },
  {
    "path": "site/versions/3.0.0/filters.md",
    "content": "# Text filters\n\nAt the moment there is only one filter, close match of [sed's \"s\" command](https://www.gnu.org/software/sed/manual/html_node/The-_0022s_0022-Command.html).\n\nThe only supported flag is \"g\"\n\n## Example\n\n```toml\nfilter = 's/foo/bar/'\nfilter = 's/.*started (.*)/\\1 has started/'\nfilter = 's#</?div>##g'\n```\n"
  },
  {
    "path": "site/versions/3.0.0/generated/expose.json",
    "content": "{\n  \"name\": \"expose\",\n  \"description\": \"Exposes all windows for a quick 'jump to' feature.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"expose\",\n      \"args\": [],\n      \"short_description\": \"Expose every client on the active workspace.\",\n      \"full_description\": \"Expose every client on the active workspace.\\n\\nIf expose is active restores everything and move to the focused window\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"include_special\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Include windows from special workspaces\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.0.0/generated/fcitx5_switcher.json",
    "content": "{\n  \"name\": \"fcitx5_switcher\",\n  \"description\": \"A plugin to auto-switch Fcitx5 input method status by window class/title.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [],\n  \"config\": [\n    {\n      \"name\": \"active_classes\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Window classes that should activate Fcitx5\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"activation\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"active_titles\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Window titles that should activate Fcitx5\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"activation\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"inactive_classes\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Window classes that should deactivate Fcitx5\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"deactivation\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"inactive_titles\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Window titles that should deactivate Fcitx5\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"deactivation\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.0.0/generated/fetch_client_menu.json",
    "content": "{\n  \"name\": \"fetch_client_menu\",\n  \"description\": \"Shows a menu to select and fetch a window to your active workspace.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"fetch_client_menu\",\n      \"args\": [],\n      \"short_description\": \"Select a client window and move it to the active workspace.\",\n      \"full_description\": \"Select a client window and move it to the active workspace.\"\n    },\n    {\n      \"name\": \"unfetch_client\",\n      \"args\": [],\n      \"short_description\": \"Return a window back to its origin.\",\n      \"full_description\": \"Return a window back to its origin.\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"engine\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Menu engine to use\",\n      \"choices\": [\n        \"fuzzel\",\n        \"tofi\",\n        \"rofi\",\n        \"wofi\",\n        \"bemenu\",\n        \"dmenu\",\n        \"anyrun\",\n        \"walker\"\n      ],\n      \"children\": null,\n      \"category\": \"menu\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"parameters\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Extra parameters for the menu engine command\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"menu\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"separator\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"|\",\n      \"description\": \"Separator between window number and title\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"appearance\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.0.0/generated/index.json",
    "content": "{\n  \"plugins\": [\n    {\n      \"name\": \"fetch_client_menu\",\n      \"description\": \"Shows a menu to select and fetch a window to your active workspace.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"layout_center\",\n      \"description\": \"A workspace layout where one window is centered and maximized while others are in the background.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": \"vEr9eeSJYDc\",\n      \"multimon\": false\n    },\n    {\n      \"name\": \"magnify\",\n      \"description\": \"Toggles zooming of viewport or sets a specific scaling factor.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": \"yN-mhh9aDuo\",\n      \"multimon\": false\n    },\n    {\n      \"name\": \"menubar\",\n      \"description\": \"Improves multi-monitor handling of the status bar and restarts it on crashes.\",\n      \"environments\": [\n        \"hyprland\",\n        \"niri\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"monitors\",\n      \"description\": \"Allows relative placement and configuration of monitors.\",\n      \"environments\": [\n        \"hyprland\",\n        \"niri\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"scratchpads\",\n      \"description\": \"Makes your applications into dropdowns & togglable popups.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": \"ZOhv59VYqkc\",\n      \"multimon\": false\n    },\n    {\n      \"name\": \"shift_monitors\",\n      \"description\": \"Moves workspaces from monitor to monitor (carousel).\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": null,\n      \"multimon\": true\n    },\n    {\n      \"name\": \"shortcuts_menu\",\n      \"description\": \"A flexible way to make your own shortcuts menus & launchers.\",\n      \"environments\": [],\n      \"stars\": 3,\n      \"demoVideoId\": \"UCuS417BZK8\",\n      \"multimon\": false\n    },\n    {\n      \"name\": \"system_notifier\",\n      \"description\": \"Opens streams (eg: journal logs) and triggers notifications.\",\n      \"environments\": [],\n      \"stars\": 3,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"toggle_special\",\n      \"description\": \"Toggle switching the focused window to a special workspace.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": \"BNZCMqkwTOo\",\n      \"multimon\": false\n    },\n    {\n      \"name\": \"wallpapers\",\n      \"description\": \"Handles random wallpapers at regular intervals, with support for rounded corners and color scheme generation.\",\n      \"environments\": [],\n      \"stars\": 3,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"workspaces_follow_focus\",\n      \"description\": \"Makes non-visible workspaces available on the currently focused screen.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": null,\n      \"multimon\": true\n    },\n    {\n      \"name\": \"expose\",\n      \"description\": \"Exposes all windows for a quick 'jump to' feature.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 2,\n      \"demoVideoId\": \"ce5HQZ3na8M\",\n      \"multimon\": false\n    },\n    {\n      \"name\": \"lost_windows\",\n      \"description\": \"Brings lost floating windows (which are out of reach) to the current workspace.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 1,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"fcitx5_switcher\",\n      \"description\": \"A plugin to auto-switch Fcitx5 input method status by window class/title.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 0,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"pyprland\",\n      \"description\": \"Internal built-in plugin allowing caching states and implementing special commands.\",\n      \"environments\": [],\n      \"stars\": 0,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"toggle_dpms\",\n      \"description\": \"Toggles the DPMS status of every plugged monitor.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 0,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.0.0/generated/layout_center.json",
    "content": "{\n  \"name\": \"layout_center\",\n  \"description\": \"A workspace layout where one window is centered and maximized while others are in the background.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"layout_center\",\n      \"args\": [\n        {\n          \"value\": \"toggle|next|prev|next2|prev2\",\n          \"required\": true\n        }\n      ],\n      \"short_description\": \"turn on/off or change the active window.\",\n      \"full_description\": \"<toggle|next|prev|next2|prev2> turn on/off or change the active window.\\n\\nArgs:\\n    what: The action to perform\\n        - toggle: Enable/disable the centered layout\\n        - next/prev: Focus the next/previous window in the stack\\n        - next2/prev2: Alternative focus commands (configurable)\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"margin\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 60,\n      \"description\": \"Margin around the centered window in pixels\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"offset\",\n      \"type\": \"str or list or tuple\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [\n        0,\n        0\n      ],\n      \"description\": \"Offset of the centered window as 'X Y' or [X, Y]\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"style\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Window rules to apply to the centered window\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"captive_focus\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Keep focus on the centered window\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"on_new_client\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"focus\",\n      \"description\": \"Behavior when a new window opens\",\n      \"choices\": [\n        \"focus\",\n        \"background\",\n        \"close\"\n      ],\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"next\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Command to run when 'next' is called and layout is disabled\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"commands\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"prev\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Command to run when 'prev' is called and layout is disabled\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"commands\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"next2\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Alternative command for 'next'\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"commands\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"prev2\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Alternative command for 'prev'\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"commands\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.0.0/generated/lost_windows.json",
    "content": "{\n  \"name\": \"lost_windows\",\n  \"description\": \"Brings lost floating windows (which are out of reach) to the current workspace.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"attract_lost\",\n      \"args\": [],\n      \"short_description\": \"Brings lost floating windows to the current workspace.\",\n      \"full_description\": \"Brings lost floating windows to the current workspace.\"\n    }\n  ],\n  \"config\": []\n}\n"
  },
  {
    "path": "site/versions/3.0.0/generated/magnify.json",
    "content": "{\n  \"name\": \"magnify\",\n  \"description\": \"Toggles zooming of viewport or sets a specific scaling factor.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"zoom\",\n      \"args\": [\n        {\n          \"value\": \"factor\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"zooms to \\\"factor\\\" or toggles zoom level if factor is omitted.\",\n      \"full_description\": \"[factor] zooms to \\\"factor\\\" or toggles zoom level if factor is omitted.\\n\\nIf factor is omitted, it toggles between the configured zoom level and no zoom.\\nFactor can be relative (e.g. +0.5 or -0.5).\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"factor\",\n      \"type\": \"float\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 2.0,\n      \"description\": \"Zoom factor when toggling\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"duration\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 0,\n      \"description\": \"Animation duration in frames (0 to disable)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.0.0/generated/menu.json",
    "content": "{\n  \"name\": \"menu\",\n  \"description\": \"Shared configuration for menu-based plugins.\",\n  \"environments\": [],\n  \"commands\": [],\n  \"config\": [\n    {\n      \"name\": \"engine\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Menu engine to use\",\n      \"choices\": [\n        \"fuzzel\",\n        \"tofi\",\n        \"rofi\",\n        \"wofi\",\n        \"bemenu\",\n        \"dmenu\",\n        \"anyrun\",\n        \"walker\"\n      ],\n      \"children\": null,\n      \"category\": \"menu\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"parameters\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Extra parameters for the menu engine command\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"menu\",\n      \"is_directory\": false\n    }\n  ],\n  \"engine_defaults\": {\n    \"fuzzel\": \"--match-mode=fuzzy -d -p '[prompt]'\",\n    \"tofi\": \"--prompt-text '[prompt]'\",\n    \"rofi\": \"-dmenu -i -p '[prompt]'\",\n    \"wofi\": \"-dmenu -i -p '[prompt]'\",\n    \"bemenu\": \"-c\",\n    \"dmenu\": \"-i\",\n    \"anyrun\": \"--plugins libstdin.so --show-results-immediately true\",\n    \"walker\": \"-d -k -p '[prompt]'\"\n  }\n}\n"
  },
  {
    "path": "site/versions/3.0.0/generated/menubar.json",
    "content": "{\n  \"name\": \"menubar\",\n  \"description\": \"Improves multi-monitor handling of the status bar and restarts it on crashes.\",\n  \"environments\": [\n    \"hyprland\",\n    \"niri\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"bar\",\n      \"args\": [\n        {\n          \"value\": \"restart|stop|toggle\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Start (default), restart, stop or toggle the menu bar.\",\n      \"full_description\": \"[restart|stop|toggle] Start (default), restart, stop or toggle the menu bar.\\n\\nArgs:\\n    args: The action to perform\\n        - (empty): Start the bar\\n        - restart: Stop and restart the bar\\n        - stop: Stop the bar\\n        - toggle: Toggle the bar on/off\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"command\",\n      \"type\": \"str\",\n      \"required\": true,\n      \"recommended\": false,\n      \"default\": \"uwsm app -- ashell\",\n      \"description\": \"Command to run the bar (supports [monitor] variable)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"monitors\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Preferred monitors list in order of priority\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.0.0/generated/monitors.json",
    "content": "{\n  \"name\": \"monitors\",\n  \"description\": \"Allows relative placement and configuration of monitors.\",\n  \"environments\": [\n    \"hyprland\",\n    \"niri\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"relayout\",\n      \"args\": [],\n      \"short_description\": \"Recompute & apply every monitors's layout.\",\n      \"full_description\": \"Recompute & apply every monitors's layout.\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"startup_relayout\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Relayout monitors on startup\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"relayout_on_config_change\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Relayout when Hyprland config is reloaded\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"new_monitor_delay\",\n      \"type\": \"float\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 1.0,\n      \"description\": \"Delay in seconds before handling new monitor\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"unknown\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Command to run when an unknown monitor is detected\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"external_commands\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"placement\",\n      \"type\": \"dict\",\n      \"required\": true,\n      \"recommended\": false,\n      \"default\": {},\n      \"description\": \"Monitor placement rules (pattern -> positioning rules)\",\n      \"choices\": null,\n      \"children\": [\n        {\n          \"name\": \"scale\",\n          \"type\": \"float\",\n          \"required\": false,\n          \"recommended\": false,\n          \"default\": null,\n          \"description\": \"UI scale factor\",\n          \"choices\": null,\n          \"children\": null,\n          \"category\": \"display\",\n          \"is_directory\": false\n        },\n        {\n          \"name\": \"rate\",\n          \"type\": \"int or float\",\n          \"required\": false,\n          \"recommended\": false,\n          \"default\": null,\n          \"description\": \"Refresh rate in Hz\",\n          \"choices\": null,\n          \"children\": null,\n          \"category\": \"display\",\n          \"is_directory\": false\n        },\n        {\n          \"name\": \"resolution\",\n          \"type\": \"str or list\",\n          \"required\": false,\n          \"recommended\": false,\n          \"default\": null,\n          \"description\": \"Display resolution (e.g., '2560x1440' or [2560, 1440])\",\n          \"choices\": null,\n          \"children\": null,\n          \"category\": \"display\",\n          \"is_directory\": false\n        },\n        {\n          \"name\": \"transform\",\n          \"type\": \"int\",\n          \"required\": false,\n          \"recommended\": false,\n          \"default\": null,\n          \"description\": \"Rotation/flip transform\",\n          \"choices\": [\n            0,\n            1,\n            2,\n            3,\n            4,\n            5,\n            6,\n            7\n          ],\n          \"children\": null,\n          \"category\": \"display\",\n          \"is_directory\": false\n        },\n        {\n          \"name\": \"disables\",\n          \"type\": \"list\",\n          \"required\": false,\n          \"recommended\": false,\n          \"default\": null,\n          \"description\": \"List of monitors to disable when this monitor is connected\",\n          \"choices\": null,\n          \"children\": null,\n          \"category\": \"behavior\",\n          \"is_directory\": false\n        }\n      ],\n      \"category\": \"placement\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"hotplug_commands\",\n      \"type\": \"dict\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": {},\n      \"description\": \"Commands to run when specific monitors are plugged (pattern -> command)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"external_commands\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"hotplug_command\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Command to run when any monitor is plugged\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"external_commands\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.0.0/generated/pyprland.json",
    "content": "{\n  \"name\": \"pyprland\",\n  \"description\": \"Internal built-in plugin allowing caching states and implementing special commands.\",\n  \"environments\": [],\n  \"commands\": [\n    {\n      \"name\": \"compgen\",\n      \"args\": [\n        {\n          \"value\": \"shell\",\n          \"required\": true\n        },\n        {\n          \"value\": \"default|path\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Generate shell completions.\",\n      \"full_description\": \"<shell> [default|path] Generate shell completions.\\n\\nUsage:\\n  pypr compgen <shell>            Output script to stdout\\n  pypr compgen <shell> default    Install to default user path\\n  pypr compgen <shell> ~/path     Install to home-relative path\\n  pypr compgen <shell> /abs/path  Install to absolute path\\n\\nExamples:\\n  pypr compgen zsh > ~/.zsh/completions/_pypr\\n  pypr compgen bash default\"\n    },\n    {\n      \"name\": \"dumpjson\",\n      \"args\": [],\n      \"short_description\": \"Dump the configuration in JSON format (after includes are processed).\",\n      \"full_description\": \"Dump the configuration in JSON format (after includes are processed).\"\n    },\n    {\n      \"name\": \"exit\",\n      \"args\": [],\n      \"short_description\": \"Terminate the pyprland daemon.\",\n      \"full_description\": \"Terminate the pyprland daemon.\"\n    },\n    {\n      \"name\": \"help\",\n      \"args\": [\n        {\n          \"value\": \"command\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Show available commands or detailed help.\",\n      \"full_description\": \"[command] Show available commands or detailed help.\\n\\nUsage:\\n  pypr help           List all commands\\n  pypr help <command> Show detailed help\"\n    },\n    {\n      \"name\": \"reload\",\n      \"args\": [],\n      \"short_description\": \"Reload the configuration file.\",\n      \"full_description\": \"Reload the configuration file.\\n\\nNew plugins will be loaded and configuration options will be updated.\\nMost plugins will use the new values on the next command invocation.\"\n    },\n    {\n      \"name\": \"version\",\n      \"args\": [],\n      \"short_description\": \"Show the pyprland version.\",\n      \"full_description\": \"Show the pyprland version.\"\n    },\n    {\n      \"name\": \"edit\",\n      \"args\": [],\n      \"short_description\": \"Open the configuration file in $EDITOR, then reload.\",\n      \"full_description\": \"Open the configuration file in $EDITOR, then reload.\\n\\nOpens pyprland.toml in your preferred editor (EDITOR or VISUAL env var,\\ndefaults to vi). After the editor closes, the configuration is reloaded.\"\n    },\n    {\n      \"name\": \"validate\",\n      \"args\": [],\n      \"short_description\": \"Validate the configuration file.\",\n      \"full_description\": \"Validate the configuration file.\\n\\nChecks the configuration file for syntax errors and validates plugin\\nconfigurations against their schemas. Does not require the daemon.\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"plugins\",\n      \"type\": \"list\",\n      \"required\": true,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"List of plugins to load\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"include\",\n      \"type\": \"list[Path]\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Additional config files or folders to include\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": true\n    },\n    {\n      \"name\": \"plugins_paths\",\n      \"type\": \"list[Path]\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Additional paths to search for third-party plugins\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": true\n    },\n    {\n      \"name\": \"colored_handlers_log\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Enable colored log output for event handlers (debugging)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"notification_type\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"auto\",\n      \"description\": \"Notification method: 'auto', 'notify-send', or 'native'\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"variables\",\n      \"type\": \"dict\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": {},\n      \"description\": \"User-defined variables for string substitution (see Variables page)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"hyprland_version\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Override auto-detected Hyprland version (e.g., '0.40.0')\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"desktop\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Override auto-detected desktop environment (e.g., 'hyprland', 'niri'). Empty means auto-detect.\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.0.0/generated/scratchpads.json",
    "content": "{\n  \"name\": \"scratchpads\",\n  \"description\": \"Makes your applications into dropdowns & togglable popups.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"attach\",\n      \"args\": [],\n      \"short_description\": \"Attach the focused window to the last focused scratchpad.\",\n      \"full_description\": \"Attach the focused window to the last focused scratchpad.\"\n    },\n    {\n      \"name\": \"hide\",\n      \"args\": [\n        {\n          \"value\": \"name\",\n          \"required\": true\n        }\n      ],\n      \"short_description\": \"hides scratchpad \\\"name\\\" (accepts \\\"*\\\").\",\n      \"full_description\": \"<name> hides scratchpad \\\"name\\\" (accepts \\\"*\\\").\\n\\nArgs:\\n    uid: The scratchpad name, or \\\"*\\\" to hide all visible scratchpads\\n    flavor: Internal hide behavior flags (default: NONE)\"\n    },\n    {\n      \"name\": \"show\",\n      \"args\": [\n        {\n          \"value\": \"name\",\n          \"required\": true\n        }\n      ],\n      \"short_description\": \"shows scratchpad \\\"name\\\" (accepts \\\"*\\\").\",\n      \"full_description\": \"<name> shows scratchpad \\\"name\\\" (accepts \\\"*\\\").\\n\\nArgs:\\n    uid: The scratchpad name, or \\\"*\\\" to show all hidden scratchpads\"\n    },\n    {\n      \"name\": \"toggle\",\n      \"args\": [\n        {\n          \"value\": \"name\",\n          \"required\": true\n        }\n      ],\n      \"short_description\": \"toggles visibility of scratchpad \\\"name\\\" (supports multiple names).\",\n      \"full_description\": \"<name> toggles visibility of scratchpad \\\"name\\\" (supports multiple names).\\n\\nArgs:\\n    uid_or_uids: Space-separated scratchpad name(s)\\n\\nExample:\\n    pypr toggle term\\n    pypr toggle term music\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"[scratchpad].command\",\n      \"type\": \"str\",\n      \"required\": true,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Command to run (omit for unmanaged scratchpads)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].class\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": true,\n      \"default\": \"\",\n      \"description\": \"Window class for matching\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].animation\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"fromTop\",\n      \"description\": \"Animation type\",\n      \"choices\": [\n        \"\",\n        \"fromTop\",\n        \"fromBottom\",\n        \"fromLeft\",\n        \"fromRight\"\n      ],\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].size\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": true,\n      \"default\": \"80% 80%\",\n      \"description\": \"Window size (e.g. '80% 80%')\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].position\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Explicit position override\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"positioning\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].margin\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 60,\n      \"description\": \"Pixels from screen edge\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"positioning\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].offset\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"100%\",\n      \"description\": \"Hide animation distance\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"positioning\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].max_size\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Maximum window size\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"positioning\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].lazy\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Start on first use\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].pinned\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Sticky to monitor\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].multi\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Allow multiple windows\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].unfocus\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Action on unfocus ('hide' or empty)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].hysteresis\",\n      \"type\": \"float\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 0.4,\n      \"description\": \"Delay before unfocus hide\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].excludes\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Scratches to hide when shown\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].restore_excluded\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Restore excluded on hide\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].preserve_aspect\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Keep size/position across shows\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].hide_delay\",\n      \"type\": \"float\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 0.0,\n      \"description\": \"Delay before hide animation\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].force_monitor\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Always show on specific monitor\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].alt_toggle\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Alternative toggle for multi-monitor\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].allow_special_workspaces\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Allow over special workspaces\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].smart_focus\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Restore focus on hide\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].close_on_hide\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Close instead of hide\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].match_by\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"pid\",\n      \"description\": \"Match method: pid, class, initialClass, title, initialTitle\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].initialClass\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Match value when match_by='initialClass'\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].initialTitle\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Match value when match_by='initialTitle'\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].title\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Match value when match_by='title'\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].process_tracking\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Enable process management\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].skip_windowrules\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Rules to skip: aspect, float, workspace\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].use\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Inherit from another scratchpad definition\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].monitor\",\n      \"type\": \"dict\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": {},\n      \"description\": \"Per-monitor config overrides\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"overrides\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.0.0/generated/shift_monitors.json",
    "content": "{\n  \"name\": \"shift_monitors\",\n  \"description\": \"Moves workspaces from monitor to monitor (carousel).\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"shift_monitors\",\n      \"args\": [\n        {\n          \"value\": \"direction\",\n          \"required\": true\n        }\n      ],\n      \"short_description\": \"Swaps monitors' workspaces in the given direction.\",\n      \"full_description\": \"<direction> Swaps monitors' workspaces in the given direction.\\n\\nArgs:\\n    arg: Integer direction (+1 or -1) to rotate workspaces across monitors\"\n    }\n  ],\n  \"config\": []\n}\n"
  },
  {
    "path": "site/versions/3.0.0/generated/shortcuts_menu.json",
    "content": "{\n  \"name\": \"shortcuts_menu\",\n  \"description\": \"A flexible way to make your own shortcuts menus & launchers.\",\n  \"environments\": [],\n  \"commands\": [\n    {\n      \"name\": \"menu\",\n      \"args\": [\n        {\n          \"value\": \"name\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Shows the menu, if \\\"name\\\" is provided, will only show this sub-menu.\",\n      \"full_description\": \"[name] Shows the menu, if \\\"name\\\" is provided, will only show this sub-menu.\\n\\nArgs:\\n    name: The menu name\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"entries\",\n      \"type\": \"dict\",\n      \"required\": true,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Menu entries structure (nested dict of commands)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"engine\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Menu engine to use\",\n      \"choices\": [\n        \"fuzzel\",\n        \"tofi\",\n        \"rofi\",\n        \"wofi\",\n        \"bemenu\",\n        \"dmenu\",\n        \"anyrun\",\n        \"walker\"\n      ],\n      \"children\": null,\n      \"category\": \"menu\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"parameters\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Extra parameters for the menu engine command\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"menu\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"separator\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \" | \",\n      \"description\": \"Separator for menu display\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"appearance\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"command_start\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Prefix for command entries\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"appearance\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"command_end\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Suffix for command entries\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"appearance\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"submenu_start\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Prefix for submenu entries\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"appearance\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"submenu_end\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\\u279c\",\n      \"description\": \"Suffix for submenu entries\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"appearance\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"skip_single\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Auto-select when only one option available\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.0.0/generated/system_notifier.json",
    "content": "{\n  \"name\": \"system_notifier\",\n  \"description\": \"Opens streams (eg: journal logs) and triggers notifications.\",\n  \"environments\": [],\n  \"commands\": [],\n  \"config\": [\n    {\n      \"name\": \"command\",\n      \"type\": \"dict\",\n      \"required\": false,\n      \"recommended\": true,\n      \"default\": {},\n      \"description\": \"This is the long-running command (eg: `tail -f <filename>`) returning the stream of text that will be updated.\\n            A common option is the system journal output (eg: `journalctl -u nginx`)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"parser\",\n      \"type\": \"dict\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": {},\n      \"description\": \"Sets the list of rules / parser to be used to extract lines of interest\\n            Must match a list of rules defined as `system_notifier.parsers.<parser_name>`.\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"parsers\",\n      \"type\": \"dict\",\n      \"required\": false,\n      \"recommended\": true,\n      \"default\": {},\n      \"description\": \"Custom parser definitions (name -> list of rules).\\n            Each rule has: pattern (required), filter, color (defaults to default_color), duration (defaults to 3 seconds)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"parsers\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"sources\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": true,\n      \"default\": [],\n      \"description\": \"Source definitions with command and parser\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"pattern\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": true,\n      \"default\": \"\",\n      \"description\": \"The pattern is any regular expression that should trigger a match.\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"default_color\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"#5555AA\",\n      \"description\": \"Default notification color\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"appearance\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"use_notify_send\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Use notify-send instead of Hyprland notifications\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.0.0/generated/toggle_dpms.json",
    "content": "{\n  \"name\": \"toggle_dpms\",\n  \"description\": \"Toggles the DPMS status of every plugged monitor.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"toggle_dpms\",\n      \"args\": [],\n      \"short_description\": \"Toggle dpms on/off for every monitor.\",\n      \"full_description\": \"Toggle dpms on/off for every monitor.\"\n    }\n  ],\n  \"config\": []\n}\n"
  },
  {
    "path": "site/versions/3.0.0/generated/toggle_special.json",
    "content": "{\n  \"name\": \"toggle_special\",\n  \"description\": \"Toggle switching the focused window to a special workspace.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"toggle_special\",\n      \"args\": [\n        {\n          \"value\": \"name\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Toggles switching the focused window to the special workspace \\\"name\\\" (default: minimized).\",\n      \"full_description\": \"[name] Toggles switching the focused window to the special workspace \\\"name\\\" (default: minimized).\\n\\nArgs:\\n    special_workspace: The special workspace name\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"name\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"minimized\",\n      \"description\": \"Default special workspace name\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.0.0/generated/wallpapers.json",
    "content": "{\n  \"name\": \"wallpapers\",\n  \"description\": \"Handles random wallpapers at regular intervals, with support for rounded corners and color scheme generation.\",\n  \"environments\": [],\n  \"commands\": [\n    {\n      \"name\": \"color\",\n      \"args\": [\n        {\n          \"value\": \"#RRGGBB\",\n          \"required\": true\n        },\n        {\n          \"value\": \"scheme\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Generate color palette from hex color.\",\n      \"full_description\": \"<#RRGGBB> [scheme] Generate color palette from hex color.\\n\\nArgs:\\n    arg: Hex color and optional scheme name\\n\\nSchemes: pastel, fluo, vibrant, mellow, neutral, earth\\n\\nExample:\\n    pypr color #ff5500 vibrant\"\n    },\n    {\n      \"name\": \"palette\",\n      \"args\": [\n        {\n          \"value\": \"color\",\n          \"required\": false\n        },\n        {\n          \"value\": \"json\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Show available color template variables.\",\n      \"full_description\": \"[color] [json] Show available color template variables.\\n\\nArgs:\\n    arg: Optional hex color and/or \\\"json\\\" flag\\n        - color: Hex color (#RRGGBB) to use for palette\\n        - json: Output in JSON format instead of human-readable\\n\\nExample:\\n    pypr palette\\n    pypr palette #ff5500\\n    pypr palette json\"\n    },\n    {\n      \"name\": \"wall\",\n      \"args\": [\n        {\n          \"value\": \"next|pause|clear|rm\",\n          \"required\": true\n        }\n      ],\n      \"short_description\": \"Control wallpaper cycling.\",\n      \"full_description\": \"<next|pause|clear|rm> Control wallpaper cycling.\\n\\nArgs:\\n    arg: The action to perform\\n        - next: Switch to the next wallpaper immediately\\n        - pause: Pause automatic wallpaper cycling\\n        - clear: Stop cycling and clear the current wallpaper\\n        - rm: Remove the current online wallpaper and show next\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"path\",\n      \"type\": \"Path or list\",\n      \"required\": true,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Path(s) to wallpaper images or directories\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"interval\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 10,\n      \"description\": \"Minutes between wallpaper changes\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"extensions\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [\n        \"png\",\n        \"jpeg\",\n        \"jpg\"\n      ],\n      \"description\": \"File extensions to include (e.g., ['png', 'jpg'])\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"recurse\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Recursively search subdirectories\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"unique\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Use different wallpaper per monitor\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"radius\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 0,\n      \"description\": \"Corner radius for rounded corners\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"appearance\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"command\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Custom command to set wallpaper ([file] and [output] variables)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"external_commands\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"post_command\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Command to run after setting wallpaper\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"external_commands\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"clear_command\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Command to run when clearing wallpaper\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"external_commands\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"color_scheme\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Color scheme for palette generation\",\n      \"choices\": [\n        \"\",\n        \"pastel\",\n        \"fluo\",\n        \"fluorescent\",\n        \"vibrant\",\n        \"mellow\",\n        \"neutral\",\n        \"earth\"\n      ],\n      \"children\": null,\n      \"category\": \"templating\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"variant\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Color variant type for palette\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"templating\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"templates\",\n      \"type\": \"dict\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Template files for color palette generation\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"templating\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"online_ratio\",\n      \"type\": \"float\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 0.0,\n      \"description\": \"Probability of fetching online (0.0-1.0)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"online\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"online_backends\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [\n        \"unsplash\",\n        \"picsum\",\n        \"wallhaven\",\n        \"reddit\"\n      ],\n      \"description\": \"Enabled online backends\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"online\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"online_keywords\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Keywords to filter online images\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"online\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"online_folder\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"online\",\n      \"description\": \"Subfolder for downloaded online images\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"online\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"cache_days\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 0,\n      \"description\": \"Days to keep cached images (0 = forever)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"cache\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"cache_max_mb\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 100,\n      \"description\": \"Maximum cache size in MB (0 = unlimited)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"cache\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"cache_max_images\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 0,\n      \"description\": \"Maximum number of cached images (0 = unlimited)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"cache\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.0.0/generated/workspaces_follow_focus.json",
    "content": "{\n  \"name\": \"workspaces_follow_focus\",\n  \"description\": \"Makes non-visible workspaces available on the currently focused screen.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"change_workspace\",\n      \"args\": [\n        {\n          \"value\": \"direction\",\n          \"required\": true\n        }\n      ],\n      \"short_description\": \"Switch workspaces of current monitor, avoiding displayed workspaces.\",\n      \"full_description\": \"<direction> Switch workspaces of current monitor, avoiding displayed workspaces.\\n\\nArgs:\\n    direction: Integer offset to move (e.g., +1 for next, -1 for previous)\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"max_workspaces\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 10,\n      \"description\": \"Maximum number of workspaces to manage\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.0.0/index.md",
    "content": "---\n# https://vitepress.dev/reference/default-theme-home-page\nlayout: home\n\nhero:\n  text: \"Extensions for your desktop environment\"\n  tagline: Enhance your desktop experience with Pyprland\n  image:\n    src: /logo.svg\n    alt: logo\n  actions:\n    - theme: brand\n      text: Getting started\n      link: ./Getting-started\n    - theme: alt\n      text: Plugins\n      link: ./Plugins\n    - theme: alt\n      text: Report something\n      link: https://github.com/hyprland-community/pyprland/issues/new/choose\n\nfeatures:\n  - title: TOML format\n    details: Simple and flexible configuration file(s)\n  - title: Customizable\n    details: Create your own Hyprland experience\n  - title: Fast and easy\n    details: Designed for performance and simplicity\n---\n\n# What is Pyprland?\n\nIt's a software that extends the functionality of your desktop environment (Hyprland, Niri, etc...), adding new features and improving the existing ones.\n\nIt also enables a high degree of customization and automation, making it easier to adapt to your workflow.\n\nTo understand the potential of Pyprland, you can check the [plugins](./Plugins) page.\n\n# Major recent changes\n\n- Major rewrite of the [Monitors plugin](/monitors) delivers improved stability and functionality.\n- The [Wallpapers plugin](/wallpapers) now applies [rounded corners](/wallpapers#radius) per display and derives cohesive [color schemes from the background](/wallpapers#templates) (Matugen/Pywal-inspired).\n\n"
  },
  {
    "path": "site/versions/3.0.0/layout_center.md",
    "content": "---\n---\n# layout_center\n\nImplements a workspace layout where one window is bigger and centered,\nother windows are tiled as usual in the background.\n\nOn `toggle`, the active window is made floating and centered if the layout wasn't enabled, else reverts the floating status.\n\nWith `next` and `prev` you can cycle the active window, keeping the same layout type.\nIf the layout_center isn't active and `next` or `prev` is used, it will call the [next](#config-next) and [prev](#config-next) configuration options.\n\nTo allow full override of the focus keys, `next2` and `prev2` are provided, they do the same actions as `next` and `prev` but allow different fallback commands.\n\n<details>\n<summary>Configuration sample</summary>\n\n```toml\n[layout_center]\nmargin = 60\noffset = [0, 30]\nnext = \"movefocus r\"\nprev = \"movefocus l\"\nnext2 = \"movefocus d\"\nprev2 = \"movefocus u\"\n```\n\nusing the following in `hyprland.conf`:\n```sh\nbind = $mainMod, M, exec, pypr layout_center toggle # toggle the layout\n## focus change keys\nbind = $mainMod, left, exec, pypr layout_center prev\nbind = $mainMod, right, exec, pypr layout_center next\nbind = $mainMod, up, exec, pypr layout_center prev2\nbind = $mainMod, down, exec, pypr layout_center next2\n```\n\nYou can completely ignore `next2` and `prev2` if you are allowing focus change in a single direction (when the layout is enabled), eg:\n\n```sh\nbind = $mainMod, up, movefocus, u\nbind = $mainMod, down, movefocus, d\n```\n\n</details>\n\n\n## Commands\n\n<PluginCommands plugin=\"layout_center\" />\n\n## Configuration\n\n<PluginConfig plugin=\"layout_center\" linkPrefix=\"config-\" />\n\n### `style` <ConfigBadges plugin=\"layout_center\" option=\"style\" /> {#config-style}\n\nCustom Hyprland style rules applied to the centered window. Requires Hyprland > 0.40.0.\n\n```toml\nstyle = [\"opacity 1\", \"bordercolor rgb(FFFF00)\"]\n```\n\n### `on_new_client` <ConfigBadges plugin=\"layout_center\" option=\"on_new_client\" /> {#config-on-new-client}\n\nBehavior when a new window opens while layout is active:\n\n- `\"focus\"` (or `\"foreground\"`) - make the new window the main window\n- `\"background\"` - make the new window appear in the background  \n- `\"close\"` - stop the centered layout when a new window opens\n\n### `next` / `prev` <ConfigBadges plugin=\"layout_center\" option=\"next\" /> {#config-next}\n\nHyprland dispatcher command to run when layout_center isn't active:\n\n```toml\nnext = \"movefocus r\"\nprev = \"movefocus l\"\n```\n\n### `next2` / `prev2` <ConfigBadges plugin=\"layout_center\" option=\"next2\" /> {#config-next2}\n\nAlternative fallback commands for vertical navigation:\n\n```toml\nnext2 = \"movefocus d\"\nprev2 = \"movefocus u\"\n```\n\n### `offset` <ConfigBadges plugin=\"layout_center\" option=\"offset\" /> {#config-offset}\n\noffset in pixels applied to the main window position\n\nExample shift the main window 20px down:\n```toml\noffset = [0, 20]\n```\n\n\n### `margin` <ConfigBadges plugin=\"layout_center\" option=\"margin\" /> {#config-margin}\n\nmargin (in pixels) used when placing the center window, calculated from the border of the screen.\n\nExample to make the main window be 100px far from the monitor's limits:\n```toml\nmargin = 100\n```\nYou can also set a different margin for width and height by using a list:\n\n```toml\nmargin = [100, 100]\n```\n"
  },
  {
    "path": "site/versions/3.0.0/lost_windows.md",
    "content": "---\n---\n# lost_windows\n\nBring windows which are not reachable in the currently focused workspace.\n*Deprecated:* Was used to work around issues in Hyprland which have been fixed since then.\n\n## Commands\n\n<PluginCommands plugin=\"lost_windows\" />\n\n## Configuration\n\nThis plugin has no configuration options.\n"
  },
  {
    "path": "site/versions/3.0.0/magnify.md",
    "content": "---\n---\n# magnify\n\nZooms in and out with an optional animation.\n\n\n<details>\n    <summary>Example</summary>\n\n```sh\npypr zoom  # sets zoom to `factor`\npypr zoom +1  # will set zoom to 3x\npypr zoom  # will set zoom to 1x\npypr zoom 1 # will (also) set zoom to 1x - effectively doing nothing\n```\n\nSample `hyprland.conf`:\n\n```sh\nbind = $mainMod , Z, exec, pypr zoom ++0.5\nbind = $mainMod SHIFT, Z, exec, pypr zoom\n```\n\n</details>\n\n## Commands\n\n<PluginCommands plugin=\"magnify\" />\n\n### `zoom [factor]`\n\n#### unset / not specified\n\nWill zoom to [factor](#config-factor) if not zoomed, else will set the zoom to 1x.\n\n#### floating or integer value\n\nWill set the zoom to the provided value.\n\n#### +value / -value\n\nUpdate (increment or decrement) the current zoom level by the provided value.\n\n#### ++value / --value\n\nUpdate (increment or decrement) the current zoom level by a non-linear scale.\nIt _looks_ more linear changes than using a single + or -.\n\n> [!NOTE]\n>\n> The non-linear scale is calculated as powers of two, eg:\n>\n> - `zoom ++1` → 2x, 4x, 8x, 16x...\n> - `zoom ++0.7` → 1.6x, 2.6x, 4.3x, 7.0x, 11.3x, 18.4x...\n> - `zoom ++0.5` → 1.4x, 2x, 2.8x, 4x, 5.7x, 8x, 11.3x, 16x...\n\n## Configuration\n\n<PluginConfig plugin=\"magnify\" linkPrefix=\"config-\" />\n\n### `factor` <ConfigBadges plugin=\"magnify\" option=\"factor\" /> {#config-factor}\n\nThe zoom level to use when `pypr zoom` is called without arguments.\n\n### `duration` <ConfigBadges plugin=\"magnify\" option=\"duration\" /> {#config-duration}\n\nAnimation duration in seconds. Not needed with recent Hyprland versions - you can customize the animation in Hyprland config instead:\n\n```C\nanimations {\n    bezier = easeInOut,0.65, 0, 0.35, 1\n    animation = zoomFactor, 1, 4, easeInOut\n}\n```\n"
  },
  {
    "path": "site/versions/3.0.0/menubar.md",
    "content": "---\n---\n\n# menubar\n\nRuns your favorite bar app (gbar, ags / hyprpanel, waybar, ...) with option to pass the \"best\" monitor from a list of monitors.\n\n- Will take care of starting the command on startup (you must not run it from another source like `hyprland.conf`).\n- Automatically restarts the menu bar on crash\n- Checks which monitors are on and take the best one from a provided list\n\n<details>\n<summary>Example</summary>\n\n```toml\n[menubar]\ncommand = \"gBar bar [monitor]\"\nmonitors = [\"DP-1\", \"HDMI-1\", \"HDMI-1-A\"]\n```\n\n</details>\n\n> [!tip]\n> This plugin supports both Hyprland and Niri. It will automatically detect the environment and use the appropriate IPC commands.\n\n## Commands\n\n<PluginCommands plugin=\"menubar\" />\n\n## Configuration\n\n<PluginConfig plugin=\"menubar\" linkPrefix=\"config-\" />\n\n### `command` <ConfigBadges plugin=\"menubar\" option=\"command\" /> {#config-command}\n\nThe command to run the bar. Use `[monitor]` as a placeholder for the monitor name:\n\n```toml\ncommand = \"waybar -o [monitor]\"\n```\n"
  },
  {
    "path": "site/versions/3.0.0/monitors.md",
    "content": "---\n---\n\n# monitors\n\nAllows relative placement of monitors depending on the model (\"description\" returned by `hyprctl monitors`).\nUseful if you have multiple monitors connected to a video signal switch or using a laptop and plugging monitors having different relative positions.\n\n> [!Tip]\n> This plugin also supports Niri. It will automatically detect the environment and use `nirictl` to apply the layout.\n> Note that \"hotplug_commands\" and \"unknown\" commands may need adjustment for Niri (e.g. using `sh -c '...'` or Niri specific tools).\n\nSyntax:\n\n\n```toml\n[monitors.placement]\n\"description match\".placement = \"other description match\"\n```\n\n<details>\n    <summary>Example to set a Sony monitor on top of a BenQ monitor</summary>\n\n```toml\n[monitors.placement]\nSony.topOf = \"BenQ\"\n\n## Character case is ignored, \"_\" can be added\nSony.Top_Of = [\"BenQ\"]\n\n## Thanks to TOML format, complex configurations can use separate \"sections\" for clarity, eg:\n\n[monitors.placement.\"My monitor brand\"]\n## You can also use \"port\" names such as *HDMI-A-1*, *DP-1*, etc...\nleftOf = \"eDP-1\"\n\n## lists are possible on the right part of the assignment:\nrightOf = [\"Sony\", \"BenQ\"]\n\n# When multiple targets are specified, only the first connected monitor\n# matching a pattern is used as the reference.\n\n## > 2.3.2: you can also set scale, transform & rate for a given monitor\n[monitors.placement.Microstep]\nrate = 100\n```\n\nTry to keep the rules as simple as possible, but relatively complex scenarios are supported.\n\n> [!note]\n> Check [wlr layout UI](https://github.com/fdev31/wlr-layout-ui) which is a nice complement to configure your monitor settings.\n\n</details>\n\n## Commands\n\n<PluginCommands plugin=\"monitors\" />\n\n## Configuration\n\n<PluginConfig plugin=\"monitors\" linkPrefix=\"config-\" />\n\n### `placement` <ConfigBadges plugin=\"monitors\" option=\"placement\" /> {#config-placement}\n\nConfigure monitor settings and relative positioning. Each monitor is identified by a [pattern](#monitor-patterns) (port name or description substring) and can have both display settings and positioning rules.\n\n```toml\n[monitors.placement.\"My monitor\"]\n# Display settings\nscale = 1.25\ntransform = 1\nrate = 144\nresolution = \"2560x1440\"\n\n# Positioning\nleftOf = \"eDP-1\"\n```\n\n#### Monitor Settings\n\nThese settings control the display properties of a monitor.\n\n##### `scale` {#placement-scale}\n\nControls UI element size. Higher values make the UI larger (zoomed in), showing less content.\n\n| Scale Value | Content Visible |\n|---------------|-----------------|\n|`0.666667` | More (zoomed out) |\n|`0.833333` | More |\n| `1.0` | Native |\n| `1.25` | Less |\n| `1.6` | Less |\n| `2.0` | 25% (zoomed in) |\n\n> [!tip]\n> For HiDPI displays, use values like `1.5` or `2.0` to make UI elements larger and more readable at the cost of screen real estate.\n\n##### `transform` {#placement-transform}\n\nRotates and optionally flips the monitor.\n\n| Value | Rotation | Description |\n|-------|----------|-------------|\n| 0 | Normal | No rotation (landscape) |\n| 1 | 90° | Portrait (rotated right) |\n| 2 | 180° | Upside down |\n| 3 | 270° | Portrait (rotated left) |\n| 4 | Flipped | Mirrored horizontally |\n| 5 | Flipped 90° | Mirrored + 90° |\n| 6 | Flipped 180° | Mirrored + 180° |\n| 7 | Flipped 270° | Mirrored + 270° |\n\n##### `rate` {#placement-rate}\n\nRefresh rate in Hz.\n\n```toml\nrate = 144\n```\n\n> [!tip]\n> Run `hyprctl monitors` to see available refresh rates for each monitor.\n\n##### `resolution` {#placement-resolution}\n\nDisplay resolution. Can be specified as a string or array.\n\n```toml\nresolution = \"2560x1440\"\n# or\nresolution = [2560, 1440]\n```\n\n> [!tip]\n> Run `hyprctl monitors` to see available resolutions for each monitor.\n\n##### `disables` {#placement-disables}\n\nList of monitors to disable when this monitor is connected. This is useful for automatically turning off a laptop's built-in display when an external monitor is plugged in.\n\n```toml\n[monitors.placement.\"External Monitor\"]\ndisables = [\"eDP-1\"]  # Disable laptop screen when this monitor is connected\n```\n\nYou can disable multiple monitors and combine with positioning rules:\n\n```toml\n[monitors.placement.\"DELL U2722D\"]\nleftOf = \"DP-2\"\ndisables = [\"eDP-1\", \"HDMI-A-2\"]\n```\n\n> [!note]\n> Monitors specified in `disables` are excluded from layout calculations. They will be re-enabled on the next relayout if the disabling monitor is disconnected.\n\n#### Positioning Rules\n\nPosition monitors relative to each other using directional keywords.\n\n**Directions:**\n\n- `leftOf` / `rightOf` — horizontal placement\n- `topOf` / `bottomOf` — vertical placement\n\n**Alignment modifiers** (for different-sized monitors):\n\n- `start` (default) — align at top/left edge\n- `center` / `middle` — center alignment\n- `end` — align at bottom/right edge\n\nCombine direction + alignment: `topCenterOf`, `leftEndOf`, `right_middle_of`, etc.\n\nEverything is case insensitive; use `_` for readability (e.g., `top_center_of`).\n\n> [!important]\n> At least one monitor must have **no placement rule** to serve as the anchor/reference point.\n> Other monitors are positioned relative to this anchor.\n\nSee [Placement Examples](#placement-examples) for visual diagrams.\n\n#### Monitor Patterns {#monitor-patterns}\n\nBoth the monitor being configured and the target monitor can be specified using:\n\n1. **Port name** (exact match) — e.g., `eDP-1`, `HDMI-A-1`, `DP-1`\n2. **Description substring** (partial match) — e.g., `Hisense`, `BenQ`, `DELL P2417H`\n\nThe plugin first checks for an exact port name match, then searches monitor descriptions for a substring match. Descriptions typically contain the manufacturer, model, and serial number.\n\n```toml\n# Target by port name\n[monitors.placement.Sony]\ntopOf = \"eDP-1\"\n\n# Target by brand/model name\n[monitors.placement.Hisense]\ntop_middle_of = \"BenQ\"\n\n# Mix both approaches\n[monitors.placement.\"DELL P2417H\"]\nright_end_of = \"HDMI-A-1\"\n```\n\n> [!tip]\n> Run `hyprctl monitors` (or `nirictl outputs` for Niri) to see the full description of each connected monitor.\n\n### `startup_relayout` <ConfigBadges plugin=\"monitors\" option=\"startup_relayout\" /> {#config-startup-relayout}\n\nWhen set to `false`, do not initialize the monitor layout on startup or when configuration is reloaded.\n\n### `relayout_on_config_change` <ConfigBadges plugin=\"monitors\" option=\"relayout_on_config_change\" /> {#config-relayout-on-config-change}\n\nWhen set to `false`, do not relayout when Hyprland config is reloaded.\n\n### `new_monitor_delay` <ConfigBadges plugin=\"monitors\" option=\"new_monitor_delay\" /> {#config-new-monitor-delay}\n\nThe layout computation happens after this delay when a new monitor is detected, to let time for things to settle.\n\n### `hotplug_command` <ConfigBadges plugin=\"monitors\" option=\"hotplug_command\" /> {#config-hotplug-command}\n\nAllows to run a command when any monitor is plugged.\n\n```toml\n[monitors]\nhotplug_command = \"wlrlui -m\"\n```\n\n### `hotplug_commands` <ConfigBadges plugin=\"monitors\" option=\"hotplug_commands\" /> {#config-hotplug-commands}\n\nAllows to run a command when a specific monitor is plugged.\n\nExample to load a specific profile using [wlr layout ui](https://github.com/fdev31/wlr-layout-ui):\n\n```toml\n[monitors.hotplug_commands]\n\"DELL P2417H CJFH277Q3HCB\" = \"wlrlui rotated\"\n```\n\n### `unknown` <ConfigBadges plugin=\"monitors\" option=\"unknown\" /> {#config-unknown}\n\nAllows to run a command when no monitor layout has been changed (no rule applied).\n\n```toml\n[monitors]\nunknown = \"wlrlui\"\n```\n\n## Placement Examples {#placement-examples}\n\nThis section provides visual diagrams to help understand monitor placement rules.\n\n### Basic Positions\n\nThe four basic placement directions position a monitor relative to another:\n\n#### `topOf` - Monitor above another\n\n<img src=\"/images/monitors/basic-top-of.svg\" alt=\"Monitor A placed on top of Monitor B\" style=\"max-width: 36%\" />\n\n```toml\n[monitors.placement.A]\ntopOf = \"B\"\n```\n\n#### `bottomOf` - Monitor below another\n\n<img src=\"/images/monitors/basic-bottom-of.svg\" alt=\"Monitor A placed below Monitor B\" style=\"max-width: 36%\" />\n\n```toml\n[monitors.placement.A]\nbottomOf = \"B\"\n```\n\n#### `leftOf` - Monitor to the left\n\n<img src=\"/images/monitors/basic-left-of.svg\" alt=\"Monitor A placed to the left of Monitor B\" style=\"max-width: 68%\" />\n\n```toml\n[monitors.placement.A]\nleftOf = \"B\"\n```\n\n#### `rightOf` - Monitor to the right\n\n<img src=\"/images/monitors/basic-right-of.svg\" alt=\"Monitor A placed to the right of Monitor B\" style=\"max-width: 68%\" />\n\n```toml\n[monitors.placement.A]\nrightOf = \"B\"\n```\n\n### Alignment Modifiers\n\nWhen monitors have different sizes, alignment modifiers control where the smaller monitor aligns along the edge.\n\n#### Horizontal placement (`leftOf` / `rightOf`)\n\n**Start (default)** - Top edges align:\n\n<img src=\"/images/monitors/align-left-start.svg\" alt=\"Monitor A to the left of B, top edges aligned\" style=\"max-width: 57%\" />\n\n```toml\n[monitors.placement.A]\nleftOf = \"B\"  # same as leftStartOf\n```\n\n**Center / Middle** - Vertically centered:\n\n<img src=\"/images/monitors/align-left-center.svg\" alt=\"Monitor A to the left of B, vertically centered\" style=\"max-width: 57%\" />\n\n```toml\n[monitors.placement.A]\nleftCenterOf = \"B\"  # or leftMiddleOf\n```\n\n**End** - Bottom edges align:\n\n<img src=\"/images/monitors/align-left-end.svg\" alt=\"Monitor A to the left of B, bottom edges aligned\" style=\"max-width: 57%\" />\n\n```toml\n[monitors.placement.A]\nleftEndOf = \"B\"\n```\n\n#### Vertical placement (`topOf` / `bottomOf`)\n\n**Start (default)** - Left edges align:\n\n<img src=\"/images/monitors/align-top-start.svg\" alt=\"Monitor A on top of B, left edges aligned\" style=\"max-width: 36%\" />\n\n```toml\n[monitors.placement.A]\ntopOf = \"B\"  # same as topStartOf\n```\n\n**Center / Middle** - Horizontally centered:\n\n<img src=\"/images/monitors/align-top-center.svg\" alt=\"Monitor A on top of B, horizontally centered\" style=\"max-width: 36%\" />\n\n```toml\n[monitors.placement.A]\ntopCenterOf = \"B\"  # or topMiddleOf\n```\n\n**End** - Right edges align:\n\n<img src=\"/images/monitors/align-top-end.svg\" alt=\"Monitor A on top of B, right edges aligned\" style=\"max-width: 36%\" />\n\n```toml\n[monitors.placement.A]\ntopEndOf = \"B\"\n```\n\n### Common Setups\n\n#### Dual side-by-side\n\n<img src=\"/images/monitors/setup-dual.svg\" alt=\"Dual monitor setup: A and B side by side\" style=\"max-width: 68%\" />\n\n```toml\n[monitors.placement.A]\nleftOf = \"B\"\n```\n\n#### Triple horizontal\n\n<img src=\"/images/monitors/setup-triple.svg\" alt=\"Triple monitor setup: A, B, C in a row\" style=\"max-width: 100%\" />\n\n```toml\n[monitors.placement.A]\nleftOf = \"B\"\n\n[monitors.placement.C]\nrightOf = \"B\"\n```\n\n#### Stacked (vertical)\n\n<img src=\"/images/monitors/setup-stacked.svg\" alt=\"Stacked monitor setup: A on top, B in middle, C at bottom\" style=\"max-width: 36%\" />\n\n```toml\n[monitors.placement.A]\ntopOf = \"B\"\n\n[monitors.placement.C]\nbottomOf = \"B\"\n```\n\n### Real-World Example: L-Shape with Portrait Monitor\n\nThis example shows a complex 3-monitor setup combining portrait mode, corner alignment, and different-sized displays.\n\n**Layout:**\n\n<img src=\"/images/monitors/real-world-l-shape.svg\" alt=\"L-shape monitor setup with portrait monitor A, anchor B, and landscape C\" style=\"max-width: 57%\" />\n\nWhere:\n\n- **A** (HDMI-A-1) = Portrait monitor (transform=1), directly on top of B (blue)\n- **B** (eDP-1) = Main anchor monitor, landscape (green)\n- **C** = Landscape monitor, positioned at the bottom-right corner of A (orange)\n\n**Configuration:**\n\n```toml\n[monitors.placement.CJFH277Q3HCB]\ntop_of = \"eDP-1\"\ntransform = 1\nscale = 0.83\n\n[monitors.placement.CJFH27888CUB]\nright_end_of = \"HDMI-A-1\"\n```\n\n**Explanation:**\n\n1. **B (eDP-1)** has no placement rule, making it the anchor/reference point\n2. **A (CJFH277Q3HCB)** is placed on top of B with `top_of = \"eDP-1\"`, rotated to portrait with `transform = 1`, and scaled to 83%\n3. **C (CJFH27888CUB)** uses `right_end_of = \"HDMI-A-1\"` to position itself to the right of A with bottom edges aligned, creating the L-shape\n\nThe `right_end_of` placement is key here: it aligns C's bottom edge with A's bottom edge, tucking C into the corner rather than aligning at the top (which `rightOf` would do).\n"
  },
  {
    "path": "site/versions/3.0.0/scratchpads.md",
    "content": "---\n---\n# scratchpads\n\nEasily toggle the visibility of applications you use the most.\n\nConfigurable and flexible, while supporting complex setups it's easy to get started with:\n\n```toml\n[scratchpads.name]\ncommand = \"command to run\"\nclass = \"the window's class\"  # check: hyprctl clients | grep class\nsize = \"[width] [height]\"  # size of the window relative to the screen size\n```\n\n<details>\n<summary>Example</summary>\n\nAs an example, defining two scratchpads:\n\n- _term_ which would be a kitty terminal on upper part of the screen\n- _volume_ which would be a pavucontrol window on the right part of the screen\n\n\n```toml\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\nmax_size = \"1920px 100%\"\nmargin = 50\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nsize = \"40% 90%\"\nunfocus = \"hide\"\nlazy = true\n```\n\nShortcuts are generally needed:\n\n```ini\nbind = $mainMod,V,exec,pypr toggle volume\nbind = $mainMod,A,exec,pypr toggle term\nbind = $mainMod,Y,exec,pypr attach\n```\n</details>\n\n- If you wish to have a more generic space for any application you may run, check [toggle_special](./toggle_special).\n- When you create a scratchpad called \"name\", it will be hidden in `special:scratch_<name>`.\n- Providing `class` allows a glitch free experience, mostly noticeable when using animations\n\n\n## Commands\n\n<PluginCommands plugin=\"scratchpads\" />\n\n> [!tip]\n> You can use `\"*\"` as a _scratchpad name_ to target every scratchpad when using `show` or `hide`.\n> You'll need to quote or escape the `*` character to avoid interpretation from your shell.\n\n## Configuration\n\n<PluginConfig plugin=\"scratchpads\" linkPrefix=\"config-\" :filter=\"['command', 'class', 'animation', 'size', 'position', 'margin', 'max_size', 'multi', 'lazy']\" />\n\n> [!tip]\n> Looking for more options? See:\n> - [Advanced Configuration](./scratchpads_advanced) - unfocus, excludes, monitor overrides, and more\n> - [Troubleshooting](./scratchpads_nonstandard) - PWAs, emacsclient, custom window matching\n\n### `command` <ConfigBadges plugin=\"scratchpads\" option=\"command\" /> {#config-command}\n\nThis is the command you wish to run in the scratchpad. It supports [variables](./Variables).\n\n### `class` <ConfigBadges plugin=\"scratchpads\" option=\"class\" /> {#config-class}\n\nAllows _Pyprland_ prepare the window for a correct animation and initial positioning.\nCheck your window's class with: `hyprctl clients | grep class`\n\n### `animation` <ConfigBadges plugin=\"scratchpads\" option=\"animation\" /> {#config-animation}\n\nType of animation to use:\n\n- `null` / `\"\"` (no animation)\n- `fromTop` (stays close to upper screen border)\n- `fromBottom` (stays close to lower screen border)\n- `fromLeft` (stays close to left screen border)\n- `fromRight` (stays close to right screen border)\n\n### `size` <ConfigBadges plugin=\"scratchpads\" option=\"size\" /> {#config-size}\n\nEach time scratchpad is shown, window will be resized according to the provided values.\n\nFor example on monitor of size `800x600` and `size= \"80% 80%\"` in config scratchpad always have size `640x480`,\nregardless of which monitor it was first launched on.\n\n> #### Format\n>\n> String with \"x y\" (or \"width height\") values using some units suffix:\n>\n> - **percents** relative to the focused screen size (`%` suffix), eg: `60% 30%`\n> - **pixels** for absolute values (`px` suffix), eg: `800px 600px`\n> - a mix is possible, eg: `800px 40%`\n\n### `position` <ConfigBadges plugin=\"scratchpads\" option=\"position\" /> {#config-position}\n\nOverrides the automatic margin-based position.\nSets the scratchpad client window position relative to the top-left corner.\n\nSame format as `size` (see above)\n\nExample of scratchpad that always sits on the top-right corner of the screen:\n\n```toml\n[scratchpads.term_quake]\ncommand = \"wezterm start --class term_quake\"\nposition = \"50% 0%\"\nsize = \"50% 50%\"\nclass = \"term_quake\"\n```\n\n> [!note]\n> If `position` is not provided, the window is placed according to `margin` on one axis and centered on the other.\n\n### `margin` <ConfigBadges plugin=\"scratchpads\" option=\"margin\" /> {#config-margin}\n\nPixels from the screen edge when using animations. Used to position the window along the animation axis.\n\n### `max_size` <ConfigBadges plugin=\"scratchpads\" option=\"max_size\" /> {#config-max-size}\n\nMaximum window size. Same format as `size`. Useful to prevent scratchpads from growing too large on big monitors.\n\n### `multi` <ConfigBadges plugin=\"scratchpads\" option=\"multi\" /> {#config-multi}\n\nWhen set to `false`, only one client window is supported for this scratchpad.\nOtherwise other matching windows will be **attach**ed to the scratchpad.\nAllows the `attach` command on the scratchpad.\n\n### `lazy` <ConfigBadges plugin=\"scratchpads\" option=\"lazy\" /> {#config-lazy}\n\nWhen `true`, the scratchpad command is only started on first use instead of at startup.\n\n## Advanced configuration\n\nTo go beyond the basic setup and have a look at every configuration item, you can read the following pages:\n\n- [Advanced](./scratchpads_advanced) contains options for fine-tuners or specific tastes (eg: i3 compatibility)\n- [Non-Standard](./scratchpads_nonstandard) contains options for \"broken\" applications\nlike progressive web apps (PWA) or emacsclient, use only if you can't get it to work otherwise\n"
  },
  {
    "path": "site/versions/3.0.0/scratchpads_advanced.md",
    "content": "---\n---\n# Fine tuning scratchpads\n\n> [!note]\n> For basic setup, see [Scratchpads](./scratchpads).\n\nAdvanced configuration options\n\n<PluginConfig plugin=\"scratchpads\" linkPrefix=\"config-\" :filter=\"['use', 'pinned', 'excludes', 'restore_excluded', 'unfocus', 'hysteresis', 'preserve_aspect', 'offset', 'hide_delay', 'force_monitor', 'alt_toggle', 'allow_special_workspaces', 'smart_focus', 'close_on_hide', 'monitor']\" />\n\n### `use` <ConfigBadges plugin=\"scratchpads\" option=\"use\" /> {#config-use}\n\nList of scratchpads (or single string) that will be used for the default values of this scratchpad.\nThink about *templates*:\n\n```toml\n[scratchpads.terminals]\nanimation = \"fromTop\"\nmargin = 50\nsize = \"75% 60%\"\nmax_size = \"1920px 100%\"\n\n[scratchpads.term]\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nuse = \"terminals\"\n```\n\n### `pinned` <ConfigBadges plugin=\"scratchpads\" option=\"pinned\" /> {#config-pinned}\n\nMakes the scratchpad \"sticky\" to the monitor, following any workspace change.\n\n### `excludes` <ConfigBadges plugin=\"scratchpads\" option=\"excludes\" /> {#config-excludes}\n\nList of scratchpads to hide when this one is displayed, eg: `excludes = [\"term\", \"volume\"]`.\nIf you want to hide every displayed scratch you can set this to the string `\"*\"` instead of a list: `excludes = \"*\"`.\n\n### `restore_excluded` <ConfigBadges plugin=\"scratchpads\" option=\"restore_excluded\" /> {#config-restore-excluded}\n\nWhen enabled, will remember the scratchpads which have been closed due to `excludes` rules, so when the scratchpad is hidden, those previously hidden scratchpads will be shown again.\n\n### `unfocus` <ConfigBadges plugin=\"scratchpads\" option=\"unfocus\" /> {#config-unfocus}\n\nWhen set to `\"hide\"`, allow to hide the window when the focus is lost.\n\nUse `hysteresis` to change the reactivity\n\n### `hysteresis` <ConfigBadges plugin=\"scratchpads\" option=\"hysteresis\" /> {#config-hysteresis}\n\nControls how fast a scratchpad hiding on unfocus will react. Check `unfocus` option.\nSet to `0` to disable.\n\n> [!important]\n> Only relevant when `unfocus=\"hide\"` is used.\n\n### `preserve_aspect` <ConfigBadges plugin=\"scratchpads\" option=\"preserve_aspect\" /> {#config-preserve-aspect}\n\nWhen set to `true`, will preserve the size and position of the scratchpad when called repeatedly from the same monitor and workspace even though an `animation` , `position` or `size` is used (those will be used for the initial setting only).\n\nForces the `lazy` option.\n\n### `offset` <ConfigBadges plugin=\"scratchpads\" option=\"offset\" /> {#config-offset}\n\nNumber of pixels for the **hide** sliding animation (how far the window will go).\n\n> [!tip]\n> - It is also possible to set a string to express percentages of the client window\n> - `margin` is automatically added to the offset\n\n### `hide_delay` <ConfigBadges plugin=\"scratchpads\" option=\"hide_delay\" /> {#config-hide-delay}\n\nDelay (in seconds) after which the hide animation happens, before hiding the scratchpad.\n\nRule of thumb, if you have an animation with speed \"7\", as in:\n```bash\n    animation = windowsOut, 1, 7, easeInOut, popin 80%\n```\nYou can divide the value by two and round to the lowest value, here `3`, then divide by 10, leading to `hide_delay = 0.3`.\n\n### `force_monitor` <ConfigBadges plugin=\"scratchpads\" option=\"force_monitor\" /> {#config-force-monitor}\n\nIf set to some monitor name (eg: `\"DP-1\"`), it will always use this monitor to show the scratchpad.\n\n### `alt_toggle` <ConfigBadges plugin=\"scratchpads\" option=\"alt_toggle\" /> {#config-alt-toggle}\n\nWhen enabled, use an alternative `toggle` command logic for multi-screen setups.\nIt applies when the `toggle` command is triggered and the toggled scratchpad is visible on a screen which is not the focused one.\n\nInstead of moving the scratchpad to the focused screen, it will hide the scratchpad.\n\n### `allow_special_workspaces` <ConfigBadges plugin=\"scratchpads\" option=\"allow_special_workspaces\" /> {#config-allow-special-workspaces}\n\nWhen enabled, you can toggle a scratchpad over a special workspace.\nIt will always use the \"normal\" workspace otherwise.\n\n> [!note]\n> Can't be disabled when using *Hyprland* < 0.39 where this behavior can't be controlled.\n\n### `smart_focus` <ConfigBadges plugin=\"scratchpads\" option=\"smart_focus\" /> {#config-smart-focus}\n\nWhen enabled, the focus will be restored in a best effort way as an attempt to improve the user experience.\nIf you face issues such as spontaneous workspace changes, you can disable this feature.\n\n\n### `close_on_hide` <ConfigBadges plugin=\"scratchpads\" option=\"close_on_hide\" /> {#config-close-on-hide}\n\nWhen enabled, the window in the scratchpad is closed instead of hidden when `pypr hide <name>` is run.\nThis option implies `lazy = true`.\nThis can be useful on laptops where background apps may increase battery power draw.\n\nNote: Currently this option changes the hide animation to use hyprland's close window animation.\n\n### `monitor` <ConfigBadges plugin=\"scratchpads\" option=\"monitor\" /> {#config-monitor}\n\nPer-monitor configuration overrides. Most display-related attributes can be changed (not `command`, `class` or `process_tracking`).\n\nUse the `monitor.<monitor name>` configuration item to override values, eg:\n\n```toml\n[scratchpads.music.monitor.eDP-1]\nposition = \"30% 50%\"\nanimation = \"fromBottom\"\n```\n\nYou may want to inline it for simple cases:\n\n```toml\n[scratchpads.music]\nmonitor = {HDMI-A-1={size = \"30% 50%\"}}\n```\n"
  },
  {
    "path": "site/versions/3.0.0/scratchpads_nonstandard.md",
    "content": "---\n---\n# Troubleshooting scratchpads\n\n> [!note]\n> For basic setup, see [Scratchpads](./scratchpads).\n\nOptions that should only be used for applications that are not behaving in a \"standard\" way, such as `emacsclient` or progressive web apps.\n\n<PluginConfig plugin=\"scratchpads\" linkPrefix=\"config-\" :filter=\"['match_by', 'initialClass', 'initialTitle', 'title', 'process_tracking', 'skip_windowrules']\" />\n\n### `match_by` <ConfigBadges plugin=\"scratchpads\" option=\"match_by\" /> {#config-match-by}\n\nWhen set to a sensitive client property value (eg: `class`, `initialClass`, `title`, `initialTitle`), will match the client window using the provided property instead of the PID of the process.\n\nThis property must be set accordingly, eg:\n\n```toml\nmatch_by = \"class\"\nclass = \"my-web-app\"\n```\n\nor\n\n```toml\nmatch_by = \"initialClass\"\ninitialClass = \"my-web-app\"\n```\n\nYou can add the \"re:\" prefix to use a regular expression, eg:\n\n```toml\nmatch_by = \"title\"\ntitle = \"re:.*some string.*\"\n```\n\n> [!note]\n> Some apps may open the graphical client window in a \"complicated\" way, to work around this, it is possible to disable the process PID matching algorithm and simply rely on window's class.\n>\n> The `match_by` attribute can be used to achieve this, eg. for emacsclient:\n> ```toml\n> [scratchpads.emacs]\n> command = \"/usr/local/bin/emacsStart.sh\"\n> class = \"Emacs\"\n> match_by = \"class\"\n> ```\n\n### `process_tracking` <ConfigBadges plugin=\"scratchpads\" option=\"process_tracking\" /> {#config-process-tracking}\n\nAllows disabling the process management. Use only if running a progressive web app (Chrome based apps) or similar.\n\nThis will automatically force `lazy = true` and set `match_by=\"class\"` if no `match_by` rule is provided, to help with the fuzzy client window matching.\n\nIt requires defining a `class` option (or the option matching your `match_by` value).\n\n```toml\n## Chat GPT on Brave\n[scratchpads.gpt]\nanimation = \"fromTop\"\ncommand = \"brave --profile-directory=Default --app=https://chat.openai.com\"\nclass = \"brave-chat.openai.com__-Default\"\nsize = \"75% 60%\"\nprocess_tracking = false\n\n## Some chrome app\n[scratchpads.music]\ncommand = \"google-chrome --profile-directory=Default --app-id=cinhimbnkkaeohfgghhklpknlkffjgod\"\nclass = \"chrome-cinhimbnkkaeohfgghhklpknlkffjgod-Default\"\nsize = \"50% 50%\"\nprocess_tracking = false\n```\n\n> [!tip]\n> To list windows by class and title you can use:\n> - `hyprctl -j clients | jq '.[]|[.class,.title]'`\n> - or if you prefer a graphical tool: `rofi -show window`\n\n> [!note]\n> Progressive web apps will share a single process for every window.\n> On top of requiring the class based window tracking (using `match_by`),\n> the process can not be managed the same way as usual apps and the correlation\n> between the process and the client window isn't as straightforward and can lead to false matches in extreme cases.\n\n### `skip_windowrules` <ConfigBadges plugin=\"scratchpads\" option=\"skip_windowrules\" /> {#config-skip-windowrules}\n\nAllows you to skip the window rules for a specific scratchpad.\nAvailable rules are:\n\n- \"aspect\" controlling size and position\n- \"float\" controlling the floating state\n- \"workspace\" which moves the window to its own workspace\n\nIf you are using an application which can spawn multiple windows and you can't see them, you can skip rules made to improve the initial display of the window.\n\n```toml\n[scratchpads.filemanager]\nanimation = \"fromBottom\"\ncommand = \"nemo\"\nclass = \"nemo\"\nsize = \"60% 60%\"\nskip_windowrules = [\"aspect\", \"workspace\"]\n```\n"
  },
  {
    "path": "site/versions/3.0.0/shift_monitors.md",
    "content": "---\n---\n\n# shift_monitors\n\nSwaps the workspaces of every screen in the given direction.\n\n> [!Note]\n> the behavior can be hard to predict if you have more than 2 monitors (depending on your layout).\n> If you use this plugin with many monitors and have some ideas about a convenient configuration, you are welcome ;)\n\n> [!Tip]\n> On Niri, this plugin moves the active workspace to the adjacent monitor instead of swapping workspaces, as Niri workspaces are dynamic.\n\nExample usage in `hyprland.conf`:\n\n```sh\nbind = $mainMod, O, exec, pypr shift_monitors +1\nbind = $mainMod SHIFT, O, exec, pypr shift_monitors -1\n```\n\n## Commands\n\n<PluginCommands plugin=\"shift_monitors\" />\n\n## Configuration\n\nThis plugin has no configuration options.\n"
  },
  {
    "path": "site/versions/3.0.0/shortcuts_menu.md",
    "content": "---\n---\n\n# shortcuts_menu\n\nPresents some menu to run shortcut commands. Supports nested menus (aka categories / sub-menus).\n\n<details>\n   <summary>Configuration examples</summary>\n\n```toml\n[shortcuts_menu.entries]\n\n\"Open Jira ticket\" = 'open-jira-ticket \"$(wl-paste)\"'\nRelayout = \"pypr relayout\"\n\"Fetch window\" = \"pypr fetch_client_menu\"\n\"Hyprland socket\" = 'kitty  socat - \"UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock\"'\n\"Hyprland logs\" = 'kitty tail -f $XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/hyprland.log'\n\n\"Serial USB Term\" = [\n    {name=\"device\", command=\"ls -1 /dev/ttyUSB*; ls -1 /dev/ttyACM*\"},\n    {name=\"speed\", options=[\"115200\", \"9600\", \"38400\", \"115200\", \"256000\", \"512000\"]},\n    \"kitty miniterm --raw --eol LF [device] [speed]\"\n]\n\n\"Color picker\" = [\n    {name=\"format\", options=[\"hex\", \"rgb\", \"hsv\", \"hsl\", \"cmyk\"]},\n    \"sleep 0.2; hyprpicker --format [format] | wl-copy\" # sleep to let the menu close before the picker opens\n]\n\nscreenshot = [\n    {name=\"what\", options=[\"output\", \"window\", \"region\", \"active\"]},\n    \"hyprshot -m [what] -o /tmp -f shot_[what].png\"\n]\n\nannotate = [\n    {name=\"fname\", command=\"ls /tmp/shot_*.png\"},\n    \"satty --filename '[fname]' --output-filename '/tmp/annotated.png'\"\n]\n\n\"Clipboard history\" = [\n    {name=\"entry\", command=\"cliphist list\", filter=\"s/\\t.*//\"},\n    \"cliphist decode '[entry]' | wl-copy\"\n]\n\n\"Copy password\" = [\n    {name=\"what\", command=\"gopass ls --flat\"},\n    \"gopass show -c [what]\"\n]\n\n\"Update/Change password\" = [\n    {name=\"what\", command=\"gopass ls --flat\"},\n    \"kitty -- gopass generate -s --strict -t '[what]' && gopass show -c '[what]'\"\n]\n```\n\n</details>\n\n\n## Commands\n\n<PluginCommands plugin=\"shortcuts_menu\" />\n\n> [!tip]\n> - If \"name\" is provided it will show the given sub-menu.\n> - You can use \".\" to reach any level of the configured menus.\n>\n>      Example to reach `shortcuts_menu.entries.utils.\"local commands\"`, use:\n>      ```sh\n>       pypr menu \"utils.local commands\"\n>      ```\n\n## Configuration\n\n<PluginConfig plugin=\"shortcuts_menu\" linkPrefix=\"config-\" />\n\n### `entries` <ConfigBadges plugin=\"shortcuts_menu\" option=\"entries\" /> {#config-entries}\n\n**Required.** Defines the menu entries. Supports [Variables](./Variables)\n\n```toml\n[shortcuts_menu.entries]\n\"entry 1\" = \"command to run\"\n\"entry 2\" = \"command to run\"\n```\n\nSubmenus can be defined too (there is no depth limit):\n\n```toml\n[shortcuts_menu.entries.\"My submenu\"]\n\"entry X\" = \"command\"\n\n[shortcuts_menu.entries.one.two.three.four.five]\nfoobar = \"ls\"\n```\n\n#### Advanced usage\n\nInstead of navigating a configured list of menu options and running a pre-defined command, you can collect various *variables* (either static list of options selected by the user, or generated from a shell command) and then run a command using those variables:\n\n```toml\n\"Play Video\" = [\n    {name=\"video_device\", command=\"ls -1 /dev/video*\"},\n    {name=\"player\", options=[\"mpv\", \"guvcview\"]},\n    \"[player] [video_device]\"\n]\n\n\"Ssh\" = [\n    {name=\"action\", options=[\"htop\", \"uptime\", \"sudo halt -p\"]},\n    {name=\"host\", options=[\"gamix\", \"gate\", \"idp\"]},\n    \"kitty --hold ssh [host] [action]\"\n]\n```\n\nYou must define a list of objects, containing:\n- `name`: the variable name\n- then the list of options, must be one of:\n    - `options` for a static list of options\n    - `command` to get the list of options from a shell command's output\n\n> [!tip]\n> You can apply post-filters to the `command` output, eg:\n> ```toml\n> {\n>   name = \"entry\",\n>   command = \"cliphist list\",\n>   filter = \"s/\\t.*//\"  # will remove everything after the TAB character\n> }\n> ```\n> Check the [filters](./filters) page for more details\n\nThe last item of the list must be a string which is the command to run. Variables can be used enclosed in `[]`.\n\n### `command_start` / `command_end` <ConfigBadges plugin=\"shortcuts_menu\" option=\"command_start\" /> {#config-command-start}\n\nAllow adding some text (eg: icon) before / after a menu entry for final commands.\n\n### `submenu_start` / `submenu_end` <ConfigBadges plugin=\"shortcuts_menu\" option=\"submenu_start\" /> {#config-submenu-start}\n\nAllow adding some text (eg: icon) before / after a menu entry leading to another menu.\n\nBy default `submenu_end` is set to a right arrow sign, while other attributes are not set.\n\n### `skip_single` <ConfigBadges plugin=\"shortcuts_menu\" option=\"skip_single\" /> {#config-skip-single}\n\nWhen disabled, shows the menu even for single options.\n\n## Hints\n\n### Multiple menus\n\nTo manage multiple distinct menus, always use a name when using the `pypr menu <name>` command.\n\nExample of a multi-menu configuration:\n\n```toml\n[shortcuts_menu.entries.\"Basic commands\"]\n\"entry X\" = \"command\"\n\"entry Y\" = \"command2\"\n\n[shortcuts_menu.entries.menu2]\n## ...\n```\n\nYou can then show the first menu using `pypr menu \"Basic commands\"`\n"
  },
  {
    "path": "site/versions/3.0.0/sidebar.json",
    "content": "{\n  \"main\": [\n    {\n      \"text\": \"Getting Started\",\n      \"link\": \"./Getting-started\"\n    },\n    {\n      \"text\": \"Configuration\",\n      \"link\": \"./Configuration\"\n    },\n    {\n      \"text\": \"Commands\",\n      \"link\": \"./Commands\"\n    },\n    {\n      \"text\": \"Plugins\",\n      \"link\": \"./Plugins\"\n    },\n    {\n      \"text\": \"Troubleshooting\",\n      \"link\": \"./Troubleshooting\"\n    },\n    {\n      \"text\": \"Development\",\n      \"link\": \"./Development\"\n    },\n    {\n      \"text\": \"Examples\",\n      \"link\": \"./Examples\"\n    },\n    {\n      \"text\": \"Architecture\",\n      \"link\": \"./Architecture\",\n      \"collapsed\": true,\n      \"items\": [\n        {\n          \"text\": \"Overview\",\n          \"link\": \"./Architecture_overview\"\n        },\n        {\n          \"text\": \"Core Components\",\n          \"link\": \"./Architecture_core\"\n        }\n      ]\n    }\n  ],\n  \"plugins\": {\n    \"text\": \"Featured plugins\",\n    \"collapsed\": false,\n    \"items\": [\n      {\n        \"text\": \"Expose\",\n        \"link\": \"./expose\"\n      },\n      {\n        \"text\": \"Fcitx5 Switcher\",\n        \"link\": \"./fcitx5_switcher\"\n      },\n      {\n        \"text\": \"Fetch client menu\",\n        \"link\": \"./fetch_client_menu\"\n      },\n      {\n        \"text\": \"Menubar\",\n        \"link\": \"./menubar\"\n      },\n      {\n        \"text\": \"Layout center\",\n        \"link\": \"./layout_center\"\n      },\n      {\n        \"text\": \"Lost windows\",\n        \"link\": \"./lost_windows\"\n      },\n      {\n        \"text\": \"Magnify\",\n        \"link\": \"./magnify\"\n      },\n      {\n        \"text\": \"Monitors\",\n        \"link\": \"./monitors\"\n      },\n      {\n        \"text\": \"Scratchpads\",\n        \"link\": \"./scratchpads\",\n        \"items\": [\n          {\n            \"text\": \"Advanced\",\n            \"link\": \"./scratchpads_advanced\"\n          },\n          {\n            \"text\": \"Special cases\",\n            \"link\": \"./scratchpads_nonstandard\"\n          }\n        ]\n      },\n      {\n        \"text\": \"Shift monitors\",\n        \"link\": \"./shift_monitors\"\n      },\n      {\n        \"text\": \"Shortcuts menu\",\n        \"link\": \"./shortcuts_menu\"\n      },\n      {\n        \"text\": \"System notifier\",\n        \"link\": \"./system_notifier\"\n      },\n      {\n        \"text\": \"Toggle dpms\",\n        \"link\": \"./toggle_dpms\"\n      },\n      {\n        \"text\": \"Toggle special\",\n        \"link\": \"./toggle_special\"\n      },\n      {\n        \"text\": \"Wallpapers\",\n        \"link\": \"./wallpapers\",\n        \"items\": [\n          {\n            \"text\": \"Online\",\n            \"link\": \"./wallpapers_online\"\n          },\n          {\n            \"text\": \"Templates\",\n            \"link\": \"./wallpapers_templates\"\n          }\n        ]\n      },\n      {\n        \"text\": \"Workspaces follow focus\",\n        \"link\": \"./workspaces_follow_focus\"\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "site/versions/3.0.0/system_notifier.md",
    "content": "---\n---\n# system_notifier\n\nThis plugin adds system notifications based on journal logs (or any program's output).\nIt monitors specified **sources** for log entries matching predefined **patterns** and generates notifications accordingly (after applying an optional **filter**).\n\nSources are commands that return a stream of text (eg: journal, mqtt, `tail -f`, ...) which is sent to a parser that will use a [regular expression pattern](https://en.wikipedia.org/wiki/Regular_expression) to detect lines of interest and optionally transform them before sending the notification.\n\n<details>\n    <summary>Minimal configuration</summary>\n\n```toml\n[[system_notifier.sources]]\ncommand = \"journalctl -fx\"\nparser = \"journal\"\n```\n\nNo **sources** are defined by default, so you will need to define at least one.\n\nIn general you will also need to define some **parsers**.\nBy default a **\"journal\"** parser is provided, otherwise you need to define your own rules.\nThis built-in configuration is close to this one, provided as an example:\n\n```toml\n[[system_notifier.parsers.journal]]\npattern = \"([a-z0-9]+): Link UP$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is active/\"\ncolor = \"#00aa00\"\n\n[[system_notifier.parsers.journal]]\npattern = \"([a-z0-9]+): Link DOWN$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is inactive/\"\ncolor = \"#ff8800\"\nduration = 15\n\n[[system_notifier.parsers.journal]]\npattern = \"Process \\d+ \\(.*\\) of .* dumped core.\"\nfilter = \"s/.*Process \\d+ \\((.*)\\) of .* dumped core./\\1 dumped core/\"\ncolor = \"#aa0000\"\n\n[[system_notifier.parsers.journal]]\npattern = \"usb \\d+-[0-9.]+: Product: \"\nfilter = \"s/.*usb \\d+-[0-9.]+: Product: (.*)/USB plugged: \\1/\"\n```\n</details>\n\n## Commands\n\n<PluginCommands plugin=\"system_notifier\" />\n\n## Configuration\n\n<PluginConfig plugin=\"system_notifier\" linkPrefix=\"config-\" />\n\n### `sources` <ConfigBadges plugin=\"system_notifier\" option=\"sources\" /> {#config-sources}\n\nList of sources to monitor. Each source must contain a `command` to run and a `parser` to use:\n\n```toml\n[[system_notifier.sources]]\ncommand = \"journalctl -fx\"\nparser = \"journal\"\n```\n\nYou can also use multiple parsers:\n\n```toml\n[[system_notifier.sources]]\ncommand = \"sudo journalctl -fkn\"\nparser = [\"journal\", \"custom_parser\"]\n```\n\n### `parsers` <ConfigBadges plugin=\"system_notifier\" option=\"parsers\" /> {#config-parsers}\n\nNamed parser configurations. Each parser rule contains:\n- `pattern`: regex to match lines of interest\n- `filter`: optional [filter](./filters) to transform text (e.g., `s/.*value: (\\d+)/Value=\\1/`)\n- `color`: optional color in `\"#hex\"` or `\"rgb()\"` format\n- `duration`: notification display time in seconds (default: 3)\n\n```toml\n[[system_notifier.parsers.custom_parser]]\npattern = 'special value:'\nfilter = \"s/.*special value: (\\d+)/Value=\\1/\"\ncolor = \"#FF5500\"\nduration = 10\n```\n\n### Built-in \"journal\" parser\n\nA `journal` parser is provided, detecting link up/down, core dumps, and USB plugs.\n\n### `use_notify_send` <ConfigBadges plugin=\"system_notifier\" option=\"use_notify_send\" /> {#config-use-notify-send}\n\nWhen enabled, forces use of `notify-send` command instead of the compositor's native notification system.\n"
  },
  {
    "path": "site/versions/3.0.0/toggle_dpms.md",
    "content": "---\n---\n\n# toggle_dpms\n\n## Commands\n\n<PluginCommands plugin=\"toggle_dpms\" />\n\n## Configuration\n\nThis plugin has no configuration options."
  },
  {
    "path": "site/versions/3.0.0/toggle_special.md",
    "content": "---\n---\n\n# toggle_special\n\nAllows moving the focused window to a special workspace and back (based on the visibility status of that workspace).\n\nIt's a companion of the `togglespecialworkspace` Hyprland's command which toggles a special workspace's visibility.\n\nYou most likely will need to configure the two commands for a complete user experience, eg:\n\n```bash\nbind = $mainMod SHIFT, N, togglespecialworkspace, stash # toggles \"stash\" special workspace visibility\nbind = $mainMod, N, exec, pypr toggle_special stash # moves window to/from the \"stash\" workspace\n```\n\nNo other configuration needed, here `MOD+SHIFT+N` will show every window in \"stash\" while `MOD+N` will move the focused window out of it/ to it.\n\n## Commands\n\n<PluginCommands plugin=\"toggle_special\" />\n\n## Configuration\n\n<PluginConfig plugin=\"toggle_special\" linkPrefix=\"config-\" />\n\n### `name` <ConfigBadges plugin=\"toggle_special\" option=\"name\" /> {#config-name}\n\nDefault special workspace name.\n"
  },
  {
    "path": "site/versions/3.0.0/wallpapers.md",
    "content": "---\n---\n\n\n# wallpapers\n\nSearch folders for images and sets the background image at a regular interval.\nPictures are selected randomly from the full list of images found.\n\nIt serves few purposes:\n\n- adding support for random images to any background setting tool\n- quickly testing different tools with a minimal effort\n- adding rounded corners to each wallpaper screen\n- generating a wallpaper-compliant color scheme usable to generate configurations for any application (matugen/pywal alike)\n\nIt allows \"zapping\" current backgrounds, clearing it to go distraction free and optionally make them different for each screen.\n\n> [!important]\n> On Hyprland, Pyprland uses **hyprpaper** by default, but you must start hyprpaper separately (e.g. `uwsm app -- hyprpaper`). For other environments, set the `command` option to launch your wallpaper application.\n\n> [!note]\n> On environments other than Hyprland and Niri, pyprland uses `wlr-randr` (Wayland) or `xrandr` (X11) for monitor detection.\n> This provides full wallpaper functionality but without automatic refresh on monitor hotplug.\n\nCached images (rounded corners, online downloads) are stored in subfolders within your configured `path` directory.\n\n<details>\n    <summary>Minimal example using defaults (requires <b>hyprpaper</b>)</summary>\n\n```toml\n[wallpapers]\npath = \"~/Pictures/wallpapers/\" # path to the folder with background images\n```\n\n</details>\n\n<details>\n<summary>More complete, using the custom <b>swww</b> command (not recommended because of its stability)</summary>\n\n```toml\n[wallpapers]\nunique = true # set a different wallpaper for each screen\npath = \"~/Pictures/wallpapers/\"\ninterval = 60 # change every hour\nextensions = [\"jpg\", \"jpeg\"]\nrecurse = true\nclear_command = \"swww clear\"\ncommand = \"swww img --outputs '[output]'  '[file]'\"\n\n```\n\nNote that for applications like `swww`, you'll need to start a daemon separately (eg: from `hyprland.conf`).\n</details>\n\n\n## Commands\n\n<PluginCommands plugin=\"wallpapers\" />\n\n> [!tip]\n> The `color` and `palette` commands are used for templating. See [Templates](./wallpapers_templates#commands) for details.\n\n## Configuration\n\n<PluginConfig plugin=\"wallpapers\" linkPrefix=\"config-\" :filter=\"['path', 'interval', 'command', 'clear_command', 'post_command', 'radius', 'extensions', 'recurse', 'unique']\" />\n\n### `path` <ConfigBadges plugin=\"wallpapers\" option=\"path\" /> {#config-path}\n\n**Required.** Path to a folder or list of folders that will be searched for wallpaper images.\n\n```toml\npath = [\"~/Pictures/Portraits/\", \"~/Pictures/Landscapes/\"]\n```\n\n### `interval` <ConfigBadges plugin=\"wallpapers\" option=\"interval\" /> {#config-interval}\n\nHow long (in minutes) a background should stay in place before changing.\n\n### `command` <ConfigBadges plugin=\"wallpapers\" option=\"command\" /> {#config-command}\n\nOverrides the default command to set the background image.\n\n> [!important]\n> **Required** for all environments except Hyprland.\n> On Hyprland, defaults to using hyprpaper if not specified.\n\n[Variables](./Variables) are replaced with the appropriate values. Use `[file]` for the image path and `[output]` for the monitor name:\n\n> [!note]\n> The `[output]` variable requires monitor detection (available on Hyprland, Niri, and fallback environments with `wlr-randr` or `xrandr`).\n\n```sh\nswaybg -i '[file]' -o '[output]'\n```\nor\n```sh\nswww img --outputs [output] [file]\n```\n\n### `clear_command` <ConfigBadges plugin=\"wallpapers\" option=\"clear_command\" /> {#config-clear-command}\n\nOverrides the default behavior which kills the `command` program.\nUse this to provide a command to clear the background:\n\n```toml\nclear_command = \"swaybg clear\"\n```\n\n### `post_command` <ConfigBadges plugin=\"wallpapers\" option=\"post_command\" /> {#config-post-command}\n\nExecutes a command after a wallpaper change. Can use `[file]`:\n\n```toml\npost_command = \"matugen image '[file]'\"\n```\n\n### `radius` <ConfigBadges plugin=\"wallpapers\" option=\"radius\" /> {#config-radius}\n\nWhen set, adds rounded borders to the wallpapers. Expressed in pixels. Disabled by default.\n\nRequires monitor detection (available on Hyprland, Niri, and fallback environments with `wlr-randr` or `xrandr`).\nFor this feature to work, you must use `[output]` in your `command` to specify the screen port name.\n\n```toml\nradius = 16\n```\n\n### `extensions` <ConfigBadges plugin=\"wallpapers\" option=\"extensions\" /> {#config-extensions}\n\nList of valid wallpaper image extensions.\n\n### `recurse` <ConfigBadges plugin=\"wallpapers\" option=\"recurse\" /> {#config-recurse}\n\nWhen enabled, will also search sub-directories recursively.\n\n### `unique` <ConfigBadges plugin=\"wallpapers\" option=\"unique\" /> {#config-unique}\n\nWhen enabled, will set a different wallpaper for each screen.\n\n> [!note]\n> Requires monitor detection (available on Hyprland, Niri, and fallback environments with `wlr-randr` or `xrandr`).\n> Usage with [templates](./wallpapers_templates) is not recommended.\n\nIf you are not using the default application, ensure you are using `[output]` in the [command](#config-command) template.\n\nExample for swaybg: `swaybg -o \"[output]\" -m fill -i \"[file]\"`\n\n## Online Wallpapers\n\nPyprland can fetch wallpapers from free online sources like Unsplash, Wallhaven, Reddit, and more. Downloaded images are stored locally and become part of your collection.\n\nSee [Online Wallpapers](./wallpapers_online) for configuration options and available backends.\n\n## Templates\n\nGenerate config files with colors extracted from your wallpaper - similar to matugen/pywal. Automatically theme your terminal, window borders, GTK apps, and more.\n\nSee [Templates](./wallpapers_templates) for full documentation including syntax, color reference, and examples.\n"
  },
  {
    "path": "site/versions/3.0.0/wallpapers_online.md",
    "content": "---\n---\n\n# Online Wallpapers\n\nPyprland can fetch wallpapers from free online sources without requiring API keys. When `online_ratio` is set, each wallpaper change has a chance to fetch a new image from the configured backends. If a fetch fails, it falls back to local images.\n\nDownloaded images are stored in the `online_folder` subfolder and become part of your local collection for future use.\n\n> [!note]\n> Online fetching requires `online_ratio > 0`. If `online_backends` is empty, online fetching is disabled.\n\n## Configuration\n\n<PluginConfig plugin=\"wallpapers\" linkPrefix=\"config-\" :filter=\"['online_ratio', 'online_backends', 'online_keywords', 'online_folder']\" />\n\n### `online_ratio` <ConfigBadges plugin=\"wallpapers\" option=\"online_ratio\" /> {#config-online-ratio}\n\nProbability (0.0 to 1.0) of fetching a wallpaper from online sources instead of local files. Set to `0.0` to disable online fetching or `1.0` to always fetch online.\n\n```toml\nonline_ratio = 0.3  # 30% chance of fetching online\n```\n\n### `online_backends` <ConfigBadges plugin=\"wallpapers\" option=\"online_backends\" /> {#config-online-backends}\n\nList of online backends to use. Defaults to all available backends. Set to an empty list to disable online fetching. See [Available Backends](#available-backends) for details.\n\n```toml\nonline_backends = [\"unsplash\", \"wallhaven\"]  # Use only these two\n```\n\n### `online_keywords` <ConfigBadges plugin=\"wallpapers\" option=\"online_keywords\" /> {#config-online-keywords}\n\nKeywords to filter online wallpaper searches. Not all backends support keywords.\n\n```toml\nonline_keywords = [\"nature\", \"landscape\", \"mountains\"]\n```\n\n### `online_folder` <ConfigBadges plugin=\"wallpapers\" option=\"online_folder\" /> {#config-online-folder}\n\nSubfolder name within `path` where downloaded online images are stored. These images persist and become part of your local collection.\n\n```toml\nonline_folder = \"online\"  # Stores in {path}/online/\n```\n\n## Cache Management\n\n<PluginConfig plugin=\"wallpapers\" linkPrefix=\"config-\" :filter=\"['cache_days', 'cache_max_mb', 'cache_max_images']\" />\n\n### `cache_days` <ConfigBadges plugin=\"wallpapers\" option=\"cache_days\" /> {#config-cache-days}\n\nDays to keep cached images before automatic cleanup. Set to `0` to keep images forever.\n\n```toml\ncache_days = 30  # Remove cached images older than 30 days\n```\n\n### `cache_max_mb` <ConfigBadges plugin=\"wallpapers\" option=\"cache_max_mb\" /> {#config-cache-max-mb}\n\nMaximum cache size in megabytes. When exceeded, oldest files are removed first. Set to `0` for unlimited.\n\n```toml\ncache_max_mb = 500  # Limit cache to 500 MB\n```\n\n### `cache_max_images` <ConfigBadges plugin=\"wallpapers\" option=\"cache_max_images\" /> {#config-cache-max-images}\n\nMaximum number of cached images. When exceeded, oldest files are removed first. Set to `0` for unlimited.\n\n```toml\ncache_max_images = 100  # Keep at most 100 cached images\n```\n\n## Available Backends\n\n| Backend | Keywords | Description |\n|---------|:--------:|-------------|\n| `unsplash` | ✓ | Unsplash Source - high quality photos |\n| `wallhaven` | ✓ | Wallhaven - curated wallpapers |\n| `reddit` | ✓ | Reddit - keywords map to wallpaper subreddits |\n| `picsum` | ✗ | Picsum Photos - random images |\n| `bing` | ✗ | Bing Daily Wallpaper |\n\n## Example Configuration\n\n```toml\n[wallpapers]\npath = \"~/Pictures/wallpapers/\"\nonline_ratio = 0.2  # 20% chance to fetch online\nonline_backends = [\"unsplash\", \"wallhaven\"]\nonline_keywords = [\"nature\", \"minimal\"]\n```\n"
  },
  {
    "path": "site/versions/3.0.0/wallpapers_templates.md",
    "content": "---\n---\n\n# Wallpaper Templates\n\nThe templates feature provides automatic theming for your desktop applications. When the wallpaper changes, pyprland:\n\n1. Extracts dominant colors from the wallpaper image\n2. Generates a Material Design-inspired color palette\n3. Processes your template files, replacing color placeholders with actual values\n4. Runs optional `post_hook` commands to apply the changes\n\nThis creates a unified color scheme across your terminal, window borders, GTK apps, and other tools - all derived from your wallpaper.\n\n> [!tip]\n> If you're migrating from *matugen* or *pywal*, your existing templates should work with minimal changes.\n\n## Commands\n\n<PluginCommands plugin=\"wallpapers\" :filter=\"['color', 'palette']\" linkPrefix=\"command-\" />\n\n### Using the `color` command {#command-color}\n\nThe `color` command allows testing the palette with a specific color instead of extracting from the wallpaper:\n\n- `pypr color \"#ff0000\"` - Re-generate the templates with the given color\n- `pypr color \"#ff0000\" neutral` - Re-generate the templates with the given color and [color scheme](#config-color-scheme) (color filter)\n\n### Using the `palette` command {#command-palette}\n\nThe `palette` command shows available color template variables:\n\n- `pypr palette` - Show palette using colors from current wallpaper\n- `pypr palette \"#ff0000\"` - Show palette for a specific color\n- `pypr palette json` - Output palette in JSON format\n\n## Configuration\n\n<PluginConfig plugin=\"wallpapers\" linkPrefix=\"config-\" :filter=\"['templates', 'color_scheme', 'variant']\" />\n\n### `templates` <ConfigBadges plugin=\"wallpapers\" option=\"templates\" /> {#config-templates}\n\nEnables automatic theming by generating config files from templates using colors extracted from the wallpaper.\n\n```toml\n[wallpapers.templates.hyprland]\ninput_path = \"~/color_configs/hyprlandcolors.sh\"\noutput_path = \"/tmp/hyprlandcolors.sh\"\npost_hook = \"sh /tmp/hyprlandcolors.sh\"\n```\n\n> [!tip]\n> Mostly compatible with *matugen* template syntax.\n\n### `color_scheme` <ConfigBadges plugin=\"wallpapers\" option=\"color_scheme\" /> {#config-color-scheme}\n\nOptional modification of the base color used in the templates. One of:\n\n- **pastel** - a bit more washed colors\n- **fluo** or **fluorescent** - for high color saturation\n- **neutral** - for low color saturation\n- **earth** - a bit more dark, a bit less blue\n- **vibrant** - for moderate to high saturation\n- **mellow** - for lower saturation\n\n### `variant` <ConfigBadges plugin=\"wallpapers\" option=\"variant\" /> {#config-variant}\n\nChanges the algorithm used to pick the primary, secondary and tertiary colors.\n\n- **islands** - uses the 3 most popular colors from the wallpaper image\n\nBy default it will pick the \"main\" color and shift the hue to get the secondary and tertiary colors.\n\n## Template Configuration\n\nEach template requires an `input_path` (template file with placeholders) and `output_path` (where to write the result):\n\n```toml\n[wallpapers.templates.hyprland]\ninput_path = \"~/color_configs/hyprlandcolors.sh\"\noutput_path = \"/tmp/hyprlandcolors.sh\"\npost_hook = \"sh /tmp/hyprlandcolors.sh\"  # optional: runs after this template\n```\n\n| Option | Required | Description |\n|--------|----------|-------------|\n| `input_path` | Yes | Path to template file containing <code v-pre>{{placeholders}}</code> |\n| `output_path` | Yes | Where to write the processed output |\n| `post_hook` | No | Command to run after this specific template is generated |\n\n> [!note]\n> **`post_hook` vs `post_command`**: The `post_hook` runs after each individual template is generated. The global [`post_command`](./wallpapers#config-post-command) runs once after the wallpaper is set and all templates are processed.\n\n## Template Syntax\n\nUse double curly braces to insert color values:\n\n```txt\n{{colors.<color_name>.<variant>.<format>}}\n```\n\n| Part | Options | Description |\n|------|---------|-------------|\n| `color_name` | See [color reference](#color-reference) | The color role (e.g., `primary`, `surface`) |\n| `variant` | `default`, `dark`, `light` | Which theme variant to use |\n| `format` | `hex`, `hex_stripped`, `rgb`, `rgba` | Output format |\n\n**Examples:**\n```txt\n{{colors.primary.default.hex}}           → #6495ED\n{{colors.primary.default.hex_stripped}}  → 6495ED\n{{colors.primary.dark.rgb}}              → rgb(100, 149, 237)\n{{colors.surface.light.rgba}}            → rgba(250, 248, 245, 1.0)\n```\n\n**Shorthand:** <code v-pre>{{colors.primary.default}}</code> is equivalent to <code v-pre>{{colors.primary.default.hex}}</code>\n\nThe `default` variant automatically selects `dark` or `light` based on [theme detection](#theme-detection).\n\n## Special Variables\n\nIn addition to colors, these variables are available in templates:\n\n| Variable | Description | Example Value |\n|----------|-------------|---------------|\n| <code v-pre>{{image}}</code> | Full path to the current wallpaper | `/home/user/Pictures/sunset.jpg` |\n| <code v-pre>{{scheme}}</code> | Detected theme | `dark` or `light` |\n\n## Color Formats\n\n| Format | Example | Typical Use |\n|--------|---------|-------------|\n| `hex` | `#6495ED` | Most applications, CSS |\n| `hex_stripped` | `6495ED` | Hyprland configs, apps that don't want `#` |\n| `rgb` | `rgb(100, 149, 237)` | CSS, GTK |\n| `rgba` | `rgba(100, 149, 237, 1.0)` | CSS with opacity |\n\n## Filters\n\nFilters modify color values. Use the pipe (`|`) syntax:\n\n```txt\n{{colors.primary.default.hex | filter_name: argument}}\n```\n\n**`set_alpha`** - Add transparency to a color\n\nConverts the color to RGBA format with the specified alpha value (0.0 to 1.0):\n\n```txt\nTemplate:  {{colors.primary.default.hex | set_alpha: 0.5}}\nOutput:    rgba(100, 149, 237, 0.5)\n\nTemplate:  {{colors.surface.default.hex | set_alpha: 0.8}}\nOutput:    rgba(26, 22, 18, 0.8)\n```\n\n**`set_lightness`** - Adjust color brightness\n\nChanges the lightness by a percentage (-100 to 100). Positive values lighten, negative values darken:\n\n```txt\nTemplate:  {{colors.primary.default.hex | set_lightness: 20}}\nOutput:    #8AB4F8  (20% lighter)\n\nTemplate:  {{colors.primary.default.hex | set_lightness: -20}}\nOutput:    #3A5980  (20% darker)\n```\n\n## Theme Detection {#theme-detection}\n\nThe `default` color variant automatically adapts to your system theme. Detection order:\n\n1. **gsettings** (GNOME/GTK): `gsettings get org.gnome.desktop.interface color-scheme`\n2. **darkman**: `darkman get`\n3. **Fallback**: defaults to `dark` if neither is available\n\nYou can check the detected theme using the <code v-pre>{{scheme}}</code> variable in your templates.\n\n## Color Reference {#color-reference}\n\nColors follow the Material Design 3 color system, organized by role:\n\n**Primary Colors** - Main accent color derived from the wallpaper\n\n| Color | Description |\n|-------|-------------|\n| `primary` | Main accent color |\n| `on_primary` | Text/icons displayed on primary color |\n| `primary_container` | Less prominent container using primary hue |\n| `on_primary_container` | Text/icons on primary container |\n| `primary_fixed` | Fixed primary that doesn't change with theme |\n| `primary_fixed_dim` | Dimmer variant of fixed primary |\n| `on_primary_fixed` | Text on fixed primary |\n| `on_primary_fixed_variant` | Variant text on fixed primary |\n\n**Secondary Colors** - Complementary accent (hue-shifted from primary)\n\n| Color | Description |\n|-------|-------------|\n| `secondary` | Secondary accent color |\n| `on_secondary` | Text/icons on secondary |\n| `secondary_container` | Container using secondary hue |\n| `on_secondary_container` | Text on secondary container |\n| `secondary_fixed`, `secondary_fixed_dim` | Fixed variants |\n| `on_secondary_fixed`, `on_secondary_fixed_variant` | Text on fixed |\n\n**Tertiary Colors** - Additional accent (hue-shifted opposite of secondary)\n\n| Color | Description |\n|-------|-------------|\n| `tertiary` | Tertiary accent color |\n| `on_tertiary` | Text/icons on tertiary |\n| `tertiary_container` | Container using tertiary hue |\n| `on_tertiary_container` | Text on tertiary container |\n| `tertiary_fixed`, `tertiary_fixed_dim` | Fixed variants |\n| `on_tertiary_fixed`, `on_tertiary_fixed_variant` | Text on fixed |\n\n**Surface Colors** - Backgrounds and containers\n\n| Color | Description |\n|-------|-------------|\n| `surface` | Default background |\n| `surface_bright` | Brighter surface variant |\n| `surface_dim` | Dimmer surface variant |\n| `surface_container_lowest` | Lowest emphasis container |\n| `surface_container_low` | Low emphasis container |\n| `surface_container` | Default container |\n| `surface_container_high` | High emphasis container |\n| `surface_container_highest` | Highest emphasis container |\n| `on_surface` | Text/icons on surface |\n| `surface_variant` | Alternative surface |\n| `on_surface_variant` | Text on surface variant |\n| `background` | App background |\n| `on_background` | Text on background |\n\n**Error Colors** - Error states and alerts\n\n| Color | Description |\n|-------|-------------|\n| `error` | Error color (red hue) |\n| `on_error` | Text on error |\n| `error_container` | Error container background |\n| `on_error_container` | Text on error container |\n\n**Utility Colors**\n\n| Color | Description |\n|-------|-------------|\n| `source` | Original extracted color (unmodified) |\n| `outline` | Borders and dividers |\n| `outline_variant` | Subtle borders |\n| `inverse_primary` | Primary for inverse surfaces |\n| `inverse_surface` | Inverse surface color |\n| `inverse_on_surface` | Text on inverse surface |\n| `surface_tint` | Tint overlay for elevation |\n| `scrim` | Overlay for modals/dialogs |\n| `shadow` | Shadow color |\n| `white` | Pure white |\n\n**ANSI Terminal Colors** - Standard terminal color palette\n\n| Color | Description |\n|-------|-------------|\n| `red` | ANSI red |\n| `green` | ANSI green |\n| `yellow` | ANSI yellow |\n| `blue` | ANSI blue |\n| `magenta` | ANSI magenta |\n| `cyan` | ANSI cyan |\n\n## Examples\n\n**Hyprland Window Borders**\n\nConfig (`pyprland.toml`):\n```toml\n[wallpapers.templates.hyprland]\ninput_path = \"~/color_configs/hyprlandcolors.sh\"\noutput_path = \"/tmp/hyprlandcolors.sh\"\npost_hook = \"sh /tmp/hyprlandcolors.sh\"\n```\n\nTemplate (`~/color_configs/hyprlandcolors.sh`):\n```txt\nhyprctl keyword general:col.active_border \"rgb({{colors.primary.default.hex_stripped}}) rgb({{colors.tertiary.default.hex_stripped}}) 30deg\"\nhyprctl keyword general:col.inactive_border \"rgb({{colors.surface_variant.default.hex_stripped}})\"\nhyprctl keyword decoration:shadow:color \"rgba({{colors.shadow.default.hex_stripped}}ee)\"\n```\n\nOutput (after processing with a blue-toned wallpaper):\n```sh\nhyprctl keyword general:col.active_border \"rgb(6495ED) rgb(ED6495) 30deg\"\nhyprctl keyword general:col.inactive_border \"rgb(3D3D3D)\"\nhyprctl keyword decoration:shadow:color \"rgba(000000ee)\"\n```\n\n**Kitty Terminal Theme**\n\nConfig:\n```toml\n[wallpapers.templates.kitty]\ninput_path = \"~/color_configs/kitty_theme.conf\"\noutput_path = \"~/.config/kitty/current-theme.conf\"\npost_hook = \"kill -SIGUSR1 $(pgrep kitty) 2>/dev/null || true\"\n```\n\nTemplate (`~/color_configs/kitty_theme.conf`):\n```sh\n# Auto-generated theme from wallpaper: {{image}}\n# Scheme: {{scheme}}\n\nforeground {{colors.on_background.default.hex}}\nbackground {{colors.background.default.hex}}\ncursor {{colors.primary.default.hex}}\ncursor_text_color {{colors.on_primary.default.hex}}\nselection_foreground {{colors.on_primary.default.hex}}\nselection_background {{colors.primary.default.hex}}\n\n# ANSI colors\ncolor0 {{colors.surface.default.hex}}\ncolor1 {{colors.red.default.hex}}\ncolor2 {{colors.green.default.hex}}\ncolor3 {{colors.yellow.default.hex}}\ncolor4 {{colors.blue.default.hex}}\ncolor5 {{colors.magenta.default.hex}}\ncolor6 {{colors.cyan.default.hex}}\ncolor7 {{colors.on_surface.default.hex}}\n```\n\n**GTK4 CSS Theme**\n\nConfig:\n```toml\n[wallpapers.templates.gtk4]\ninput_path = \"~/color_configs/gtk.css\"\noutput_path = \"~/.config/gtk-4.0/colors.css\"\n```\n\nTemplate:\n```css\n/* Auto-generated from wallpaper */\n@define-color accent_bg_color {{colors.primary.default.hex}};\n@define-color accent_fg_color {{colors.on_primary.default.hex}};\n@define-color window_bg_color {{colors.surface.default.hex}};\n@define-color window_fg_color {{colors.on_surface.default.hex}};\n@define-color headerbar_bg_color {{colors.surface_container.default.hex}};\n@define-color card_bg_color {{colors.surface_container_low.default.hex}};\n@define-color view_bg_color {{colors.background.default.hex}};\n@define-color popover_bg_color {{colors.surface_container_high.default.hex}};\n\n/* With transparency */\n@define-color sidebar_bg_color {{colors.surface_container.default.hex | set_alpha: 0.95}};\n```\n\n**JSON Export (for external tools)**\n\nConfig:\n```toml\n[wallpapers.templates.json]\ninput_path = \"~/color_configs/colors.json\"\noutput_path = \"~/.cache/current-colors.json\"\npost_hook = \"notify-send 'Theme Updated' 'New colors from wallpaper'\"\n```\n\nTemplate:\n```json\n{\n  \"scheme\": \"{{scheme}}\",\n  \"wallpaper\": \"{{image}}\",\n  \"colors\": {\n    \"primary\": \"{{colors.primary.default.hex}}\",\n    \"secondary\": \"{{colors.secondary.default.hex}}\",\n    \"tertiary\": \"{{colors.tertiary.default.hex}}\",\n    \"background\": \"{{colors.background.default.hex}}\",\n    \"surface\": \"{{colors.surface.default.hex}}\",\n    \"error\": \"{{colors.error.default.hex}}\"\n  }\n}\n```\n\n## Troubleshooting\n\nFor general pyprland issues, see the [Troubleshooting](./Troubleshooting) page.\n\n**Template not updating?**\n- Verify `input_path` exists and is readable\n- Check pyprland logs:\n  - **Systemd**: `journalctl --user -u pyprland -f`\n  - **exec-once**: Check your log file (e.g., `tail -f ~/pypr.log`)\n- Enable debug logging with `--debug` or `--debug <logfile>` (see [Getting Started](./Getting-started#running-the-daemon))\n- Ensure the wallpapers plugin is loaded in your config\n\n**Colors look wrong or washed out?**\n- Try different [`color_scheme`](#config-color-scheme) values: `vibrant`, `pastel`, `fluo`\n- Use [`variant = \"islands\"`](#config-variant) to pick colors from different areas of the image\n\n**Theme detection not working?**\n- Install `darkman` or ensure gsettings is available\n- Force a theme by using `.dark` or `.light` variants instead of `.default`\n\n**`post_hook` not running?**\n- Commands run asynchronously; check for errors in logs\n- Ensure the command is valid and executable\n- Enable debug logging to see command execution details\n"
  },
  {
    "path": "site/versions/3.0.0/workspaces_follow_focus.md",
    "content": "---\n---\n\n# workspaces_follow_focus\n\nMake non-visible workspaces follow the focused monitor.\nAlso provides commands to switch between workspaces while preserving the current monitor assignments:\n\nSyntax:\n```toml\n[workspaces_follow_focus]\nmax_workspaces = 4 # number of workspaces before cycling\n```\nExample usage in `hyprland.conf`:\n\n```ini\nbind = $mainMod, K, exec, pypr change_workspace +1\nbind = $mainMod, J, exec, pypr change_workspace -1\n ```\n\n## Commands\n\n<PluginCommands plugin=\"workspaces_follow_focus\" />\n\n## Configuration\n\n<PluginConfig plugin=\"workspaces_follow_focus\" linkPrefix=\"config-\" />\n\n"
  },
  {
    "path": "site/versions/3.1.1/Architecture.md",
    "content": "# Architecture\n\nThis section provides a comprehensive overview of Pyprland's internal architecture, designed for developers who want to understand, extend, or contribute to the project.\n\n> [!tip]\n> For a practical guide to writing plugins, see the [Development](./Development) document.\n\n## Sections\n\n| Section | Description |\n|---------|-------------|\n| [Overview](./Architecture_overview) | High-level architecture, executive summary, data flow, directory structure, design patterns |\n| [Core Components](./Architecture_core) | Manager, plugins, adapters, IPC layer, socket protocol, C client, configuration, data models |\n\n## Quick Links\n\n### Overview\n\n- [Executive Summary](./Architecture_overview#executive-summary) - What Pyprland is and how it works\n- [High-Level Architecture](./Architecture_overview#high-level-architecture) - Visual overview of all components\n- [Data Flow](./Architecture_overview#data-flow) - Event processing and command processing sequences\n- [Directory Structure](./Architecture_overview#directory-structure) - Source code organization\n- [Design Patterns](./Architecture_overview#design-patterns) - Patterns used throughout the codebase\n\n### Core Components\n\n- [Entry Points](./Architecture_core#entry-points) - Daemon vs client mode\n- [Manager](./Architecture_core#manager) - The core orchestrator\n- [Plugin System](./Architecture_core#plugin-system) - Base class, lifecycle, built-in plugins\n- [Backend Adapter Layer](./Architecture_core#backend-adapter-layer) - Hyprland and Niri abstractions\n- [IPC Layer](./Architecture_core#ipc-layer) - Window manager communication\n- [Socket Protocol](./Architecture_core#pyprland-socket-protocol) - Client-daemon protocol specification\n- [pypr-client](./Architecture_core#pypr-client) - Lightweight alternative for keybindings\n- [Configuration System](./Architecture_core#configuration-system) - TOML config system\n- [Data Models](./Architecture_core#data-models) - TypedDict definitions\n\n## Further Reading\n\n- [Development Guide](./Development) - How to write plugins\n- [Plugin Documentation](./Plugins) - List of available plugins\n- [Sample Extension](https://github.com/fdev31/pyprland/tree/main/sample_extension) - Example external plugin package\n- [Hyprland IPC](https://wiki.hyprland.org/IPC/) - Hyprland's IPC documentation\n"
  },
  {
    "path": "site/versions/3.1.1/Architecture_core.md",
    "content": "# Core Components\n\nThis document details the core components of Pyprland's architecture.\n\n## Entry Points\n\nThe application can run in two modes: **daemon** (background service) or **client** (send commands to running daemon).\n\n```mermaid\nflowchart LR\n    main([\"🚀 main()\"]) --> detect{\"❓ Args?\"}\n    detect -->|No arguments| daemon[\"🔧 run_daemon()\"]\n    detect -->|Command given| client[\"📤 run_client()\"]\n    daemon --> Pyprland([\"⚙️ Pyprland().run()\"])\n    client --> socket([\"📡 Send via socket\"])\n    Pyprland --> events[\"📨 Listen for events\"]\n    socket --> response([\"✅ Receive response\"])\n\n    style main fill:#7fb3d3,stroke:#5a8fa8,color:#000\n    style detect fill:#d4c875,stroke:#a89a50,color:#000\n    style daemon fill:#8fbc8f,stroke:#6a9a6a,color:#000\n    style client fill:#8fbc8f,stroke:#6a9a6a,color:#000\n    style Pyprland fill:#d4a574,stroke:#a67c50,color:#000\n    style socket fill:#d4a574,stroke:#a67c50,color:#000\n```\n\n| Entry Point | File | Purpose |\n|-------------|------|---------|\n| `pypr` | [`command.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/command.py) | Main CLI entry (daemon or client mode) |\n| Daemon mode | [`pypr_daemon.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/pypr_daemon.py) | Start the background daemon |\n| Client mode | [`client.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/client.py) | Send command to running daemon |\n\n## Manager\n\nThe [`Pyprland`](https://github.com/fdev31/pyprland/blob/main/pyprland/manager.py) class is the core orchestrator, responsible for:\n\n| Responsibility | Method/Attribute |\n|----------------|------------------|\n| Plugin loading | `_load_plugins()` |\n| Event dispatching | `_run_event()` |\n| Command handling | `handle_command()` |\n| Server lifecycle | `run()`, `serve()` |\n| Configuration | `load_config()`, `config` |\n| Shared state | `state: SharedState` |\n\n**Key Design Patterns:**\n\n- **Per-plugin async task queues** (`queues: dict[str, asyncio.Queue]`) - ensures plugin isolation\n- **Deduplication** via `@remove_duplicate` decorator - prevents rapid duplicate events\n- **Plugin isolation** - each plugin processes events independently\n\n## Plugin System\n\n### Base Class\n\nAll plugins inherit from the [`Plugin`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/interface.py) base class:\n\n```python\nclass Plugin:\n    name: str                    # Plugin identifier\n    config: Configuration        # Plugin-specific config section\n    state: SharedState           # Shared application state\n    backend: EnvironmentBackend  # WM abstraction layer\n    log: Logger                  # Plugin-specific logger\n    \n    # Lifecycle hooks\n    async def init() -> None           # Called once at startup\n    async def on_reload() -> None      # Called on init and config reload\n    async def exit() -> None           # Called on shutdown\n    \n    # Config validation\n    config_schema: ClassVar[list[ConfigField]]\n    def validate_config() -> list[str]\n```\n\n### Event Handler Protocol\n\nPlugins implement handlers by naming convention. See [`protocols.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/protocols.py) for the full protocol definitions:\n\n```python\n# Hyprland events: event_<eventname>\nasync def event_openwindow(self, params: str) -> None: ...\nasync def event_closewindow(self, addr: str) -> None: ...\nasync def event_workspace(self, workspace: str) -> None: ...\n\n# Commands: run_<command>\nasync def run_toggle(self, name: str) -> str | None: ...\n\n# Niri events: niri_<eventtype>\nasync def niri_outputschanged(self, data: dict) -> None: ...\n```\n\n### Plugin Lifecycle\n\n```mermaid\nsequenceDiagram\n    autonumber\n    participant M as ⚙️ Manager\n    participant P as 🔌 Plugin\n    participant C as 📄 Config\n\n    rect rgba(143, 188, 143, 0.2)\n        Note over M,C: Initialization Phase\n        M->>P: __init__(name)\n        M->>P: init()\n        M->>C: load TOML config\n        C-->>M: config data\n        M->>P: load_config(config)\n        M->>P: validate_config()\n        P-->>M: validation errors (if any)\n        M->>P: on_reload()\n    end\n    \n    rect rgba(127, 179, 211, 0.2)\n        Note over M,P: Runtime Phase\n        loop Events from WM\n            M->>P: event_*(data)\n            P-->>M: (async processing)\n        end\n        \n        loop Commands from User\n            M->>P: run_*(args)\n            P-->>M: result\n        end\n    end\n    \n    rect rgba(212, 165, 116, 0.2)\n        Note over M,P: Shutdown Phase\n        M->>P: exit()\n        P-->>M: cleanup complete\n    end\n```\n\n### Built-in Plugins\n\n| Plugin | Source | Description |\n|--------|--------|-------------|\n| `pyprland` (core) | [`plugins/pyprland/`](https://github.com/fdev31/pyprland/tree/main/pyprland/plugins/pyprland) | Internal state management |\n| `scratchpads` | [`plugins/scratchpads/`](https://github.com/fdev31/pyprland/tree/main/pyprland/plugins/scratchpads) | Dropdown/scratchpad windows |\n| `monitors` | [`plugins/monitors/`](https://github.com/fdev31/pyprland/tree/main/pyprland/plugins/monitors) | Monitor layout management |\n| `wallpapers` | [`plugins/wallpapers/`](https://github.com/fdev31/pyprland/tree/main/pyprland/plugins/wallpapers) | Wallpaper cycling, color schemes |\n| `expose` | [`plugins/expose.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/expose.py) | Window overview |\n| `magnify` | [`plugins/magnify.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/magnify.py) | Zoom functionality |\n| `layout_center` | [`plugins/layout_center.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/layout_center.py) | Centered layout mode |\n| `fetch_client_menu` | [`plugins/fetch_client_menu.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/fetch_client_menu.py) | Menu-based window switching |\n| `shortcuts_menu` | [`plugins/shortcuts_menu.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/shortcuts_menu.py) | Shortcut launcher |\n| `toggle_dpms` | [`plugins/toggle_dpms.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/toggle_dpms.py) | Screen power toggle |\n| `toggle_special` | [`plugins/toggle_special.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/toggle_special.py) | Special workspace toggle |\n| `system_notifier` | [`plugins/system_notifier.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/system_notifier.py) | System notifications |\n| `lost_windows` | [`plugins/lost_windows.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/lost_windows.py) | Recover lost windows |\n| `shift_monitors` | [`plugins/shift_monitors.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/shift_monitors.py) | Shift windows between monitors |\n| `workspaces_follow_focus` | [`plugins/workspaces_follow_focus.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/workspaces_follow_focus.py) | Workspace follows focus |\n| `fcitx5_switcher` | [`plugins/fcitx5_switcher.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/fcitx5_switcher.py) | Input method switching |\n| `menubar` | [`plugins/menubar.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/menubar.py) | Menu bar integration |\n\n## Backend Adapter Layer\n\nThe adapter layer abstracts differences between window managers. See [`adapters/`](https://github.com/fdev31/pyprland/tree/main/pyprland/adapters) for the full implementation.\n\n```mermaid\nclassDiagram\n    class EnvironmentBackend {\n        <<abstract>>\n        #state: SharedState\n        #log: Logger\n        +get_clients() list~ClientInfo~\n        +get_monitors() list~MonitorInfo~\n        +execute(command) bool\n        +execute_json(command) Any\n        +execute_batch(commands) None\n        +notify(message, duration, color) None\n        +parse_event(raw_data) tuple\n    }\n    \n    class HyprlandBackend {\n        +get_clients()\n        +get_monitors()\n        +execute()\n        +parse_event()\n    }\n    note for HyprlandBackend \"Communicates via<br/>HYPRLAND_INSTANCE_SIGNATURE<br/>socket paths\"\n    \n    class NiriBackend {\n        +get_clients()\n        +get_monitors()\n        +execute()\n        +parse_event()\n    }\n    note for NiriBackend \"Communicates via<br/>NIRI_SOCKET<br/>JSON protocol\"\n    \n    EnvironmentBackend <|-- HyprlandBackend : implements\n    EnvironmentBackend <|-- NiriBackend : implements\n```\n\n| Class | Source |\n|-------|--------|\n| `EnvironmentBackend` | [`adapters/backend.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/backend.py) |\n| `HyprlandBackend` | [`adapters/hyprland.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/hyprland.py) |\n| `NiriBackend` | [`adapters/niri.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/niri.py) |\n\nThe backend is selected automatically based on environment:\n- If `NIRI_SOCKET` is set -> `NiriBackend`\n- Otherwise -> `HyprlandBackend`\n\n## IPC Layer\n\nLow-level socket communication with the window manager is handled in [`ipc.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/ipc.py):\n\n| Function | Purpose |\n|----------|---------|\n| `hyprctl_connection()` | Context manager for Hyprland command socket |\n| `niri_connection()` | Context manager for Niri socket |\n| `get_response()` | Send command, receive JSON response |\n| `get_event_stream()` | Subscribe to WM event stream |\n| `niri_request()` | Send Niri-specific request |\n| `@retry_on_reset` | Decorator for automatic connection retry |\n\n**Socket Paths:**\n\n| Socket | Path |\n|--------|------|\n| Hyprland commands | `$XDG_RUNTIME_DIR/hypr/$SIGNATURE/.socket.sock` |\n| Hyprland events | `$XDG_RUNTIME_DIR/hypr/$SIGNATURE/.socket2.sock` |\n| Niri | `$NIRI_SOCKET` |\n| Pyprland (Hyprland) | `$XDG_RUNTIME_DIR/hypr/$SIGNATURE/.pyprland.sock` |\n| Pyprland (Niri) | `dirname($NIRI_SOCKET)/.pyprland.sock` |\n| Pyprland (standalone) | `$XDG_DATA_HOME/.pyprland.sock` |\n\n## Pyprland Socket Protocol\n\nThe daemon exposes a Unix domain socket for client-daemon communication. This simple text-based protocol allows any language to implement a client.\n\n### Socket Path\n\nThe socket location depends on the environment:\n\n| Environment | Socket Path |\n|-------------|-------------|\n| Hyprland | `$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.pyprland.sock` |\n| Niri | `dirname($NIRI_SOCKET)/.pyprland.sock` |\n| Standalone | `$XDG_DATA_HOME/.pyprland.sock` (defaults to `~/.local/share/.pyprland.sock`) |\n\nIf the Hyprland path exceeds 107 characters, a shortened path is used:\n\n```\n/tmp/.pypr-$HYPRLAND_INSTANCE_SIGNATURE/.pyprland.sock\n```\n\n### Protocol\n\n```mermaid\nsequenceDiagram\n    autonumber\n    participant Client as 💻 Client\n    participant Socket as 📡 Unix Socket\n    participant Daemon as ⚙️ Daemon\n\n    rect rgba(127, 179, 211, 0.2)\n        Note over Client,Socket: Request\n        Client->>Socket: Connect\n        Client->>Socket: \"command args\\n\"\n        Client->>Socket: EOF (shutdown write)\n    end\n\n    rect rgba(143, 188, 143, 0.2)\n        Note over Socket,Daemon: Processing\n        Socket->>Daemon: read_command()\n        Daemon->>Daemon: Execute command\n    end\n\n    rect rgba(212, 165, 116, 0.2)\n        Note over Daemon,Client: Response\n        Daemon->>Socket: \"OK\\n\" or \"ERROR: msg\\n\"\n        Socket->>Client: Read until EOF\n        Client->>Client: Parse & exit\n    end\n```\n\n| Direction | Format |\n|-----------|--------|\n| **Request** | `<command> [args...]\\n` (newline-terminated, then EOF) |\n| **Response** | `OK [output]` or `ERROR: <message>` or raw text (legacy) |\n\n**Response Prefixes:**\n\n| Prefix | Meaning | Exit Code |\n|--------|---------|-----------|\n| `OK` | Command succeeded | 0 |\n| `OK <output>` | Command succeeded with output | 0 |\n| `ERROR: <msg>` | Command failed | 4 |\n| *(raw text)* | Legacy response (help, version, dumpjson) | 0 |\n\n**Exit Codes:**\n\n| Code | Name | Description |\n|------|------|-------------|\n| 0 | SUCCESS | Command completed successfully |\n| 1 | USAGE_ERROR | No command provided or invalid arguments |\n| 2 | ENV_ERROR | Missing environment variables |\n| 3 | CONNECTION_ERROR | Cannot connect to daemon |\n| 4 | COMMAND_ERROR | Command execution failed |\n\nSee [`models.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/models.py) for `ExitCode` and `ResponsePrefix` definitions.\n\n## pypr-client {#pypr-client}\n\nFor performance-critical use cases (e.g., keybindings), `pypr-client` is a lightweight C client available as an alternative to `pypr`. It supports all commands except `validate` and `edit` (which require Python).\n\n| File | Description |\n|------|-------------|\n| [`client/pypr-client.c`](https://github.com/fdev31/pyprland/blob/main/client/pypr-client.c) | C implementation of the pypr client |\n\n**Build:**\n\n```bash\ncd client\ngcc -O2 -o pypr-client pypr-client.c\n```\n\n**Features:**\n\n- Minimal dependencies (libc only)\n- Fast startup (~1ms vs ~50ms for Python)\n- Same protocol as Python client\n- Proper exit codes for scripting\n\n**Comparison:**\n\n| Aspect | `pypr` | `pypr-client` |\n|--------|--------|---------------|\n| Startup time | ~50ms | ~1ms |\n| Dependencies | Python 3.11+ | libc |\n| Daemon mode | Yes | No |\n| Commands | All | All except `validate`, `edit` |\n| Best for | Interactive use, daemon | Keybindings |\n| Source | [`client.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/client.py) | [`pypr-client.c`](https://github.com/fdev31/pyprland/blob/main/client/pypr-client.c) |\n\n## Configuration System\n\nConfiguration is stored in TOML format at `~/.config/pypr/config.toml`:\n\n```toml\n[pyprland]\nplugins = [\"scratchpads\", \"monitors\", \"magnify\"]\n\n[scratchpads.term]\ncommand = \"kitty --class scratchpad\"\nposition = \"50% 50%\"\nsize = \"80% 80%\"\n\n[monitors]\nunknown = \"extend\"\n```\n\n| Component | Source | Description |\n|-----------|--------|-------------|\n| `Configuration` | [`config.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/config.py) | Dict wrapper with typed accessors |\n| `ConfigValidator` | [`validation.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/validation.py) | Schema-based validation |\n| `ConfigField` | [`validation.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/validation.py) | Field definition (name, type, required, default) |\n\n## Shared State\n\nThe [`SharedState`](https://github.com/fdev31/pyprland/blob/main/pyprland/common.py) dataclass maintains commonly needed information:\n\n```python\n@dataclass\nclass SharedState:\n    active_workspace: str    # Current workspace name\n    active_monitor: str      # Current monitor name  \n    active_window: str       # Current window address\n    environment: str         # \"hyprland\" or \"niri\"\n    variables: dict          # User-defined variables\n    monitors: list[str]      # All monitor names\n    hyprland_version: VersionInfo\n```\n\n## Data Models\n\nTypedDict definitions in [`models.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/models.py) ensure type safety:\n\n```python\nclass ClientInfo(TypedDict):\n    address: str\n    mapped: bool\n    hidden: bool\n    workspace: WorkspaceInfo\n    class_: str  # aliased from \"class\"\n    title: str\n    # ... more fields\n\nclass MonitorInfo(TypedDict):\n    name: str\n    width: int\n    height: int\n    x: int\n    y: int\n    focused: bool\n    transform: int\n    # ... more fields\n```\n"
  },
  {
    "path": "site/versions/3.1.1/Architecture_overview.md",
    "content": "# Architecture Overview\n\nThis document provides a high-level overview of Pyprland's architecture, data flow, and design patterns.\n\n> [!tip]\n> For a practical guide to writing plugins, see the [Development](./Development) document.\n\n## Executive Summary\n\n**Pyprland** is a plugin-based companion application for tiling window managers (Hyprland, Niri). It operates as a daemon that extends the window manager's capabilities through a modular plugin system, communicating via Unix domain sockets (IPC).\n\n| Attribute | Value |\n|-----------|-------|\n| Language | Python 3.11+ |\n| License | MIT |\n| Architecture | Daemon/Client, Plugin-based |\n| Async Framework | asyncio |\n\n## High-Level Architecture\n\n```mermaid\nflowchart TB\n    subgraph User[\"👤 User Layer\"]\n        KB([\"⌨️ Keyboard Bindings\"])\n        CLI([\"💻 pypr / pypr-client\"])\n    end\n\n    subgraph Pyprland[\"🔶 Pyprland Daemon\"]\n        direction TB\n        CMD[\"🎯 Command Handler\"]\n        EVT[\"📨 Event Listener\"]\n        \n        subgraph Plugins[\"🔌 Plugin Registry\"]\n            P1[\"scratchpads\"]\n            P2[\"monitors\"]\n            P3[\"wallpapers\"]\n            P4[\"expose\"]\n            P5[\"...\"]\n        end\n        \n        subgraph Adapters[\"🔄 Backend Adapters\"]\n            HB[\"HyprlandBackend\"]\n            NB[\"NiriBackend\"]\n        end\n        \n        MGR[\"⚙️ Manager<br/>Orchestrator\"]\n        STATE[\"📦 SharedState\"]\n    end\n\n    subgraph WM[\"🪟 Window Manager\"]\n        HYPR([\"Hyprland\"])\n        NIRI([\"Niri\"])\n    end\n\n    KB --> CLI\n    CLI -->|Unix Socket| CMD\n    CMD --> MGR\n    MGR --> Plugins\n    Plugins --> Adapters\n    EVT -->|Event Stream| MGR\n    Adapters <-->|IPC Socket| WM\n    WM -->|Events| EVT\n    MGR --> STATE\n    Plugins --> STATE\n\n    style User fill:#7fb3d3,stroke:#5a8fa8,color:#000\n    style Pyprland fill:#d4a574,stroke:#a67c50,color:#000\n    style WM fill:#8fbc8f,stroke:#6a9a6a,color:#000\n    style Plugins fill:#c9a86c,stroke:#9a7a4a,color:#000\n    style Adapters fill:#c9a86c,stroke:#9a7a4a,color:#000\n```\n\n## Data Flow\n\n### Event Processing\n\nWhen the window manager emits an event (window opened, workspace changed, etc.):\n\n```mermaid\nsequenceDiagram\n    autonumber\n    participant WM as 🪟 Window Manager\n    participant IPC as 📡 IPC Layer\n    participant MGR as ⚙️ Manager\n    participant Q1 as 📥 Plugin A Queue\n    participant Q2 as 📥 Plugin B Queue\n    participant P1 as 🔌 Plugin A\n    participant P2 as 🔌 Plugin B\n\n    rect rgba(143, 188, 143, 0.2)\n        Note over WM,IPC: Event Reception\n        WM->>+IPC: Event stream (async)\n        IPC->>-MGR: Parse event (name, params)\n    end\n\n    rect rgba(127, 179, 211, 0.2)\n        Note over MGR,Q2: Event Distribution\n        par Parallel queuing\n            MGR->>Q1: Queue event\n            MGR->>Q2: Queue event\n        end\n    end\n\n    rect rgba(212, 165, 116, 0.2)\n        Note over Q1,WM: Plugin Execution\n        par Parallel processing\n            Q1->>P1: event_openwindow()\n            P1->>WM: Execute commands\n        and\n            Q2->>P2: event_openwindow()\n            P2->>WM: Execute commands\n        end\n    end\n```\n\n### Command Processing\n\nWhen the user runs `pypr <command>`:\n\n```mermaid\nsequenceDiagram\n    autonumber\n    participant User as 👤 User\n    participant CLI as 💻 pypr / pypr-client\n    participant Socket as 📡 Unix Socket\n    participant MGR as ⚙️ Manager\n    participant Plugin as 🔌 Plugin\n    participant Backend as 🔄 Backend\n    participant WM as 🪟 Window Manager\n\n    rect rgba(127, 179, 211, 0.2)\n        Note over User,Socket: Request Phase\n        User->>CLI: pypr toggle term\n        CLI->>Socket: Connect & send command\n        Socket->>MGR: handle_command()\n    end\n\n    rect rgba(212, 165, 116, 0.2)\n        Note over MGR,Plugin: Routing Phase\n        MGR->>MGR: Find plugin with run_toggle\n        MGR->>Plugin: run_toggle(\"term\")\n    end\n\n    rect rgba(143, 188, 143, 0.2)\n        Note over Plugin,WM: Execution Phase\n        Plugin->>Backend: execute(command)\n        Backend->>WM: IPC call\n        WM-->>Backend: Response\n        Backend-->>Plugin: Result\n    end\n\n    rect rgba(150, 120, 160, 0.2)\n        Note over Plugin,User: Response Phase\n        Plugin-->>MGR: Return value\n        MGR-->>Socket: Response\n        Socket-->>CLI: Display result\n    end\n```\n\n## Directory Structure\n\nAll source files are in the [`pyprland/`](https://github.com/fdev31/pyprland/tree/main/pyprland) directory:\n\n```\npyprland/\n├── command.py           # CLI entry point, argument parsing\n├── pypr_daemon.py       # Daemon startup logic\n├── manager.py           # Core Pyprland class (orchestrator)\n├── client.py            # Client mode implementation\n├── ipc.py               # Socket communication with WM\n├── config.py            # Configuration wrapper\n├── validation.py        # Config validation framework\n├── common.py            # Shared utilities, SharedState, logging\n├── constants.py         # Global constants\n├── models.py            # TypedDict definitions\n├── version.py           # Version string\n├── aioops.py            # Async file ops, DebouncedTask\n├── completions.py       # Shell completion generators\n├── help.py              # Help system\n├── ansi.py              # Terminal colors/styling\n├── debug.py             # Debug utilities\n│\n├── adapters/            # Window manager abstraction\n│   ├── backend.py       # Abstract EnvironmentBackend\n│   ├── hyprland.py      # Hyprland implementation\n│   ├── niri.py          # Niri implementation\n│   ├── menus.py         # Menu engine abstraction (rofi, wofi, etc.)\n│   └── units.py         # Unit conversion utilities\n│\n└── plugins/             # Plugin implementations\n    ├── interface.py     # Plugin base class\n    ├── protocols.py     # Event handler protocols\n    │\n    ├── pyprland/        # Core internal plugin\n    ├── scratchpads/     # Scratchpad plugin (complex, multi-file)\n    ├── monitors/        # Monitor management\n    ├── wallpapers/      # Wallpaper management\n    │\n    └── *.py             # Simple single-file plugins\n```\n\n## Design Patterns\n\n| Pattern | Usage |\n|---------|-------|\n| **Plugin Architecture** | Extensibility via [`Plugin`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/interface.py) base class |\n| **Adapter Pattern** | [`EnvironmentBackend`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/backend.py) abstracts WM differences |\n| **Strategy Pattern** | Menu engines in [`menus.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/menus.py) (rofi, wofi, tofi, etc.) |\n| **Observer Pattern** | Event handlers subscribe to WM events |\n| **Async Task Queues** | Per-plugin isolation, prevents blocking |\n| **Decorator Pattern** | `@retry_on_reset` in [`ipc.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/ipc.py), `@remove_duplicate` in [`manager.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/manager.py) |\n| **Template Method** | Plugin lifecycle hooks (`init`, `on_reload`, `exit`) |\n"
  },
  {
    "path": "site/versions/3.1.1/Commands.md",
    "content": "# Commands\n\n<script setup>\nimport PluginCommands from './components/PluginCommands.vue'\n</script>\n\nThis page covers the `pypr` command-line interface and available commands.\n\n## Overview\n\nThe `pypr` command operates in two modes:\n\n| Usage | Mode | Description |\n|-------|------|-------------|\n| `pypr` | Daemon | Starts the Pyprland daemon (foreground) |\n| `pypr <command>` | Client | Sends a command to the running daemon |\n\n\nThere is also an optional `pypr-client` command which is designed for running in keyboard-bindings since it starts faster but doesn't support every built-in command (eg: `validate`, `edit`).\n\n> [!tip]\n> Command names can use dashes or underscores interchangeably.\n> E.g., `pypr shift_monitors` and `pypr shift-monitors` are equivalent.\n\n## Built-in Commands\n\nThese commands are always available, regardless of which plugins are loaded:\n\n<PluginCommands plugin=\"pyprland\"  version=\"3.1.1\" />\n\n## Plugin Commands\n\nEach plugin can add its own commands. Use `pypr help` to see the commands made available by the list of plugins you set in your configuration file.\n\nExamples:\n- `scratchpads` plugin adds: `toggle`, `show`, `hide`\n- `magnify` plugin adds: `zoom`\n- `expose` plugin adds: `expose`\n\nSee individual [plugin documentation](./Plugins) for command details.\n\n## Shell Completions {#command-compgen}\n\nPyprland can generate shell completions dynamically based on your loaded plugins and configuration.\n\n### Generating Completions\n\nWith the daemon running:\n\n```sh\n# Output to stdout (redirect to file)\npypr compgen zsh > ~/.zsh/completions/_pypr\n\n# Install to default user path\npypr compgen bash default\npypr compgen zsh default\npypr compgen fish default\n\n# Install to custom path (absolute or ~/)\npypr compgen bash ~/custom/path/pypr\npypr compgen zsh /etc/zsh/completions/_pypr\n```\n\n> [!warning]\n> Relative paths may not do what you expect. Use `default`, an absolute path, or a `~/` path.\n\n### Default Installation Paths\n\n| Shell | Default Path |\n|-------|--------------|\n| Bash | `~/.local/share/bash-completion/completions/pypr` |\n| Zsh | `~/.zsh/completions/_pypr` |\n| Fish | `~/.config/fish/completions/pypr.fish` |\n\n> [!tip]\n> For Zsh, the default path may not be in your `$fpath`. Pypr will show instructions to add it.\n\n> [!note]\n> Regenerate completions after adding new plugins or scratchpads to keep them up to date.\n\n## pypr-client {#pypr-client}\n\n`pypr-client` is a lightweight, compiled alternative to `pypr` for sending commands to the daemon. It's significantly faster and ideal for key bindings.\n\n### When to Use It\n\n- In `hyprland.conf` key bindings where startup time matters\n- When you need minimal latency (e.g., toggling scratchpads)\n\n### Limitations\n\n- Cannot run the daemon (use `pypr` for that)\n- Does not support `validate` or `edit` commands (these require Python)\n\n### Installation\n\nDepending on your installation method, `pypr-client` may already be available. If not:\n\n1. Download the [source code](https://github.com/hyprland-community/pyprland/tree/main/client/)\n2. Compile it: `gcc -o pypr-client pypr-client.c`\n\nRust and Go versions are also available in the same directory.\n\n### Usage in hyprland.conf\n\n```ini\n# Use pypr-client for faster key bindings\n$pypr = /usr/bin/pypr-client\n\nbind = $mainMod, A, exec, $pypr toggle term\nbind = $mainMod, B, exec, $pypr expose\nbind = $mainMod SHIFT, Z, exec, $pypr zoom\n```\n\n> [!tip]\n> If using [uwsm](https://github.com/Vladimir-csp/uwsm), wrap the command:\n> ```ini\n> $pypr = uwsm-app -- /usr/bin/pypr-client\n> ```\n\nFor technical details about the client-daemon protocol, see [Architecture: Socket Protocol](./Architecture_core#pyprland-socket-protocol).\n\n## Debugging\n\nTo run the daemon with debug logging:\n\n```sh\npypr --debug\n```\n\nTo also save logs to a file:\n\n```sh\npypr --debug $HOME/pypr.log\n```\n\nOr in `hyprland.conf`:\n\n```ini\nexec-once = /usr/bin/pypr --debug $HOME/pypr.log\n```\n\nThe log file will contain detailed information useful for troubleshooting.\n"
  },
  {
    "path": "site/versions/3.1.1/Configuration.md",
    "content": "# Configuration\n\nThis page covers the configuration file format and available options.\n\n## File Location\n\nThe default configuration file is:\n\n```\n~/.config/pypr/config.toml\n```\n\nYou can specify a different path using the `--config` flag:\n\n```sh\npypr --config /path/to/config.toml\n```\n\n## Format\n\nPyprland uses the [TOML format](https://toml.io/). The basic structure is:\n\n```toml\n[pyprland]\nplugins = [\"plugin_name\", \"other_plugin\"]\n\n[plugin_name]\noption = \"value\"\n\n[plugin_name.nested_option]\nsuboption = 42\n```\n\n## [pyprland] Section\n\nThe main section configures the Pyprland daemon itself.\n\n<PluginConfig plugin=\"pyprland\" linkPrefix=\"config-\"  version=\"3.1.1\" />\n\n### `include` <ConfigBadges plugin=\"pyprland\" option=\"include\"  version=\"3.1.1\" /> {#config-include}\n\nList of additional configuration files to include. See [Multiple Configuration Files](./MultipleConfigurationFiles) for details.\n\n### `notification_type` <ConfigBadges plugin=\"pyprland\" option=\"notification_type\"  version=\"3.1.1\" /> {#config-notification-type}\n\nControls how notifications are displayed:\n\n| Value | Behavior |\n|-------|----------|\n| `\"auto\"` | Adapts to environment (Niri uses `notify-send`, Hyprland uses `hyprctl notify`) |\n| `\"notify-send\"` | Forces use of `notify-send` command |\n| `\"native\"` | Forces use of compositor's native notification system |\n\n### `variables` <ConfigBadges plugin=\"pyprland\" option=\"variables\"  version=\"3.1.1\" /> {#config-variables}\n\nCustom variables that can be used in plugin configurations. See [Variables](./Variables) for usage details.\n\n## Examples\n\n```toml\n[pyprland]\nplugins = [\n    \"scratchpads\",\n    \"magnify\",\n    \"expose\",\n]\nnotification_type = \"notify-send\"\n```\n\n### Plugin Configuration\n\nEach plugin can have its own configuration section. The format depends on the plugin:\n\n```toml\n# Simple options\n[magnify]\nfactor = 2\n\n# Nested options (e.g., scratchpads)\n[scratchpads.term]\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\n```\n\nSee individual [plugin documentation](./Plugins) for available options.\n\n### Multiple Configuration Files\n\nYou can split your configuration across multiple files using `include`:\n\n```toml\n[pyprland]\ninclude = [\n    \"~/.config/pypr/scratchpads.toml\",\n    \"~/.config/pypr/monitors.toml\",\n]\nplugins = [\"scratchpads\", \"monitors\"]\n```\n\nSee [Multiple Configuration Files](./MultipleConfigurationFiles) for details.\n\n## Hyprland Integration\n\nMost plugins provide commands that you'll want to bind to keys. Add bindings to your `hyprland.conf`:\n\n```ini\n# Define pypr command (adjust path as needed)\n$pypr = /usr/bin/pypr\n\n# Example bindings\nbind = $mainMod, A, exec, $pypr toggle term\nbind = $mainMod, B, exec, $pypr expose\nbind = $mainMod SHIFT, Z, exec, $pypr zoom\n```\n\n> [!tip]\n> For faster key bindings, use `pypr-client` instead of `pypr`. See [Commands](./Commands#pypr-client) for details.\n\n> [!tip]\n> Command names can use dashes or underscores interchangeably.\n> E.g., `pypr shift_monitors` and `pypr shift-monitors` are equivalent.\n\n## Validation\n\nYou can validate your configuration without running the daemon:\n\n```sh\npypr validate\n```\n\nThis checks your config against plugin schemas and reports any errors.\n\n## Tips\n\n- See [Examples](./Examples) for complete configuration samples\n- See [Optimizations](./Optimizations) for performance tips\n- Only enable plugins you actually use in the `plugins` array\n"
  },
  {
    "path": "site/versions/3.1.1/Development.md",
    "content": "# Development\n\nIt's easy to write your own plugin by making a Python package and then indicating its name as the plugin name.\n\n> [!tip]\n> For details on internal architecture, data flows, and design patterns, see the [Architecture](./Architecture) document.\n\n[Contributing guidelines](https://github.com/fdev31/pyprland/blob/main/CONTRIBUTING.md)\n\n## Development Setup\n\n### Prerequisites\n\n- Python 3.11+\n- [uv](https://docs.astral.sh/uv/getting-started/installation/) for dependency management\n- [pre-commit](https://pre-commit.com/) for Git hooks\n\n### Initial Setup\n\n```sh\n# Clone the repository\ngit clone https://github.com/fdev31/pyprland.git\ncd pyprland\n\n# Install dev and lint dependencies\nuv sync --all-groups\n\n# Install pre-commit hooks\nuv run pre-commit install\nuv run pre-commit install --hook-type pre-push\n```\n\n## Quick Start\n\n### Debugging\n\nTo get detailed logs when an error occurs, use:\n\n```sh\npypr --debug\n```\n\nThis displays logs in the console. To also save logs to a file:\n\n```sh\npypr --debug $HOME/pypr.log\n```\n\n### Quick Experimentation\n\n> [!note]\n> To quickly get started, you can directly edit the built-in [`experimental`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/experimental.py) plugin.\n> To distribute your plugin, create your own Python package or submit a pull request.\n\n### Custom Plugin Paths\n\n> [!tip]\n> Set `plugins_paths = [\"/custom/path\"]` in the `[pyprland]` section of your config to add extra plugin search paths during development.\n\n## Writing Plugins\n\n### Plugin Loading\n\nPlugins are loaded by their full Python module path:\n\n```toml\n[pyprland]\nplugins = [\"mypackage.myplugin\"]\n```\n\nThe module must provide an `Extension` class inheriting from [`Plugin`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/interface.py).\n\n> [!note]\n> If your extension is at the root level (not recommended), you can import it using the `external:` prefix:\n> ```toml\n> plugins = [\"external:myplugin\"]\n> ```\n> Prefer namespaced packages like `johns_pyprland.super_feature` instead.\n\n### Plugin Attributes\n\nYour `Extension` class has access to these attributes:\n\n| Attribute | Type | Description |\n|-----------|------|-------------|\n| `self.name` | `str` | Plugin identifier |\n| `self.config` | [`Configuration`](https://github.com/fdev31/pyprland/blob/main/pyprland/config.py) | Plugin's TOML config section |\n| `self.state` | [`SharedState`](https://github.com/fdev31/pyprland/blob/main/pyprland/common.py) | Shared application state (active workspace, monitor, etc.) |\n| `self.backend` | [`EnvironmentBackend`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/backend.py) | WM interaction: commands, queries, notifications |\n| `self.log` | `Logger` | Plugin-specific logger |\n\n### Creating Your First Plugin\n\n```python\nfrom pyprland.plugins.interface import Plugin\n\n\nclass Extension(Plugin):\n    \"\"\"My custom plugin.\"\"\"\n\n    async def init(self) -> None:\n        \"\"\"Called once at startup.\"\"\"\n        self.log.info(\"My plugin initialized\")\n\n    async def on_reload(self) -> None:\n        \"\"\"Called on init and config reload.\"\"\"\n        self.log.info(f\"Config: {self.config}\")\n\n    async def exit(self) -> None:\n        \"\"\"Cleanup on shutdown.\"\"\"\n        pass\n```\n\n### Adding Commands\n\nAdd `run_<commandname>` methods to handle `pypr <commandname>` calls.\n\nThe **first line** of the docstring appears in `pypr help`:\n\n```python\nclass Extension(Plugin):\n    zoomed = False\n\n    async def run_togglezoom(self, args: str) -> str | None:\n        \"\"\"Toggle zoom level.\n\n        This second line won't appear in CLI help.\n        \"\"\"\n        if self.zoomed:\n            await self.backend.execute(\"keyword misc:cursor_zoom_factor 1\")\n        else:\n            await self.backend.execute(\"keyword misc:cursor_zoom_factor 2\")\n        self.zoomed = not self.zoomed\n```\n\n### Reacting to Events\n\nAdd `event_<eventname>` methods to react to [Hyprland events](https://wiki.hyprland.org/IPC/):\n\n```python\nasync def event_openwindow(self, params: str) -> None:\n    \"\"\"React to window open events.\"\"\"\n    addr, workspace, cls, title = params.split(\",\", 3)\n    self.log.debug(f\"Window opened: {title}\")\n\nasync def event_workspace(self, workspace: str) -> None:\n    \"\"\"React to workspace changes.\"\"\"\n    self.log.info(f\"Switched to workspace: {workspace}\")\n```\n\n> [!note]\n> **Code Safety:** Pypr ensures only one handler runs at a time per plugin, so you don't need concurrency handling. Each plugin runs independently in parallel. See [Architecture - Manager](./Architecture#manager) for details.\n\n### Configuration Schema\n\nDefine expected config fields for automatic validation using [`ConfigField`](https://github.com/fdev31/pyprland/blob/main/pyprland/validation.py):\n\n```python\nfrom pyprland.plugins.interface import Plugin\nfrom pyprland.validation import ConfigField\n\n\nclass Extension(Plugin):\n    config_schema = [\n        ConfigField(\"enabled\", bool, required=False, default=True),\n        ConfigField(\"timeout\", int, required=False, default=5000),\n        ConfigField(\"command\", str, required=True),\n    ]\n\n    async def on_reload(self) -> None:\n        # Config is validated before on_reload is called\n        cmd = self.config[\"command\"]  # Guaranteed to exist\n```\n\n### Using Menus\n\nFor plugins that need menu interaction (rofi, wofi, tofi, etc.), use [`MenuMixin`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/menus.py):\n\n```python\nfrom pyprland.adapters.menus import MenuMixin\nfrom pyprland.plugins.interface import Plugin\n\n\nclass Extension(MenuMixin, Plugin):\n    async def run_select(self, args: str) -> None:\n        \"\"\"Show a selection menu.\"\"\"\n        await self.ensure_menu_configured()\n\n        options = [\"Option 1\", \"Option 2\", \"Option 3\"]\n        selected = await self.menu(options, \"Choose an option:\")\n\n        if selected:\n            await self.backend.notify_info(f\"Selected: {selected}\")\n```\n\n## Reusable Code\n\n### Shared State\n\nAccess commonly needed information without fetching it:\n\n```python\n# Current workspace, monitor, window\nworkspace = self.state.active_workspace\nmonitor = self.state.active_monitor\nwindow_addr = self.state.active_window\n\n# Environment detection\nif self.state.environment == \"niri\":\n    # Niri-specific logic\n    pass\n```\n\nSee [Architecture - Shared State](./Architecture#shared-state) for all available fields.\n\n### Mixins\n\nUse mixins for common functionality:\n\n```python\nfrom pyprland.common import CastBoolMixin\nfrom pyprland.plugins.interface import Plugin\n\n\nclass Extension(CastBoolMixin, Plugin):\n    async def on_reload(self) -> None:\n        # Safely cast config values to bool\n        enabled = self.cast_bool(self.config.get(\"enabled\", True))\n```\n\n## Development Workflow\n\nRestart the daemon after making changes:\n\n```sh\npypr exit ; pypr --debug\n```\n\n### API Documentation\n\nGenerate and browse the full API documentation:\n\n```sh\ntox run -e doc\n# Then visit http://localhost:8080\n```\n\n## Testing & Quality Assurance\n\n### Running All Checks\n\nBefore submitting a PR, run the full test suite:\n\n```sh\ntox\n```\n\nThis runs unit tests across Python versions and linting checks.\n\n### Tox Environments\n\n| Environment | Command | Description |\n|-------------|---------|-------------|\n| `py314-unit` | `tox run -e py314-unit` | Unit tests (Python 3.14) |\n| `py311-unit` | `tox run -e py311-unit` | Unit tests (Python 3.11) |\n| `py312-unit` | `tox run -e py312-unit` | Unit tests (Python 3.12) |\n| `py314-linting` | `tox run -e py314-linting` | Full linting suite (mypy, ruff, pylint, flake8) |\n| `py314-wiki` | `tox run -e py314-wiki` | Check plugin documentation coverage |\n| `doc` | `tox run -e doc` | Generate API docs with pdoc |\n| `coverage` | `tox run -e coverage` | Run tests with coverage report |\n| `deadcode` | `tox run -e deadcode` | Detect dead code with vulture |\n\n### Quick Test Commands\n\n```sh\n# Run unit tests only\ntox run -e py314-unit\n\n# Run linting only\ntox run -e py314-linting\n\n# Check documentation coverage\ntox run -e py314-wiki\n\n# Run tests with coverage\ntox run -e coverage\n```\n\n## Pre-commit Hooks\n\nPre-commit hooks ensure code quality before commits and pushes.\n\n### Installation\n\n```sh\npip install pre-commit\npre-commit install\npre-commit install --hook-type pre-push\n```\n\n### What Runs Automatically\n\n**On every commit:**\n\n| Hook | Purpose |\n|------|---------|\n| `versionMgmt` | Auto-increment version number |\n| `wikiDocGen` | Regenerate plugin documentation JSON |\n| `wikiDocCheck` | Verify documentation coverage |\n| `ruff-check` | Lint Python code |\n| `ruff-format` | Format Python code |\n| `flake8` | Additional Python linting |\n| `check-yaml` | Validate YAML files |\n| `check-json` | Validate JSON files |\n| `pretty-format-json` | Auto-format JSON files |\n| `beautysh` | Format shell scripts |\n| `yamllint` | Lint YAML files |\n\n**On push:**\n\n| Hook | Purpose |\n|------|---------|\n| `runtests` | Run full pytest suite |\n\n### Manual Execution\n\nRun all hooks manually:\n\n```sh\npre-commit run --all-files\n```\n\nRun a specific hook:\n\n```sh\npre-commit run ruff-check --all-files\n```\n\n## Packaging & Distribution\n\n### Creating an External Plugin Package\n\nSee the [sample extension](https://github.com/fdev31/pyprland/tree/main/sample_extension) for a complete example with:\n- Proper package structure\n- `pyproject.toml` configuration\n- Example plugin code: [`focus_counter.py`](https://github.com/fdev31/pyprland/blob/main/sample_extension/pypr_examples/focus_counter.py)\n\n### Development Installation\n\nInstall your package in editable mode for testing:\n\n```sh\ncd your-plugin-package/\npip install -e .\n```\n\n### Publishing\n\nWhen ready to distribute:\n\n```sh\nuv publish\n```\n\nDon't forget to update the details in your `pyproject.toml` file first.\n\n### Example Usage\n\nAdd your plugin to the config:\n\n```toml\n[pyprland]\nplugins = [\"pypr_examples.focus_counter\"]\n\n[\"pypr_examples.focus_counter\"]\nmultiplier = 2\n```\n\n> [!important]\n> Contact the maintainer to get your extension listed on the home page.\n\n## Further Reading\n\n- [Architecture](./Architecture) - Internal system design, data flows, and design patterns\n- [Plugins](./Plugins) - List of available built-in plugins\n- [Sample Extension](https://github.com/fdev31/pyprland/tree/main/sample_extension) - Complete example plugin package\n- [Hyprland IPC](https://wiki.hyprland.org/IPC/) - Hyprland's IPC documentation\n"
  },
  {
    "path": "site/versions/3.1.1/Examples.md",
    "content": "# Examples\n\nThis page provides complete configuration examples to help you get started.\n\n## Basic Setup\n\nA minimal configuration with a few popular plugins:\n\n### pyprland.toml\n\n```toml\n[pyprland]\nplugins = [\n    \"scratchpads\",\n    \"magnify\",\n    \"expose\",\n]\n\n[scratchpads.term]\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\nanimation = \"fromTop\"\n\n[scratchpads.volume]\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nsize = \"40% 90%\"\nanimation = \"fromRight\"\nlazy = true\n```\n\n### hyprland.conf\n\n```ini\n$pypr = /usr/bin/pypr\n\nbind = $mainMod, A, exec, $pypr toggle term\nbind = $mainMod, V, exec, $pypr toggle volume\nbind = $mainMod, B, exec, $pypr expose\nbind = $mainMod SHIFT, Z, exec, $pypr zoom\n```\n\n## Full-Featured Setup\n\nA comprehensive configuration demonstrating multiple plugins and features:\n\n### pyprland.toml\n\n```toml\n[pyprland]\nplugins = [\n  \"scratchpads\",\n  \"lost_windows\",\n  \"monitors\",\n  \"toggle_dpms\",\n  \"magnify\",\n  \"expose\",\n  \"shift_monitors\",\n  \"workspaces_follow_focus\",\n]\n\n[monitors.placement]\n\"Acer\".top_center_of = \"Sony\"\n\n[workspaces_follow_focus]\nmax_workspaces = 9\n\n[expose]\ninclude_special = false\n\n[scratchpads.stb]\nanimation = \"fromBottom\"\ncommand = \"kitty --class kitty-stb sstb\"\nclass = \"kitty-stb\"\nlazy = true\nsize = \"75% 45%\"\n\n[scratchpads.stb-logs]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-stb-logs stbLog\"\nclass = \"kitty-stb-logs\"\nlazy = true\nsize = \"75% 40%\"\n\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nlazy = true\nsize = \"40% 90%\"\nunfocus = \"hide\"\n```\n\n### hyprland.conf\n\n```ini\n# Use pypr-client for faster response in key bindings\n$pypr = uwsm-app -- /usr/local/bin/pypr-client\n\nbind = $mainMod SHIFT,  Z, exec, $pypr zoom\nbind = $mainMod ALT,    P, exec, $pypr toggle_dpms\nbind = $mainMod SHIFT,  O, exec, $pypr shift_monitors +1\nbind = $mainMod,        B, exec, $pypr expose\nbind = $mainMod,        K, exec, $pypr change_workspace +1\nbind = $mainMod,        J, exec, $pypr change_workspace -1\nbind = $mainMod,        L, exec, $pypr toggle_dpms\nbind = $mainMod  SHIFT, M, exec, $pypr toggle stb stb-logs\nbind = $mainMod,        A, exec, $pypr toggle term\nbind = $mainMod,        V, exec, $pypr toggle volume\n```\n\n> [!note]\n> This example uses `pypr-client` for faster key binding response. See [Commands: pypr-client](./Commands#pypr-client) for details.\n\n## Advanced Features\n\n### Variables\n\nYou can define reusable variables in your configuration to avoid repetition and make it easier to switch terminals or other tools.\n\nDefine variables in the `[pyprland.variables]` section:\n\n```toml\n[pyprland.variables]\nterm = \"foot\"\nterm_classed = \"foot -a\"  # For kitty, use \"kitty --class\"\n```\n\nThen use them in plugin configurations that support variable substitution:\n\n```toml\n[scratchpads.term]\ncommand = \"[term_classed] scratchterm\"\nclass = \"scratchterm\"\n```\n\nThis way, switching from `foot` to `kitty` only requires changing the variables, not every scratchpad definition.\n\nSee [Variables](./Variables) for more details.\n\n### Text Filters\n\nSome plugins support text filters for transforming output. Filters use a syntax similar to sed's `s` command:\n\n```toml\nfilter = 's/foo/bar/'           # Replace first \"foo\" with \"bar\"\nfilter = 's/foo/bar/g'          # Replace all occurrences\nfilter = 's/.*started (.*)/\\1 has started/'  # Regex with capture groups\nfilter = 's#</?div>##g'         # Use different delimiter\n```\n\nSee [Filters](./filters) for more details.\n\n## Community Examples\n\nBrowse community-contributed configuration files:\n\n- [GitHub examples folder](https://github.com/hyprland-community/pyprland/tree/main/examples)\n\nFeel free to share your own configurations by contributing to the repository.\n\n## Tips\n\n- [Optimizations](./Optimizations) - Performance tuning tips\n- [Troubleshooting](./Troubleshooting) - Common issues and solutions\n- [Multiple Configuration Files](./MultipleConfigurationFiles) - Split your config for better organization\n"
  },
  {
    "path": "site/versions/3.1.1/Getting-started.md",
    "content": "# Getting Started\n\nPypr consists of two things:\n\n- **A tool**: `pypr` which runs the daemon (service) and allows you to interact with it\n- **A config file**: `~/.config/pypr/config.toml` using the [TOML](https://toml.io/en/) format\n\n> [!important]\n> - With no arguments, `pypr` runs the daemon (doesn't fork to background)\n> - With arguments, it sends commands to the running daemon\n\n> [!tip]\n> For keybindings, use `pypr-client` instead of `pypr` for faster response (~1ms vs ~50ms). See [Commands: pypr-client](./Commands#pypr-client) for details.\n\n## Installation\n\nCheck your OS package manager first:\n\n- **Arch Linux**: Available on AUR, e.g., with [yay](https://github.com/Jguer/yay): `yay pyprland`\n- **NixOS**: See the [Nix](./Nix) page for instructions\n\nOtherwise, install via pip (preferably in a [virtual environment](./InstallVirtualEnvironment)):\n\n```sh\npip install pyprland\n```\n\n## Minimal Configuration\n\nCreate `~/.config/pypr/config.toml` with:\n\n```toml\n[pyprland]\nplugins = [\n    \"scratchpads\",\n    \"gamemode\",\n    \"magnify\",\n]\n```\n\nThis enables only few plugins. See the [Plugins](./Plugins) page for the full list.\n\n## Running the Daemon\n\n> [!caution]\n> If you installed pypr outside your OS package manager (e.g., pip, virtual environment), use the full path to the `pypr` command. Get it with `which pypr` in a working terminal.\n\n### Option 1: Hyprland exec-once\n\nAdd to your `hyprland.conf`:\n\n```ini\nexec-once = /usr/bin/pypr\n```\n\nFor debugging, use:\n\n```ini\nexec-once = /usr/bin/pypr --debug\n```\n\nOr to also save logs to a file:\n\n```ini\nexec-once = /usr/bin/pypr --debug $HOME/pypr.log\n```\n\n### Option 2: Systemd User Service\n\nCreate `~/.config/systemd/user/pyprland.service`:\n\n```ini\n[Unit]\nDescription=Starts pyprland daemon\nAfter=graphical-session.target\nWants=graphical-session.target\n# Optional: wait for other services to start first\n# Wants=hyprpaper.service\nStartLimitIntervalSec=600\nStartLimitBurst=5\n\n[Service]\nType=simple\n# Optional: only start on specific compositor\n# For Hyprland:\n# ExecStartPre=/bin/sh -c '[ \"$XDG_CURRENT_DESKTOP\" = \"Hyprland\" ] || exit 0'\n# For Niri:\n# ExecStartPre=/bin/sh -c '[ \"$XDG_CURRENT_DESKTOP\" = \"niri\" ] || exit 0'\nExecStart=pypr\nRestart=always\n\n[Install]\nWantedBy=graphical-session.target\n```\n\nThen enable and start the service:\n\n```sh\nsystemctl enable --user --now pyprland.service\n```\n\n## Verifying It Works\n\nOnce the daemon is running, check available commands:\n\n```sh\npypr help\n```\n\nIf something isn't working, check the [Troubleshooting](./Troubleshooting) page.\n\n## Next Steps\n\n- [Configuration](./Configuration) - Full configuration reference\n- [Commands](./Commands) - CLI commands and shell completions\n- [Plugins](./Plugins) - Browse available plugins\n- [Examples](./Examples) - Complete configuration examples\n"
  },
  {
    "path": "site/versions/3.1.1/InstallVirtualEnvironment.md",
    "content": "# Virtual env\n\nEven though the best way to get Pyprland installed is to use your operating system package manager,\nfor some usages or users it can be convenient to use a virtual environment.\n\nThis is very easy to achieve in a couple of steps:\n\n```shell\npython -m venv ~/pypr-env\n~/pypr-env/bin/pip install pyprland\n```\n\n**That's all folks!**\n\nThe only extra care to take is to use `pypr` from the virtual environment, eg:\n\n- adding the environment's \"bin\" folder to the `PATH` (using `export PATH=\"$PATH:~/pypr-env/bin/\"` in your shell configuration file)\n- always using the full path to the pypr command (in `hyprland.conf`: `exec-once = ~/pypr-env/bin/pypr` or with debug logging: `exec-once = ~/pypr-env/bin/pypr --debug $HOME/pypr.log`)\n\n# Going bleeding edge!\n\nIf you would rather like to use the latest version available (not released yet), then you can clone the git repository and install from it:\n\n```shell\ncd ~/pypr-env\ngit clone git@github.com:hyprland-community/pyprland.git pyprland-sources\ncd pyprland-sources\n../bin/pip install -e .\n```\n\n## Updating\n\n```shell\ncd ~/pypr-env\ngit pull -r\n```\n\n# Troubelshooting\n\nIf things go wrong, try (eg: after a system upgrade where Python got updated):\n\n```shell\npython -m venv --upgrade ~/pypr-env\ncd  ~/pypr-env\n../bin/pip install -e .\n```\n"
  },
  {
    "path": "site/versions/3.1.1/Menu.md",
    "content": "# Menu capability\n\nMenu based plugins have the following configuration options:\n\n<PluginConfig plugin=\"menu\" linkPrefix=\"config-\"  version=\"3.1.1\" />\n\n### `engine` <ConfigBadges plugin=\"menu\" option=\"engine\"  version=\"3.1.1\" /> {#config-engine}\n\nAuto-detects the available menu engine if not set.\n\nSupported engines (tested in order):\n\n<EngineList />\n\n> [!note]\n> If your menu system isn't supported, you can open a [feature request](https://github.com/hyprland-community/pyprland/issues/new?assignees=fdev31&labels=bug&projects=&template=feature_request.md&title=%5BFEAT%5D+Description+of+the+feature)\n>\n> In case the engine isn't recognized, `engine` + `parameters` configuration options will be used to start the process, it requires a dmenu-like behavior.\n\n### `parameters` <ConfigBadges plugin=\"menu\" option=\"parameters\"  version=\"3.1.1\" /> {#config-parameters}\n\nExtra parameters added to the engine command. Setting this will override the engine's default value.\n\n> [!tip]\n> You can use `[prompt]` in the parameters, it will be replaced by the prompt, eg for rofi/wofi:\n> ```sh\n> -dmenu -matching fuzzy -i -p '[prompt]'\n> ```\n\n#### Default parameters per engine\n\n<EngineDefaults  version=\"3.1.1\" />\n"
  },
  {
    "path": "site/versions/3.1.1/MultipleConfigurationFiles.md",
    "content": "### Multiple configuration files\n\nYou can also split your configuration into multiple files that will be loaded in the provided order after the main file:\n```toml\n[pyprland]\ninclude = [\"/shared/pyprland.toml\", \"~/pypr_extra_config.toml\"]\n```\nYou can also load folders, in which case TOML files in the folder will be loaded in alphabetical order:\n```toml\n[pyprland]\ninclude = [\"~/.config/pypr.d/\"]\n```\n\nAnd then add a `~/.config/pypr.d/monitors.toml` file:\n```toml\npyprland.plugins = [ \"monitors\" ]\n\n[monitors.placement]\nBenQ.Top_Center_Of = \"DP-1\" # projo\n\"CJFH277Q3HCB\".top_of = \"eDP-1\" # work\n```\n\n> [!tip]\n> To check the final merged configuration, you can use the `dumpjson` command.\n\n"
  },
  {
    "path": "site/versions/3.1.1/Nix.md",
    "content": "# Nix\n\nYou are recommended to get the latest pyprland package from the [flake.nix](https://github.com/hyprland-community/pyprland/blob/main/flake.nix)\nprovided within this repository. To use it in your system, you may add pyprland\nto your flake inputs.\n\n## Flake\n\n```nix\n## flake.nix\n{\n  inputs = {\n    nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n    pyprland.url = \"github:hyprland-community/pyprland\";\n  };\n\n  outputs = { self, nixpkgs, pyprland, ...}: let\n  in {\n    nixosConfigurations.<yourHostname> = nixpkgs.lib.nixosSystem {\n      #         remember to replace this with your system arch ↓\n      environment.systemPackages = [ pyprland.packages.\"x86_64-linux\".pyprland ];\n      # ...\n    };\n  };\n}\n```\n\nAlternatively, if you are using the Nix package manager but not NixOS as your\nmain distribution, you may use `nix profile` tool to install pyprland from this\nrepository using the following command.\n\n```bash\nnix profile install github:nix-community/pyprland\n```\n\nThe package will now be in your latest profile. You may use `nix profile list`\nto verify your installation.\n\n## Nixpkgs\n\nPyprland is available under nixpkgs, and can be installed by adding\n`pkgs.pyprland` to either `environment.systemPackages` or `home.packages`\ndepending on whether you want it available system-wide or to only a single\nuser using home-manager. If the derivation available in nixpkgs is out-of-date\nthen you may consider using `overrideAttrs` to update the source locally.\n\n```nix\nlet\n  pyprland = pkgs.pyprland.overrideAttrs {\n    version = \"your-version-here\";\n    src = fetchFromGitHub {\n      owner = \"hyprland-community\";\n      repo = \"pyprland\";\n      rev = \"tag-or-revision\";\n      # leave empty for the first time, add the new hash from the error message\n      hash = \"\";\n    };\n  };\nin {\n  # add the overridden package to systemPackages\n  environment.systemPackages = [pyprland];\n}\n```\n"
  },
  {
    "path": "site/versions/3.1.1/Optimizations.md",
    "content": "## Optimizing\n\n### Plugins\n\n- Only enable the plugins you are using in the `plugins` array (in `[pyprland]` section).\n- Leaving the configuration for plugins which are not enabled will have no impact.\n- Using multiple configuration files only have a small impact on the startup time.\n\n### Pypr\n\nYou can run `pypr` using `pypy` (version 3) for a more snappy experience.\nOne way is to use a `pypy3` virtual environment:\n\n```bash\npypy3 -m venv pypr-venv\nsource ./pypr-venv/bin/activate\ncd <pypr source folder>\npip install -e .\n```\n\n### Pypr command\n\nIn case you want to save some time when interacting with the daemon, the simplest is to use `pypr-client`. See [Commands: pypr-client](./Commands#pypr-client) for details. If `pypr-client` isn't available from your OS package and you cannot compile code,\nyou can use `socat` instead (needs to be installed).\n\nExample of a `pypr-cli` command (should be reachable from your environment's `PATH`):\n\n#### Hyprland\n```sh\n#!/bin/sh\nsocat - \"UNIX-CONNECT:${XDG_RUNTIME_DIR}/hypr/${HYPRLAND_INSTANCE_SIGNATURE}/.pyprland.sock\" <<< $@\n```\n\n#### Niri\n```sh\n#!/bin/sh\nsocat - \"UNIX-CONNECT:$(dirname ${NIRI_SOCKET})/.pyprland.sock\" <<< $@\n```\n\n#### Standalone (other window manager)\n```sh\n#!/bin/sh\nsocat - \"UNIX-CONNECT:${XDG_DATA_HOME:-$HOME/.local/share}/.pyprland.sock\" <<< $@\n```\n\nOn slow systems this may make a difference.\nNote that `validate` and `edit` commands require the standard `pypr` command.\n"
  },
  {
    "path": "site/versions/3.1.1/Plugins.md",
    "content": "<script setup>\nimport PluginList from '/components/PluginList.vue'\n</script>\n\nThis page lists every plugin provided by Pyprland out of the box, more can be enabled if you install the matching package.\n\n\"🌟\" indicates some maturity & reliability level of the plugin, considering age, attention paid and complexity - from 0 to 3.\n\nA badge such as <Badge type=\"tip\">multi-monitor</Badge> indicates a requirement.\n\nSome plugins require an external **graphical menu system**, such as *rofi*.\nEach plugin can use a different menu system but the [configuration is unified](Menu). In case no [engine](Menu#engine) is provided some auto-detection of installed applications will happen.\n\n<PluginList version=\"3.1.1\" />\n"
  },
  {
    "path": "site/versions/3.1.1/Troubleshooting.md",
    "content": "# Troubleshooting\n\n## Checking Logs\n\nHow you access logs depends on how you run pyprland.\n\n### Systemd Service\n\nIf you run pyprland as a [systemd user service](./Getting-started#option-2-systemd-user-service):\n\n```sh\njournalctl --user -u pyprland -f\n```\n\n### exec-once (Hyprland)\n\nIf you run pyprland via [exec-once](./Getting-started#option-1-hyprland-exec-once), logs go to stderr by default and are typically lost.\n\nTo enable debug logging, add `--debug` to your exec-once command. Optionally specify a file path to also save logs to a file:\n\n```ini\nexec-once = /usr/bin/pypr --debug $HOME/pypr.log\n```\n\nThen check the log file:\n\n```sh\ntail -f ~/pypr.log\n```\n\n> [!tip]\n> Use a path like `$HOME/pypr.log` or `/tmp/pypr.log` to avoid cluttering your home directory.\n\n### Running from Terminal\n\nFor quick debugging, run pypr directly in a terminal:\n\n```sh\npypr --debug\n```\n\nThis shows debug output directly in the terminal. Optionally add a file path to also save logs to a file.\n\n## General Issues\n\nIn case of trouble running a `pypr` command:\n\n1. Kill the existing pypr daemon if running (try `pypr exit` first)\n2. Run from a terminal with `--debug` to see error messages\n\nIf the client says it can't connect, the daemon likely didn't start. Check if it's running:\n\n```sh\nps aux | grep pypr\n```\n\nYou can try starting it manually from a terminal:\n\n```sh\npypr --debug\n```\n\nThis will show any startup errors directly in the terminal.\n\n## Force Hyprland Version\n\nIn case your `hyprctl version -j` command isn't returning an accurate version, you can make Pyprland ignore it and use a provided value instead:\n\n```toml\n[pyprland]\nhyprland_version = \"0.41.0\"\n```\n\n## Unresponsive Scratchpads\n\nScratchpads aren't responding for a few seconds after trying to show one (which didn't show!)\n\nThis may happen if an application is very slow to start.\nIn that case pypr will wait for a window, blocking other scratchpad operations, before giving up after a few seconds.\n\nNote that other plugins shouldn't be blocked by this.\n\nMore scratchpads troubleshooting can be found [here](./scratchpads_nonstandard).\n\n## See Also\n\n- [Getting Started: Running the Daemon](./Getting-started#running-the-daemon) - Setup options\n- [Commands: Debugging](./Commands#debugging) - Debug flag reference\n"
  },
  {
    "path": "site/versions/3.1.1/Variables.md",
    "content": "# Variables & substitutions\n\nSome commands support shared global variables, they must be defined in the *pyprland* section of the configuration:\n```toml\n[pyprland.variables]\nterm = \"foot\"\nterm_classed = \"foot -a\" # kitty uses --class\n```\n\nIf a plugin supports it, you can then use the variables in the attribute that supports it, eg:\n\n```toml\n[myplugin]\nsome_variable = \"the terminal is [term]\"\n```\n"
  },
  {
    "path": "site/versions/3.1.1/components/CommandList.vue",
    "content": "<template>\n    <dl>\n    </dl>\n    <ul v-for=\"command in commands\" :key=\"command.name\">\n        <li><code v-html=\"command.name.replace(/[   ]*$/, '').replace(/ /g, '&ensp;')\" /> <span\n                v-html=\"command.description\" /></li>\n    </ul>\n</template>\n\n<script>\nexport default {\n    props: {\n        commands: {\n            type: Array,\n            required: true\n        }\n    }\n}\n</script>\n"
  },
  {
    "path": "site/versions/3.1.1/components/ConfigBadges.vue",
    "content": "<template>\n    <span v-if=\"loaded && item\" class=\"config-badges\">\n        <Badge type=\"info\">{{ typeIcon }}{{ item.type }}</Badge>\n        <Badge v-if=\"hasDefault\" type=\"tip\">=<code>{{ formattedDefault }}</code></Badge>\n        <Badge v-if=\"item.required\" type=\"danger\">required</Badge>\n        <Badge v-else-if=\"item.recommended\" type=\"warning\">recommended</Badge>\n    </span>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted } from 'vue'\nimport { getPluginData } from './jsonLoader.js'\n\nconst props = defineProps({\n    plugin: {\n        type: String,\n        required: true\n    },\n    option: {\n        type: String,\n        required: true\n    },\n    version: {\n        type: String,\n        default: null\n    }\n})\n\nconst item = ref(null)\nconst loaded = ref(false)\n\nonMounted(() => {\n    try {\n        const data = getPluginData(props.plugin, props.version)\n        if (data) {\n            const config = data.config || []\n            // Find the option - handle both \"option\" and \"[prefix].option\" formats\n            item.value = config.find(c => {\n                const baseName = c.name.replace(/^\\[.*?\\]\\./, '')\n                return baseName === props.option || c.name === props.option\n            })\n        }\n    } catch (e) {\n        console.error(`Failed to load config for plugin: ${props.plugin}`, e)\n    } finally {\n        loaded.value = true\n    }\n})\n\nconst typeIcon = computed(() => {\n    if (!item.value) return ''\n    const type = item.value.type || ''\n    if (type.includes('Path')) {\n        return item.value.is_directory ? '\\u{1F4C1} ' : '\\u{1F4C4} '\n    }\n    return ''\n})\n\nconst hasDefault = computed(() => {\n    if (!item.value) return false\n    const value = item.value.default\n    if (value === null || value === undefined) return false\n    if (value === '') return false\n    if (Array.isArray(value) && value.length === 0) return false\n    if (typeof value === 'object' && Object.keys(value).length === 0) return false\n    return true\n})\n\nconst formattedDefault = computed(() => {\n    if (!item.value) return ''\n    const value = item.value.default\n    if (typeof value === 'boolean') {\n        return value ? 'true' : 'false'\n    }\n    if (typeof value === 'string') {\n        return `\"${value}\"`\n    }\n    if (Array.isArray(value)) {\n        return JSON.stringify(value)\n    }\n    return String(value)\n})\n</script>\n\n<style scoped>\n.config-badges {\n    margin-left: 0.5em;\n}\n\n.config-badges code {\n    background: transparent;\n    font-size: 0.9em;\n}\n</style>\n"
  },
  {
    "path": "site/versions/3.1.1/components/ConfigTable.vue",
    "content": "<template>\n  <!-- Grouped by category (only at top level, when categories exist) -->\n  <div v-if=\"hasCategories && !isNested\" class=\"config-categories\">\n    <details\n      v-for=\"group in groupedItems\"\n      :key=\"group.category\"\n      :open=\"group.category === 'basic'\"\n      class=\"config-category\"\n    >\n      <summary class=\"config-category-header\">\n        {{ getCategoryDisplayName(group.category) }}\n        <span class=\"config-category-count\">({{ group.items.length }})</span>\n        <a v-if=\"group.category === 'menu'\" href=\"./Menu\" class=\"config-category-link\">See full documentation</a>\n      </summary>\n      <table class=\"config-table\">\n        <thead>\n          <tr>\n            <th>Option</th>\n            <th>Description</th>\n          </tr>\n        </thead>\n        <tbody>\n          <template v-for=\"item in group.items\" :key=\"item.name\">\n            <tr>\n              <td class=\"config-option-cell\">\n                <a v-if=\"isDocumented(item.name)\" :href=\"'#' + getAnchor(item.name)\" class=\"config-link\" title=\"More details below\">\n                  <span v-if=\"hasChildren(item)\" class=\"config-has-children\" title=\"Has child options\">+</span>\n                  <code>{{ item.name }}</code>\n                  <span class=\"config-info-icon\">i</span>\n                </a>\n                <template v-else>\n                  <span v-if=\"hasChildren(item)\" class=\"config-has-children\" title=\"Has child options\">+</span>\n                  <code>{{ item.name }}</code>\n                </template>\n                <Badge type=\"info\">{{ getTypeIcon(item) }}{{ item.type }}</Badge>\n                <Badge v-if=\"item.required\" type=\"danger\">required</Badge>\n                <Badge v-else-if=\"item.recommended\" type=\"warning\">recommended</Badge>\n                <div v-if=\"hasDefault(item.default)\" type=\"tip\">=<code>{{ formatDefault(item.default) }}</code></div>\n              </td>\n              <td class=\"config-description\" v-html=\"renderDescription(item.description)\" />\n            </tr>\n            <!-- Children row (recursive) -->\n            <tr v-if=\"hasChildren(item)\" class=\"config-children-row\">\n              <td colspan=\"2\" class=\"config-children-cell\">\n                <details class=\"config-children-details\">\n                  <summary><code>{{ item.name }}</code> options</summary>\n                  <config-table\n                    :items=\"item.children\"\n                    :is-nested=\"true\"\n                    :option-to-anchor=\"optionToAnchor\"\n                    :parent-name=\"getQualifiedName(item.name)\"\n                  />\n                </details>\n              </td>\n            </tr>\n          </template>\n        </tbody>\n      </table>\n    </details>\n  </div>\n\n  <!-- Flat table (for nested tables or when no categories) -->\n  <table v-else :class=\"['config-table', { 'config-nested': isNested }]\">\n    <thead>\n      <tr>\n        <th>Option</th>\n        <th>Description</th>\n      </tr>\n    </thead>\n    <tbody>\n      <template v-for=\"item in items\" :key=\"item.name\">\n        <tr>\n          <td class=\"config-option-cell\">\n            <a v-if=\"isDocumented(item.name)\" :href=\"'#' + getAnchor(item.name)\" class=\"config-link\" title=\"More details below\">\n              <span v-if=\"hasChildren(item)\" class=\"config-has-children\" title=\"Has child options\">+</span>\n              <code>{{ item.name }}</code>\n              <span class=\"config-info-icon\">i</span>\n            </a>\n            <template v-else>\n              <span v-if=\"hasChildren(item)\" class=\"config-has-children\" title=\"Has child options\">+</span>\n              <code>{{ item.name }}</code>\n            </template>\n            <Badge type=\"info\">{{ item.type }}</Badge>\n            <Badge v-if=\"item.required\" type=\"danger\">required</Badge>\n            <Badge v-else-if=\"item.recommended\" type=\"warning\">recommended</Badge>\n            <div v-if=\"hasDefault(item.default)\" type=\"tip\">=<code>{{ formatDefault(item.default) }}</code></div>\n          </td>\n          <td class=\"config-description\" v-html=\"renderDescription(item.description)\" />\n        </tr>\n        <!-- Children row (recursive) -->\n        <tr v-if=\"hasChildren(item)\" class=\"config-children-row\">\n          <td colspan=\"2\" class=\"config-children-cell\">\n            <details class=\"config-children-details\">\n              <summary><code>{{ item.name }}</code> options</summary>\n              <config-table\n                :items=\"item.children\"\n                :is-nested=\"true\"\n                :option-to-anchor=\"optionToAnchor\"\n                :parent-name=\"getQualifiedName(item.name)\"\n              />\n            </details>\n          </td>\n        </tr>\n      </template>\n    </tbody>\n  </table>\n</template>\n\n<script>\nimport { hasChildren, hasDefault, formatDefault, renderDescription } from './configHelpers.js'\n\n// Category display order and names\nconst CATEGORY_ORDER = ['basic', 'menu', 'appearance', 'positioning', 'behavior', 'external_commands', 'templating', 'placement', 'advanced', 'overrides', '']\nconst CATEGORY_NAMES = {\n  'basic': 'Basic',\n  'menu': 'Menu',\n  'appearance': 'Appearance',\n  'positioning': 'Positioning',\n  'behavior': 'Behavior',\n  'external_commands': 'External commands',\n  'templating': 'Templating',\n  'placement': 'Placement',\n  'advanced': 'Advanced',\n  'overrides': 'Overrides',\n  '': 'Other'\n}\n\nexport default {\n  name: 'ConfigTable',\n  props: {\n    items: { type: Array, required: true },\n    isNested: { type: Boolean, default: false },\n    optionToAnchor: { type: Object, default: () => ({}) },\n    parentName: { type: String, default: '' }\n  },\n  computed: {\n    hasCategories() {\n      // Only group if there are multiple distinct categories\n      const categories = new Set(this.items.map(item => item.category || ''))\n      return categories.size > 1\n    },\n    groupedItems() {\n      // Group items by category\n      const groups = {}\n      for (const item of this.items) {\n        const category = item.category || ''\n        if (!groups[category]) {\n          groups[category] = []\n        }\n        groups[category].push(item)\n      }\n\n      // Sort groups by CATEGORY_ORDER\n      const result = []\n      for (const cat of CATEGORY_ORDER) {\n        if (groups[cat]) {\n          result.push({ category: cat, items: groups[cat] })\n          delete groups[cat]\n        }\n      }\n      // Add any remaining categories not in the order list\n      for (const cat of Object.keys(groups).sort()) {\n        result.push({ category: cat, items: groups[cat] })\n      }\n\n      return result\n    }\n  },\n  methods: {\n    hasChildren,\n    hasDefault,\n    formatDefault,\n    renderDescription,\n    getTypeIcon(item) {\n      const type = item.type || ''\n      if (type.includes('Path')) {\n        return item.is_directory ? '\\u{1F4C1} ' : '\\u{1F4C4} '\n      }\n      return ''\n    },\n    getCategoryDisplayName(category) {\n      return CATEGORY_NAMES[category] || category.charAt(0).toUpperCase() + category.slice(1)\n    },\n    getQualifiedName(name) {\n      const baseName = name.replace(/^\\[.*?\\]\\./, '')\n      return this.parentName ? `${this.parentName}.${baseName}` : baseName\n    },\n    isDocumented(name) {\n      if (Object.keys(this.optionToAnchor).length === 0) return false\n      const qualifiedName = this.getQualifiedName(name)\n      const anchorKey = qualifiedName.replace(/\\./g, '-')\n      return anchorKey in this.optionToAnchor || qualifiedName in this.optionToAnchor\n    },\n    getAnchor(name) {\n      const qualifiedName = this.getQualifiedName(name)\n      const anchorKey = qualifiedName.replace(/\\./g, '-')\n      return this.optionToAnchor[anchorKey] || this.optionToAnchor[qualifiedName] || ''\n    }\n  }\n}\n</script>\n\n<style scoped>\n.config-categories {\n  display: flex;\n  flex-direction: column;\n  gap: 0.5rem;\n}\n\n.config-category {\n  border: 1px solid var(--vp-c-divider);\n  border-radius: 8px;\n  overflow: hidden;\n}\n\n.config-category[open] {\n  border-color: var(--vp-c-brand);\n}\n\n.config-category-header {\n  display: flex;\n  align-items: center;\n  gap: 0.5rem;\n  padding: 0.75rem 1rem;\n  background: var(--vp-c-bg-soft);\n  font-weight: 600;\n  cursor: pointer;\n  user-select: none;\n}\n\n.config-category-header:hover {\n  background: var(--vp-c-bg-mute);\n}\n\n.config-category-count {\n  font-weight: 400;\n  color: var(--vp-c-text-2);\n  font-size: 0.875em;\n}\n\n.config-category-link {\n  margin-left: auto;\n  font-weight: 400;\n  font-size: 0.875em;\n  color: var(--vp-c-brand);\n  text-decoration: none;\n}\n\n.config-category-link:hover {\n  text-decoration: underline;\n}\n\n.config-category .config-table {\n  margin: 0;\n  border: none;\n  border-radius: 0;\n}\n\n.config-category .config-table thead {\n  background: var(--vp-c-bg-alt);\n}\n</style>\n"
  },
  {
    "path": "site/versions/3.1.1/components/EngineDefaults.vue",
    "content": "<template>\n  <div v-if=\"error\" class=\"data-error\">{{ error }}</div>\n  <table v-else-if=\"engineDefaults\" class=\"data-table\">\n    <thead>\n      <tr>\n        <th>Engine</th>\n        <th>Default Parameters</th>\n      </tr>\n    </thead>\n    <tbody>\n      <tr v-for=\"(params, engine) in engineDefaults\" :key=\"engine\">\n        <td><code>{{ engine }}</code></td>\n        <td><code>{{ params || '-' }}</code></td>\n      </tr>\n    </tbody>\n  </table>\n</template>\n\n<script setup>\nimport { computed } from 'vue'\nimport { getPluginData } from './jsonLoader.js'\n\nconst props = defineProps({\n  version: {\n    type: String,\n    default: null\n  }\n})\n\nconst data = computed(() => {\n  try {\n    return getPluginData('menu', props.version)\n  } catch (e) {\n    console.error('Failed to load menu data:', e)\n    return null\n  }\n})\n\nconst engineDefaults = computed(() => data.value?.engine_defaults || null)\nconst error = computed(() => data.value === null ? 'Failed to load engine defaults' : null)\n</script>\n"
  },
  {
    "path": "site/versions/3.1.1/components/EngineList.vue",
    "content": "<template>\n  <div v-if=\"error\" class=\"data-error\">{{ error }}</div>\n  <ul v-else class=\"engine-list\">\n    <li></li>\n    <li v-for=\"engine in engines\" :key=\"engine\">\n            <code>{{ engine }}</code>\n    </li>\n  </ul>\n</template>\n\n<script setup>\nimport { computed } from 'vue'\nimport { getPluginData } from './jsonLoader.js'\n\nconst props = defineProps({\n  version: {\n    type: String,\n    default: null\n  }\n})\n\nconst data = computed(() => {\n  try {\n    return getPluginData('menu', props.version)\n  } catch (e) {\n    console.error('Failed to load menu data:', e)\n    return null\n  }\n})\n\nconst engineDefaults = computed(() => data.value?.engine_defaults ?? {})\nconst engines = computed(() => Object.keys(engineDefaults.value))\nconst error = computed(() => (data.value === null ? 'Failed to load engine defaults' : null))\n</script>\n\n<style scoped>\n.engine-list {\n  list-style: none;\n  margin: 0;\n  padding: 0;\n  overflow: hidden;\n}\n\n.engine-list li {\n  float: left;\n  margin-right: 0.75rem;\n}\n</style>\n"
  },
  {
    "path": "site/versions/3.1.1/components/PluginCommands.vue",
    "content": "<template>\n  <div v-if=\"loading\" class=\"command-loading\">Loading commands...</div>\n  <div v-else-if=\"error\" class=\"command-error\">{{ error }}</div>\n  <div v-else-if=\"filteredCommands.length === 0\" class=\"command-empty\">\n    No commands are provided by this plugin.\n  </div>\n  <div v-else class=\"command-box\">\n    <ul class=\"command-list\">\n      <li v-for=\"command in filteredCommands\" :key=\"command.name\" class=\"command-item\">\n        <a v-if=\"isDocumented(command.name)\" :href=\"'#' + getAnchor(command.name)\" class=\"command-link\" title=\"More details below\">\n          <code class=\"command-name\">{{ command.name }}</code>\n          <span class=\"command-info-icon\">i</span>\n        </a>\n        <code v-else class=\"command-name\">{{ command.name }}</code>\n        <template v-for=\"(arg, idx) in command.args\" :key=\"idx\">\n          <code class=\"command-arg\">{{ formatArg(arg) }}</code>\n        </template>\n        <span class=\"command-desc\" v-html=\"renderDescription(command.short_description)\" />\n      </li>\n    </ul>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted } from 'vue'\nimport { renderDescription } from './configHelpers.js'\nimport { usePluginData } from './usePluginData.js'\nimport { getPluginData } from './jsonLoader.js'\n\nconst props = defineProps({\n  plugin: {\n    type: String,\n    required: true\n  },\n  filter: {\n    type: Array,\n    default: null\n  },\n  linkPrefix: {\n    type: String,\n    default: ''\n  },\n  version: {\n    type: String,\n    default: null\n  }\n})\n\nconst commandToAnchor = ref({})\n\nconst { data: commands, loading, error } = usePluginData(async () => {\n  const data = getPluginData(props.plugin, props.version)\n  if (!data) throw new Error(`Plugin data not found: ${props.plugin}`)\n  return data.commands || []\n})\n\nconst filteredCommands = computed(() => {\n  if (!props.filter || props.filter.length === 0) {\n    return commands.value\n  }\n  return commands.value.filter(cmd => props.filter.includes(cmd.name))\n})\n\nonMounted(() => {\n  if (props.linkPrefix) {\n    const anchors = document.querySelectorAll('h3[id], h4[id], h5[id]')\n    const mapping = {}\n    anchors.forEach(heading => {\n      mapping[heading.id] = heading.id\n      // Also extract command names from <code> elements\n      const codes = heading.querySelectorAll('code')\n      codes.forEach(code => {\n        mapping[code.textContent] = heading.id\n      })\n    })\n    commandToAnchor.value = mapping\n  }\n})\n\nfunction isDocumented(name) {\n  if (Object.keys(commandToAnchor.value).length === 0) return false\n  const anchorKey = `${props.linkPrefix}${name}`\n  return anchorKey in commandToAnchor.value || name in commandToAnchor.value\n}\n\nfunction getAnchor(name) {\n  const anchorKey = `${props.linkPrefix}${name}`\n  return commandToAnchor.value[anchorKey] || commandToAnchor.value[name] || ''\n}\n\nfunction formatArg(arg) {\n  return arg.required ? arg.value : `[${arg.value}]`\n}\n</script>\n"
  },
  {
    "path": "site/versions/3.1.1/components/PluginConfig.vue",
    "content": "<template>\n  <div v-if=\"loading\" class=\"config-loading\">Loading configuration...</div>\n  <div v-else-if=\"error\" class=\"config-error\">{{ error }}</div>\n  <div v-else-if=\"filteredConfig.length === 0\" class=\"config-empty\">No configuration options available.</div>\n  <config-table\n    v-else\n    :items=\"filteredConfig\"\n    :option-to-anchor=\"optionToAnchor\"\n  />\n</template>\n\n<script>\nimport ConfigTable from './ConfigTable.vue'\nimport { getPluginData } from './jsonLoader.js'\n\nexport default {\n  components: {\n    ConfigTable\n  },\n  props: {\n    plugin: {\n      type: String,\n      required: true\n    },\n    linkPrefix: {\n      type: String,\n      default: ''\n    },\n    filter: {\n      type: Array,\n      default: null\n    },\n    version: {\n      type: String,\n      default: null\n    }\n  },\n  data() {\n    return {\n      config: [],\n      loading: true,\n      error: null,\n      optionToAnchor: {}\n    }\n  },\n  computed: {\n    filteredConfig() {\n      if (!this.filter || this.filter.length === 0) {\n        return this.config\n      }\n      return this.config.filter(item => {\n        const baseName = item.name.replace(/^\\[.*?\\]\\./, '')\n        return this.filter.includes(baseName)\n      })\n    }\n  },\n  mounted() {\n    try {\n      const data = getPluginData(this.plugin, this.version)\n      if (!data) {\n        this.error = `Plugin data not found: ${this.plugin}`\n      } else {\n        this.config = data.config || []\n      }\n    } catch (e) {\n      this.error = `Failed to load configuration for plugin: ${this.plugin}`\n      console.error(e)\n    } finally {\n      this.loading = false\n    }\n    \n    // Scan page for documented option anchors (h3, h4, h5) and build option->anchor mapping\n    if (this.linkPrefix) {\n      const anchors = document.querySelectorAll('h3[id], h4[id], h5[id]')\n      const mapping = {}\n      anchors.forEach(heading => {\n        // Map by anchor ID directly (e.g., \"placement-scale\" -> \"placement-scale\")\n        // This allows qualified lookups like \"placement.scale\" -> \"placement-scale\"\n        mapping[heading.id] = heading.id\n        // Also extract option names from <code> elements for top-level matching\n        const codes = heading.querySelectorAll('code')\n        codes.forEach(code => {\n          mapping[code.textContent] = heading.id\n        })\n      })\n      this.optionToAnchor = mapping\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "site/versions/3.1.1/components/PluginList.vue",
    "content": "<template>\n    <div>\n        <h1>Built-in plugins</h1>\n        <div v-if=\"loading\">Loading plugins...</div>\n        <div v-else-if=\"error\">{{ error }}</div>\n        <div v-else v-for=\"plugin in sortedPlugins\" :key=\"plugin.name\" class=\"plugin-item\">\n            <div class=\"plugin-info\">\n                <h3>\n                    <a :href=\"plugin.name + '.html'\">{{ plugin.name }}</a>\n                    <span v-html=\"'&nbsp;' + getStars(plugin.stars)\"></span>\n                    <span v-if=\"plugin.multimon\">\n                        <Badge type=\"tip\" text=\"multi-monitor\" />\n                    </span>\n                    <span v-if=\"plugin.environments && plugin.environments.length\">\n                        <Badge v-for=\"env in plugin.environments\" :key=\"env\" type=\"tip\" :text=\"env\" style=\"margin-left: 5px;\" />\n                    </span>\n                </h3>\n                <p v-html=\"plugin.description\" />\n            </div>\n            <a v-if=\"plugin.demoVideoId\" :href=\"'https://www.youtube.com/watch?v=' + plugin.demoVideoId\"\n                class=\"plugin-video\">\n                <img :src=\"'https://img.youtube.com/vi/' + plugin.demoVideoId + '/1.jpg'\" alt=\"Demo video thumbnail\" />\n            </a>\n        </div>\n    </div>\n</template>\n\n<style scoped>\n.plugin-item {\n    display: flex;\n    align-items: flex-start;\n    margin-bottom: 1.5rem;\n}\n\n.plugin-info {\n    flex: 1;\n}\n\n.plugin-video {\n    margin-left: 1rem;\n}\n\n.plugin-video img {\n    max-width: 200px;\n    /* Adjust as needed */\n    height: auto;\n}\n</style>\n\n\n<script setup>\nimport { computed } from 'vue'\nimport { usePluginData } from './usePluginData.js'\nimport { getPluginData } from './jsonLoader.js'\n\nconst props = defineProps({\n    version: {\n        type: String,\n        default: null\n    }\n})\n\nconst { data: plugins, loading, error } = usePluginData(async () => {\n    const data = getPluginData('index', props.version)\n    if (!data) throw new Error('Plugin index not found')\n    // Filter out internal plugins like 'pyprland'\n    return (data.plugins || []).filter(p => p.name !== 'pyprland')\n})\n\nconst sortedPlugins = computed(() => {\n    if (!plugins.value?.length) return []\n    return plugins.value.slice().sort((a, b) => a.name.localeCompare(b.name))\n})\n\nfunction getStars(count) {\n    return count > 0 ? '&#11088;'.repeat(count) : ''\n}\n</script>\n"
  },
  {
    "path": "site/versions/3.1.1/components/configHelpers.js",
    "content": "/**\n * Shared helper functions for config table components.\n */\n\nimport MarkdownIt from 'markdown-it'\n\nconst md = new MarkdownIt({ html: true, linkify: true })\n\n/**\n * Check if a config item has children.\n * @param {Object} item - Config item\n * @returns {boolean}\n */\nexport function hasChildren(item) {\n  return item.children && item.children.length > 0\n}\n\n/**\n * Check if a value represents a meaningful default (not empty/null).\n * @param {*} value - Default value to check\n * @returns {boolean}\n */\nexport function hasDefault(value) {\n  if (value === null || value === undefined) return false\n  if (value === '') return false\n  if (Array.isArray(value) && value.length === 0) return false\n  if (typeof value === 'object' && Object.keys(value).length === 0) return false\n  return true\n}\n\n/**\n * Format a default value for display.\n * @param {*} value - Value to format\n * @returns {string}\n */\nexport function formatDefault(value) {\n  if (typeof value === 'boolean') {\n    return value ? 'true' : 'false'\n  }\n  if (typeof value === 'string') {\n    return `\"${value}\"`\n  }\n  if (Array.isArray(value)) {\n    return JSON.stringify(value)\n  }\n  return String(value)\n}\n\n/**\n * Render description text with markdown support.\n * Transforms <opt1|opt2|...> patterns to styled inline code blocks.\n * @param {string} text - Description text\n * @returns {string} - HTML string\n */\nexport function renderDescription(text) {\n  if (!text) return ''\n  // Transform <opt1|opt2|...> patterns to styled inline code blocks\n  text = text.replace(/<([^>|]+(?:\\|[^>|]+)+)>/g, (match, choices) => {\n    return choices.split('|').map(c => `\\`${c}\\``).join(' | ')\n  })\n  // Use render() to support links, then strip wrapping <p> tags\n  const html = md.render(text)\n  return html.replace(/^<p>/, '').replace(/<\\/p>\\n?$/, '')\n}\n"
  },
  {
    "path": "site/versions/3.1.1/components/jsonLoader.js",
    "content": "/**\n * JSON loader with glob imports for version-aware plugin data.\n *\n * Uses Vite's import.meta.glob to pre-bundle all JSON files at build time,\n * enabling runtime selection based on version.\n */\n\n// Pre-load all JSON files at build time\nconst currentJson = import.meta.glob('../generated/*.json', { eager: true })\nconst versionedJson = import.meta.glob('../versions/*/generated/*.json', { eager: true })\n\n/**\n * Get plugin data from the appropriate JSON file.\n *\n * @param {string} name - JSON filename without extension (e.g., 'scratchpads', 'index', 'menu')\n * @param {string|null} version - Version string (e.g., '3.0.0') or null for current\n * @returns {object|null} - Parsed JSON data or null if not found\n */\nexport function getPluginData(name, version = null) {\n  const filename = `${name}.json`\n\n  if (version) {\n    const key = `../versions/${version}/generated/${filename}`\n    const data = versionedJson[key]\n    return data?.default || data || null\n  }\n\n  const key = `../generated/${filename}`\n  const data = currentJson[key]\n  return data?.default || data || null\n}\n"
  },
  {
    "path": "site/versions/3.1.1/components/usePluginData.js",
    "content": "/**\n * Composable for loading plugin data with loading/error states.\n *\n * Provides a standardized pattern for async data loading in Vue components.\n */\n\nimport { ref, onMounted } from 'vue'\n\n/**\n * Load data asynchronously with loading and error state management.\n *\n * @param {Function} loader - Async function that returns the data\n * @returns {Object} - { data, loading, error } refs\n *\n * @example\n * // Load commands from a plugin JSON file\n * const { data: commands, loading, error } = usePluginData(async () => {\n *   const module = await import(`../generated/${props.plugin}.json`)\n *   return module.commands || []\n * })\n *\n * @example\n * // Load with default value\n * const { data: config, loading, error } = usePluginData(\n *   async () => {\n *     const module = await import('../generated/menu.json')\n *     return module.engine_defaults || {}\n *   }\n * )\n */\nexport function usePluginData(loader) {\n  const data = ref(null)\n  const loading = ref(true)\n  const error = ref(null)\n\n  onMounted(async () => {\n    try {\n      data.value = await loader()\n    } catch (e) {\n      error.value = e.message || 'Failed to load data'\n      console.error(e)\n    } finally {\n      loading.value = false\n    }\n  })\n\n  return { data, loading, error }\n}\n"
  },
  {
    "path": "site/versions/3.1.1/expose.md",
    "content": "---\n---\n\n# expose\n\nImplements the \"expose\" effect, showing every client window on the focused screen.\n\nFor a similar feature using a menu, try the [fetch_client_menu](./fetch_client_menu) plugin (less intrusive).\n\nSample `hyprland.conf`:\n\n```bash\n# Setup the key binding\nbind = $mainMod, B, exec, pypr expose\n\n# Add some style to the \"exposed\" workspace\nworkspace = special:exposed,gapsout:60,gapsin:30,bordersize:5,border:true,shadow:false\n```\n\n`MOD+B` will bring every client to the focused workspace, pressed again it will go to this workspace.\n\nCheck [workspace rules](https://wiki.hyprland.org/Configuring/Workspace-Rules/#rules) for styling options.\n\n> [!note]\n> If you are looking for `toggle_minimized`, check the [toggle_special](./toggle_special) plugin\n\n\n## Commands\n\n<PluginCommands plugin=\"expose\"  version=\"3.1.1\" />\n\n## Configuration\n\n<PluginConfig plugin=\"expose\" linkPrefix=\"config-\"  version=\"3.1.1\" />\n\n"
  },
  {
    "path": "site/versions/3.1.1/fcitx5_switcher.md",
    "content": "---\n---\n\n# fcitx5_switcher\n\nThis is a useful tool for CJK input method users.\n\nIt can automatically switch fcitx5 input method status based on window class and title.\n\n<details>\n<summary>Example</summary>\n\n```toml\n[fcitx5_switcher]\nactive_classes = [\"wechat\", \"QQ\", \"zoom\"]\ninactive_classes = [\n    \"code\",\n    \"kitty\",\n    \"google-chrome\",\n]\nactive_titles = []\ninactive_titles = []\n```\n\nIn this example, if the window class is \"wechat\" or \"QQ\" or \"zoom\", the input method will be activated. If the window class is \"code\" or \"kitty\" or \"google-chrome\", the input method will be inactivated.\n\n</details>\n\n## Commands\n\n<PluginCommands plugin=\"fcitx5_switcher\"  version=\"3.1.1\" />\n\n## Configuration\n\n<PluginConfig plugin=\"fcitx5_switcher\" linkPrefix=\"config-\"  version=\"3.1.1\" />\n\n"
  },
  {
    "path": "site/versions/3.1.1/fetch_client_menu.md",
    "content": "---\n---\n\n# fetch_client_menu\n\nBring any window to the active workspace using a menu.\n\nA bit like the [expose](./expose) plugin but using a menu instead (less intrusive).\n\nIt brings the window to the current workspace, while [expose](./expose) moves the currently focused screen to the application workspace.\n\n## Commands\n\n<PluginCommands plugin=\"fetch_client_menu\"  version=\"3.1.1\" />\n\n## Configuration\n\n<PluginConfig plugin=\"fetch_client_menu\" linkPrefix=\"config-\"  version=\"3.1.1\" />"
  },
  {
    "path": "site/versions/3.1.1/filters.md",
    "content": "# Text filters\n\nAt the moment there is only one filter, close match of [sed's \"s\" command](https://www.gnu.org/software/sed/manual/html_node/The-_0022s_0022-Command.html).\n\nThe only supported flag is \"g\"\n\n## Example\n\n```toml\nfilter = 's/foo/bar/'\nfilter = 's/.*started (.*)/\\1 has started/'\nfilter = 's#</?div>##g'\n```\n"
  },
  {
    "path": "site/versions/3.1.1/gamemode.md",
    "content": "---\n---\n\n# gamemode\n\nToggle game mode for improved performance. When enabled, disables animations, blur, shadows, gaps, and rounding. When disabled, reloads the Hyprland config to restore original settings.\n\nThis is useful when gaming or running performance-intensive applications where visual effects may cause frame drops or input lag.\n\n<details>\n    <summary>Example</summary>\n\nSample `hyprland.conf`:\n\n```sh\nbind = $mainMod, G, exec, pypr gamemode\n```\n\n</details>\n\n## Commands\n\n<PluginCommands plugin=\"gamemode\"  version=\"3.1.1\" />\n\n## Configuration\n\n<PluginConfig plugin=\"gamemode\" linkPrefix=\"config-\"  version=\"3.1.1\" />\n\n### `auto` <ConfigBadges plugin=\"gamemode\" option=\"auto\"  version=\"3.1.1\" /> {#config-auto}\n\nEnable automatic game mode detection. When enabled, pyprland monitors window open/close events and automatically enables game mode when a window matching one of the configured patterns is detected. Game mode is disabled when all matching windows are closed.\n\n```toml\n[gamemode]\nauto = true\n```\n\n### `patterns` <ConfigBadges plugin=\"gamemode\" option=\"patterns\"  version=\"3.1.1\" /> {#config-patterns}\n\nList of glob patterns to match window class names for automatic game mode activation. Uses shell-style wildcards (`*`, `?`, `[seq]`, `[!seq]`).\n\nThe default pattern `steam_app_*` matches all Steam games, which have window classes like `steam_app_870780`.\n\n```toml\n[gamemode]\nauto = true\npatterns = [\"steam_app_*\", \"gamescope*\", \"lutris_*\"]\n```\n\nTo find the window class of a specific application, run:\n\n```sh\nhyprctl clients -j | jq '.[].class'\n```\n\n### `border_size` <ConfigBadges plugin=\"gamemode\" option=\"border_size\"  version=\"3.1.1\" /> {#config-border_size}\n\nBorder size to use when game mode is enabled. Since gaps are removed, a visible border helps distinguish window boundaries.\n\n### `notify` <ConfigBadges plugin=\"gamemode\" option=\"notify\"  version=\"3.1.1\" /> {#config-notify}\n\nWhether to show a notification when toggling game mode on or off.\n"
  },
  {
    "path": "site/versions/3.1.1/generated/expose.json",
    "content": "{\n  \"name\": \"expose\",\n  \"description\": \"Exposes all windows for a quick 'jump to' feature.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"expose\",\n      \"args\": [],\n      \"short_description\": \"Expose every client on the active workspace.\",\n      \"full_description\": \"Expose every client on the active workspace.\\n\\nIf expose is active restores everything and move to the focused window\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"include_special\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Include windows from special workspaces\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.1.1/generated/fcitx5_switcher.json",
    "content": "{\n  \"name\": \"fcitx5_switcher\",\n  \"description\": \"A plugin to auto-switch Fcitx5 input method status by window class/title.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [],\n  \"config\": [\n    {\n      \"name\": \"active_classes\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Window classes that should activate Fcitx5\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"activation\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"active_titles\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Window titles that should activate Fcitx5\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"activation\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"inactive_classes\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Window classes that should deactivate Fcitx5\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"deactivation\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"inactive_titles\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Window titles that should deactivate Fcitx5\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"deactivation\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.1.1/generated/fetch_client_menu.json",
    "content": "{\n  \"name\": \"fetch_client_menu\",\n  \"description\": \"Shows a menu to select and fetch a window to your active workspace.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"fetch_client_menu\",\n      \"args\": [],\n      \"short_description\": \"Select a client window and move it to the active workspace.\",\n      \"full_description\": \"Select a client window and move it to the active workspace.\"\n    },\n    {\n      \"name\": \"unfetch_client\",\n      \"args\": [],\n      \"short_description\": \"Return a window back to its origin.\",\n      \"full_description\": \"Return a window back to its origin.\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"engine\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Menu engine to use\",\n      \"choices\": [\n        \"fuzzel\",\n        \"tofi\",\n        \"rofi\",\n        \"wofi\",\n        \"bemenu\",\n        \"dmenu\",\n        \"anyrun\",\n        \"walker\",\n        \"vicinae\"\n      ],\n      \"children\": null,\n      \"category\": \"menu\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"parameters\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Extra parameters for the menu engine command\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"menu\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"separator\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"|\",\n      \"description\": \"Separator between window number and title\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"appearance\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"center_on_fetch\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Center the fetched window on the focused monitor (floating)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"margin\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 60,\n      \"description\": \"Margin from monitor edges in pixels when centering/resizing\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.1.1/generated/gamemode.json",
    "content": "{\n  \"name\": \"gamemode\",\n  \"description\": \"Toggle game mode for improved performance.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"gamemode\",\n      \"args\": [],\n      \"short_description\": \"Toggle game mode (disables animations, blur, shadows, gaps, rounding).\",\n      \"full_description\": \"Toggle game mode (disables animations, blur, shadows, gaps, rounding).\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"border_size\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 1,\n      \"description\": \"Border size when game mode is enabled\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"notify\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Show notification when toggling\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"auto\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Automatically enable game mode when matching windows are detected\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"patterns\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [\n        \"steam_app_*\"\n      ],\n      \"description\": \"Glob patterns to match window class for auto mode\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.1.1/generated/index.json",
    "content": "{\n  \"plugins\": [\n    {\n      \"name\": \"fetch_client_menu\",\n      \"description\": \"Shows a menu to select and fetch a window to your active workspace.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"layout_center\",\n      \"description\": \"A workspace layout where one window is centered and maximized while others are in the background.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": \"vEr9eeSJYDc\",\n      \"multimon\": false\n    },\n    {\n      \"name\": \"magnify\",\n      \"description\": \"Toggles zooming of viewport or sets a specific scaling factor.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": \"yN-mhh9aDuo\",\n      \"multimon\": false\n    },\n    {\n      \"name\": \"menubar\",\n      \"description\": \"Improves multi-monitor handling of the status bar and restarts it on crashes.\",\n      \"environments\": [\n        \"hyprland\",\n        \"niri\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"monitors\",\n      \"description\": \"Allows relative placement and configuration of monitors.\",\n      \"environments\": [\n        \"hyprland\",\n        \"niri\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"scratchpads\",\n      \"description\": \"Makes your applications into dropdowns & togglable popups.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": \"ZOhv59VYqkc\",\n      \"multimon\": false\n    },\n    {\n      \"name\": \"shift_monitors\",\n      \"description\": \"Moves workspaces from monitor to monitor (carousel).\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": null,\n      \"multimon\": true\n    },\n    {\n      \"name\": \"shortcuts_menu\",\n      \"description\": \"A flexible way to make your own shortcuts menus & launchers.\",\n      \"environments\": [],\n      \"stars\": 3,\n      \"demoVideoId\": \"UCuS417BZK8\",\n      \"multimon\": false\n    },\n    {\n      \"name\": \"system_notifier\",\n      \"description\": \"Opens streams (eg: journal logs) and triggers notifications.\",\n      \"environments\": [],\n      \"stars\": 3,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"toggle_special\",\n      \"description\": \"Toggle switching the focused window to a special workspace.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": \"BNZCMqkwTOo\",\n      \"multimon\": false\n    },\n    {\n      \"name\": \"wallpapers\",\n      \"description\": \"Handles random wallpapers at regular intervals, with support for rounded corners and color scheme generation.\",\n      \"environments\": [],\n      \"stars\": 3,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"workspaces_follow_focus\",\n      \"description\": \"Makes non-visible workspaces available on the currently focused screen.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": null,\n      \"multimon\": true\n    },\n    {\n      \"name\": \"expose\",\n      \"description\": \"Exposes all windows for a quick 'jump to' feature.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 2,\n      \"demoVideoId\": \"ce5HQZ3na8M\",\n      \"multimon\": false\n    },\n    {\n      \"name\": \"gamemode\",\n      \"description\": \"Toggle game mode for improved performance.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 2,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"lost_windows\",\n      \"description\": \"Brings lost floating windows (which are out of reach) to the current workspace.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 1,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"fcitx5_switcher\",\n      \"description\": \"A plugin to auto-switch Fcitx5 input method status by window class/title.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 0,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"pyprland\",\n      \"description\": \"Internal built-in plugin allowing caching states and implementing special commands.\",\n      \"environments\": [],\n      \"stars\": 0,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"toggle_dpms\",\n      \"description\": \"Toggles the DPMS status of every plugged monitor.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 0,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.1.1/generated/layout_center.json",
    "content": "{\n  \"name\": \"layout_center\",\n  \"description\": \"A workspace layout where one window is centered and maximized while others are in the background.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"layout_center\",\n      \"args\": [\n        {\n          \"value\": \"toggle|next|prev|next2|prev2\",\n          \"required\": true\n        }\n      ],\n      \"short_description\": \"turn on/off or change the active window.\",\n      \"full_description\": \"<toggle|next|prev|next2|prev2> turn on/off or change the active window.\\n\\nArgs:\\n    what: The action to perform\\n        - toggle: Enable/disable the centered layout\\n        - next/prev: Focus the next/previous window in the stack\\n        - next2/prev2: Alternative focus commands (configurable)\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"margin\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 60,\n      \"description\": \"Margin around the centered window in pixels\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"offset\",\n      \"type\": \"str or list or tuple\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [\n        0,\n        0\n      ],\n      \"description\": \"Offset of the centered window as 'X Y' or [X, Y]\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"style\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Window rules to apply to the centered window\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"captive_focus\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Keep focus on the centered window\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"on_new_client\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"focus\",\n      \"description\": \"Behavior when a new window opens\",\n      \"choices\": [\n        \"focus\",\n        \"background\",\n        \"close\"\n      ],\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"next\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Command to run when 'next' is called and layout is disabled\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"commands\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"prev\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Command to run when 'prev' is called and layout is disabled\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"commands\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"next2\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Alternative command for 'next'\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"commands\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"prev2\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Alternative command for 'prev'\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"commands\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.1.1/generated/lost_windows.json",
    "content": "{\n  \"name\": \"lost_windows\",\n  \"description\": \"Brings lost floating windows (which are out of reach) to the current workspace.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"attract_lost\",\n      \"args\": [],\n      \"short_description\": \"Brings lost floating windows to the current workspace.\",\n      \"full_description\": \"Brings lost floating windows to the current workspace.\"\n    }\n  ],\n  \"config\": []\n}\n"
  },
  {
    "path": "site/versions/3.1.1/generated/magnify.json",
    "content": "{\n  \"name\": \"magnify\",\n  \"description\": \"Toggles zooming of viewport or sets a specific scaling factor.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"zoom\",\n      \"args\": [\n        {\n          \"value\": \"factor\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"zooms to \\\"factor\\\" or toggles zoom level if factor is omitted.\",\n      \"full_description\": \"[factor] zooms to \\\"factor\\\" or toggles zoom level if factor is omitted.\\n\\nIf factor is omitted, it toggles between the configured zoom level and no zoom.\\nFactor can be relative (e.g. +0.5 or -0.5).\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"factor\",\n      \"type\": \"float\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 2.0,\n      \"description\": \"Zoom factor when toggling\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"duration\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 0,\n      \"description\": \"Animation duration in frames (0 to disable)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.1.1/generated/menu.json",
    "content": "{\n  \"name\": \"menu\",\n  \"description\": \"Shared configuration for menu-based plugins.\",\n  \"environments\": [],\n  \"commands\": [],\n  \"config\": [\n    {\n      \"name\": \"engine\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Menu engine to use\",\n      \"choices\": [\n        \"fuzzel\",\n        \"tofi\",\n        \"rofi\",\n        \"wofi\",\n        \"bemenu\",\n        \"dmenu\",\n        \"anyrun\",\n        \"walker\",\n        \"vicinae\"\n      ],\n      \"children\": null,\n      \"category\": \"menu\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"parameters\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Extra parameters for the menu engine command\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"menu\",\n      \"is_directory\": false\n    }\n  ],\n  \"engine_defaults\": {\n    \"fuzzel\": \"--match-mode=fuzzy -d -p '[prompt]'\",\n    \"tofi\": \"--prompt-text '[prompt]'\",\n    \"rofi\": \"-dmenu -i -p '[prompt]'\",\n    \"wofi\": \"-dmenu -i -p '[prompt]'\",\n    \"bemenu\": \"-c\",\n    \"dmenu\": \"-i\",\n    \"anyrun\": \"--plugins libstdin.so --show-results-immediately true\",\n    \"walker\": \"-d -k -p '[prompt]'\",\n    \"vicinae\": \"dmenu --no-quick-look\"\n  }\n}\n"
  },
  {
    "path": "site/versions/3.1.1/generated/menubar.json",
    "content": "{\n  \"name\": \"menubar\",\n  \"description\": \"Improves multi-monitor handling of the status bar and restarts it on crashes.\",\n  \"environments\": [\n    \"hyprland\",\n    \"niri\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"bar\",\n      \"args\": [\n        {\n          \"value\": \"restart|stop|toggle\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Start (default), restart, stop or toggle the menu bar.\",\n      \"full_description\": \"[restart|stop|toggle] Start (default), restart, stop or toggle the menu bar.\\n\\nArgs:\\n    args: The action to perform\\n        - (empty): Start the bar\\n        - restart: Stop and restart the bar\\n        - stop: Stop the bar\\n        - toggle: Toggle the bar on/off\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"command\",\n      \"type\": \"str\",\n      \"required\": true,\n      \"recommended\": false,\n      \"default\": \"uwsm app -- ashell\",\n      \"description\": \"Command to run the bar (supports [monitor] variable)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"monitors\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Preferred monitors list in order of priority\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.1.1/generated/monitors.json",
    "content": "{\n  \"name\": \"monitors\",\n  \"description\": \"Allows relative placement and configuration of monitors.\",\n  \"environments\": [\n    \"hyprland\",\n    \"niri\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"relayout\",\n      \"args\": [],\n      \"short_description\": \"Recompute & apply every monitors's layout.\",\n      \"full_description\": \"Recompute & apply every monitors's layout.\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"startup_relayout\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Relayout monitors on startup\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"relayout_on_config_change\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Relayout when Hyprland config is reloaded\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"new_monitor_delay\",\n      \"type\": \"float\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 1.0,\n      \"description\": \"Delay in seconds before handling new monitor\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"unknown\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Command to run when an unknown monitor is detected\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"external_commands\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"placement\",\n      \"type\": \"dict\",\n      \"required\": true,\n      \"recommended\": false,\n      \"default\": {},\n      \"description\": \"Monitor placement rules (pattern -> positioning rules)\",\n      \"choices\": null,\n      \"children\": [\n        {\n          \"name\": \"scale\",\n          \"type\": \"float\",\n          \"required\": false,\n          \"recommended\": false,\n          \"default\": null,\n          \"description\": \"UI scale factor\",\n          \"choices\": null,\n          \"children\": null,\n          \"category\": \"display\",\n          \"is_directory\": false\n        },\n        {\n          \"name\": \"rate\",\n          \"type\": \"int or float\",\n          \"required\": false,\n          \"recommended\": false,\n          \"default\": null,\n          \"description\": \"Refresh rate in Hz\",\n          \"choices\": null,\n          \"children\": null,\n          \"category\": \"display\",\n          \"is_directory\": false\n        },\n        {\n          \"name\": \"resolution\",\n          \"type\": \"str or list\",\n          \"required\": false,\n          \"recommended\": false,\n          \"default\": null,\n          \"description\": \"Display resolution (e.g., '2560x1440' or [2560, 1440])\",\n          \"choices\": null,\n          \"children\": null,\n          \"category\": \"display\",\n          \"is_directory\": false\n        },\n        {\n          \"name\": \"transform\",\n          \"type\": \"int\",\n          \"required\": false,\n          \"recommended\": false,\n          \"default\": null,\n          \"description\": \"Rotation/flip transform\",\n          \"choices\": [\n            0,\n            1,\n            2,\n            3,\n            4,\n            5,\n            6,\n            7\n          ],\n          \"children\": null,\n          \"category\": \"display\",\n          \"is_directory\": false\n        },\n        {\n          \"name\": \"disables\",\n          \"type\": \"list\",\n          \"required\": false,\n          \"recommended\": false,\n          \"default\": null,\n          \"description\": \"List of monitors to disable when this monitor is connected\",\n          \"choices\": null,\n          \"children\": null,\n          \"category\": \"behavior\",\n          \"is_directory\": false\n        }\n      ],\n      \"category\": \"placement\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"hotplug_commands\",\n      \"type\": \"dict\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": {},\n      \"description\": \"Commands to run when specific monitors are plugged (pattern -> command)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"external_commands\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"hotplug_command\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Command to run when any monitor is plugged\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"external_commands\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.1.1/generated/pyprland.json",
    "content": "{\n  \"name\": \"pyprland\",\n  \"description\": \"Internal built-in plugin allowing caching states and implementing special commands.\",\n  \"environments\": [],\n  \"commands\": [\n    {\n      \"name\": \"compgen\",\n      \"args\": [\n        {\n          \"value\": \"shell\",\n          \"required\": true\n        },\n        {\n          \"value\": \"default|path\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Generate shell completions.\",\n      \"full_description\": \"<shell> [default|path] Generate shell completions.\\n\\nUsage:\\n  pypr compgen <shell>            Output script to stdout\\n  pypr compgen <shell> default    Install to default user path\\n  pypr compgen <shell> ~/path     Install to home-relative path\\n  pypr compgen <shell> /abs/path  Install to absolute path\\n\\nExamples:\\n  pypr compgen zsh > ~/.zsh/completions/_pypr\\n  pypr compgen bash default\"\n    },\n    {\n      \"name\": \"doc\",\n      \"args\": [\n        {\n          \"value\": \"plugin.option\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Show plugin and configuration documentation.\",\n      \"full_description\": \"[plugin.option] Show plugin and configuration documentation.\\n\\nUsage:\\n  pypr doc                 List all plugins\\n  pypr doc <plugin>        Show plugin documentation\\n  pypr doc <plugin.option> Show config option details\\n  pypr doc <plugin> <opt>  Same as plugin.option\\n\\nExamples:\\n  pypr doc scratchpads\\n  pypr doc scratchpads.animation\\n  pypr doc wallpapers path\"\n    },\n    {\n      \"name\": \"dumpjson\",\n      \"args\": [],\n      \"short_description\": \"Dump the configuration in JSON format (after includes are processed).\",\n      \"full_description\": \"Dump the configuration in JSON format (after includes are processed).\"\n    },\n    {\n      \"name\": \"exit\",\n      \"args\": [],\n      \"short_description\": \"Terminate the pyprland daemon.\",\n      \"full_description\": \"Terminate the pyprland daemon.\"\n    },\n    {\n      \"name\": \"get\",\n      \"args\": [\n        {\n          \"value\": \"plugin.key\",\n          \"required\": true\n        }\n      ],\n      \"short_description\": \"Get a configuration value.\",\n      \"full_description\": \"<plugin.key> Get a configuration value.\\n\\nArgs:\\n    path: Dot-separated path (e.g., 'wallpapers.online_ratio')\\n\\nExamples:\\n    pypr get wallpapers.online_ratio\\n    pypr get scratchpads.term.command\"\n    },\n    {\n      \"name\": \"help\",\n      \"args\": [\n        {\n          \"value\": \"command\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Show available commands or detailed help.\",\n      \"full_description\": \"[command] Show available commands or detailed help.\\n\\nUsage:\\n  pypr help           List all commands\\n  pypr help <command> Show detailed help\"\n    },\n    {\n      \"name\": \"reload\",\n      \"args\": [],\n      \"short_description\": \"Reload the configuration file.\",\n      \"full_description\": \"Reload the configuration file.\\n\\nNew plugins will be loaded and configuration options will be updated.\\nMost plugins will use the new values on the next command invocation.\"\n    },\n    {\n      \"name\": \"set\",\n      \"args\": [\n        {\n          \"value\": \"plugin.key\",\n          \"required\": true\n        },\n        {\n          \"value\": \"value\",\n          \"required\": true\n        }\n      ],\n      \"short_description\": \"Set a configuration value.\",\n      \"full_description\": \"<plugin.key> <value> Set a configuration value.\\n\\nArgs:\\n    args: Path and value (e.g., 'wallpapers.online_ratio 0.5')\\n\\nUse 'None' to unset a non-required option.\\n\\nExamples:\\n    pypr set wallpapers.online_ratio 0.5\\n    pypr set wallpapers.path /new/path\\n    pypr set scratchpads.term.lazy true\\n    pypr set wallpapers.online_ratio None\"\n    },\n    {\n      \"name\": \"version\",\n      \"args\": [],\n      \"short_description\": \"Show the pyprland version.\",\n      \"full_description\": \"Show the pyprland version.\"\n    },\n    {\n      \"name\": \"edit\",\n      \"args\": [],\n      \"short_description\": \"Open the configuration file in $EDITOR, then reload.\",\n      \"full_description\": \"Open the configuration file in $EDITOR, then reload.\\n\\nOpens pyprland.toml in your preferred editor (EDITOR or VISUAL env var,\\ndefaults to vi). After the editor closes, the configuration is reloaded.\"\n    },\n    {\n      \"name\": \"validate\",\n      \"args\": [],\n      \"short_description\": \"Validate the configuration file.\",\n      \"full_description\": \"Validate the configuration file.\\n\\nChecks the configuration file for syntax errors and validates plugin\\nconfigurations against their schemas. Does not require the daemon.\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"plugins\",\n      \"type\": \"list\",\n      \"required\": true,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"List of plugins to load\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"include\",\n      \"type\": \"list[Path]\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Additional config files or folders to include\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": true\n    },\n    {\n      \"name\": \"plugins_paths\",\n      \"type\": \"list[Path]\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Additional paths to search for third-party plugins\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": true\n    },\n    {\n      \"name\": \"colored_handlers_log\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Enable colored log output for event handlers (debugging)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"notification_type\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"auto\",\n      \"description\": \"Notification method: 'auto', 'notify-send', or 'native'\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"variables\",\n      \"type\": \"dict\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": {},\n      \"description\": \"User-defined variables for string substitution (see Variables page)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"hyprland_version\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Override auto-detected Hyprland version (e.g., '0.40.0')\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"desktop\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Override auto-detected desktop environment (e.g., 'hyprland', 'niri'). Empty means auto-detect.\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.1.1/generated/scratchpads.json",
    "content": "{\n  \"name\": \"scratchpads\",\n  \"description\": \"Makes your applications into dropdowns & togglable popups.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"attach\",\n      \"args\": [],\n      \"short_description\": \"Attach the focused window to the last focused scratchpad.\",\n      \"full_description\": \"Attach the focused window to the last focused scratchpad.\"\n    },\n    {\n      \"name\": \"hide\",\n      \"args\": [\n        {\n          \"value\": \"name\",\n          \"required\": true\n        }\n      ],\n      \"short_description\": \"hides scratchpad \\\"name\\\" (accepts \\\"*\\\").\",\n      \"full_description\": \"<name> hides scratchpad \\\"name\\\" (accepts \\\"*\\\").\\n\\nArgs:\\n    uid: The scratchpad name, or \\\"*\\\" to hide all visible scratchpads\\n    flavor: Internal hide behavior flags (default: NONE)\"\n    },\n    {\n      \"name\": \"show\",\n      \"args\": [\n        {\n          \"value\": \"name\",\n          \"required\": true\n        }\n      ],\n      \"short_description\": \"shows scratchpad \\\"name\\\" (accepts \\\"*\\\").\",\n      \"full_description\": \"<name> shows scratchpad \\\"name\\\" (accepts \\\"*\\\").\\n\\nArgs:\\n    uid: The scratchpad name, or \\\"*\\\" to show all hidden scratchpads\"\n    },\n    {\n      \"name\": \"toggle\",\n      \"args\": [\n        {\n          \"value\": \"name\",\n          \"required\": true\n        }\n      ],\n      \"short_description\": \"toggles visibility of scratchpad \\\"name\\\" (supports multiple names).\",\n      \"full_description\": \"<name> toggles visibility of scratchpad \\\"name\\\" (supports multiple names).\\n\\nArgs:\\n    uid_or_uids: Space-separated scratchpad name(s)\\n\\nExample:\\n    pypr toggle term\\n    pypr toggle term music\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"[scratchpad].command\",\n      \"type\": \"str\",\n      \"required\": true,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Command to run (omit for unmanaged scratchpads)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].class\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": true,\n      \"default\": \"\",\n      \"description\": \"Window class for matching\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].animation\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"fromTop\",\n      \"description\": \"Animation type\",\n      \"choices\": [\n        \"\",\n        \"fromTop\",\n        \"fromBottom\",\n        \"fromLeft\",\n        \"fromRight\"\n      ],\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].size\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": true,\n      \"default\": \"80% 80%\",\n      \"description\": \"Window size (e.g. '80% 80%')\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].position\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Explicit position override\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"positioning\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].margin\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 60,\n      \"description\": \"Pixels from screen edge\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"positioning\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].offset\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"100%\",\n      \"description\": \"Hide animation distance\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"positioning\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].max_size\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Maximum window size\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"positioning\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].lazy\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Start on first use\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].pinned\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Sticky to monitor\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].multi\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Allow multiple windows\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].unfocus\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Action on unfocus ('hide' or empty)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].hysteresis\",\n      \"type\": \"float\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 0.4,\n      \"description\": \"Delay before unfocus hide\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].excludes\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Scratches to hide when shown\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].restore_excluded\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Restore excluded on hide\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].preserve_aspect\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Keep size/position across shows\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].hide_delay\",\n      \"type\": \"float\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 0.0,\n      \"description\": \"Delay before hide animation\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].force_monitor\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Always show on specific monitor\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].alt_toggle\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Alternative toggle for multi-monitor\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].allow_special_workspaces\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Allow over special workspaces\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].smart_focus\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Restore focus on hide\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].close_on_hide\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Close instead of hide\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].match_by\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"pid\",\n      \"description\": \"Match method: pid, class, initialClass, title, initialTitle\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].initialClass\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Match value when match_by='initialClass'\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].initialTitle\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Match value when match_by='initialTitle'\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].title\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Match value when match_by='title'\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].process_tracking\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Enable process management\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].skip_windowrules\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Rules to skip: aspect, float, workspace\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].use\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Inherit from another scratchpad definition\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].monitor\",\n      \"type\": \"dict\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": {},\n      \"description\": \"Per-monitor config overrides\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"overrides\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.1.1/generated/shift_monitors.json",
    "content": "{\n  \"name\": \"shift_monitors\",\n  \"description\": \"Moves workspaces from monitor to monitor (carousel).\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"shift_monitors\",\n      \"args\": [\n        {\n          \"value\": \"direction\",\n          \"required\": true\n        }\n      ],\n      \"short_description\": \"Swaps monitors' workspaces in the given direction.\",\n      \"full_description\": \"<direction> Swaps monitors' workspaces in the given direction.\\n\\nArgs:\\n    arg: Integer direction (+1 or -1) to rotate workspaces across monitors\"\n    }\n  ],\n  \"config\": []\n}\n"
  },
  {
    "path": "site/versions/3.1.1/generated/shortcuts_menu.json",
    "content": "{\n  \"name\": \"shortcuts_menu\",\n  \"description\": \"A flexible way to make your own shortcuts menus & launchers.\",\n  \"environments\": [],\n  \"commands\": [\n    {\n      \"name\": \"menu\",\n      \"args\": [\n        {\n          \"value\": \"name\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Shows the menu, if \\\"name\\\" is provided, will only show this sub-menu.\",\n      \"full_description\": \"[name] Shows the menu, if \\\"name\\\" is provided, will only show this sub-menu.\\n\\nArgs:\\n    name: The menu name\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"entries\",\n      \"type\": \"dict\",\n      \"required\": true,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Menu entries structure (nested dict of commands)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"engine\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Menu engine to use\",\n      \"choices\": [\n        \"fuzzel\",\n        \"tofi\",\n        \"rofi\",\n        \"wofi\",\n        \"bemenu\",\n        \"dmenu\",\n        \"anyrun\",\n        \"walker\",\n        \"vicinae\"\n      ],\n      \"children\": null,\n      \"category\": \"menu\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"parameters\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Extra parameters for the menu engine command\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"menu\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"separator\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \" | \",\n      \"description\": \"Separator for menu display\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"appearance\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"command_start\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Prefix for command entries\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"appearance\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"command_end\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Suffix for command entries\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"appearance\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"submenu_start\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Prefix for submenu entries\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"appearance\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"submenu_end\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\\u279c\",\n      \"description\": \"Suffix for submenu entries\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"appearance\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"skip_single\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Auto-select when only one option available\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.1.1/generated/system_notifier.json",
    "content": "{\n  \"name\": \"system_notifier\",\n  \"description\": \"Opens streams (eg: journal logs) and triggers notifications.\",\n  \"environments\": [],\n  \"commands\": [],\n  \"config\": [\n    {\n      \"name\": \"command\",\n      \"type\": \"dict\",\n      \"required\": false,\n      \"recommended\": true,\n      \"default\": {},\n      \"description\": \"This is the long-running command (eg: `tail -f <filename>`) returning the stream of text that will be updated.\\n            A common option is the system journal output (eg: `journalctl -u nginx`)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"parser\",\n      \"type\": \"dict\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": {},\n      \"description\": \"Sets the list of rules / parser to be used to extract lines of interest\\n            Must match a list of rules defined as `system_notifier.parsers.<parser_name>`.\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"parsers\",\n      \"type\": \"dict\",\n      \"required\": false,\n      \"recommended\": true,\n      \"default\": {},\n      \"description\": \"Custom parser definitions (name -> list of rules).\\n            Each rule has: pattern (required), filter, color (defaults to default_color), duration (defaults to 3 seconds)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"parsers\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"sources\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": true,\n      \"default\": [],\n      \"description\": \"Source definitions with command and parser\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"pattern\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": true,\n      \"default\": \"\",\n      \"description\": \"The pattern is any regular expression that should trigger a match.\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"default_color\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"#5555AA\",\n      \"description\": \"Default notification color\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"appearance\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"use_notify_send\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Use notify-send instead of Hyprland notifications\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.1.1/generated/toggle_dpms.json",
    "content": "{\n  \"name\": \"toggle_dpms\",\n  \"description\": \"Toggles the DPMS status of every plugged monitor.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"toggle_dpms\",\n      \"args\": [],\n      \"short_description\": \"Toggle dpms on/off for every monitor.\",\n      \"full_description\": \"Toggle dpms on/off for every monitor.\"\n    }\n  ],\n  \"config\": []\n}\n"
  },
  {
    "path": "site/versions/3.1.1/generated/toggle_special.json",
    "content": "{\n  \"name\": \"toggle_special\",\n  \"description\": \"Toggle switching the focused window to a special workspace.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"toggle_special\",\n      \"args\": [\n        {\n          \"value\": \"name\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Toggles switching the focused window to the special workspace \\\"name\\\" (default: minimized).\",\n      \"full_description\": \"[name] Toggles switching the focused window to the special workspace \\\"name\\\" (default: minimized).\\n\\nArgs:\\n    special_workspace: The special workspace name\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"name\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"minimized\",\n      \"description\": \"Default special workspace name\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.1.1/generated/wallpapers.json",
    "content": "{\n  \"name\": \"wallpapers\",\n  \"description\": \"Handles random wallpapers at regular intervals, with support for rounded corners and color scheme generation.\",\n  \"environments\": [],\n  \"commands\": [\n    {\n      \"name\": \"color\",\n      \"args\": [\n        {\n          \"value\": \"#RRGGBB\",\n          \"required\": true\n        },\n        {\n          \"value\": \"scheme\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Generate color palette from hex color.\",\n      \"full_description\": \"<#RRGGBB> [scheme] Generate color palette from hex color.\\n\\nArgs:\\n    arg: Hex color and optional scheme name\\n\\nSchemes: pastel, fluo, vibrant, mellow, neutral, earth\\n\\nExample:\\n    pypr color #ff5500 vibrant\"\n    },\n    {\n      \"name\": \"palette\",\n      \"args\": [\n        {\n          \"value\": \"color\",\n          \"required\": false\n        },\n        {\n          \"value\": \"json\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Show available color template variables.\",\n      \"full_description\": \"[color] [json] Show available color template variables.\\n\\nArgs:\\n    arg: Optional hex color and/or \\\"json\\\" flag\\n        - color: Hex color (#RRGGBB) to use for palette\\n        - json: Output in JSON format instead of human-readable\\n\\nExample:\\n    pypr palette\\n    pypr palette #ff5500\\n    pypr palette json\"\n    },\n    {\n      \"name\": \"wall cleanup\",\n      \"args\": [\n        {\n          \"value\": \"all\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Clean up rounded images cache.\",\n      \"full_description\": \"[all] Clean up rounded images cache.\\n\\nWithout arguments: removes orphaned files (source no longer exists).\\nWith 'all': removes ALL rounded cache files.\\n\\nExample:\\n    pypr wall cleanup\\n    pypr wall cleanup all\"\n    },\n    {\n      \"name\": \"wall clear\",\n      \"args\": [],\n      \"short_description\": \"Stop cycling and clear the current wallpaper.\",\n      \"full_description\": \"Stop cycling and clear the current wallpaper.\"\n    },\n    {\n      \"name\": \"wall info\",\n      \"args\": [\n        {\n          \"value\": \"json\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Show current wallpaper information.\",\n      \"full_description\": \"[json] Show current wallpaper information.\\n\\nArgs:\\n    arg: Optional \\\"json\\\" flag for JSON output\\n\\nExample:\\n    pypr wall info\\n    pypr wall info json\"\n    },\n    {\n      \"name\": \"wall next\",\n      \"args\": [],\n      \"short_description\": \"Switch to the next wallpaper immediately.\",\n      \"full_description\": \"Switch to the next wallpaper immediately.\"\n    },\n    {\n      \"name\": \"wall pause\",\n      \"args\": [],\n      \"short_description\": \"Pause automatic wallpaper cycling.\",\n      \"full_description\": \"Pause automatic wallpaper cycling.\"\n    },\n    {\n      \"name\": \"wall rm\",\n      \"args\": [],\n      \"short_description\": \"Remove the current online wallpaper and show next.\",\n      \"full_description\": \"Remove the current online wallpaper and show next.\\n\\nOnly removes online wallpapers (files in the online folder).\\nShows an error notification for local wallpapers.\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"path\",\n      \"type\": \"Path or list\",\n      \"required\": true,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Path(s) to wallpaper images or directories\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"interval\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 10,\n      \"description\": \"Minutes between wallpaper changes\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"extensions\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [\n        \"png\",\n        \"jpeg\",\n        \"jpg\"\n      ],\n      \"description\": \"File extensions to include (e.g., ['png', 'jpg'])\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"recurse\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Recursively search subdirectories\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"unique\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Use different wallpaper per monitor\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"radius\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 0,\n      \"description\": \"Corner radius for rounded corners\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"appearance\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"command\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Custom command to set wallpaper ([file] and [output] variables)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"external_commands\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"post_command\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Command to run after setting wallpaper\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"external_commands\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"clear_command\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Command to run when clearing wallpaper\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"external_commands\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"color_scheme\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Color scheme for palette generation\",\n      \"choices\": [\n        \"\",\n        \"pastel\",\n        \"fluo\",\n        \"vibrant\",\n        \"mellow\",\n        \"neutral\",\n        \"earth\",\n        \"fluorescent\"\n      ],\n      \"children\": null,\n      \"category\": \"templating\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"variant\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Color variant type for palette\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"templating\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"templates\",\n      \"type\": \"dict\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Template files for color palette generation\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"templating\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"online_ratio\",\n      \"type\": \"float\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 0.0,\n      \"description\": \"Probability of fetching online (0.0-1.0)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"online\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"online_backends\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [\n        \"unsplash\",\n        \"picsum\",\n        \"wallhaven\",\n        \"reddit\"\n      ],\n      \"description\": \"Enabled online backends\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"online\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"online_keywords\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Keywords to filter online images\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"online\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"online_folder\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"online\",\n      \"description\": \"Subfolder for downloaded online images\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"online\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"cache_days\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 0,\n      \"description\": \"Days to keep cached images (0 = forever)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"cache\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"cache_max_mb\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 100,\n      \"description\": \"Maximum cache size in MB (0 = unlimited)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"cache\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"cache_max_images\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 0,\n      \"description\": \"Maximum number of cached images (0 = unlimited)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"cache\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.1.1/generated/workspaces_follow_focus.json",
    "content": "{\n  \"name\": \"workspaces_follow_focus\",\n  \"description\": \"Makes non-visible workspaces available on the currently focused screen.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"change_workspace\",\n      \"args\": [\n        {\n          \"value\": \"direction\",\n          \"required\": true\n        }\n      ],\n      \"short_description\": \"Switch workspaces of current monitor, avoiding displayed workspaces.\",\n      \"full_description\": \"<direction> Switch workspaces of current monitor, avoiding displayed workspaces.\\n\\nArgs:\\n    direction: Integer offset to move (e.g., +1 for next, -1 for previous)\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"max_workspaces\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 10,\n      \"description\": \"Maximum number of workspaces to manage\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.1.1/index.md",
    "content": "---\n# https://vitepress.dev/reference/default-theme-home-page\nlayout: home\n\nhero:\n  text: \"Extensions for your desktop environment\"\n  tagline: Enhance your desktop experience with Pyprland\n  image:\n    src: /logo.svg\n    alt: logo\n  actions:\n    - theme: brand\n      text: Getting started\n      link: ./Getting-started\n    - theme: alt\n      text: Plugins\n      link: ./Plugins\n    - theme: alt\n      text: Report something\n      link: https://github.com/hyprland-community/pyprland/issues/new/choose\n\nfeatures:\n  - title: TOML format\n    details: Simple and flexible configuration file(s)\n  - title: Customizable\n    details: Create your own Hyprland experience\n  - title: Fast and easy\n    details: Designed for performance and simplicity\n---\n\n# What is Pyprland?\n\nIt's a software that extends the functionality of your desktop environment (Hyprland, Niri, etc...), adding new features and improving the existing ones.\n\nIt also enables a high degree of customization and automation, making it easier to adapt to your workflow.\n\nTo understand the potential of Pyprland, you can check the [plugins](./Plugins) page.\n\n# Major recent changes\n\n- Major rewrite of the [Monitors plugin](/monitors) delivers improved stability and functionality.\n- The [Wallpapers plugin](/wallpapers) now applies [rounded corners](/wallpapers#radius) per display and derives cohesive [color schemes from the background](/wallpapers#templates) (Matugen/Pywal-inspired).\n\n## Version 3.1.1 archive\n"
  },
  {
    "path": "site/versions/3.1.1/layout_center.md",
    "content": "---\n---\n# layout_center\n\nImplements a workspace layout where one window is bigger and centered,\nother windows are tiled as usual in the background.\n\nOn `toggle`, the active window is made floating and centered if the layout wasn't enabled, else reverts the floating status.\n\nWith `next` and `prev` you can cycle the active window, keeping the same layout type.\nIf the layout_center isn't active and `next` or `prev` is used, it will call the [next](#config-next) and [prev](#config-next) configuration options.\n\nTo allow full override of the focus keys, `next2` and `prev2` are provided, they do the same actions as `next` and `prev` but allow different fallback commands.\n\n<details>\n<summary>Configuration sample</summary>\n\n```toml\n[layout_center]\nmargin = 60\noffset = [0, 30]\nnext = \"movefocus r\"\nprev = \"movefocus l\"\nnext2 = \"movefocus d\"\nprev2 = \"movefocus u\"\n```\n\nusing the following in `hyprland.conf`:\n```sh\nbind = $mainMod, M, exec, pypr layout_center toggle # toggle the layout\n## focus change keys\nbind = $mainMod, left, exec, pypr layout_center prev\nbind = $mainMod, right, exec, pypr layout_center next\nbind = $mainMod, up, exec, pypr layout_center prev2\nbind = $mainMod, down, exec, pypr layout_center next2\n```\n\nYou can completely ignore `next2` and `prev2` if you are allowing focus change in a single direction (when the layout is enabled), eg:\n\n```sh\nbind = $mainMod, up, movefocus, u\nbind = $mainMod, down, movefocus, d\n```\n\n</details>\n\n\n## Commands\n\n<PluginCommands plugin=\"layout_center\"  version=\"3.1.1\" />\n\n## Configuration\n\n<PluginConfig plugin=\"layout_center\" linkPrefix=\"config-\"  version=\"3.1.1\" />\n\n### `style` <ConfigBadges plugin=\"layout_center\" option=\"style\"  version=\"3.1.1\" /> {#config-style}\n\nCustom Hyprland style rules applied to the centered window. Requires Hyprland > 0.40.0.\n\n```toml\nstyle = [\"opacity 1\", \"bordercolor rgb(FFFF00)\"]\n```\n\n### `on_new_client` <ConfigBadges plugin=\"layout_center\" option=\"on_new_client\"  version=\"3.1.1\" /> {#config-on-new-client}\n\nBehavior when a new window opens while layout is active:\n\n- `\"focus\"` (or `\"foreground\"`) - make the new window the main window\n- `\"background\"` - make the new window appear in the background  \n- `\"close\"` - stop the centered layout when a new window opens\n\n### `next` / `prev` <ConfigBadges plugin=\"layout_center\" option=\"next\"  version=\"3.1.1\" /> {#config-next}\n\nHyprland dispatcher command to run when layout_center isn't active:\n\n```toml\nnext = \"movefocus r\"\nprev = \"movefocus l\"\n```\n\n### `next2` / `prev2` <ConfigBadges plugin=\"layout_center\" option=\"next2\"  version=\"3.1.1\" /> {#config-next2}\n\nAlternative fallback commands for vertical navigation:\n\n```toml\nnext2 = \"movefocus d\"\nprev2 = \"movefocus u\"\n```\n\n### `offset` <ConfigBadges plugin=\"layout_center\" option=\"offset\"  version=\"3.1.1\" /> {#config-offset}\n\noffset in pixels applied to the main window position\n\nExample shift the main window 20px down:\n```toml\noffset = [0, 20]\n```\n\n\n### `margin` <ConfigBadges plugin=\"layout_center\" option=\"margin\"  version=\"3.1.1\" /> {#config-margin}\n\nmargin (in pixels) used when placing the center window, calculated from the border of the screen.\n\nExample to make the main window be 100px far from the monitor's limits:\n```toml\nmargin = 100\n```\nYou can also set a different margin for width and height by using a list:\n\n```toml\nmargin = [100, 100]\n```\n"
  },
  {
    "path": "site/versions/3.1.1/lost_windows.md",
    "content": "---\n---\n# lost_windows\n\nBring windows which are not reachable in the currently focused workspace.\n*Deprecated:* Was used to work around issues in Hyprland which have been fixed since then.\n\n## Commands\n\n<PluginCommands plugin=\"lost_windows\"  version=\"3.1.1\" />\n\n## Configuration\n\nThis plugin has no configuration options.\n"
  },
  {
    "path": "site/versions/3.1.1/magnify.md",
    "content": "---\n---\n# magnify\n\nZooms in and out with an optional animation.\n\n\n<details>\n    <summary>Example</summary>\n\n```sh\npypr zoom  # sets zoom to `factor`\npypr zoom +1  # will set zoom to 3x\npypr zoom  # will set zoom to 1x\npypr zoom 1 # will (also) set zoom to 1x - effectively doing nothing\n```\n\nSample `hyprland.conf`:\n\n```sh\nbind = $mainMod , Z, exec, pypr zoom ++0.5\nbind = $mainMod SHIFT, Z, exec, pypr zoom\n```\n\n</details>\n\n## Commands\n\n<PluginCommands plugin=\"magnify\"  version=\"3.1.1\" />\n\n### `zoom [factor]`\n\n#### unset / not specified\n\nWill zoom to [factor](#config-factor) if not zoomed, else will set the zoom to 1x.\n\n#### floating or integer value\n\nWill set the zoom to the provided value.\n\n#### +value / -value\n\nUpdate (increment or decrement) the current zoom level by the provided value.\n\n#### ++value / --value\n\nUpdate (increment or decrement) the current zoom level by a non-linear scale.\nIt _looks_ more linear changes than using a single + or -.\n\n> [!NOTE]\n>\n> The non-linear scale is calculated as powers of two, eg:\n>\n> - `zoom ++1` → 2x, 4x, 8x, 16x...\n> - `zoom ++0.7` → 1.6x, 2.6x, 4.3x, 7.0x, 11.3x, 18.4x...\n> - `zoom ++0.5` → 1.4x, 2x, 2.8x, 4x, 5.7x, 8x, 11.3x, 16x...\n\n## Configuration\n\n<PluginConfig plugin=\"magnify\" linkPrefix=\"config-\"  version=\"3.1.1\" />\n\n### `factor` <ConfigBadges plugin=\"magnify\" option=\"factor\"  version=\"3.1.1\" /> {#config-factor}\n\nThe zoom level to use when `pypr zoom` is called without arguments.\n\n### `duration` <ConfigBadges plugin=\"magnify\" option=\"duration\"  version=\"3.1.1\" /> {#config-duration}\n\nAnimation duration in seconds. Not needed with recent Hyprland versions - you can customize the animation in Hyprland config instead:\n\n```C\nanimations {\n    bezier = easeInOut,0.65, 0, 0.35, 1\n    animation = zoomFactor, 1, 4, easeInOut\n}\n```\n"
  },
  {
    "path": "site/versions/3.1.1/menubar.md",
    "content": "---\n---\n\n# menubar\n\nRuns your favorite bar app (gbar, ags / hyprpanel, waybar, ...) with option to pass the \"best\" monitor from a list of monitors.\n\n- Will take care of starting the command on startup (you must not run it from another source like `hyprland.conf`).\n- Automatically restarts the menu bar on crash\n- Checks which monitors are on and take the best one from a provided list\n\n<details>\n<summary>Example</summary>\n\n```toml\n[menubar]\ncommand = \"gBar bar [monitor]\"\nmonitors = [\"DP-1\", \"HDMI-1\", \"HDMI-1-A\"]\n```\n\n</details>\n\n> [!tip]\n> This plugin supports both Hyprland and Niri. It will automatically detect the environment and use the appropriate IPC commands.\n\n## Commands\n\n<PluginCommands plugin=\"menubar\"  version=\"3.1.1\" />\n\n## Configuration\n\n<PluginConfig plugin=\"menubar\" linkPrefix=\"config-\"  version=\"3.1.1\" />\n\n### `command` <ConfigBadges plugin=\"menubar\" option=\"command\"  version=\"3.1.1\" /> {#config-command}\n\nThe command to run the bar. Use `[monitor]` as a placeholder for the monitor name:\n\n```toml\ncommand = \"waybar -o [monitor]\"\n```\n"
  },
  {
    "path": "site/versions/3.1.1/monitors.md",
    "content": "---\n---\n\n# monitors\n\nAllows relative placement of monitors depending on the model (\"description\" returned by `hyprctl monitors`).\nUseful if you have multiple monitors connected to a video signal switch or using a laptop and plugging monitors having different relative positions.\n\n> [!Tip]\n> This plugin also supports Niri. It will automatically detect the environment and use `nirictl` to apply the layout.\n> Note that \"hotplug_commands\" and \"unknown\" commands may need adjustment for Niri (e.g. using `sh -c '...'` or Niri specific tools).\n\nSyntax:\n\n\n```toml\n[monitors.placement]\n\"description match\".placement = \"other description match\"\n```\n\n<details>\n    <summary>Example to set a Sony monitor on top of a BenQ monitor</summary>\n\n```toml\n[monitors.placement]\nSony.topOf = \"BenQ\"\n\n## Character case is ignored, \"_\" can be added\nSony.Top_Of = [\"BenQ\"]\n\n## Thanks to TOML format, complex configurations can use separate \"sections\" for clarity, eg:\n\n[monitors.placement.\"My monitor brand\"]\n## You can also use \"port\" names such as *HDMI-A-1*, *DP-1*, etc...\nleftOf = \"eDP-1\"\n\n## lists are possible on the right part of the assignment:\nrightOf = [\"Sony\", \"BenQ\"]\n\n# When multiple targets are specified, only the first connected monitor\n# matching a pattern is used as the reference.\n\n## > 2.3.2: you can also set scale, transform & rate for a given monitor\n[monitors.placement.Microstep]\nrate = 100\n```\n\nTry to keep the rules as simple as possible, but relatively complex scenarios are supported.\n\n> [!note]\n> Check [wlr layout UI](https://github.com/fdev31/wlr-layout-ui) which is a nice complement to configure your monitor settings.\n\n</details>\n\n## Commands\n\n<PluginCommands plugin=\"monitors\"  version=\"3.1.1\" />\n\n## Configuration\n\n<PluginConfig plugin=\"monitors\" linkPrefix=\"config-\"  version=\"3.1.1\" />\n\n### `placement` <ConfigBadges plugin=\"monitors\" option=\"placement\"  version=\"3.1.1\" /> {#config-placement}\n\nConfigure monitor settings and relative positioning. Each monitor is identified by a [pattern](#monitor-patterns) (port name or description substring) and can have both display settings and positioning rules.\n\n```toml\n[monitors.placement.\"My monitor\"]\n# Display settings\nscale = 1.25\ntransform = 1\nrate = 144\nresolution = \"2560x1440\"\n\n# Positioning\nleftOf = \"eDP-1\"\n```\n\n#### Monitor Settings\n\nThese settings control the display properties of a monitor.\n\n##### `scale` {#placement-scale}\n\nControls UI element size. Higher values make the UI larger (zoomed in), showing less content.\n\n| Scale Value | Content Visible |\n|---------------|-----------------|\n|`0.666667` | More (zoomed out) |\n|`0.833333` | More |\n| `1.0` | Native |\n| `1.25` | Less |\n| `1.6` | Less |\n| `2.0` | 25% (zoomed in) |\n\n> [!tip]\n> For HiDPI displays, use values like `1.5` or `2.0` to make UI elements larger and more readable at the cost of screen real estate.\n\n##### `transform` {#placement-transform}\n\nRotates and optionally flips the monitor.\n\n| Value | Rotation | Description |\n|-------|----------|-------------|\n| 0 | Normal | No rotation (landscape) |\n| 1 | 90° | Portrait (rotated right) |\n| 2 | 180° | Upside down |\n| 3 | 270° | Portrait (rotated left) |\n| 4 | Flipped | Mirrored horizontally |\n| 5 | Flipped 90° | Mirrored + 90° |\n| 6 | Flipped 180° | Mirrored + 180° |\n| 7 | Flipped 270° | Mirrored + 270° |\n\n##### `rate` {#placement-rate}\n\nRefresh rate in Hz.\n\n```toml\nrate = 144\n```\n\n> [!tip]\n> Run `hyprctl monitors` to see available refresh rates for each monitor.\n\n##### `resolution` {#placement-resolution}\n\nDisplay resolution. Can be specified as a string or array.\n\n```toml\nresolution = \"2560x1440\"\n# or\nresolution = [2560, 1440]\n```\n\n> [!tip]\n> Run `hyprctl monitors` to see available resolutions for each monitor.\n\n##### `disables` {#placement-disables}\n\nList of monitors to disable when this monitor is connected. This is useful for automatically turning off a laptop's built-in display when an external monitor is plugged in.\n\n```toml\n[monitors.placement.\"External Monitor\"]\ndisables = [\"eDP-1\"]  # Disable laptop screen when this monitor is connected\n```\n\nYou can disable multiple monitors and combine with positioning rules:\n\n```toml\n[monitors.placement.\"DELL U2722D\"]\nleftOf = \"DP-2\"\ndisables = [\"eDP-1\", \"HDMI-A-2\"]\n```\n\n> [!note]\n> Monitors specified in `disables` are excluded from layout calculations. They will be re-enabled on the next relayout if the disabling monitor is disconnected.\n\n#### Positioning Rules\n\nPosition monitors relative to each other using directional keywords.\n\n**Directions:**\n\n- `leftOf` / `rightOf` — horizontal placement\n- `topOf` / `bottomOf` — vertical placement\n\n**Alignment modifiers** (for different-sized monitors):\n\n- `start` (default) — align at top/left edge\n- `center` / `middle` — center alignment\n- `end` — align at bottom/right edge\n\nCombine direction + alignment: `topCenterOf`, `leftEndOf`, `right_middle_of`, etc.\n\nEverything is case insensitive; use `_` for readability (e.g., `top_center_of`).\n\n> [!important]\n> At least one monitor must have **no placement rule** to serve as the anchor/reference point.\n> Other monitors are positioned relative to this anchor.\n\nSee [Placement Examples](#placement-examples) for visual diagrams.\n\n#### Monitor Patterns {#monitor-patterns}\n\nBoth the monitor being configured and the target monitor can be specified using:\n\n1. **Port name** (exact match) — e.g., `eDP-1`, `HDMI-A-1`, `DP-1`\n2. **Description substring** (partial match) — e.g., `Hisense`, `BenQ`, `DELL P2417H`\n\nThe plugin first checks for an exact port name match, then searches monitor descriptions for a substring match. Descriptions typically contain the manufacturer, model, and serial number.\n\n```toml\n# Target by port name\n[monitors.placement.Sony]\ntopOf = \"eDP-1\"\n\n# Target by brand/model name\n[monitors.placement.Hisense]\ntop_middle_of = \"BenQ\"\n\n# Mix both approaches\n[monitors.placement.\"DELL P2417H\"]\nright_end_of = \"HDMI-A-1\"\n```\n\n> [!tip]\n> Run `hyprctl monitors` (or `nirictl outputs` for Niri) to see the full description of each connected monitor.\n\n### `startup_relayout` <ConfigBadges plugin=\"monitors\" option=\"startup_relayout\"  version=\"3.1.1\" /> {#config-startup-relayout}\n\nWhen set to `false`, do not initialize the monitor layout on startup or when configuration is reloaded.\n\n### `relayout_on_config_change` <ConfigBadges plugin=\"monitors\" option=\"relayout_on_config_change\"  version=\"3.1.1\" /> {#config-relayout-on-config-change}\n\nWhen set to `false`, do not relayout when Hyprland config is reloaded.\n\n### `new_monitor_delay` <ConfigBadges plugin=\"monitors\" option=\"new_monitor_delay\"  version=\"3.1.1\" /> {#config-new-monitor-delay}\n\nThe layout computation happens after this delay when a new monitor is detected, to let time for things to settle.\n\n### `hotplug_command` <ConfigBadges plugin=\"monitors\" option=\"hotplug_command\"  version=\"3.1.1\" /> {#config-hotplug-command}\n\nAllows to run a command when any monitor is plugged.\n\n```toml\n[monitors]\nhotplug_command = \"wlrlui -m\"\n```\n\n### `hotplug_commands` <ConfigBadges plugin=\"monitors\" option=\"hotplug_commands\"  version=\"3.1.1\" /> {#config-hotplug-commands}\n\nAllows to run a command when a specific monitor is plugged.\n\nExample to load a specific profile using [wlr layout ui](https://github.com/fdev31/wlr-layout-ui):\n\n```toml\n[monitors.hotplug_commands]\n\"DELL P2417H CJFH277Q3HCB\" = \"wlrlui rotated\"\n```\n\n### `unknown` <ConfigBadges plugin=\"monitors\" option=\"unknown\"  version=\"3.1.1\" /> {#config-unknown}\n\nAllows to run a command when no monitor layout has been changed (no rule applied).\n\n```toml\n[monitors]\nunknown = \"wlrlui\"\n```\n\n## Placement Examples {#placement-examples}\n\nThis section provides visual diagrams to help understand monitor placement rules.\n\n### Basic Positions\n\nThe four basic placement directions position a monitor relative to another:\n\n#### `topOf` - Monitor above another\n\n<img src=\"/images/monitors/basic-top-of.svg\" alt=\"Monitor A placed on top of Monitor B\" style=\"max-width: 36%\" />\n\n```toml\n[monitors.placement.A]\ntopOf = \"B\"\n```\n\n#### `bottomOf` - Monitor below another\n\n<img src=\"/images/monitors/basic-bottom-of.svg\" alt=\"Monitor A placed below Monitor B\" style=\"max-width: 36%\" />\n\n```toml\n[monitors.placement.A]\nbottomOf = \"B\"\n```\n\n#### `leftOf` - Monitor to the left\n\n<img src=\"/images/monitors/basic-left-of.svg\" alt=\"Monitor A placed to the left of Monitor B\" style=\"max-width: 68%\" />\n\n```toml\n[monitors.placement.A]\nleftOf = \"B\"\n```\n\n#### `rightOf` - Monitor to the right\n\n<img src=\"/images/monitors/basic-right-of.svg\" alt=\"Monitor A placed to the right of Monitor B\" style=\"max-width: 68%\" />\n\n```toml\n[monitors.placement.A]\nrightOf = \"B\"\n```\n\n### Alignment Modifiers\n\nWhen monitors have different sizes, alignment modifiers control where the smaller monitor aligns along the edge.\n\n#### Horizontal placement (`leftOf` / `rightOf`)\n\n**Start (default)** - Top edges align:\n\n<img src=\"/images/monitors/align-left-start.svg\" alt=\"Monitor A to the left of B, top edges aligned\" style=\"max-width: 57%\" />\n\n```toml\n[monitors.placement.A]\nleftOf = \"B\"  # same as leftStartOf\n```\n\n**Center / Middle** - Vertically centered:\n\n<img src=\"/images/monitors/align-left-center.svg\" alt=\"Monitor A to the left of B, vertically centered\" style=\"max-width: 57%\" />\n\n```toml\n[monitors.placement.A]\nleftCenterOf = \"B\"  # or leftMiddleOf\n```\n\n**End** - Bottom edges align:\n\n<img src=\"/images/monitors/align-left-end.svg\" alt=\"Monitor A to the left of B, bottom edges aligned\" style=\"max-width: 57%\" />\n\n```toml\n[monitors.placement.A]\nleftEndOf = \"B\"\n```\n\n#### Vertical placement (`topOf` / `bottomOf`)\n\n**Start (default)** - Left edges align:\n\n<img src=\"/images/monitors/align-top-start.svg\" alt=\"Monitor A on top of B, left edges aligned\" style=\"max-width: 36%\" />\n\n```toml\n[monitors.placement.A]\ntopOf = \"B\"  # same as topStartOf\n```\n\n**Center / Middle** - Horizontally centered:\n\n<img src=\"/images/monitors/align-top-center.svg\" alt=\"Monitor A on top of B, horizontally centered\" style=\"max-width: 36%\" />\n\n```toml\n[monitors.placement.A]\ntopCenterOf = \"B\"  # or topMiddleOf\n```\n\n**End** - Right edges align:\n\n<img src=\"/images/monitors/align-top-end.svg\" alt=\"Monitor A on top of B, right edges aligned\" style=\"max-width: 36%\" />\n\n```toml\n[monitors.placement.A]\ntopEndOf = \"B\"\n```\n\n### Common Setups\n\n#### Dual side-by-side\n\n<img src=\"/images/monitors/setup-dual.svg\" alt=\"Dual monitor setup: A and B side by side\" style=\"max-width: 68%\" />\n\n```toml\n[monitors.placement.A]\nleftOf = \"B\"\n```\n\n#### Triple horizontal\n\n<img src=\"/images/monitors/setup-triple.svg\" alt=\"Triple monitor setup: A, B, C in a row\" style=\"max-width: 100%\" />\n\n```toml\n[monitors.placement.A]\nleftOf = \"B\"\n\n[monitors.placement.C]\nrightOf = \"B\"\n```\n\n#### Stacked (vertical)\n\n<img src=\"/images/monitors/setup-stacked.svg\" alt=\"Stacked monitor setup: A on top, B in middle, C at bottom\" style=\"max-width: 36%\" />\n\n```toml\n[monitors.placement.A]\ntopOf = \"B\"\n\n[monitors.placement.C]\nbottomOf = \"B\"\n```\n\n### Real-World Example: L-Shape with Portrait Monitor\n\nThis example shows a complex 3-monitor setup combining portrait mode, corner alignment, and different-sized displays.\n\n**Layout:**\n\n<img src=\"/images/monitors/real-world-l-shape.svg\" alt=\"L-shape monitor setup with portrait monitor A, anchor B, and landscape C\" style=\"max-width: 57%\" />\n\nWhere:\n\n- **A** (HDMI-A-1) = Portrait monitor (transform=1), directly on top of B (blue)\n- **B** (eDP-1) = Main anchor monitor, landscape (green)\n- **C** = Landscape monitor, positioned at the bottom-right corner of A (orange)\n\n**Configuration:**\n\n```toml\n[monitors.placement.CJFH277Q3HCB]\ntop_of = \"eDP-1\"\ntransform = 1\nscale = 0.83\n\n[monitors.placement.CJFH27888CUB]\nright_end_of = \"HDMI-A-1\"\n```\n\n**Explanation:**\n\n1. **B (eDP-1)** has no placement rule, making it the anchor/reference point\n2. **A (CJFH277Q3HCB)** is placed on top of B with `top_of = \"eDP-1\"`, rotated to portrait with `transform = 1`, and scaled to 83%\n3. **C (CJFH27888CUB)** uses `right_end_of = \"HDMI-A-1\"` to position itself to the right of A with bottom edges aligned, creating the L-shape\n\nThe `right_end_of` placement is key here: it aligns C's bottom edge with A's bottom edge, tucking C into the corner rather than aligning at the top (which `rightOf` would do).\n"
  },
  {
    "path": "site/versions/3.1.1/scratchpads.md",
    "content": "---\n---\n# scratchpads\n\nEasily toggle the visibility of applications you use the most.\n\nConfigurable and flexible, while supporting complex setups it's easy to get started with:\n\n```toml\n[scratchpads.name]\ncommand = \"command to run\"\nclass = \"the window's class\"  # check: hyprctl clients | grep class\nsize = \"[width] [height]\"  # size of the window relative to the screen size\n```\n\n<details>\n<summary>Example</summary>\n\nAs an example, defining two scratchpads:\n\n- _term_ which would be a kitty terminal on upper part of the screen\n- _volume_ which would be a pavucontrol window on the right part of the screen\n\n\n```toml\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\nmax_size = \"1920px 100%\"\nmargin = 50\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nsize = \"40% 90%\"\nunfocus = \"hide\"\nlazy = true\n```\n\nShortcuts are generally needed:\n\n```ini\nbind = $mainMod,V,exec,pypr toggle volume\nbind = $mainMod,A,exec,pypr toggle term\nbind = $mainMod,Y,exec,pypr attach\n```\n</details>\n\n- If you wish to have a more generic space for any application you may run, check [toggle_special](./toggle_special).\n- When you create a scratchpad called \"name\", it will be hidden in `special:scratch_<name>`.\n- Providing `class` allows a glitch free experience, mostly noticeable when using animations\n\n\n## Commands\n\n<PluginCommands plugin=\"scratchpads\"  version=\"3.1.1\" />\n\n> [!tip]\n> You can use `\"*\"` as a _scratchpad name_ to target every scratchpad when using `show` or `hide`.\n> You'll need to quote or escape the `*` character to avoid interpretation from your shell.\n\n## Configuration\n\n<PluginConfig plugin=\"scratchpads\" linkPrefix=\"config-\" :filter=\"['command', 'class', 'animation', 'size', 'position', 'margin', 'max_size', 'multi', 'lazy']\"  version=\"3.1.1\" />\n\n> [!tip]\n> Looking for more options? See:\n> - [Advanced Configuration](./scratchpads_advanced) - unfocus, excludes, monitor overrides, and more\n> - [Troubleshooting](./scratchpads_nonstandard) - PWAs, emacsclient, custom window matching\n\n### `command` <ConfigBadges plugin=\"scratchpads\" option=\"command\"  version=\"3.1.1\" /> {#config-command}\n\nThis is the command you wish to run in the scratchpad. It supports [variables](./Variables).\n\n### `class` <ConfigBadges plugin=\"scratchpads\" option=\"class\"  version=\"3.1.1\" /> {#config-class}\n\nAllows _Pyprland_ prepare the window for a correct animation and initial positioning.\nCheck your window's class with: `hyprctl clients | grep class`\n\n### `animation` <ConfigBadges plugin=\"scratchpads\" option=\"animation\"  version=\"3.1.1\" /> {#config-animation}\n\nType of animation to use:\n\n- `null` / `\"\"` (no animation)\n- `fromTop` (stays close to upper screen border)\n- `fromBottom` (stays close to lower screen border)\n- `fromLeft` (stays close to left screen border)\n- `fromRight` (stays close to right screen border)\n\n### `size` <ConfigBadges plugin=\"scratchpads\" option=\"size\"  version=\"3.1.1\" /> {#config-size}\n\nEach time scratchpad is shown, window will be resized according to the provided values.\n\nFor example on monitor of size `800x600` and `size= \"80% 80%\"` in config scratchpad always have size `640x480`,\nregardless of which monitor it was first launched on.\n\n> #### Format\n>\n> String with \"x y\" (or \"width height\") values using some units suffix:\n>\n> - **percents** relative to the focused screen size (`%` suffix), eg: `60% 30%`\n> - **pixels** for absolute values (`px` suffix), eg: `800px 600px`\n> - a mix is possible, eg: `800px 40%`\n\n### `position` <ConfigBadges plugin=\"scratchpads\" option=\"position\"  version=\"3.1.1\" /> {#config-position}\n\nOverrides the automatic margin-based position.\nSets the scratchpad client window position relative to the top-left corner.\n\nSame format as `size` (see above)\n\nExample of scratchpad that always sits on the top-right corner of the screen:\n\n```toml\n[scratchpads.term_quake]\ncommand = \"wezterm start --class term_quake\"\nposition = \"50% 0%\"\nsize = \"50% 50%\"\nclass = \"term_quake\"\n```\n\n> [!note]\n> If `position` is not provided, the window is placed according to `margin` on one axis and centered on the other.\n\n### `margin` <ConfigBadges plugin=\"scratchpads\" option=\"margin\"  version=\"3.1.1\" /> {#config-margin}\n\nPixels from the screen edge when using animations. Used to position the window along the animation axis.\n\n### `max_size` <ConfigBadges plugin=\"scratchpads\" option=\"max_size\"  version=\"3.1.1\" /> {#config-max-size}\n\nMaximum window size. Same format as `size`. Useful to prevent scratchpads from growing too large on big monitors.\n\n### `multi` <ConfigBadges plugin=\"scratchpads\" option=\"multi\"  version=\"3.1.1\" /> {#config-multi}\n\nWhen set to `false`, only one client window is supported for this scratchpad.\nOtherwise other matching windows will be **attach**ed to the scratchpad.\nAllows the `attach` command on the scratchpad.\n\n### `lazy` <ConfigBadges plugin=\"scratchpads\" option=\"lazy\"  version=\"3.1.1\" /> {#config-lazy}\n\nWhen `true`, the scratchpad command is only started on first use instead of at startup.\n\n## Advanced configuration\n\nTo go beyond the basic setup and have a look at every configuration item, you can read the following pages:\n\n- [Advanced](./scratchpads_advanced) contains options for fine-tuners or specific tastes (eg: i3 compatibility)\n- [Non-Standard](./scratchpads_nonstandard) contains options for \"broken\" applications\nlike progressive web apps (PWA) or emacsclient, use only if you can't get it to work otherwise\n"
  },
  {
    "path": "site/versions/3.1.1/scratchpads_advanced.md",
    "content": "---\n---\n# Fine tuning scratchpads\n\n> [!note]\n> For basic setup, see [Scratchpads](./scratchpads).\n\nAdvanced configuration options\n\n<PluginConfig plugin=\"scratchpads\" linkPrefix=\"config-\" :filter=\"['use', 'pinned', 'excludes', 'restore_excluded', 'unfocus', 'hysteresis', 'preserve_aspect', 'offset', 'hide_delay', 'force_monitor', 'alt_toggle', 'allow_special_workspaces', 'smart_focus', 'close_on_hide', 'monitor']\"  version=\"3.1.1\" />\n\n### `use` <ConfigBadges plugin=\"scratchpads\" option=\"use\"  version=\"3.1.1\" /> {#config-use}\n\nList of scratchpads (or single string) that will be used for the default values of this scratchpad.\nThink about *templates*:\n\n```toml\n[scratchpads.terminals]\nanimation = \"fromTop\"\nmargin = 50\nsize = \"75% 60%\"\nmax_size = \"1920px 100%\"\n\n[scratchpads.term]\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nuse = \"terminals\"\n```\n\n### `pinned` <ConfigBadges plugin=\"scratchpads\" option=\"pinned\"  version=\"3.1.1\" /> {#config-pinned}\n\nMakes the scratchpad \"sticky\" to the monitor, following any workspace change.\n\n### `excludes` <ConfigBadges plugin=\"scratchpads\" option=\"excludes\"  version=\"3.1.1\" /> {#config-excludes}\n\nList of scratchpads to hide when this one is displayed, eg: `excludes = [\"term\", \"volume\"]`.\nIf you want to hide every displayed scratch you can set this to the string `\"*\"` instead of a list: `excludes = \"*\"`.\n\n### `restore_excluded` <ConfigBadges plugin=\"scratchpads\" option=\"restore_excluded\"  version=\"3.1.1\" /> {#config-restore-excluded}\n\nWhen enabled, will remember the scratchpads which have been closed due to `excludes` rules, so when the scratchpad is hidden, those previously hidden scratchpads will be shown again.\n\n### `unfocus` <ConfigBadges plugin=\"scratchpads\" option=\"unfocus\"  version=\"3.1.1\" /> {#config-unfocus}\n\nWhen set to `\"hide\"`, allow to hide the window when the focus is lost.\n\nUse `hysteresis` to change the reactivity\n\n### `hysteresis` <ConfigBadges plugin=\"scratchpads\" option=\"hysteresis\"  version=\"3.1.1\" /> {#config-hysteresis}\n\nControls how fast a scratchpad hiding on unfocus will react. Check `unfocus` option.\nSet to `0` to disable.\n\n> [!important]\n> Only relevant when `unfocus=\"hide\"` is used.\n\n### `preserve_aspect` <ConfigBadges plugin=\"scratchpads\" option=\"preserve_aspect\"  version=\"3.1.1\" /> {#config-preserve-aspect}\n\nWhen set to `true`, will preserve the size and position of the scratchpad when called repeatedly from the same monitor and workspace even though an `animation` , `position` or `size` is used (those will be used for the initial setting only).\n\nForces the `lazy` option.\n\n### `offset` <ConfigBadges plugin=\"scratchpads\" option=\"offset\"  version=\"3.1.1\" /> {#config-offset}\n\nNumber of pixels for the **hide** sliding animation (how far the window will go).\n\n> [!tip]\n> - It is also possible to set a string to express percentages of the client window\n> - `margin` is automatically added to the offset\n\n### `hide_delay` <ConfigBadges plugin=\"scratchpads\" option=\"hide_delay\"  version=\"3.1.1\" /> {#config-hide-delay}\n\nDelay (in seconds) after which the hide animation happens, before hiding the scratchpad.\n\nRule of thumb, if you have an animation with speed \"7\", as in:\n```bash\n    animation = windowsOut, 1, 7, easeInOut, popin 80%\n```\nYou can divide the value by two and round to the lowest value, here `3`, then divide by 10, leading to `hide_delay = 0.3`.\n\n### `force_monitor` <ConfigBadges plugin=\"scratchpads\" option=\"force_monitor\"  version=\"3.1.1\" /> {#config-force-monitor}\n\nIf set to some monitor name (eg: `\"DP-1\"`), it will always use this monitor to show the scratchpad.\n\n### `alt_toggle` <ConfigBadges plugin=\"scratchpads\" option=\"alt_toggle\"  version=\"3.1.1\" /> {#config-alt-toggle}\n\nWhen enabled, use an alternative `toggle` command logic for multi-screen setups.\nIt applies when the `toggle` command is triggered and the toggled scratchpad is visible on a screen which is not the focused one.\n\nInstead of moving the scratchpad to the focused screen, it will hide the scratchpad.\n\n### `allow_special_workspaces` <ConfigBadges plugin=\"scratchpads\" option=\"allow_special_workspaces\"  version=\"3.1.1\" /> {#config-allow-special-workspaces}\n\nWhen enabled, you can toggle a scratchpad over a special workspace.\nIt will always use the \"normal\" workspace otherwise.\n\n> [!note]\n> Can't be disabled when using *Hyprland* < 0.39 where this behavior can't be controlled.\n\n### `smart_focus` <ConfigBadges plugin=\"scratchpads\" option=\"smart_focus\"  version=\"3.1.1\" /> {#config-smart-focus}\n\nWhen enabled, the focus will be restored in a best effort way as an attempt to improve the user experience.\nIf you face issues such as spontaneous workspace changes, you can disable this feature.\n\n\n### `close_on_hide` <ConfigBadges plugin=\"scratchpads\" option=\"close_on_hide\"  version=\"3.1.1\" /> {#config-close-on-hide}\n\nWhen enabled, the window in the scratchpad is closed instead of hidden when `pypr hide <name>` is run.\nThis option implies `lazy = true`.\nThis can be useful on laptops where background apps may increase battery power draw.\n\nNote: Currently this option changes the hide animation to use hyprland's close window animation.\n\n### `monitor` <ConfigBadges plugin=\"scratchpads\" option=\"monitor\"  version=\"3.1.1\" /> {#config-monitor}\n\nPer-monitor configuration overrides. Most display-related attributes can be changed (not `command`, `class` or `process_tracking`).\n\nUse the `monitor.<monitor name>` configuration item to override values, eg:\n\n```toml\n[scratchpads.music.monitor.eDP-1]\nposition = \"30% 50%\"\nanimation = \"fromBottom\"\n```\n\nYou may want to inline it for simple cases:\n\n```toml\n[scratchpads.music]\nmonitor = {HDMI-A-1={size = \"30% 50%\"}}\n```\n"
  },
  {
    "path": "site/versions/3.1.1/scratchpads_nonstandard.md",
    "content": "---\n---\n# Troubleshooting scratchpads\n\n> [!note]\n> For basic setup, see [Scratchpads](./scratchpads).\n\nOptions that should only be used for applications that are not behaving in a \"standard\" way, such as `emacsclient` or progressive web apps.\n\n<PluginConfig plugin=\"scratchpads\" linkPrefix=\"config-\" :filter=\"['match_by', 'initialClass', 'initialTitle', 'title', 'process_tracking', 'skip_windowrules']\"  version=\"3.1.1\" />\n\n### `match_by` <ConfigBadges plugin=\"scratchpads\" option=\"match_by\"  version=\"3.1.1\" /> {#config-match-by}\n\nWhen set to a sensitive client property value (eg: `class`, `initialClass`, `title`, `initialTitle`), will match the client window using the provided property instead of the PID of the process.\n\nThis property must be set accordingly, eg:\n\n```toml\nmatch_by = \"class\"\nclass = \"my-web-app\"\n```\n\nor\n\n```toml\nmatch_by = \"initialClass\"\ninitialClass = \"my-web-app\"\n```\n\nYou can add the \"re:\" prefix to use a regular expression, eg:\n\n```toml\nmatch_by = \"title\"\ntitle = \"re:.*some string.*\"\n```\n\n> [!note]\n> Some apps may open the graphical client window in a \"complicated\" way, to work around this, it is possible to disable the process PID matching algorithm and simply rely on window's class.\n>\n> The `match_by` attribute can be used to achieve this, eg. for emacsclient:\n> ```toml\n> [scratchpads.emacs]\n> command = \"/usr/local/bin/emacsStart.sh\"\n> class = \"Emacs\"\n> match_by = \"class\"\n> ```\n\n### `process_tracking` <ConfigBadges plugin=\"scratchpads\" option=\"process_tracking\"  version=\"3.1.1\" /> {#config-process-tracking}\n\nAllows disabling the process management. Use only if running a progressive web app (Chrome based apps) or similar.\n\nThis will automatically force `lazy = true` and set `match_by=\"class\"` if no `match_by` rule is provided, to help with the fuzzy client window matching.\n\nIt requires defining a `class` option (or the option matching your `match_by` value).\n\n```toml\n## Chat GPT on Brave\n[scratchpads.gpt]\nanimation = \"fromTop\"\ncommand = \"brave --profile-directory=Default --app=https://chat.openai.com\"\nclass = \"brave-chat.openai.com__-Default\"\nsize = \"75% 60%\"\nprocess_tracking = false\n\n## Some chrome app\n[scratchpads.music]\ncommand = \"google-chrome --profile-directory=Default --app-id=cinhimbnkkaeohfgghhklpknlkffjgod\"\nclass = \"chrome-cinhimbnkkaeohfgghhklpknlkffjgod-Default\"\nsize = \"50% 50%\"\nprocess_tracking = false\n```\n\n> [!tip]\n> To list windows by class and title you can use:\n> - `hyprctl -j clients | jq '.[]|[.class,.title]'`\n> - or if you prefer a graphical tool: `rofi -show window`\n\n> [!note]\n> Progressive web apps will share a single process for every window.\n> On top of requiring the class based window tracking (using `match_by`),\n> the process can not be managed the same way as usual apps and the correlation\n> between the process and the client window isn't as straightforward and can lead to false matches in extreme cases.\n\n### `skip_windowrules` <ConfigBadges plugin=\"scratchpads\" option=\"skip_windowrules\"  version=\"3.1.1\" /> {#config-skip-windowrules}\n\nAllows you to skip the window rules for a specific scratchpad.\nAvailable rules are:\n\n- \"aspect\" controlling size and position\n- \"float\" controlling the floating state\n- \"workspace\" which moves the window to its own workspace\n\nIf you are using an application which can spawn multiple windows and you can't see them, you can skip rules made to improve the initial display of the window.\n\n```toml\n[scratchpads.filemanager]\nanimation = \"fromBottom\"\ncommand = \"nemo\"\nclass = \"nemo\"\nsize = \"60% 60%\"\nskip_windowrules = [\"aspect\", \"workspace\"]\n```\n"
  },
  {
    "path": "site/versions/3.1.1/shift_monitors.md",
    "content": "---\n---\n\n# shift_monitors\n\nSwaps the workspaces of every screen in the given direction.\n\n> [!Note]\n> the behavior can be hard to predict if you have more than 2 monitors (depending on your layout).\n> If you use this plugin with many monitors and have some ideas about a convenient configuration, you are welcome ;)\n\n> [!Tip]\n> On Niri, this plugin moves the active workspace to the adjacent monitor instead of swapping workspaces, as Niri workspaces are dynamic.\n\nExample usage in `hyprland.conf`:\n\n```sh\nbind = $mainMod, O, exec, pypr shift_monitors +1\nbind = $mainMod SHIFT, O, exec, pypr shift_monitors -1\n```\n\n## Commands\n\n<PluginCommands plugin=\"shift_monitors\"  version=\"3.1.1\" />\n\n## Configuration\n\nThis plugin has no configuration options.\n"
  },
  {
    "path": "site/versions/3.1.1/shortcuts_menu.md",
    "content": "---\n---\n\n# shortcuts_menu\n\nPresents some menu to run shortcut commands. Supports nested menus (aka categories / sub-menus).\n\n<details>\n   <summary>Configuration examples</summary>\n\n```toml\n[shortcuts_menu.entries]\n\n\"Open Jira ticket\" = 'open-jira-ticket \"$(wl-paste)\"'\nRelayout = \"pypr relayout\"\n\"Fetch window\" = \"pypr fetch_client_menu\"\n\"Hyprland socket\" = 'kitty  socat - \"UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock\"'\n\"Hyprland logs\" = 'kitty tail -f $XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/hyprland.log'\n\n\"Serial USB Term\" = [\n    {name=\"device\", command=\"ls -1 /dev/ttyUSB*; ls -1 /dev/ttyACM*\"},\n    {name=\"speed\", options=[\"115200\", \"9600\", \"38400\", \"115200\", \"256000\", \"512000\"]},\n    \"kitty miniterm --raw --eol LF [device] [speed]\"\n]\n\n\"Color picker\" = [\n    {name=\"format\", options=[\"hex\", \"rgb\", \"hsv\", \"hsl\", \"cmyk\"]},\n    \"sleep 0.2; hyprpicker --format [format] | wl-copy\" # sleep to let the menu close before the picker opens\n]\n\nscreenshot = [\n    {name=\"what\", options=[\"output\", \"window\", \"region\", \"active\"]},\n    \"hyprshot -m [what] -o /tmp -f shot_[what].png\"\n]\n\nannotate = [\n    {name=\"fname\", command=\"ls /tmp/shot_*.png\"},\n    \"satty --filename '[fname]' --output-filename '/tmp/annotated.png'\"\n]\n\n\"Clipboard history\" = [\n    {name=\"entry\", command=\"cliphist list\", filter=\"s/\\t.*//\"},\n    \"cliphist decode '[entry]' | wl-copy\"\n]\n\n\"Copy password\" = [\n    {name=\"what\", command=\"gopass ls --flat\"},\n    \"gopass show -c [what]\"\n]\n\n\"Update/Change password\" = [\n    {name=\"what\", command=\"gopass ls --flat\"},\n    \"kitty -- gopass generate -s --strict -t '[what]' && gopass show -c '[what]'\"\n]\n```\n\n</details>\n\n\n## Commands\n\n<PluginCommands plugin=\"shortcuts_menu\"  version=\"3.1.1\" />\n\n> [!tip]\n> - If \"name\" is provided it will show the given sub-menu.\n> - You can use \".\" to reach any level of the configured menus.\n>\n>      Example to reach `shortcuts_menu.entries.utils.\"local commands\"`, use:\n>      ```sh\n>       pypr menu \"utils.local commands\"\n>      ```\n\n## Configuration\n\n<PluginConfig plugin=\"shortcuts_menu\" linkPrefix=\"config-\"  version=\"3.1.1\" />\n\n### `entries` <ConfigBadges plugin=\"shortcuts_menu\" option=\"entries\"  version=\"3.1.1\" /> {#config-entries}\n\n**Required.** Defines the menu entries. Supports [Variables](./Variables)\n\n```toml\n[shortcuts_menu.entries]\n\"entry 1\" = \"command to run\"\n\"entry 2\" = \"command to run\"\n```\n\nSubmenus can be defined too (there is no depth limit):\n\n```toml\n[shortcuts_menu.entries.\"My submenu\"]\n\"entry X\" = \"command\"\n\n[shortcuts_menu.entries.one.two.three.four.five]\nfoobar = \"ls\"\n```\n\n#### Advanced usage\n\nInstead of navigating a configured list of menu options and running a pre-defined command, you can collect various *variables* (either static list of options selected by the user, or generated from a shell command) and then run a command using those variables:\n\n```toml\n\"Play Video\" = [\n    {name=\"video_device\", command=\"ls -1 /dev/video*\"},\n    {name=\"player\", options=[\"mpv\", \"guvcview\"]},\n    \"[player] [video_device]\"\n]\n\n\"Ssh\" = [\n    {name=\"action\", options=[\"htop\", \"uptime\", \"sudo halt -p\"]},\n    {name=\"host\", options=[\"gamix\", \"gate\", \"idp\"]},\n    \"kitty --hold ssh [host] [action]\"\n]\n```\n\nYou must define a list of objects, containing:\n- `name`: the variable name\n- then the list of options, must be one of:\n    - `options` for a static list of options\n    - `command` to get the list of options from a shell command's output\n\n> [!tip]\n> You can apply post-filters to the `command` output, eg:\n> ```toml\n> {\n>   name = \"entry\",\n>   command = \"cliphist list\",\n>   filter = \"s/\\t.*//\"  # will remove everything after the TAB character\n> }\n> ```\n> Check the [filters](./filters) page for more details\n\nThe last item of the list must be a string which is the command to run. Variables can be used enclosed in `[]`.\n\n### `command_start` / `command_end` <ConfigBadges plugin=\"shortcuts_menu\" option=\"command_start\"  version=\"3.1.1\" /> {#config-command-start}\n\nAllow adding some text (eg: icon) before / after a menu entry for final commands.\n\n### `submenu_start` / `submenu_end` <ConfigBadges plugin=\"shortcuts_menu\" option=\"submenu_start\"  version=\"3.1.1\" /> {#config-submenu-start}\n\nAllow adding some text (eg: icon) before / after a menu entry leading to another menu.\n\nBy default `submenu_end` is set to a right arrow sign, while other attributes are not set.\n\n### `skip_single` <ConfigBadges plugin=\"shortcuts_menu\" option=\"skip_single\"  version=\"3.1.1\" /> {#config-skip-single}\n\nWhen disabled, shows the menu even for single options.\n\n## Hints\n\n### Multiple menus\n\nTo manage multiple distinct menus, always use a name when using the `pypr menu <name>` command.\n\nExample of a multi-menu configuration:\n\n```toml\n[shortcuts_menu.entries.\"Basic commands\"]\n\"entry X\" = \"command\"\n\"entry Y\" = \"command2\"\n\n[shortcuts_menu.entries.menu2]\n## ...\n```\n\nYou can then show the first menu using `pypr menu \"Basic commands\"`\n"
  },
  {
    "path": "site/versions/3.1.1/sidebar.json",
    "content": "{\n  \"main\": [\n    {\n      \"text\": \"Getting Started\",\n      \"link\": \"./Getting-started\"\n    },\n    {\n      \"text\": \"Configuration\",\n      \"link\": \"./Configuration\"\n    },\n    {\n      \"text\": \"Commands\",\n      \"link\": \"./Commands\"\n    },\n    {\n      \"text\": \"Plugins\",\n      \"link\": \"./Plugins\"\n    },\n    {\n      \"text\": \"Troubleshooting\",\n      \"link\": \"./Troubleshooting\"\n    },\n    {\n      \"text\": \"Development\",\n      \"link\": \"./Development\"\n    },\n    {\n      \"text\": \"Examples\",\n      \"link\": \"./Examples\"\n    },\n    {\n      \"text\": \"Architecture\",\n      \"link\": \"./Architecture\",\n      \"collapsed\": true,\n      \"items\": [\n        {\n          \"text\": \"Overview\",\n          \"link\": \"./Architecture_overview\"\n        },\n        {\n          \"text\": \"Core Components\",\n          \"link\": \"./Architecture_core\"\n        }\n      ]\n    }\n  ],\n  \"plugins\": {\n    \"text\": \"Featured plugins\",\n    \"collapsed\": false,\n    \"items\": [\n      {\n        \"text\": \"Expose\",\n        \"link\": \"./expose\"\n      },\n      {\n        \"text\": \"Fcitx5 Switcher\",\n        \"link\": \"./fcitx5_switcher\"\n      },\n      {\n        \"text\": \"Fetch client menu\",\n        \"link\": \"./fetch_client_menu\"\n      },\n      {\n        \"text\": \"Gamemode\",\n        \"link\": \"./gamemode\"\n      },\n      {\n        \"text\": \"Menubar\",\n        \"link\": \"./menubar\"\n      },\n      {\n        \"text\": \"Layout center\",\n        \"link\": \"./layout_center\"\n      },\n      {\n        \"text\": \"Lost windows\",\n        \"link\": \"./lost_windows\"\n      },\n      {\n        \"text\": \"Magnify\",\n        \"link\": \"./magnify\"\n      },\n      {\n        \"text\": \"Monitors\",\n        \"link\": \"./monitors\"\n      },\n      {\n        \"text\": \"Scratchpads\",\n        \"link\": \"./scratchpads\",\n        \"items\": [\n          {\n            \"text\": \"Advanced\",\n            \"link\": \"./scratchpads_advanced\"\n          },\n          {\n            \"text\": \"Special cases\",\n            \"link\": \"./scratchpads_nonstandard\"\n          }\n        ]\n      },\n      {\n        \"text\": \"Shift monitors\",\n        \"link\": \"./shift_monitors\"\n      },\n      {\n        \"text\": \"Shortcuts menu\",\n        \"link\": \"./shortcuts_menu\"\n      },\n      {\n        \"text\": \"System notifier\",\n        \"link\": \"./system_notifier\"\n      },\n      {\n        \"text\": \"Toggle dpms\",\n        \"link\": \"./toggle_dpms\"\n      },\n      {\n        \"text\": \"Toggle special\",\n        \"link\": \"./toggle_special\"\n      },\n      {\n        \"text\": \"Wallpapers\",\n        \"link\": \"./wallpapers\",\n        \"items\": [\n          {\n            \"text\": \"Online\",\n            \"link\": \"./wallpapers_online\"\n          },\n          {\n            \"text\": \"Templates\",\n            \"link\": \"./wallpapers_templates\"\n          }\n        ]\n      },\n      {\n        \"text\": \"Workspaces follow focus\",\n        \"link\": \"./workspaces_follow_focus\"\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "site/versions/3.1.1/system_notifier.md",
    "content": "---\n---\n# system_notifier\n\nThis plugin adds system notifications based on journal logs (or any program's output).\nIt monitors specified **sources** for log entries matching predefined **patterns** and generates notifications accordingly (after applying an optional **filter**).\n\nSources are commands that return a stream of text (eg: journal, mqtt, `tail -f`, ...) which is sent to a parser that will use a [regular expression pattern](https://en.wikipedia.org/wiki/Regular_expression) to detect lines of interest and optionally transform them before sending the notification.\n\n<details>\n    <summary>Minimal configuration</summary>\n\n```toml\n[[system_notifier.sources]]\ncommand = \"journalctl -fx\"\nparser = \"journal\"\n```\n\nNo **sources** are defined by default, so you will need to define at least one.\n\nIn general you will also need to define some **parsers**.\nBy default a **\"journal\"** parser is provided, otherwise you need to define your own rules.\nThis built-in configuration is close to this one, provided as an example:\n\n```toml\n[[system_notifier.parsers.journal]]\npattern = \"([a-z0-9]+): Link UP$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is active/\"\ncolor = \"#00aa00\"\n\n[[system_notifier.parsers.journal]]\npattern = \"([a-z0-9]+): Link DOWN$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is inactive/\"\ncolor = \"#ff8800\"\nduration = 15\n\n[[system_notifier.parsers.journal]]\npattern = \"Process \\d+ \\(.*\\) of .* dumped core.\"\nfilter = \"s/.*Process \\d+ \\((.*)\\) of .* dumped core./\\1 dumped core/\"\ncolor = \"#aa0000\"\n\n[[system_notifier.parsers.journal]]\npattern = \"usb \\d+-[0-9.]+: Product: \"\nfilter = \"s/.*usb \\d+-[0-9.]+: Product: (.*)/USB plugged: \\1/\"\n```\n</details>\n\n## Commands\n\n<PluginCommands plugin=\"system_notifier\"  version=\"3.1.1\" />\n\n## Configuration\n\n<PluginConfig plugin=\"system_notifier\" linkPrefix=\"config-\"  version=\"3.1.1\" />\n\n### `sources` <ConfigBadges plugin=\"system_notifier\" option=\"sources\"  version=\"3.1.1\" /> {#config-sources}\n\nList of sources to monitor. Each source must contain a `command` to run and a `parser` to use:\n\n```toml\n[[system_notifier.sources]]\ncommand = \"journalctl -fx\"\nparser = \"journal\"\n```\n\nYou can also use multiple parsers:\n\n```toml\n[[system_notifier.sources]]\ncommand = \"sudo journalctl -fkn\"\nparser = [\"journal\", \"custom_parser\"]\n```\n\n### `parsers` <ConfigBadges plugin=\"system_notifier\" option=\"parsers\"  version=\"3.1.1\" /> {#config-parsers}\n\nNamed parser configurations. Each parser rule contains:\n- `pattern`: regex to match lines of interest\n- `filter`: optional [filter](./filters) to transform text (e.g., `s/.*value: (\\d+)/Value=\\1/`)\n- `color`: optional color in `\"#hex\"` or `\"rgb()\"` format\n- `duration`: notification display time in seconds (default: 3)\n\n```toml\n[[system_notifier.parsers.custom_parser]]\npattern = 'special value:'\nfilter = \"s/.*special value: (\\d+)/Value=\\1/\"\ncolor = \"#FF5500\"\nduration = 10\n```\n\n### Built-in \"journal\" parser\n\nA `journal` parser is provided, detecting link up/down, core dumps, and USB plugs.\n\n### `use_notify_send` <ConfigBadges plugin=\"system_notifier\" option=\"use_notify_send\"  version=\"3.1.1\" /> {#config-use-notify-send}\n\nWhen enabled, forces use of `notify-send` command instead of the compositor's native notification system.\n"
  },
  {
    "path": "site/versions/3.1.1/toggle_dpms.md",
    "content": "---\n---\n\n# toggle_dpms\n\n## Commands\n\n<PluginCommands plugin=\"toggle_dpms\"  version=\"3.1.1\" />\n\n## Configuration\n\nThis plugin has no configuration options."
  },
  {
    "path": "site/versions/3.1.1/toggle_special.md",
    "content": "---\n---\n\n# toggle_special\n\nAllows moving the focused window to a special workspace and back (based on the visibility status of that workspace).\n\nIt's a companion of the `togglespecialworkspace` Hyprland's command which toggles a special workspace's visibility.\n\nYou most likely will need to configure the two commands for a complete user experience, eg:\n\n```bash\nbind = $mainMod SHIFT, N, togglespecialworkspace, stash # toggles \"stash\" special workspace visibility\nbind = $mainMod, N, exec, pypr toggle_special stash # moves window to/from the \"stash\" workspace\n```\n\nNo other configuration needed, here `MOD+SHIFT+N` will show every window in \"stash\" while `MOD+N` will move the focused window out of it/ to it.\n\n## Commands\n\n<PluginCommands plugin=\"toggle_special\"  version=\"3.1.1\" />\n\n## Configuration\n\n<PluginConfig plugin=\"toggle_special\" linkPrefix=\"config-\"  version=\"3.1.1\" />\n\n### `name` <ConfigBadges plugin=\"toggle_special\" option=\"name\"  version=\"3.1.1\" /> {#config-name}\n\nDefault special workspace name.\n"
  },
  {
    "path": "site/versions/3.1.1/wallpapers.md",
    "content": "---\n---\n\n\n# wallpapers\n\nSearch folders for images and sets the background image at a regular interval.\nPictures are selected randomly from the full list of images found.\n\nIt serves few purposes:\n\n- adding support for random images to any background setting tool\n- quickly testing different tools with a minimal effort\n- adding rounded corners to each wallpaper screen\n- generating a wallpaper-compliant color scheme usable to generate configurations for any application (matugen/pywal alike)\n\nIt allows \"zapping\" current backgrounds, clearing it to go distraction free and optionally make them different for each screen.\n\n> [!important]\n> On Hyprland, Pyprland uses **hyprpaper** by default, but you must start hyprpaper separately (e.g. `uwsm app -- hyprpaper`). For other environments, set the `command` option to launch your wallpaper application.\n\n> [!note]\n> On environments other than Hyprland and Niri, pyprland uses `wlr-randr` (Wayland) or `xrandr` (X11) for monitor detection.\n> This provides full wallpaper functionality but without automatic refresh on monitor hotplug.\n\nCached images (rounded corners, online downloads) are stored in subfolders within your configured `path` directory.\n\n<details>\n    <summary>Minimal example using defaults (requires <b>hyprpaper</b>)</summary>\n\n```toml\n[wallpapers]\npath = \"~/Pictures/wallpapers/\" # path to the folder with background images\n```\n\n</details>\n\n<details>\n<summary>More complete, using the custom <b>swww</b> command (not recommended because of its stability)</summary>\n\n```toml\n[wallpapers]\nunique = true # set a different wallpaper for each screen\npath = \"~/Pictures/wallpapers/\"\ninterval = 60 # change every hour\nextensions = [\"jpg\", \"jpeg\"]\nrecurse = true\nclear_command = \"swww clear\"\ncommand = \"swww img --outputs '[output]'  '[file]'\"\n\n```\n\nNote that for applications like `swww`, you'll need to start a daemon separately (eg: from `hyprland.conf`).\n</details>\n\n\n## Commands\n\n<PluginCommands plugin=\"wallpapers\"  version=\"3.1.1\" />\n\n> [!tip]\n> The `color` and `palette` commands are used for templating. See [Templates](./wallpapers_templates#commands) for details.\n\n## Configuration\n\n<PluginConfig plugin=\"wallpapers\" linkPrefix=\"config-\" :filter=\"['path', 'interval', 'command', 'clear_command', 'post_command', 'radius', 'extensions', 'recurse', 'unique']\"  version=\"3.1.1\" />\n\n### `path` <ConfigBadges plugin=\"wallpapers\" option=\"path\"  version=\"3.1.1\" /> {#config-path}\n\n**Required.** Path to a folder or list of folders that will be searched for wallpaper images.\n\n```toml\npath = [\"~/Pictures/Portraits/\", \"~/Pictures/Landscapes/\"]\n```\n\n### `interval` <ConfigBadges plugin=\"wallpapers\" option=\"interval\"  version=\"3.1.1\" /> {#config-interval}\n\nHow long (in minutes) a background should stay in place before changing.\n\n### `command` <ConfigBadges plugin=\"wallpapers\" option=\"command\"  version=\"3.1.1\" /> {#config-command}\n\nOverrides the default command to set the background image.\n\n> [!important]\n> **Required** for all environments except Hyprland.\n> On Hyprland, defaults to using hyprpaper if not specified.\n\n[Variables](./Variables) are replaced with the appropriate values. Use `[file]` for the image path and `[output]` for the monitor name:\n\n> [!note]\n> The `[output]` variable requires monitor detection (available on Hyprland, Niri, and fallback environments with `wlr-randr` or `xrandr`).\n\n```sh\nswaybg -i '[file]' -o '[output]'\n```\nor\n```sh\nswww img --outputs [output] [file]\n```\n\n### `clear_command` <ConfigBadges plugin=\"wallpapers\" option=\"clear_command\"  version=\"3.1.1\" /> {#config-clear-command}\n\nOverrides the default behavior which kills the `command` program.\nUse this to provide a command to clear the background:\n\n```toml\nclear_command = \"swaybg clear\"\n```\n\n### `post_command` <ConfigBadges plugin=\"wallpapers\" option=\"post_command\"  version=\"3.1.1\" /> {#config-post-command}\n\nExecutes a command after a wallpaper change. Can use `[file]`:\n\n```toml\npost_command = \"matugen image '[file]'\"\n```\n\n### `radius` <ConfigBadges plugin=\"wallpapers\" option=\"radius\"  version=\"3.1.1\" /> {#config-radius}\n\nWhen set, adds rounded borders to the wallpapers. Expressed in pixels. Disabled by default.\n\nRequires monitor detection (available on Hyprland, Niri, and fallback environments with `wlr-randr` or `xrandr`).\nFor this feature to work, you must use `[output]` in your `command` to specify the screen port name.\n\n```toml\nradius = 16\n```\n\n### `extensions` <ConfigBadges plugin=\"wallpapers\" option=\"extensions\"  version=\"3.1.1\" /> {#config-extensions}\n\nList of valid wallpaper image extensions.\n\n### `recurse` <ConfigBadges plugin=\"wallpapers\" option=\"recurse\"  version=\"3.1.1\" /> {#config-recurse}\n\nWhen enabled, will also search sub-directories recursively.\n\n### `unique` <ConfigBadges plugin=\"wallpapers\" option=\"unique\"  version=\"3.1.1\" /> {#config-unique}\n\nWhen enabled, will set a different wallpaper for each screen.\n\n> [!note]\n> Requires monitor detection (available on Hyprland, Niri, and fallback environments with `wlr-randr` or `xrandr`).\n> Usage with [templates](./wallpapers_templates) is not recommended.\n\nIf you are not using the default application, ensure you are using `[output]` in the [command](#config-command) template.\n\nExample for swaybg: `swaybg -o \"[output]\" -m fill -i \"[file]\"`\n\n## Online Wallpapers\n\nPyprland can fetch wallpapers from free online sources like Unsplash, Wallhaven, Reddit, and more. Downloaded images are stored locally and become part of your collection.\n\nSee [Online Wallpapers](./wallpapers_online) for configuration options and available backends.\n\n## Templates\n\nGenerate config files with colors extracted from your wallpaper - similar to matugen/pywal. Automatically theme your terminal, window borders, GTK apps, and more.\n\nSee [Templates](./wallpapers_templates) for full documentation including syntax, color reference, and examples.\n"
  },
  {
    "path": "site/versions/3.1.1/wallpapers_online.md",
    "content": "---\n---\n\n# Online Wallpapers\n\nPyprland can fetch wallpapers from free online sources without requiring API keys. When `online_ratio` is set, each wallpaper change has a chance to fetch a new image from the configured backends. If a fetch fails, it falls back to local images.\n\nDownloaded images are stored in the `online_folder` subfolder and become part of your local collection for future use.\n\n> [!note]\n> Online fetching requires `online_ratio > 0`. If `online_backends` is empty, online fetching is disabled.\n\n## Configuration\n\n<PluginConfig plugin=\"wallpapers\" linkPrefix=\"config-\" :filter=\"['online_ratio', 'online_backends', 'online_keywords', 'online_folder']\"  version=\"3.1.1\" />\n\n### `online_ratio` <ConfigBadges plugin=\"wallpapers\" option=\"online_ratio\"  version=\"3.1.1\" /> {#config-online-ratio}\n\nProbability (0.0 to 1.0) of fetching a wallpaper from online sources instead of local files. Set to `0.0` to disable online fetching or `1.0` to always fetch online.\n\n```toml\nonline_ratio = 0.3  # 30% chance of fetching online\n```\n\n### `online_backends` <ConfigBadges plugin=\"wallpapers\" option=\"online_backends\"  version=\"3.1.1\" /> {#config-online-backends}\n\nList of online backends to use. Defaults to all available backends. Set to an empty list to disable online fetching. See [Available Backends](#available-backends) for details.\n\n```toml\nonline_backends = [\"unsplash\", \"wallhaven\"]  # Use only these two\n```\n\n### `online_keywords` <ConfigBadges plugin=\"wallpapers\" option=\"online_keywords\"  version=\"3.1.1\" /> {#config-online-keywords}\n\nKeywords to filter online wallpaper searches. Not all backends support keywords.\n\n```toml\nonline_keywords = [\"nature\", \"landscape\", \"mountains\"]\n```\n\n### `online_folder` <ConfigBadges plugin=\"wallpapers\" option=\"online_folder\"  version=\"3.1.1\" /> {#config-online-folder}\n\nSubfolder name within `path` where downloaded online images are stored. These images persist and become part of your local collection.\n\n```toml\nonline_folder = \"online\"  # Stores in {path}/online/\n```\n\n## Cache Management\n\n<PluginConfig plugin=\"wallpapers\" linkPrefix=\"config-\" :filter=\"['cache_days', 'cache_max_mb', 'cache_max_images']\"  version=\"3.1.1\" />\n\n### `cache_days` <ConfigBadges plugin=\"wallpapers\" option=\"cache_days\"  version=\"3.1.1\" /> {#config-cache-days}\n\nDays to keep cached images before automatic cleanup. Set to `0` to keep images forever.\n\n```toml\ncache_days = 30  # Remove cached images older than 30 days\n```\n\n### `cache_max_mb` <ConfigBadges plugin=\"wallpapers\" option=\"cache_max_mb\"  version=\"3.1.1\" /> {#config-cache-max-mb}\n\nMaximum cache size in megabytes. When exceeded, oldest files are removed first. Set to `0` for unlimited.\n\n```toml\ncache_max_mb = 500  # Limit cache to 500 MB\n```\n\n### `cache_max_images` <ConfigBadges plugin=\"wallpapers\" option=\"cache_max_images\"  version=\"3.1.1\" /> {#config-cache-max-images}\n\nMaximum number of cached images. When exceeded, oldest files are removed first. Set to `0` for unlimited.\n\n```toml\ncache_max_images = 100  # Keep at most 100 cached images\n```\n\n## Available Backends\n\n| Backend | Keywords | Description |\n|---------|:--------:|-------------|\n| `unsplash` | ✓ | Unsplash Source - high quality photos |\n| `wallhaven` | ✓ | Wallhaven - curated wallpapers |\n| `reddit` | ✓ | Reddit - keywords map to wallpaper subreddits |\n| `picsum` | ✗ | Picsum Photos - random images |\n| `bing` | ✗ | Bing Daily Wallpaper |\n\n## Example Configuration\n\n```toml\n[wallpapers]\npath = \"~/Pictures/wallpapers/\"\nonline_ratio = 0.2  # 20% chance to fetch online\nonline_backends = [\"unsplash\", \"wallhaven\"]\nonline_keywords = [\"nature\", \"minimal\"]\n```\n"
  },
  {
    "path": "site/versions/3.1.1/wallpapers_templates.md",
    "content": "---\n---\n\n# Wallpaper Templates\n\nThe templates feature provides automatic theming for your desktop applications. When the wallpaper changes, pyprland:\n\n1. Extracts dominant colors from the wallpaper image\n2. Generates a Material Design-inspired color palette\n3. Processes your template files, replacing color placeholders with actual values\n4. Runs optional `post_hook` commands to apply the changes\n\nThis creates a unified color scheme across your terminal, window borders, GTK apps, and other tools - all derived from your wallpaper.\n\n> [!tip]\n> If you're migrating from *matugen* or *pywal*, your existing templates should work with minimal changes.\n\n## Commands\n\n<PluginCommands plugin=\"wallpapers\" :filter=\"['color', 'palette']\" linkPrefix=\"command-\"  version=\"3.1.1\" />\n\n### Using the `color` command {#command-color}\n\nThe `color` command allows testing the palette with a specific color instead of extracting from the wallpaper:\n\n- `pypr color \"#ff0000\"` - Re-generate the templates with the given color\n- `pypr color \"#ff0000\" neutral` - Re-generate the templates with the given color and [color scheme](#config-color-scheme) (color filter)\n\n### Using the `palette` command {#command-palette}\n\nThe `palette` command shows available color template variables:\n\n- `pypr palette` - Show palette using colors from current wallpaper\n- `pypr palette \"#ff0000\"` - Show palette for a specific color\n- `pypr palette json` - Output palette in JSON format\n\n## Configuration\n\n<PluginConfig plugin=\"wallpapers\" linkPrefix=\"config-\" :filter=\"['templates', 'color_scheme', 'variant']\"  version=\"3.1.1\" />\n\n### `templates` <ConfigBadges plugin=\"wallpapers\" option=\"templates\"  version=\"3.1.1\" /> {#config-templates}\n\nEnables automatic theming by generating config files from templates using colors extracted from the wallpaper.\n\n```toml\n[wallpapers.templates.hyprland]\ninput_path = \"~/color_configs/hyprlandcolors.sh\"\noutput_path = \"/tmp/hyprlandcolors.sh\"\npost_hook = \"sh /tmp/hyprlandcolors.sh\"\n```\n\n> [!tip]\n> Mostly compatible with *matugen* template syntax.\n\n### `color_scheme` <ConfigBadges plugin=\"wallpapers\" option=\"color_scheme\"  version=\"3.1.1\" /> {#config-color-scheme}\n\nOptional modification of the base color used in the templates. One of:\n\n- **pastel** - a bit more washed colors\n- **fluo** or **fluorescent** - for high color saturation\n- **neutral** - for low color saturation\n- **earth** - a bit more dark, a bit less blue\n- **vibrant** - for moderate to high saturation\n- **mellow** - for lower saturation\n\n### `variant` <ConfigBadges plugin=\"wallpapers\" option=\"variant\"  version=\"3.1.1\" /> {#config-variant}\n\nChanges the algorithm used to pick the primary, secondary and tertiary colors.\n\n- **islands** - uses the 3 most popular colors from the wallpaper image\n\nBy default it will pick the \"main\" color and shift the hue to get the secondary and tertiary colors.\n\n## Template Configuration\n\nEach template requires an `input_path` (template file with placeholders) and `output_path` (where to write the result):\n\n```toml\n[wallpapers.templates.hyprland]\ninput_path = \"~/color_configs/hyprlandcolors.sh\"\noutput_path = \"/tmp/hyprlandcolors.sh\"\npost_hook = \"sh /tmp/hyprlandcolors.sh\"  # optional: runs after this template\n```\n\n| Option | Required | Description |\n|--------|----------|-------------|\n| `input_path` | Yes | Path to template file containing <code v-pre>{{placeholders}}</code> |\n| `output_path` | Yes | Where to write the processed output |\n| `post_hook` | No | Command to run after this specific template is generated |\n\n> [!note]\n> **`post_hook` vs `post_command`**: The `post_hook` runs after each individual template is generated. The global [`post_command`](./wallpapers#config-post-command) runs once after the wallpaper is set and all templates are processed.\n\n## Template Syntax\n\nUse double curly braces to insert color values:\n\n```txt\n{{colors.<color_name>.<variant>.<format>}}\n```\n\n| Part | Options | Description |\n|------|---------|-------------|\n| `color_name` | See [color reference](#color-reference) | The color role (e.g., `primary`, `surface`) |\n| `variant` | `default`, `dark`, `light` | Which theme variant to use |\n| `format` | `hex`, `hex_stripped`, `rgb`, `rgba` | Output format |\n\n**Examples:**\n```txt\n{{colors.primary.default.hex}}           → #6495ED\n{{colors.primary.default.hex_stripped}}  → 6495ED\n{{colors.primary.dark.rgb}}              → rgb(100, 149, 237)\n{{colors.surface.light.rgba}}            → rgba(250, 248, 245, 1.0)\n```\n\n**Shorthand:** <code v-pre>{{colors.primary.default}}</code> is equivalent to <code v-pre>{{colors.primary.default.hex}}</code>\n\nThe `default` variant automatically selects `dark` or `light` based on [theme detection](#theme-detection).\n\n## Special Variables\n\nIn addition to colors, these variables are available in templates:\n\n| Variable | Description | Example Value |\n|----------|-------------|---------------|\n| <code v-pre>{{image}}</code> | Full path to the current wallpaper | `/home/user/Pictures/sunset.jpg` |\n| <code v-pre>{{scheme}}</code> | Detected theme | `dark` or `light` |\n\n## Color Formats\n\n| Format | Example | Typical Use |\n|--------|---------|-------------|\n| `hex` | `#6495ED` | Most applications, CSS |\n| `hex_stripped` | `6495ED` | Hyprland configs, apps that don't want `#` |\n| `rgb` | `rgb(100, 149, 237)` | CSS, GTK |\n| `rgba` | `rgba(100, 149, 237, 1.0)` | CSS with opacity |\n\n## Filters\n\nFilters modify color values. Use the pipe (`|`) syntax:\n\n```txt\n{{colors.primary.default.hex | filter_name: argument}}\n```\n\n**`set_alpha`** - Add transparency to a color\n\nConverts the color to RGBA format with the specified alpha value (0.0 to 1.0):\n\n```txt\nTemplate:  {{colors.primary.default.hex | set_alpha: 0.5}}\nOutput:    rgba(100, 149, 237, 0.5)\n\nTemplate:  {{colors.surface.default.hex | set_alpha: 0.8}}\nOutput:    rgba(26, 22, 18, 0.8)\n```\n\n**`set_lightness`** - Adjust color brightness\n\nChanges the lightness by a percentage (-100 to 100). Positive values lighten, negative values darken:\n\n```txt\nTemplate:  {{colors.primary.default.hex | set_lightness: 20}}\nOutput:    #8AB4F8  (20% lighter)\n\nTemplate:  {{colors.primary.default.hex | set_lightness: -20}}\nOutput:    #3A5980  (20% darker)\n```\n\n## Theme Detection {#theme-detection}\n\nThe `default` color variant automatically adapts to your system theme. Detection order:\n\n1. **gsettings** (GNOME/GTK): `gsettings get org.gnome.desktop.interface color-scheme`\n2. **darkman**: `darkman get`\n3. **Fallback**: defaults to `dark` if neither is available\n\nYou can check the detected theme using the <code v-pre>{{scheme}}</code> variable in your templates.\n\n## Color Reference {#color-reference}\n\nColors follow the Material Design 3 color system, organized by role:\n\n**Primary Colors** - Main accent color derived from the wallpaper\n\n| Color | Description |\n|-------|-------------|\n| `primary` | Main accent color |\n| `on_primary` | Text/icons displayed on primary color |\n| `primary_container` | Less prominent container using primary hue |\n| `on_primary_container` | Text/icons on primary container |\n| `primary_fixed` | Fixed primary that doesn't change with theme |\n| `primary_fixed_dim` | Dimmer variant of fixed primary |\n| `on_primary_fixed` | Text on fixed primary |\n| `on_primary_fixed_variant` | Variant text on fixed primary |\n\n**Secondary Colors** - Complementary accent (hue-shifted from primary)\n\n| Color | Description |\n|-------|-------------|\n| `secondary` | Secondary accent color |\n| `on_secondary` | Text/icons on secondary |\n| `secondary_container` | Container using secondary hue |\n| `on_secondary_container` | Text on secondary container |\n| `secondary_fixed`, `secondary_fixed_dim` | Fixed variants |\n| `on_secondary_fixed`, `on_secondary_fixed_variant` | Text on fixed |\n\n**Tertiary Colors** - Additional accent (hue-shifted opposite of secondary)\n\n| Color | Description |\n|-------|-------------|\n| `tertiary` | Tertiary accent color |\n| `on_tertiary` | Text/icons on tertiary |\n| `tertiary_container` | Container using tertiary hue |\n| `on_tertiary_container` | Text on tertiary container |\n| `tertiary_fixed`, `tertiary_fixed_dim` | Fixed variants |\n| `on_tertiary_fixed`, `on_tertiary_fixed_variant` | Text on fixed |\n\n**Surface Colors** - Backgrounds and containers\n\n| Color | Description |\n|-------|-------------|\n| `surface` | Default background |\n| `surface_bright` | Brighter surface variant |\n| `surface_dim` | Dimmer surface variant |\n| `surface_container_lowest` | Lowest emphasis container |\n| `surface_container_low` | Low emphasis container |\n| `surface_container` | Default container |\n| `surface_container_high` | High emphasis container |\n| `surface_container_highest` | Highest emphasis container |\n| `on_surface` | Text/icons on surface |\n| `surface_variant` | Alternative surface |\n| `on_surface_variant` | Text on surface variant |\n| `background` | App background |\n| `on_background` | Text on background |\n\n**Error Colors** - Error states and alerts\n\n| Color | Description |\n|-------|-------------|\n| `error` | Error color (red hue) |\n| `on_error` | Text on error |\n| `error_container` | Error container background |\n| `on_error_container` | Text on error container |\n\n**Utility Colors**\n\n| Color | Description |\n|-------|-------------|\n| `source` | Original extracted color (unmodified) |\n| `outline` | Borders and dividers |\n| `outline_variant` | Subtle borders |\n| `inverse_primary` | Primary for inverse surfaces |\n| `inverse_surface` | Inverse surface color |\n| `inverse_on_surface` | Text on inverse surface |\n| `surface_tint` | Tint overlay for elevation |\n| `scrim` | Overlay for modals/dialogs |\n| `shadow` | Shadow color |\n| `white` | Pure white |\n\n**ANSI Terminal Colors** - Standard terminal color palette\n\n| Color | Description |\n|-------|-------------|\n| `red` | ANSI red |\n| `green` | ANSI green |\n| `yellow` | ANSI yellow |\n| `blue` | ANSI blue |\n| `magenta` | ANSI magenta |\n| `cyan` | ANSI cyan |\n\n## Examples\n\n**Hyprland Window Borders**\n\nConfig (`pyprland.toml`):\n```toml\n[wallpapers.templates.hyprland]\ninput_path = \"~/color_configs/hyprlandcolors.sh\"\noutput_path = \"/tmp/hyprlandcolors.sh\"\npost_hook = \"sh /tmp/hyprlandcolors.sh\"\n```\n\nTemplate (`~/color_configs/hyprlandcolors.sh`):\n```txt\nhyprctl keyword general:col.active_border \"rgb({{colors.primary.default.hex_stripped}}) rgb({{colors.tertiary.default.hex_stripped}}) 30deg\"\nhyprctl keyword general:col.inactive_border \"rgb({{colors.surface_variant.default.hex_stripped}})\"\nhyprctl keyword decoration:shadow:color \"rgba({{colors.shadow.default.hex_stripped}}ee)\"\n```\n\nOutput (after processing with a blue-toned wallpaper):\n```sh\nhyprctl keyword general:col.active_border \"rgb(6495ED) rgb(ED6495) 30deg\"\nhyprctl keyword general:col.inactive_border \"rgb(3D3D3D)\"\nhyprctl keyword decoration:shadow:color \"rgba(000000ee)\"\n```\n\n**Kitty Terminal Theme**\n\nConfig:\n```toml\n[wallpapers.templates.kitty]\ninput_path = \"~/color_configs/kitty_theme.conf\"\noutput_path = \"~/.config/kitty/current-theme.conf\"\npost_hook = \"kill -SIGUSR1 $(pgrep kitty) 2>/dev/null || true\"\n```\n\nTemplate (`~/color_configs/kitty_theme.conf`):\n```sh\n# Auto-generated theme from wallpaper: {{image}}\n# Scheme: {{scheme}}\n\nforeground {{colors.on_background.default.hex}}\nbackground {{colors.background.default.hex}}\ncursor {{colors.primary.default.hex}}\ncursor_text_color {{colors.on_primary.default.hex}}\nselection_foreground {{colors.on_primary.default.hex}}\nselection_background {{colors.primary.default.hex}}\n\n# ANSI colors\ncolor0 {{colors.surface.default.hex}}\ncolor1 {{colors.red.default.hex}}\ncolor2 {{colors.green.default.hex}}\ncolor3 {{colors.yellow.default.hex}}\ncolor4 {{colors.blue.default.hex}}\ncolor5 {{colors.magenta.default.hex}}\ncolor6 {{colors.cyan.default.hex}}\ncolor7 {{colors.on_surface.default.hex}}\n```\n\n**GTK4 CSS Theme**\n\nConfig:\n```toml\n[wallpapers.templates.gtk4]\ninput_path = \"~/color_configs/gtk.css\"\noutput_path = \"~/.config/gtk-4.0/colors.css\"\n```\n\nTemplate:\n```css\n/* Auto-generated from wallpaper */\n@define-color accent_bg_color {{colors.primary.default.hex}};\n@define-color accent_fg_color {{colors.on_primary.default.hex}};\n@define-color window_bg_color {{colors.surface.default.hex}};\n@define-color window_fg_color {{colors.on_surface.default.hex}};\n@define-color headerbar_bg_color {{colors.surface_container.default.hex}};\n@define-color card_bg_color {{colors.surface_container_low.default.hex}};\n@define-color view_bg_color {{colors.background.default.hex}};\n@define-color popover_bg_color {{colors.surface_container_high.default.hex}};\n\n/* With transparency */\n@define-color sidebar_bg_color {{colors.surface_container.default.hex | set_alpha: 0.95}};\n```\n\n**JSON Export (for external tools)**\n\nConfig:\n```toml\n[wallpapers.templates.json]\ninput_path = \"~/color_configs/colors.json\"\noutput_path = \"~/.cache/current-colors.json\"\npost_hook = \"notify-send 'Theme Updated' 'New colors from wallpaper'\"\n```\n\nTemplate:\n```json\n{\n  \"scheme\": \"{{scheme}}\",\n  \"wallpaper\": \"{{image}}\",\n  \"colors\": {\n    \"primary\": \"{{colors.primary.default.hex}}\",\n    \"secondary\": \"{{colors.secondary.default.hex}}\",\n    \"tertiary\": \"{{colors.tertiary.default.hex}}\",\n    \"background\": \"{{colors.background.default.hex}}\",\n    \"surface\": \"{{colors.surface.default.hex}}\",\n    \"error\": \"{{colors.error.default.hex}}\"\n  }\n}\n```\n\n## Troubleshooting\n\nFor general pyprland issues, see the [Troubleshooting](./Troubleshooting) page.\n\n**Template not updating?**\n- Verify `input_path` exists and is readable\n- Check pyprland logs:\n  - **Systemd**: `journalctl --user -u pyprland -f`\n  - **exec-once**: Check your log file (e.g., `tail -f ~/pypr.log`)\n- Enable debug logging with `--debug` or `--debug <logfile>` (see [Getting Started](./Getting-started#running-the-daemon))\n- Ensure the wallpapers plugin is loaded in your config\n\n**Colors look wrong or washed out?**\n- Try different [`color_scheme`](#config-color-scheme) values: `vibrant`, `pastel`, `fluo`\n- Use [`variant = \"islands\"`](#config-variant) to pick colors from different areas of the image\n\n**Theme detection not working?**\n- Install `darkman` or ensure gsettings is available\n- Force a theme by using `.dark` or `.light` variants instead of `.default`\n\n**`post_hook` not running?**\n- Commands run asynchronously; check for errors in logs\n- Ensure the command is valid and executable\n- Enable debug logging to see command execution details\n"
  },
  {
    "path": "site/versions/3.1.1/workspaces_follow_focus.md",
    "content": "---\n---\n\n# workspaces_follow_focus\n\nMake non-visible workspaces follow the focused monitor.\nAlso provides commands to switch between workspaces while preserving the current monitor assignments:\n\nSyntax:\n```toml\n[workspaces_follow_focus]\nmax_workspaces = 4 # number of workspaces before cycling\n```\nExample usage in `hyprland.conf`:\n\n```ini\nbind = $mainMod, K, exec, pypr change_workspace +1\nbind = $mainMod, J, exec, pypr change_workspace -1\n ```\n\n## Commands\n\n<PluginCommands plugin=\"workspaces_follow_focus\"  version=\"3.1.1\" />\n\n## Configuration\n\n<PluginConfig plugin=\"workspaces_follow_focus\" linkPrefix=\"config-\"  version=\"3.1.1\" />\n\n"
  },
  {
    "path": "site/versions/3.2.1/Architecture.md",
    "content": "# Architecture\n\nThis section provides a comprehensive overview of Pyprland's internal architecture, designed for developers who want to understand, extend, or contribute to the project.\n\n> [!tip]\n> For a practical guide to writing plugins, see the [Development](./Development) document.\n\n## Sections\n\n| Section | Description |\n|---------|-------------|\n| [Overview](./Architecture_overview) | High-level architecture, executive summary, data flow, directory structure, design patterns |\n| [Core Components](./Architecture_core) | Manager, plugins, adapters, IPC layer, socket protocol, C client, configuration, data models |\n\n## Quick Links\n\n### Overview\n\n- [Executive Summary](./Architecture_overview#executive-summary) - What Pyprland is and how it works\n- [High-Level Architecture](./Architecture_overview#high-level-architecture) - Visual overview of all components\n- [Data Flow](./Architecture_overview#data-flow) - Event processing and command processing sequences\n- [Directory Structure](./Architecture_overview#directory-structure) - Source code organization\n- [Design Patterns](./Architecture_overview#design-patterns) - Patterns used throughout the codebase\n\n### Core Components\n\n- [Entry Points](./Architecture_core#entry-points) - Daemon vs client mode\n- [Manager](./Architecture_core#manager) - The core orchestrator\n- [Plugin System](./Architecture_core#plugin-system) - Base class, lifecycle, built-in plugins\n- [Backend Adapter Layer](./Architecture_core#backend-adapter-layer) - Hyprland and Niri abstractions\n- [IPC Layer](./Architecture_core#ipc-layer) - Window manager communication\n- [Socket Protocol](./Architecture_core#pyprland-socket-protocol) - Client-daemon protocol specification\n- [pypr-client](./Architecture_core#pypr-client) - Lightweight alternative for keybindings\n- [Configuration System](./Architecture_core#configuration-system) - TOML config system\n- [Data Models](./Architecture_core#data-models) - TypedDict definitions\n\n## Further Reading\n\n- [Development Guide](./Development) - How to write plugins\n- [Plugin Documentation](./Plugins) - List of available plugins\n- [Sample Extension](https://github.com/fdev31/pyprland/tree/main/sample_extension) - Example external plugin package\n- [Hyprland IPC](https://wiki.hyprland.org/IPC/) - Hyprland's IPC documentation\n"
  },
  {
    "path": "site/versions/3.2.1/Architecture_core.md",
    "content": "# Core Components\n\nThis document details the core components of Pyprland's architecture.\n\n## Entry Points\n\nThe application can run in two modes: **daemon** (background service) or **client** (send commands to running daemon).\n\n```mermaid\nflowchart LR\n    main([\"🚀 main()\"]) --> detect{\"❓ Args?\"}\n    detect -->|No arguments| daemon[\"🔧 run_daemon()\"]\n    detect -->|Command given| client[\"📤 run_client()\"]\n    daemon --> Pyprland([\"⚙️ Pyprland().run()\"])\n    client --> socket([\"📡 Send via socket\"])\n    Pyprland --> events[\"📨 Listen for events\"]\n    socket --> response([\"✅ Receive response\"])\n\n    style main fill:#7fb3d3,stroke:#5a8fa8,color:#000\n    style detect fill:#d4c875,stroke:#a89a50,color:#000\n    style daemon fill:#8fbc8f,stroke:#6a9a6a,color:#000\n    style client fill:#8fbc8f,stroke:#6a9a6a,color:#000\n    style Pyprland fill:#d4a574,stroke:#a67c50,color:#000\n    style socket fill:#d4a574,stroke:#a67c50,color:#000\n```\n\n| Entry Point | File | Purpose |\n|-------------|------|---------|\n| `pypr` | [`command.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/command.py) | Main CLI entry (daemon or client mode) |\n| Daemon mode | [`pypr_daemon.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/pypr_daemon.py) | Start the background daemon |\n| Client mode | [`client.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/client.py) | Send command to running daemon |\n\n## Manager\n\nThe [`Pyprland`](https://github.com/fdev31/pyprland/blob/main/pyprland/manager.py) class is the core orchestrator, responsible for:\n\n| Responsibility | Method/Attribute |\n|----------------|------------------|\n| Plugin loading | `_load_plugins()` |\n| Event dispatching | `_run_event()` |\n| Command handling | `handle_command()` |\n| Server lifecycle | `run()`, `serve()` |\n| Configuration | `load_config()`, `config` |\n| Shared state | `state: SharedState` |\n\n**Key Design Patterns:**\n\n- **Per-plugin async task queues** (`queues: dict[str, asyncio.Queue]`) - ensures plugin isolation\n- **Deduplication** via `@remove_duplicate` decorator - prevents rapid duplicate events\n- **Plugin isolation** - each plugin processes events independently\n\n## Plugin System\n\n### Base Class\n\nAll plugins inherit from the [`Plugin`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/interface.py) base class:\n\n```python\nclass Plugin:\n    name: str                    # Plugin identifier\n    config: Configuration        # Plugin-specific config section\n    state: SharedState           # Shared application state\n    backend: EnvironmentBackend  # WM abstraction layer\n    log: Logger                  # Plugin-specific logger\n    \n    # Lifecycle hooks\n    async def init() -> None           # Called once at startup\n    async def on_reload() -> None      # Called on init and config reload\n    async def exit() -> None           # Called on shutdown\n    \n    # Config validation\n    config_schema: ClassVar[list[ConfigField]]\n    def validate_config() -> list[str]\n```\n\n### Event Handler Protocol\n\nPlugins implement handlers by naming convention. See [`protocols.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/protocols.py) for the full protocol definitions:\n\n```python\n# Hyprland events: event_<eventname>\nasync def event_openwindow(self, params: str) -> None: ...\nasync def event_closewindow(self, addr: str) -> None: ...\nasync def event_workspace(self, workspace: str) -> None: ...\n\n# Commands: run_<command>\nasync def run_toggle(self, name: str) -> str | None: ...\n\n# Niri events: niri_<eventtype>\nasync def niri_outputschanged(self, data: dict) -> None: ...\n```\n\n### Plugin Lifecycle\n\n```mermaid\nsequenceDiagram\n    autonumber\n    participant M as ⚙️ Manager\n    participant P as 🔌 Plugin\n    participant C as 📄 Config\n\n    rect rgba(143, 188, 143, 0.2)\n        Note over M,C: Initialization Phase\n        M->>P: __init__(name)\n        M->>P: init()\n        M->>C: load TOML config\n        C-->>M: config data\n        M->>P: load_config(config)\n        M->>P: validate_config()\n        P-->>M: validation errors (if any)\n        M->>P: on_reload()\n    end\n    \n    rect rgba(127, 179, 211, 0.2)\n        Note over M,P: Runtime Phase\n        loop Events from WM\n            M->>P: event_*(data)\n            P-->>M: (async processing)\n        end\n        \n        loop Commands from User\n            M->>P: run_*(args)\n            P-->>M: result\n        end\n    end\n    \n    rect rgba(212, 165, 116, 0.2)\n        Note over M,P: Shutdown Phase\n        M->>P: exit()\n        P-->>M: cleanup complete\n    end\n```\n\n### Built-in Plugins\n\n| Plugin | Source | Description |\n|--------|--------|-------------|\n| `pyprland` (core) | [`plugins/pyprland/`](https://github.com/fdev31/pyprland/tree/main/pyprland/plugins/pyprland) | Internal state management |\n| `scratchpads` | [`plugins/scratchpads/`](https://github.com/fdev31/pyprland/tree/main/pyprland/plugins/scratchpads) | Dropdown/scratchpad windows |\n| `monitors` | [`plugins/monitors/`](https://github.com/fdev31/pyprland/tree/main/pyprland/plugins/monitors) | Monitor layout management |\n| `wallpapers` | [`plugins/wallpapers/`](https://github.com/fdev31/pyprland/tree/main/pyprland/plugins/wallpapers) | Wallpaper cycling, color schemes |\n| `expose` | [`plugins/expose.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/expose.py) | Window overview |\n| `magnify` | [`plugins/magnify.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/magnify.py) | Zoom functionality |\n| `layout_center` | [`plugins/layout_center.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/layout_center.py) | Centered layout mode |\n| `fetch_client_menu` | [`plugins/fetch_client_menu.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/fetch_client_menu.py) | Menu-based window switching |\n| `shortcuts_menu` | [`plugins/shortcuts_menu.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/shortcuts_menu.py) | Shortcut launcher |\n| `toggle_dpms` | [`plugins/toggle_dpms.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/toggle_dpms.py) | Screen power toggle |\n| `toggle_special` | [`plugins/toggle_special.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/toggle_special.py) | Special workspace toggle |\n| `system_notifier` | [`plugins/system_notifier.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/system_notifier.py) | System notifications |\n| `lost_windows` | [`plugins/lost_windows.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/lost_windows.py) | Recover lost windows |\n| `shift_monitors` | [`plugins/shift_monitors.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/shift_monitors.py) | Shift windows between monitors |\n| `workspaces_follow_focus` | [`plugins/workspaces_follow_focus.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/workspaces_follow_focus.py) | Workspace follows focus |\n| `fcitx5_switcher` | [`plugins/fcitx5_switcher.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/fcitx5_switcher.py) | Input method switching |\n| `menubar` | [`plugins/menubar.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/menubar.py) | Menu bar integration |\n\n## Backend Adapter Layer\n\nThe adapter layer abstracts differences between window managers. See [`adapters/`](https://github.com/fdev31/pyprland/tree/main/pyprland/adapters) for the full implementation.\n\n```mermaid\nclassDiagram\n    class EnvironmentBackend {\n        <<abstract>>\n        #state: SharedState\n        #log: Logger\n        +get_clients() list~ClientInfo~\n        +get_monitors() list~MonitorInfo~\n        +execute(command) bool\n        +execute_json(command) Any\n        +execute_batch(commands) None\n        +notify(message, duration, color) None\n        +parse_event(raw_data) tuple\n    }\n    \n    class HyprlandBackend {\n        +get_clients()\n        +get_monitors()\n        +execute()\n        +parse_event()\n    }\n    note for HyprlandBackend \"Communicates via<br/>HYPRLAND_INSTANCE_SIGNATURE<br/>socket paths\"\n    \n    class NiriBackend {\n        +get_clients()\n        +get_monitors()\n        +execute()\n        +parse_event()\n    }\n    note for NiriBackend \"Communicates via<br/>NIRI_SOCKET<br/>JSON protocol\"\n    \n    EnvironmentBackend <|-- HyprlandBackend : implements\n    EnvironmentBackend <|-- NiriBackend : implements\n```\n\n| Class | Source |\n|-------|--------|\n| `EnvironmentBackend` | [`adapters/backend.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/backend.py) |\n| `HyprlandBackend` | [`adapters/hyprland.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/hyprland.py) |\n| `NiriBackend` | [`adapters/niri.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/niri.py) |\n\nThe backend is selected automatically based on environment:\n- If `NIRI_SOCKET` is set -> `NiriBackend`\n- Otherwise -> `HyprlandBackend`\n\n## IPC Layer\n\nLow-level socket communication with the window manager is handled in [`ipc.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/ipc.py):\n\n| Function | Purpose |\n|----------|---------|\n| `hyprctl_connection()` | Context manager for Hyprland command socket |\n| `niri_connection()` | Context manager for Niri socket |\n| `get_response()` | Send command, receive JSON response |\n| `get_event_stream()` | Subscribe to WM event stream |\n| `niri_request()` | Send Niri-specific request |\n| `@retry_on_reset` | Decorator for automatic connection retry |\n\n**Socket Paths:**\n\n| Socket | Path |\n|--------|------|\n| Hyprland commands | `$XDG_RUNTIME_DIR/hypr/$SIGNATURE/.socket.sock` |\n| Hyprland events | `$XDG_RUNTIME_DIR/hypr/$SIGNATURE/.socket2.sock` |\n| Niri | `$NIRI_SOCKET` |\n| Pyprland (Hyprland) | `$XDG_RUNTIME_DIR/hypr/$SIGNATURE/.pyprland.sock` |\n| Pyprland (Niri) | `dirname($NIRI_SOCKET)/.pyprland.sock` |\n| Pyprland (standalone) | `$XDG_DATA_HOME/.pyprland.sock` |\n\n## Pyprland Socket Protocol\n\nThe daemon exposes a Unix domain socket for client-daemon communication. This simple text-based protocol allows any language to implement a client.\n\n### Socket Path\n\nThe socket location depends on the environment:\n\n| Environment | Socket Path |\n|-------------|-------------|\n| Hyprland | `$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.pyprland.sock` |\n| Niri | `dirname($NIRI_SOCKET)/.pyprland.sock` |\n| Standalone | `$XDG_DATA_HOME/.pyprland.sock` (defaults to `~/.local/share/.pyprland.sock`) |\n\nIf the Hyprland path exceeds 107 characters, a shortened path is used:\n\n```\n/tmp/.pypr-$HYPRLAND_INSTANCE_SIGNATURE/.pyprland.sock\n```\n\n### Protocol\n\n```mermaid\nsequenceDiagram\n    autonumber\n    participant Client as 💻 Client\n    participant Socket as 📡 Unix Socket\n    participant Daemon as ⚙️ Daemon\n\n    rect rgba(127, 179, 211, 0.2)\n        Note over Client,Socket: Request\n        Client->>Socket: Connect\n        Client->>Socket: \"command args\\n\"\n        Client->>Socket: EOF (shutdown write)\n    end\n\n    rect rgba(143, 188, 143, 0.2)\n        Note over Socket,Daemon: Processing\n        Socket->>Daemon: read_command()\n        Daemon->>Daemon: Execute command\n    end\n\n    rect rgba(212, 165, 116, 0.2)\n        Note over Daemon,Client: Response\n        Daemon->>Socket: \"OK\\n\" or \"ERROR: msg\\n\"\n        Socket->>Client: Read until EOF\n        Client->>Client: Parse & exit\n    end\n```\n\n| Direction | Format |\n|-----------|--------|\n| **Request** | `<command> [args...]\\n` (newline-terminated, then EOF) |\n| **Response** | `OK [output]` or `ERROR: <message>` or raw text (legacy) |\n\n**Response Prefixes:**\n\n| Prefix | Meaning | Exit Code |\n|--------|---------|-----------|\n| `OK` | Command succeeded | 0 |\n| `OK <output>` | Command succeeded with output | 0 |\n| `ERROR: <msg>` | Command failed | 4 |\n| *(raw text)* | Legacy response (help, version, dumpjson) | 0 |\n\n**Exit Codes:**\n\n| Code | Name | Description |\n|------|------|-------------|\n| 0 | SUCCESS | Command completed successfully |\n| 1 | USAGE_ERROR | No command provided or invalid arguments |\n| 2 | ENV_ERROR | Missing environment variables |\n| 3 | CONNECTION_ERROR | Cannot connect to daemon |\n| 4 | COMMAND_ERROR | Command execution failed |\n\nSee [`models.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/models.py) for `ExitCode` and `ResponsePrefix` definitions.\n\n## pypr-client {#pypr-client}\n\nFor performance-critical use cases (e.g., keybindings), `pypr-client` is a lightweight C client available as an alternative to `pypr`. It supports all commands except `validate` and `edit` (which require Python).\n\n| File | Description |\n|------|-------------|\n| [`client/pypr-client.c`](https://github.com/fdev31/pyprland/blob/main/client/pypr-client.c) | C implementation of the pypr client |\n\n**Build:**\n\n```bash\ncd client\ngcc -O2 -o pypr-client pypr-client.c\n```\n\n**Features:**\n\n- Minimal dependencies (libc only)\n- Fast startup (~1ms vs ~50ms for Python)\n- Same protocol as Python client\n- Proper exit codes for scripting\n\n**Comparison:**\n\n| Aspect | `pypr` | `pypr-client` |\n|--------|--------|---------------|\n| Startup time | ~50ms | ~1ms |\n| Dependencies | Python 3.11+ | libc |\n| Daemon mode | Yes | No |\n| Commands | All | All except `validate`, `edit` |\n| Best for | Interactive use, daemon | Keybindings |\n| Source | [`client.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/client.py) | [`pypr-client.c`](https://github.com/fdev31/pyprland/blob/main/client/pypr-client.c) |\n\n## Configuration System\n\nConfiguration is stored in TOML format at `~/.config/pypr/config.toml`:\n\n```toml\n[pyprland]\nplugins = [\"scratchpads\", \"monitors\", \"magnify\"]\n\n[scratchpads.term]\ncommand = \"kitty --class scratchpad\"\nposition = \"50% 50%\"\nsize = \"80% 80%\"\n\n[monitors]\nunknown = \"extend\"\n```\n\n| Component | Source | Description |\n|-----------|--------|-------------|\n| `Configuration` | [`config.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/config.py) | Dict wrapper with typed accessors |\n| `ConfigValidator` | [`validation.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/validation.py) | Schema-based validation |\n| `ConfigField` | [`validation.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/validation.py) | Field definition (name, type, required, default) |\n\n## Shared State\n\nThe [`SharedState`](https://github.com/fdev31/pyprland/blob/main/pyprland/common.py) dataclass maintains commonly needed information:\n\n```python\n@dataclass\nclass SharedState:\n    active_workspace: str    # Current workspace name\n    active_monitor: str      # Current monitor name  \n    active_window: str       # Current window address\n    environment: str         # \"hyprland\" or \"niri\"\n    variables: dict          # User-defined variables\n    monitors: list[str]      # All monitor names\n    hyprland_version: VersionInfo\n```\n\n## Data Models\n\nTypedDict definitions in [`models.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/models.py) ensure type safety:\n\n```python\nclass ClientInfo(TypedDict):\n    address: str\n    mapped: bool\n    hidden: bool\n    workspace: WorkspaceInfo\n    class_: str  # aliased from \"class\"\n    title: str\n    # ... more fields\n\nclass MonitorInfo(TypedDict):\n    name: str\n    width: int\n    height: int\n    x: int\n    y: int\n    focused: bool\n    transform: int\n    # ... more fields\n```\n"
  },
  {
    "path": "site/versions/3.2.1/Architecture_overview.md",
    "content": "# Architecture Overview\n\nThis document provides a high-level overview of Pyprland's architecture, data flow, and design patterns.\n\n> [!tip]\n> For a practical guide to writing plugins, see the [Development](./Development) document.\n\n## Executive Summary\n\n**Pyprland** is a plugin-based companion application for tiling window managers (Hyprland, Niri). It operates as a daemon that extends the window manager's capabilities through a modular plugin system, communicating via Unix domain sockets (IPC).\n\n| Attribute | Value |\n|-----------|-------|\n| Language | Python 3.11+ |\n| License | MIT |\n| Architecture | Daemon/Client, Plugin-based |\n| Async Framework | asyncio |\n\n## High-Level Architecture\n\n```mermaid\nflowchart TB\n    subgraph User[\"👤 User Layer\"]\n        KB([\"⌨️ Keyboard Bindings\"])\n        CLI([\"💻 pypr / pypr-client\"])\n    end\n\n    subgraph Pyprland[\"🔶 Pyprland Daemon\"]\n        direction TB\n        CMD[\"🎯 Command Handler\"]\n        EVT[\"📨 Event Listener\"]\n        \n        subgraph Plugins[\"🔌 Plugin Registry\"]\n            P1[\"scratchpads\"]\n            P2[\"monitors\"]\n            P3[\"wallpapers\"]\n            P4[\"expose\"]\n            P5[\"...\"]\n        end\n        \n        subgraph Adapters[\"🔄 Backend Adapters\"]\n            HB[\"HyprlandBackend\"]\n            NB[\"NiriBackend\"]\n        end\n        \n        MGR[\"⚙️ Manager<br/>Orchestrator\"]\n        STATE[\"📦 SharedState\"]\n    end\n\n    subgraph WM[\"🪟 Window Manager\"]\n        HYPR([\"Hyprland\"])\n        NIRI([\"Niri\"])\n    end\n\n    KB --> CLI\n    CLI -->|Unix Socket| CMD\n    CMD --> MGR\n    MGR --> Plugins\n    Plugins --> Adapters\n    EVT -->|Event Stream| MGR\n    Adapters <-->|IPC Socket| WM\n    WM -->|Events| EVT\n    MGR --> STATE\n    Plugins --> STATE\n\n    style User fill:#7fb3d3,stroke:#5a8fa8,color:#000\n    style Pyprland fill:#d4a574,stroke:#a67c50,color:#000\n    style WM fill:#8fbc8f,stroke:#6a9a6a,color:#000\n    style Plugins fill:#c9a86c,stroke:#9a7a4a,color:#000\n    style Adapters fill:#c9a86c,stroke:#9a7a4a,color:#000\n```\n\n## Data Flow\n\n### Event Processing\n\nWhen the window manager emits an event (window opened, workspace changed, etc.):\n\n```mermaid\nsequenceDiagram\n    autonumber\n    participant WM as 🪟 Window Manager\n    participant IPC as 📡 IPC Layer\n    participant MGR as ⚙️ Manager\n    participant Q1 as 📥 Plugin A Queue\n    participant Q2 as 📥 Plugin B Queue\n    participant P1 as 🔌 Plugin A\n    participant P2 as 🔌 Plugin B\n\n    rect rgba(143, 188, 143, 0.2)\n        Note over WM,IPC: Event Reception\n        WM->>+IPC: Event stream (async)\n        IPC->>-MGR: Parse event (name, params)\n    end\n\n    rect rgba(127, 179, 211, 0.2)\n        Note over MGR,Q2: Event Distribution\n        par Parallel queuing\n            MGR->>Q1: Queue event\n            MGR->>Q2: Queue event\n        end\n    end\n\n    rect rgba(212, 165, 116, 0.2)\n        Note over Q1,WM: Plugin Execution\n        par Parallel processing\n            Q1->>P1: event_openwindow()\n            P1->>WM: Execute commands\n        and\n            Q2->>P2: event_openwindow()\n            P2->>WM: Execute commands\n        end\n    end\n```\n\n### Command Processing\n\nWhen the user runs `pypr <command>`:\n\n```mermaid\nsequenceDiagram\n    autonumber\n    participant User as 👤 User\n    participant CLI as 💻 pypr / pypr-client\n    participant Socket as 📡 Unix Socket\n    participant MGR as ⚙️ Manager\n    participant Plugin as 🔌 Plugin\n    participant Backend as 🔄 Backend\n    participant WM as 🪟 Window Manager\n\n    rect rgba(127, 179, 211, 0.2)\n        Note over User,Socket: Request Phase\n        User->>CLI: pypr toggle term\n        CLI->>Socket: Connect & send command\n        Socket->>MGR: handle_command()\n    end\n\n    rect rgba(212, 165, 116, 0.2)\n        Note over MGR,Plugin: Routing Phase\n        MGR->>MGR: Find plugin with run_toggle\n        MGR->>Plugin: run_toggle(\"term\")\n    end\n\n    rect rgba(143, 188, 143, 0.2)\n        Note over Plugin,WM: Execution Phase\n        Plugin->>Backend: execute(command)\n        Backend->>WM: IPC call\n        WM-->>Backend: Response\n        Backend-->>Plugin: Result\n    end\n\n    rect rgba(150, 120, 160, 0.2)\n        Note over Plugin,User: Response Phase\n        Plugin-->>MGR: Return value\n        MGR-->>Socket: Response\n        Socket-->>CLI: Display result\n    end\n```\n\n## Directory Structure\n\nAll source files are in the [`pyprland/`](https://github.com/fdev31/pyprland/tree/main/pyprland) directory:\n\n```\npyprland/\n├── command.py           # CLI entry point, argument parsing\n├── pypr_daemon.py       # Daemon startup logic\n├── manager.py           # Core Pyprland class (orchestrator)\n├── client.py            # Client mode implementation\n├── ipc.py               # Socket communication with WM\n├── config.py            # Configuration wrapper\n├── validation.py        # Config validation framework\n├── common.py            # Shared utilities, SharedState, logging\n├── constants.py         # Global constants\n├── models.py            # TypedDict definitions\n├── version.py           # Version string\n├── aioops.py            # Async file ops, DebouncedTask\n├── completions.py       # Shell completion generators\n├── help.py              # Help system\n├── ansi.py              # Terminal colors/styling\n├── debug.py             # Debug utilities\n│\n├── adapters/            # Window manager abstraction\n│   ├── backend.py       # Abstract EnvironmentBackend\n│   ├── hyprland.py      # Hyprland implementation\n│   ├── niri.py          # Niri implementation\n│   ├── menus.py         # Menu engine abstraction (rofi, wofi, etc.)\n│   └── units.py         # Unit conversion utilities\n│\n└── plugins/             # Plugin implementations\n    ├── interface.py     # Plugin base class\n    ├── protocols.py     # Event handler protocols\n    │\n    ├── pyprland/        # Core internal plugin\n    ├── scratchpads/     # Scratchpad plugin (complex, multi-file)\n    ├── monitors/        # Monitor management\n    ├── wallpapers/      # Wallpaper management\n    │\n    └── *.py             # Simple single-file plugins\n```\n\n## Design Patterns\n\n| Pattern | Usage |\n|---------|-------|\n| **Plugin Architecture** | Extensibility via [`Plugin`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/interface.py) base class |\n| **Adapter Pattern** | [`EnvironmentBackend`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/backend.py) abstracts WM differences |\n| **Strategy Pattern** | Menu engines in [`menus.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/menus.py) (rofi, wofi, tofi, etc.) |\n| **Observer Pattern** | Event handlers subscribe to WM events |\n| **Async Task Queues** | Per-plugin isolation, prevents blocking |\n| **Decorator Pattern** | `@retry_on_reset` in [`ipc.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/ipc.py), `@remove_duplicate` in [`manager.py`](https://github.com/fdev31/pyprland/blob/main/pyprland/manager.py) |\n| **Template Method** | Plugin lifecycle hooks (`init`, `on_reload`, `exit`) |\n"
  },
  {
    "path": "site/versions/3.2.1/Commands.md",
    "content": "# Commands\n\n<script setup>\nimport PluginCommands from './components/PluginCommands.vue'\n</script>\n\nThis page covers the `pypr` command-line interface and available commands.\n\n## Overview\n\nThe `pypr` command operates in two modes:\n\n| Usage | Mode | Description |\n|-------|------|-------------|\n| `pypr` | Daemon | Starts the Pyprland daemon (foreground) |\n| `pypr <command>` | Client | Sends a command to the running daemon |\n\n\nThere is also an optional `pypr-client` command which is designed for running in keyboard-bindings since it starts faster but doesn't support every built-in command (eg: `validate`, `edit`).\n\n> [!tip]\n> Command names can use dashes or underscores interchangeably.\n> E.g., `pypr shift_monitors` and `pypr shift-monitors` are equivalent.\n\n## Built-in Commands\n\nThese commands are always available, regardless of which plugins are loaded:\n\n<PluginCommands plugin=\"pyprland\"  version=\"3.2.1\" />\n\n## Plugin Commands\n\nEach plugin can add its own commands. Use `pypr help` to see the commands made available by the list of plugins you set in your configuration file.\n\nExamples:\n- `scratchpads` plugin adds: `toggle`, `show`, `hide`\n- `magnify` plugin adds: `zoom`\n- `expose` plugin adds: `expose`\n\nSee individual [plugin documentation](./Plugins) for command details.\n\n## Shell Completions {#command-compgen}\n\nPyprland can generate shell completions dynamically based on your loaded plugins and configuration.\n\n### Generating Completions\n\nWith the daemon running:\n\n```sh\n# Output to stdout (redirect to file)\npypr compgen zsh > ~/.zsh/completions/_pypr\n\n# Install to default user path\npypr compgen bash default\npypr compgen zsh default\npypr compgen fish default\n\n# Install to custom path (absolute or ~/)\npypr compgen bash ~/custom/path/pypr\npypr compgen zsh /etc/zsh/completions/_pypr\n```\n\n> [!warning]\n> Relative paths may not do what you expect. Use `default`, an absolute path, or a `~/` path.\n\n### Default Installation Paths\n\n| Shell | Default Path |\n|-------|--------------|\n| Bash | `~/.local/share/bash-completion/completions/pypr` |\n| Zsh | `~/.zsh/completions/_pypr` |\n| Fish | `~/.config/fish/completions/pypr.fish` |\n\n> [!tip]\n> For Zsh, the default path may not be in your `$fpath`. Pypr will show instructions to add it.\n\n> [!note]\n> Regenerate completions after adding new plugins or scratchpads to keep them up to date.\n\n## pypr-client {#pypr-client}\n\n`pypr-client` is a lightweight, compiled alternative to `pypr` for sending commands to the daemon. It's significantly faster and ideal for key bindings.\n\n### When to Use It\n\n- In `hyprland.conf` key bindings where startup time matters\n- When you need minimal latency (e.g., toggling scratchpads)\n\n### Limitations\n\n- Cannot run the daemon (use `pypr` for that)\n- Does not support `validate` or `edit` commands (these require Python)\n\n### Installation\n\nDepending on your installation method, `pypr-client` may already be available. If not:\n\n1. Download the [source code](https://github.com/hyprland-community/pyprland/tree/main/client/)\n2. Compile it: `gcc -o pypr-client pypr-client.c`\n\nRust and Go versions are also available in the same directory.\n\n### Usage in hyprland.conf\n\n```ini\n# Use pypr-client for faster key bindings\n$pypr = /usr/bin/pypr-client\n\nbind = $mainMod, A, exec, $pypr toggle term\nbind = $mainMod, B, exec, $pypr expose\nbind = $mainMod SHIFT, Z, exec, $pypr zoom\n```\n\n> [!tip]\n> If using [uwsm](https://github.com/Vladimir-csp/uwsm), wrap the command:\n> ```ini\n> $pypr = uwsm-app -- /usr/bin/pypr-client\n> ```\n\nFor technical details about the client-daemon protocol, see [Architecture: Socket Protocol](./Architecture_core#pyprland-socket-protocol).\n\n## Debugging\n\nTo run the daemon with debug logging:\n\n```sh\npypr --debug\n```\n\nTo also save logs to a file:\n\n```sh\npypr --debug $HOME/pypr.log\n```\n\nOr in `hyprland.conf`:\n\n```ini\nexec-once = /usr/bin/pypr --debug $HOME/pypr.log\n```\n\nThe log file will contain detailed information useful for troubleshooting.\n"
  },
  {
    "path": "site/versions/3.2.1/Configuration.md",
    "content": "# Configuration\n\nThis page covers the configuration file format and available options.\n\n## File Location\n\nThe default configuration file is:\n\n```\n~/.config/pypr/config.toml\n```\n\nYou can specify a different path using the `--config` flag:\n\n```sh\npypr --config /path/to/config.toml\n```\n\n## Format\n\nPyprland uses the [TOML format](https://toml.io/). The basic structure is:\n\n```toml\n[pyprland]\nplugins = [\"plugin_name\", \"other_plugin\"]\n\n[plugin_name]\noption = \"value\"\n\n[plugin_name.nested_option]\nsuboption = 42\n```\n\n## [pyprland] Section\n\nThe main section configures the Pyprland daemon itself.\n\n<PluginConfig plugin=\"pyprland\" linkPrefix=\"config-\"  version=\"3.2.1\" />\n\n### `include` <ConfigBadges plugin=\"pyprland\" option=\"include\"  version=\"3.2.1\" /> {#config-include}\n\nList of additional configuration files to include. See [Multiple Configuration Files](./MultipleConfigurationFiles) for details.\n\n### `notification_type` <ConfigBadges plugin=\"pyprland\" option=\"notification_type\"  version=\"3.2.1\" /> {#config-notification-type}\n\nControls how notifications are displayed:\n\n| Value | Behavior |\n|-------|----------|\n| `\"auto\"` | Adapts to environment (Niri uses `notify-send`, Hyprland uses `hyprctl notify`) |\n| `\"notify-send\"` | Forces use of `notify-send` command |\n| `\"native\"` | Forces use of compositor's native notification system |\n\n### `variables` <ConfigBadges plugin=\"pyprland\" option=\"variables\"  version=\"3.2.1\" /> {#config-variables}\n\nCustom variables that can be used in plugin configurations. See [Variables](./Variables) for usage details.\n\n## Examples\n\n```toml\n[pyprland]\nplugins = [\n    \"scratchpads\",\n    \"magnify\",\n    \"expose\",\n]\nnotification_type = \"notify-send\"\n```\n\n### Plugin Configuration\n\nEach plugin can have its own configuration section. The format depends on the plugin:\n\n```toml\n# Simple options\n[magnify]\nfactor = 2\n\n# Nested options (e.g., scratchpads)\n[scratchpads.term]\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\n```\n\nSee individual [plugin documentation](./Plugins) for available options.\n\n### Multiple Configuration Files\n\nYou can split your configuration across multiple files using `include`:\n\n```toml\n[pyprland]\ninclude = [\n    \"~/.config/pypr/scratchpads.toml\",\n    \"~/.config/pypr/monitors.toml\",\n]\nplugins = [\"scratchpads\", \"monitors\"]\n```\n\nSee [Multiple Configuration Files](./MultipleConfigurationFiles) for details.\n\n## Hyprland Integration\n\nMost plugins provide commands that you'll want to bind to keys. Add bindings to your `hyprland.conf`:\n\n```ini\n# Define pypr command (adjust path as needed)\n$pypr = /usr/bin/pypr\n\n# Example bindings\nbind = $mainMod, A, exec, $pypr toggle term\nbind = $mainMod, B, exec, $pypr expose\nbind = $mainMod SHIFT, Z, exec, $pypr zoom\n```\n\n> [!tip]\n> For faster key bindings, use `pypr-client` instead of `pypr`. See [Commands](./Commands#pypr-client) for details.\n\n> [!tip]\n> Command names can use dashes or underscores interchangeably.\n> E.g., `pypr shift_monitors` and `pypr shift-monitors` are equivalent.\n\n## Validation\n\nYou can validate your configuration without running the daemon:\n\n```sh\npypr validate\n```\n\nThis checks your config against plugin schemas and reports any errors.\n\n## Tips\n\n- See [Examples](./Examples) for complete configuration samples\n- See [Optimizations](./Optimizations) for performance tips\n- Only enable plugins you actually use in the `plugins` array\n"
  },
  {
    "path": "site/versions/3.2.1/Development.md",
    "content": "# Development\n\nIt's easy to write your own plugin by making a Python package and then indicating its name as the plugin name.\n\n> [!tip]\n> For details on internal architecture, data flows, and design patterns, see the [Architecture](./Architecture) document.\n\n[Contributing guidelines](https://github.com/fdev31/pyprland/blob/main/CONTRIBUTING.md)\n\n## Development Setup\n\n### Prerequisites\n\n- Python 3.11+\n- [uv](https://docs.astral.sh/uv/getting-started/installation/) for dependency management\n- [pre-commit](https://pre-commit.com/) for Git hooks\n\n### Initial Setup\n\n```sh\n# Clone the repository\ngit clone https://github.com/fdev31/pyprland.git\ncd pyprland\n\n# Install dev and lint dependencies\nuv sync --all-groups\n\n# Install pre-commit hooks\nuv run pre-commit install\nuv run pre-commit install --hook-type pre-push\n```\n\n## Quick Start\n\n### Debugging\n\nTo get detailed logs when an error occurs, use:\n\n```sh\npypr --debug\n```\n\nThis displays logs in the console. To also save logs to a file:\n\n```sh\npypr --debug $HOME/pypr.log\n```\n\n### Quick Experimentation\n\n> [!note]\n> To quickly get started, you can directly edit the built-in [`experimental`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/experimental.py) plugin.\n> To distribute your plugin, create your own Python package or submit a pull request.\n\n### Custom Plugin Paths\n\n> [!tip]\n> Set `plugins_paths = [\"/custom/path\"]` in the `[pyprland]` section of your config to add extra plugin search paths during development.\n\n## Writing Plugins\n\n### Plugin Loading\n\nPlugins are loaded by their full Python module path:\n\n```toml\n[pyprland]\nplugins = [\"mypackage.myplugin\"]\n```\n\nThe module must provide an `Extension` class inheriting from [`Plugin`](https://github.com/fdev31/pyprland/blob/main/pyprland/plugins/interface.py).\n\n> [!note]\n> If your extension is at the root level (not recommended), you can import it using the `external:` prefix:\n> ```toml\n> plugins = [\"external:myplugin\"]\n> ```\n> Prefer namespaced packages like `johns_pyprland.super_feature` instead.\n\n### Plugin Attributes\n\nYour `Extension` class has access to these attributes:\n\n| Attribute | Type | Description |\n|-----------|------|-------------|\n| `self.name` | `str` | Plugin identifier |\n| `self.config` | [`Configuration`](https://github.com/fdev31/pyprland/blob/main/pyprland/config.py) | Plugin's TOML config section |\n| `self.state` | [`SharedState`](https://github.com/fdev31/pyprland/blob/main/pyprland/common.py) | Shared application state (active workspace, monitor, etc.) |\n| `self.backend` | [`EnvironmentBackend`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/backend.py) | WM interaction: commands, queries, notifications |\n| `self.log` | `Logger` | Plugin-specific logger |\n\n### Creating Your First Plugin\n\n```python\nfrom pyprland.plugins.interface import Plugin\n\n\nclass Extension(Plugin):\n    \"\"\"My custom plugin.\"\"\"\n\n    async def init(self) -> None:\n        \"\"\"Called once at startup.\"\"\"\n        self.log.info(\"My plugin initialized\")\n\n    async def on_reload(self) -> None:\n        \"\"\"Called on init and config reload.\"\"\"\n        self.log.info(f\"Config: {self.config}\")\n\n    async def exit(self) -> None:\n        \"\"\"Cleanup on shutdown.\"\"\"\n        pass\n```\n\n### Adding Commands\n\nAdd `run_<commandname>` methods to handle `pypr <commandname>` calls.\n\nThe **first line** of the docstring appears in `pypr help`:\n\n```python\nclass Extension(Plugin):\n    zoomed = False\n\n    async def run_togglezoom(self, args: str) -> str | None:\n        \"\"\"Toggle zoom level.\n\n        This second line won't appear in CLI help.\n        \"\"\"\n        if self.zoomed:\n            await self.backend.execute(\"keyword misc:cursor_zoom_factor 1\")\n        else:\n            await self.backend.execute(\"keyword misc:cursor_zoom_factor 2\")\n        self.zoomed = not self.zoomed\n```\n\n### Reacting to Events\n\nAdd `event_<eventname>` methods to react to [Hyprland events](https://wiki.hyprland.org/IPC/):\n\n```python\nasync def event_openwindow(self, params: str) -> None:\n    \"\"\"React to window open events.\"\"\"\n    addr, workspace, cls, title = params.split(\",\", 3)\n    self.log.debug(f\"Window opened: {title}\")\n\nasync def event_workspace(self, workspace: str) -> None:\n    \"\"\"React to workspace changes.\"\"\"\n    self.log.info(f\"Switched to workspace: {workspace}\")\n```\n\n> [!note]\n> **Code Safety:** Pypr ensures only one handler runs at a time per plugin, so you don't need concurrency handling. Each plugin runs independently in parallel. See [Architecture - Manager](./Architecture#manager) for details.\n\n### Configuration Schema\n\nDefine expected config fields for automatic validation using [`ConfigField`](https://github.com/fdev31/pyprland/blob/main/pyprland/validation.py):\n\n```python\nfrom pyprland.plugins.interface import Plugin\nfrom pyprland.validation import ConfigField\n\n\nclass Extension(Plugin):\n    config_schema = [\n        ConfigField(\"enabled\", bool, required=False, default=True),\n        ConfigField(\"timeout\", int, required=False, default=5000),\n        ConfigField(\"command\", str, required=True),\n    ]\n\n    async def on_reload(self) -> None:\n        # Config is validated before on_reload is called\n        cmd = self.config[\"command\"]  # Guaranteed to exist\n```\n\n### Using Menus\n\nFor plugins that need menu interaction (rofi, wofi, tofi, etc.), use [`MenuMixin`](https://github.com/fdev31/pyprland/blob/main/pyprland/adapters/menus.py):\n\n```python\nfrom pyprland.adapters.menus import MenuMixin\nfrom pyprland.plugins.interface import Plugin\n\n\nclass Extension(MenuMixin, Plugin):\n    async def run_select(self, args: str) -> None:\n        \"\"\"Show a selection menu.\"\"\"\n        await self.ensure_menu_configured()\n\n        options = [\"Option 1\", \"Option 2\", \"Option 3\"]\n        selected = await self.menu(options, \"Choose an option:\")\n\n        if selected:\n            await self.backend.notify_info(f\"Selected: {selected}\")\n```\n\n## Reusable Code\n\n### Shared State\n\nAccess commonly needed information without fetching it:\n\n```python\n# Current workspace, monitor, window\nworkspace = self.state.active_workspace\nmonitor = self.state.active_monitor\nwindow_addr = self.state.active_window\n\n# Environment detection\nif self.state.environment == \"niri\":\n    # Niri-specific logic\n    pass\n```\n\nSee [Architecture - Shared State](./Architecture#shared-state) for all available fields.\n\n### Mixins\n\nUse mixins for common functionality:\n\n```python\nfrom pyprland.common import CastBoolMixin\nfrom pyprland.plugins.interface import Plugin\n\n\nclass Extension(CastBoolMixin, Plugin):\n    async def on_reload(self) -> None:\n        # Safely cast config values to bool\n        enabled = self.cast_bool(self.config.get(\"enabled\", True))\n```\n\n## Development Workflow\n\nRestart the daemon after making changes:\n\n```sh\npypr exit ; pypr --debug\n```\n\n### API Documentation\n\nGenerate and browse the full API documentation:\n\n```sh\ntox run -e doc\n# Then visit http://localhost:8080\n```\n\n## Testing & Quality Assurance\n\n### Running All Checks\n\nBefore submitting a PR, run the full test suite:\n\n```sh\ntox\n```\n\nThis runs unit tests across Python versions and linting checks.\n\n### Tox Environments\n\n| Environment | Command | Description |\n|-------------|---------|-------------|\n| `py314-unit` | `tox run -e py314-unit` | Unit tests (Python 3.14) |\n| `py311-unit` | `tox run -e py311-unit` | Unit tests (Python 3.11) |\n| `py312-unit` | `tox run -e py312-unit` | Unit tests (Python 3.12) |\n| `py314-linting` | `tox run -e py314-linting` | Full linting suite (mypy, ruff, pylint, flake8) |\n| `py314-wiki` | `tox run -e py314-wiki` | Check plugin documentation coverage |\n| `doc` | `tox run -e doc` | Generate API docs with pdoc |\n| `coverage` | `tox run -e coverage` | Run tests with coverage report |\n| `deadcode` | `tox run -e deadcode` | Detect dead code with vulture |\n\n### Quick Test Commands\n\n```sh\n# Run unit tests only\ntox run -e py314-unit\n\n# Run linting only\ntox run -e py314-linting\n\n# Check documentation coverage\ntox run -e py314-wiki\n\n# Run tests with coverage\ntox run -e coverage\n```\n\n## Pre-commit Hooks\n\nPre-commit hooks ensure code quality before commits and pushes.\n\n### Installation\n\n```sh\npip install pre-commit\npre-commit install\npre-commit install --hook-type pre-push\n```\n\n### What Runs Automatically\n\n**On every commit:**\n\n| Hook | Purpose |\n|------|---------|\n| `versionMgmt` | Auto-increment version number |\n| `wikiDocGen` | Regenerate plugin documentation JSON |\n| `wikiDocCheck` | Verify documentation coverage |\n| `ruff-check` | Lint Python code |\n| `ruff-format` | Format Python code |\n| `flake8` | Additional Python linting |\n| `check-yaml` | Validate YAML files |\n| `check-json` | Validate JSON files |\n| `pretty-format-json` | Auto-format JSON files |\n| `beautysh` | Format shell scripts |\n| `yamllint` | Lint YAML files |\n\n**On push:**\n\n| Hook | Purpose |\n|------|---------|\n| `runtests` | Run full pytest suite |\n\n### Manual Execution\n\nRun all hooks manually:\n\n```sh\npre-commit run --all-files\n```\n\nRun a specific hook:\n\n```sh\npre-commit run ruff-check --all-files\n```\n\n## Packaging & Distribution\n\n### Creating an External Plugin Package\n\nSee the [sample extension](https://github.com/fdev31/pyprland/tree/main/sample_extension) for a complete example with:\n- Proper package structure\n- `pyproject.toml` configuration\n- Example plugin code: [`focus_counter.py`](https://github.com/fdev31/pyprland/blob/main/sample_extension/pypr_examples/focus_counter.py)\n\n### Development Installation\n\nInstall your package in editable mode for testing:\n\n```sh\ncd your-plugin-package/\npip install -e .\n```\n\n### Publishing\n\nWhen ready to distribute:\n\n```sh\nuv publish\n```\n\nDon't forget to update the details in your `pyproject.toml` file first.\n\n### Example Usage\n\nAdd your plugin to the config:\n\n```toml\n[pyprland]\nplugins = [\"pypr_examples.focus_counter\"]\n\n[\"pypr_examples.focus_counter\"]\nmultiplier = 2\n```\n\n> [!important]\n> Contact the maintainer to get your extension listed on the home page.\n\n## Further Reading\n\n- [Architecture](./Architecture) - Internal system design, data flows, and design patterns\n- [Plugins](./Plugins) - List of available built-in plugins\n- [Sample Extension](https://github.com/fdev31/pyprland/tree/main/sample_extension) - Complete example plugin package\n- [Hyprland IPC](https://wiki.hyprland.org/IPC/) - Hyprland's IPC documentation\n"
  },
  {
    "path": "site/versions/3.2.1/Examples.md",
    "content": "# Examples\n\nThis page provides complete configuration examples to help you get started.\n\n## Basic Setup\n\nA minimal configuration with a few popular plugins:\n\n### pyprland.toml\n\n```toml\n[pyprland]\nplugins = [\n    \"scratchpads\",\n    \"magnify\",\n    \"expose\",\n]\n\n[scratchpads.term]\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\nanimation = \"fromTop\"\n\n[scratchpads.volume]\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nsize = \"40% 90%\"\nanimation = \"fromRight\"\nlazy = true\n```\n\n### hyprland.conf\n\n```ini\n$pypr = /usr/bin/pypr\n\nbind = $mainMod, A, exec, $pypr toggle term\nbind = $mainMod, V, exec, $pypr toggle volume\nbind = $mainMod, B, exec, $pypr expose\nbind = $mainMod SHIFT, Z, exec, $pypr zoom\n```\n\n## Full-Featured Setup\n\nA comprehensive configuration demonstrating multiple plugins and features:\n\n### pyprland.toml\n\n```toml\n[pyprland]\nplugins = [\n  \"scratchpads\",\n  \"lost_windows\",\n  \"monitors\",\n  \"toggle_dpms\",\n  \"magnify\",\n  \"expose\",\n  \"shift_monitors\",\n  \"workspaces_follow_focus\",\n]\n\n[monitors.placement]\n\"Acer\".top_center_of = \"Sony\"\n\n[workspaces_follow_focus]\nmax_workspaces = 9\n\n[expose]\ninclude_special = false\n\n[scratchpads.stb]\nanimation = \"fromBottom\"\ncommand = \"kitty --class kitty-stb sstb\"\nclass = \"kitty-stb\"\nlazy = true\nsize = \"75% 45%\"\n\n[scratchpads.stb-logs]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-stb-logs stbLog\"\nclass = \"kitty-stb-logs\"\nlazy = true\nsize = \"75% 40%\"\n\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nlazy = true\nsize = \"40% 90%\"\nunfocus = \"hide\"\n```\n\n### hyprland.conf\n\n```ini\n# Use pypr-client for faster response in key bindings\n$pypr = uwsm-app -- /usr/local/bin/pypr-client\n\nbind = $mainMod SHIFT,  Z, exec, $pypr zoom\nbind = $mainMod ALT,    P, exec, $pypr toggle_dpms\nbind = $mainMod SHIFT,  O, exec, $pypr shift_monitors +1\nbind = $mainMod,        B, exec, $pypr expose\nbind = $mainMod,        K, exec, $pypr change_workspace +1\nbind = $mainMod,        J, exec, $pypr change_workspace -1\nbind = $mainMod,        L, exec, $pypr toggle_dpms\nbind = $mainMod  SHIFT, M, exec, $pypr toggle stb stb-logs\nbind = $mainMod,        A, exec, $pypr toggle term\nbind = $mainMod,        V, exec, $pypr toggle volume\n```\n\n> [!note]\n> This example uses `pypr-client` for faster key binding response. See [Commands: pypr-client](./Commands#pypr-client) for details.\n\n## Advanced Features\n\n### Variables\n\nYou can define reusable variables in your configuration to avoid repetition and make it easier to switch terminals or other tools.\n\nDefine variables in the `[pyprland.variables]` section:\n\n```toml\n[pyprland.variables]\nterm = \"foot\"\nterm_classed = \"foot -a\"  # For kitty, use \"kitty --class\"\n```\n\nThen use them in plugin configurations that support variable substitution:\n\n```toml\n[scratchpads.term]\ncommand = \"[term_classed] scratchterm\"\nclass = \"scratchterm\"\n```\n\nThis way, switching from `foot` to `kitty` only requires changing the variables, not every scratchpad definition.\n\nSee [Variables](./Variables) for more details.\n\n### Text Filters\n\nSome plugins support text filters for transforming output. Filters use a syntax similar to sed's `s` command:\n\n```toml\nfilter = 's/foo/bar/'           # Replace first \"foo\" with \"bar\"\nfilter = 's/foo/bar/g'          # Replace all occurrences\nfilter = 's/.*started (.*)/\\1 has started/'  # Regex with capture groups\nfilter = 's#</?div>##g'         # Use different delimiter\n```\n\nSee [Filters](./filters) for more details.\n\n## Community Examples\n\nBrowse community-contributed configuration files:\n\n- [GitHub examples folder](https://github.com/hyprland-community/pyprland/tree/main/examples)\n\nFeel free to share your own configurations by contributing to the repository.\n\n## Tips\n\n- [Optimizations](./Optimizations) - Performance tuning tips\n- [Troubleshooting](./Troubleshooting) - Common issues and solutions\n- [Multiple Configuration Files](./MultipleConfigurationFiles) - Split your config for better organization\n"
  },
  {
    "path": "site/versions/3.2.1/Getting-started.md",
    "content": "# Getting Started\n\nPypr consists of two things:\n\n- **A tool**: `pypr` which runs the daemon (service) and allows you to interact with it\n- **A config file**: `~/.config/pypr/config.toml` using the [TOML](https://toml.io/en/) format\n\n> [!important]\n> - With no arguments, `pypr` runs the daemon (doesn't fork to background)\n> - With arguments, it sends commands to the running daemon\n\n> [!tip]\n> For keybindings, use `pypr-client` instead of `pypr` for faster response (~1ms vs ~50ms). See [Commands: pypr-client](./Commands#pypr-client) for details.\n\n## Installation\n\nCheck your OS package manager first:\n\n- **Arch Linux**: Available on AUR, e.g., with [yay](https://github.com/Jguer/yay): `yay pyprland`\n- **NixOS**: See the [Nix](./Nix) page for instructions\n\nOtherwise, you may want using the [uv](https://docs.astral.sh/uv/) way:\n\n```sh\n# Install pyprland\nuv tool install pyprland\n```\n\n\nelse, install via pip (in a [virtual environment](./InstallVirtualEnvironment)):\n\n```sh\npip install pyprland\n```\n\n## Minimal Configuration\n\nCreate `~/.config/pypr/config.toml` with:\n\n```toml\n[pyprland]\nplugins = [\n    \"scratchpads\",\n    \"gamemode\",\n    \"magnify\",\n]\n```\n\nThis enables only few plugins. See the [Plugins](./Plugins) page for the full list.\n\n## Running the Daemon\n\n> [!caution]\n> If you installed pypr outside your OS package manager (e.g., pip, virtual environment), use the full path to the `pypr` command. Get it with `which pypr` in a working terminal.\n\n### Option 1: Hyprland exec-once\n\nAdd to your `hyprland.conf`:\n\n```ini\nexec-once = /usr/bin/pypr\n```\n\nFor debugging, use:\n\n```ini\nexec-once = /usr/bin/pypr --debug\n```\n\nOr to also save logs to a file:\n\n```ini\nexec-once = /usr/bin/pypr --debug $HOME/pypr.log\n```\n\n### Option 2: Systemd User Service\n\nCreate `~/.config/systemd/user/pyprland.service`:\n\n```ini\n[Unit]\nDescription=Starts pyprland daemon\nAfter=graphical-session.target\nWants=graphical-session.target\n# Optional: wait for other services to start first\n# Wants=hyprpaper.service\nStartLimitIntervalSec=600\nStartLimitBurst=5\n\n[Service]\nType=simple\n# Optional: only start on specific compositor\n# For Hyprland:\n# ExecStartPre=/bin/sh -c '[ \"$XDG_CURRENT_DESKTOP\" = \"Hyprland\" ] || exit 0'\n# For Niri:\n# ExecStartPre=/bin/sh -c '[ \"$XDG_CURRENT_DESKTOP\" = \"niri\" ] || exit 0'\nExecStart=pypr\nRestart=always\n\n[Install]\nWantedBy=graphical-session.target\n```\n\nThen enable and start the service:\n\n```sh\nsystemctl enable --user --now pyprland.service\n```\n\n## Verifying It Works\n\nOnce the daemon is running, check available commands:\n\n```sh\npypr help\n```\n\nIf something isn't working, check the [Troubleshooting](./Troubleshooting) page.\n\n## Next Steps\n\n- [Configuration](./Configuration) - Full configuration reference\n- [Commands](./Commands) - CLI commands and shell completions\n- [Plugins](./Plugins) - Browse available plugins\n- [Examples](./Examples) - Complete configuration examples\n"
  },
  {
    "path": "site/versions/3.2.1/InstallVirtualEnvironment.md",
    "content": "# Virtual env\n\nEven though the best way to get Pyprland installed is to use your operating system package manager,\nfor some usages or users it can be convenient to use a virtual environment.\n\nThis is very easy to achieve in a couple of steps:\n\n```shell\npython -m venv ~/pypr-env\n~/pypr-env/bin/pip install pyprland\n```\n\n**That's all folks!**\n\nThe only extra care to take is to use `pypr` from the virtual environment, eg:\n\n- adding the environment's \"bin\" folder to the `PATH` (using `export PATH=\"$PATH:~/pypr-env/bin/\"` in your shell configuration file)\n- always using the full path to the pypr command (in `hyprland.conf`: `exec-once = ~/pypr-env/bin/pypr` or with debug logging: `exec-once = ~/pypr-env/bin/pypr --debug $HOME/pypr.log`)\n\n# Going bleeding edge!\n\nIf you would rather like to use the latest version available (not released yet), then you can clone the git repository and install from it:\n\n```shell\ncd ~/pypr-env\ngit clone git@github.com:hyprland-community/pyprland.git pyprland-sources\ncd pyprland-sources\n../bin/pip install -e .\n```\n\n## Updating\n\n```shell\ncd ~/pypr-env\ngit pull -r\n```\n\n# Troubelshooting\n\nIf things go wrong, try (eg: after a system upgrade where Python got updated):\n\n```shell\npython -m venv --upgrade ~/pypr-env\ncd  ~/pypr-env\n../bin/pip install -e .\n```\n"
  },
  {
    "path": "site/versions/3.2.1/Menu.md",
    "content": "# Menu capability\n\nMenu based plugins have the following configuration options:\n\n<PluginConfig plugin=\"menu\" linkPrefix=\"config-\"  version=\"3.2.1\" />\n\n### `engine` <ConfigBadges plugin=\"menu\" option=\"engine\"  version=\"3.2.1\" /> {#config-engine}\n\nAuto-detects the available menu engine if not set.\n\nSupported engines (tested in order):\n\n<EngineList />\n\n> [!note]\n> If your menu system isn't supported, you can open a [feature request](https://github.com/hyprland-community/pyprland/issues/new?assignees=fdev31&labels=bug&projects=&template=feature_request.md&title=%5BFEAT%5D+Description+of+the+feature)\n>\n> In case the engine isn't recognized, `engine` + `parameters` configuration options will be used to start the process, it requires a dmenu-like behavior.\n\n### `parameters` <ConfigBadges plugin=\"menu\" option=\"parameters\"  version=\"3.2.1\" /> {#config-parameters}\n\nExtra parameters added to the engine command. Setting this will override the engine's default value.\n\n> [!tip]\n> You can use `[prompt]` in the parameters, it will be replaced by the prompt, eg for rofi/wofi:\n> ```sh\n> -dmenu -matching fuzzy -i -p '[prompt]'\n> ```\n\n#### Default parameters per engine\n\n<EngineDefaults  version=\"3.2.1\" />\n"
  },
  {
    "path": "site/versions/3.2.1/MultipleConfigurationFiles.md",
    "content": "### Multiple configuration files\n\nYou can also split your configuration into multiple files that will be loaded in the provided order after the main file:\n```toml\n[pyprland]\ninclude = [\"/shared/pyprland.toml\", \"~/pypr_extra_config.toml\"]\n```\nYou can also load folders, in which case TOML files in the folder will be loaded in alphabetical order:\n```toml\n[pyprland]\ninclude = [\"~/.config/pypr.d/\"]\n```\n\nAnd then add a `~/.config/pypr.d/monitors.toml` file:\n```toml\npyprland.plugins = [ \"monitors\" ]\n\n[monitors.placement]\nBenQ.Top_Center_Of = \"DP-1\" # projo\n\"CJFH277Q3HCB\".top_of = \"eDP-1\" # work\n```\n\n> [!tip]\n> To check the final merged configuration, you can use the `dumpjson` command.\n\n"
  },
  {
    "path": "site/versions/3.2.1/Nix.md",
    "content": "# Nix\n\nYou are recommended to get the latest pyprland package from the [flake.nix](https://github.com/hyprland-community/pyprland/blob/main/flake.nix)\nprovided within this repository. To use it in your system, you may add pyprland\nto your flake inputs.\n\n## Flake\n\n```nix\n## flake.nix\n{\n  inputs = {\n    nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n    pyprland.url = \"github:hyprland-community/pyprland\";\n  };\n\n  outputs = { self, nixpkgs, pyprland, ...}: let\n  in {\n    nixosConfigurations.<yourHostname> = nixpkgs.lib.nixosSystem {\n      #         remember to replace this with your system arch ↓\n      environment.systemPackages = [ pyprland.packages.\"x86_64-linux\".pyprland ];\n      # ...\n    };\n  };\n}\n```\n\nAlternatively, if you are using the Nix package manager but not NixOS as your\nmain distribution, you may use `nix profile` tool to install pyprland from this\nrepository using the following command.\n\n```bash\nnix profile install github:nix-community/pyprland\n```\n\nThe package will now be in your latest profile. You may use `nix profile list`\nto verify your installation.\n\n## Nixpkgs\n\nPyprland is available under nixpkgs, and can be installed by adding\n`pkgs.pyprland` to either `environment.systemPackages` or `home.packages`\ndepending on whether you want it available system-wide or to only a single\nuser using home-manager. If the derivation available in nixpkgs is out-of-date\nthen you may consider using `overrideAttrs` to update the source locally.\n\n```nix\nlet\n  pyprland = pkgs.pyprland.overrideAttrs {\n    version = \"your-version-here\";\n    src = fetchFromGitHub {\n      owner = \"hyprland-community\";\n      repo = \"pyprland\";\n      rev = \"tag-or-revision\";\n      # leave empty for the first time, add the new hash from the error message\n      hash = \"\";\n    };\n  };\nin {\n  # add the overridden package to systemPackages\n  environment.systemPackages = [pyprland];\n}\n```\n"
  },
  {
    "path": "site/versions/3.2.1/Optimizations.md",
    "content": "## Optimizing\n\n### Plugins\n\n- Only enable the plugins you are using in the `plugins` array (in `[pyprland]` section).\n- Leaving the configuration for plugins which are not enabled will have no impact.\n- Using multiple configuration files only have a small impact on the startup time.\n\n### Pypr\n\nYou can run `pypr` using `pypy` (version 3) for a more snappy experience.\nOne way is to use a `pypy3` virtual environment:\n\n```bash\npypy3 -m venv pypr-venv\nsource ./pypr-venv/bin/activate\ncd <pypr source folder>\npip install -e .\n```\n\n### Pypr command\n\nIn case you want to save some time when interacting with the daemon, the simplest is to use `pypr-client`. See [Commands: pypr-client](./Commands#pypr-client) for details. If `pypr-client` isn't available from your OS package and you cannot compile code,\nyou can use `socat` instead (needs to be installed).\n\nExample of a `pypr-cli` command (should be reachable from your environment's `PATH`):\n\n#### Hyprland\n```sh\n#!/bin/sh\nsocat - \"UNIX-CONNECT:${XDG_RUNTIME_DIR}/hypr/${HYPRLAND_INSTANCE_SIGNATURE}/.pyprland.sock\" <<< $@\n```\n\n#### Niri\n```sh\n#!/bin/sh\nsocat - \"UNIX-CONNECT:$(dirname ${NIRI_SOCKET})/.pyprland.sock\" <<< $@\n```\n\n#### Standalone (other window manager)\n```sh\n#!/bin/sh\nsocat - \"UNIX-CONNECT:${XDG_DATA_HOME:-$HOME/.local/share}/.pyprland.sock\" <<< $@\n```\n\nOn slow systems this may make a difference.\nNote that `validate` and `edit` commands require the standard `pypr` command.\n"
  },
  {
    "path": "site/versions/3.2.1/Plugins.md",
    "content": "<script setup>\nimport PluginList from '/components/PluginList.vue'\n</script>\n\nThis page lists every plugin provided by Pyprland out of the box, more can be enabled if you install the matching package.\n\n\"🌟\" indicates some maturity & reliability level of the plugin, considering age, attention paid and complexity - from 0 to 3.\n\nA badge such as <Badge type=\"tip\">multi-monitor</Badge> indicates a requirement.\n\nSome plugins require an external **graphical menu system**, such as *rofi*.\nEach plugin can use a different menu system but the [configuration is unified](Menu). In case no [engine](Menu#engine) is provided some auto-detection of installed applications will happen.\n\n<PluginList version=\"3.2.1\" />\n"
  },
  {
    "path": "site/versions/3.2.1/Troubleshooting.md",
    "content": "# Troubleshooting\n\n## Checking Logs\n\nHow you access logs depends on how you run pyprland.\n\n### Systemd Service\n\nIf you run pyprland as a [systemd user service](./Getting-started#option-2-systemd-user-service):\n\n```sh\njournalctl --user -u pyprland -f\n```\n\n### exec-once (Hyprland)\n\nIf you run pyprland via [exec-once](./Getting-started#option-1-hyprland-exec-once), logs go to stderr by default and are typically lost.\n\nTo enable debug logging, add `--debug` to your exec-once command. Optionally specify a file path to also save logs to a file:\n\n```ini\nexec-once = /usr/bin/pypr --debug $HOME/pypr.log\n```\n\nThen check the log file:\n\n```sh\ntail -f ~/pypr.log\n```\n\n> [!tip]\n> Use a path like `$HOME/pypr.log` or `/tmp/pypr.log` to avoid cluttering your home directory.\n\n### Running from Terminal\n\nFor quick debugging, run pypr directly in a terminal:\n\n```sh\npypr --debug\n```\n\nThis shows debug output directly in the terminal. Optionally add a file path to also save logs to a file.\n\n## General Issues\n\nIn case of trouble running a `pypr` command:\n\n1. Kill the existing pypr daemon if running (try `pypr exit` first)\n2. Run from a terminal with `--debug` to see error messages\n\nIf the client says it can't connect, the daemon likely didn't start. Check if it's running:\n\n```sh\nps aux | grep pypr\n```\n\nYou can try starting it manually from a terminal:\n\n```sh\npypr --debug\n```\n\nThis will show any startup errors directly in the terminal.\n\n## Force Hyprland Version\n\nIn case your `hyprctl version -j` command isn't returning an accurate version, you can make Pyprland ignore it and use a provided value instead:\n\n```toml\n[pyprland]\nhyprland_version = \"0.41.0\"\n```\n\n## Unresponsive Scratchpads\n\nScratchpads aren't responding for a few seconds after trying to show one (which didn't show!)\n\nThis may happen if an application is very slow to start.\nIn that case pypr will wait for a window, blocking other scratchpad operations, before giving up after a few seconds.\n\nNote that other plugins shouldn't be blocked by this.\n\nMore scratchpads troubleshooting can be found [here](./scratchpads_nonstandard).\n\n## See Also\n\n- [Getting Started: Running the Daemon](./Getting-started#running-the-daemon) - Setup options\n- [Commands: Debugging](./Commands#debugging) - Debug flag reference\n"
  },
  {
    "path": "site/versions/3.2.1/Variables.md",
    "content": "# Variables & substitutions\n\nSome commands support shared global variables, they must be defined in the *pyprland* section of the configuration:\n```toml\n[pyprland.variables]\nterm = \"foot\"\nterm_classed = \"foot -a\" # kitty uses --class\n```\n\nIf a plugin supports it, you can then use the variables in the attribute that supports it, eg:\n\n```toml\n[myplugin]\nsome_variable = \"the terminal is [term]\"\n```\n"
  },
  {
    "path": "site/versions/3.2.1/components/CommandList.vue",
    "content": "<template>\n    <dl>\n    </dl>\n    <ul v-for=\"command in commands\" :key=\"command.name\">\n        <li><code v-html=\"command.name.replace(/[   ]*$/, '').replace(/ /g, '&ensp;')\" /> <span\n                v-html=\"command.description\" /></li>\n    </ul>\n</template>\n\n<script>\nexport default {\n    props: {\n        commands: {\n            type: Array,\n            required: true\n        }\n    }\n}\n</script>\n"
  },
  {
    "path": "site/versions/3.2.1/components/ConfigBadges.vue",
    "content": "<template>\n    <span v-if=\"loaded && item\" class=\"config-badges\">\n        <Badge type=\"info\">{{ typeIcon }}{{ item.type }}</Badge>\n        <Badge v-if=\"hasDefault\" type=\"tip\">=<code>{{ formattedDefault }}</code></Badge>\n        <Badge v-if=\"item.required\" type=\"danger\">required</Badge>\n        <Badge v-else-if=\"item.recommended\" type=\"warning\">recommended</Badge>\n    </span>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted } from 'vue'\nimport { getPluginData } from './jsonLoader.js'\n\nconst props = defineProps({\n    plugin: {\n        type: String,\n        required: true\n    },\n    option: {\n        type: String,\n        required: true\n    },\n    version: {\n        type: String,\n        default: null\n    }\n})\n\nconst item = ref(null)\nconst loaded = ref(false)\n\nonMounted(() => {\n    try {\n        const data = getPluginData(props.plugin, props.version)\n        if (data) {\n            const config = data.config || []\n            // Find the option - handle both \"option\" and \"[prefix].option\" formats\n            item.value = config.find(c => {\n                const baseName = c.name.replace(/^\\[.*?\\]\\./, '')\n                return baseName === props.option || c.name === props.option\n            })\n        }\n    } catch (e) {\n        console.error(`Failed to load config for plugin: ${props.plugin}`, e)\n    } finally {\n        loaded.value = true\n    }\n})\n\nconst typeIcon = computed(() => {\n    if (!item.value) return ''\n    const type = item.value.type || ''\n    if (type.includes('Path')) {\n        return item.value.is_directory ? '\\u{1F4C1} ' : '\\u{1F4C4} '\n    }\n    return ''\n})\n\nconst hasDefault = computed(() => {\n    if (!item.value) return false\n    const value = item.value.default\n    if (value === null || value === undefined) return false\n    if (value === '') return false\n    if (Array.isArray(value) && value.length === 0) return false\n    if (typeof value === 'object' && Object.keys(value).length === 0) return false\n    return true\n})\n\nconst formattedDefault = computed(() => {\n    if (!item.value) return ''\n    const value = item.value.default\n    if (typeof value === 'boolean') {\n        return value ? 'true' : 'false'\n    }\n    if (typeof value === 'string') {\n        return `\"${value}\"`\n    }\n    if (Array.isArray(value)) {\n        return JSON.stringify(value)\n    }\n    return String(value)\n})\n</script>\n\n<style scoped>\n.config-badges {\n    margin-left: 0.5em;\n}\n\n.config-badges code {\n    background: transparent;\n    font-size: 0.9em;\n}\n</style>\n"
  },
  {
    "path": "site/versions/3.2.1/components/ConfigTable.vue",
    "content": "<template>\n  <!-- Grouped by category (only at top level, when categories exist) -->\n  <div v-if=\"hasCategories && !isNested\" class=\"config-categories\">\n    <details\n      v-for=\"group in groupedItems\"\n      :key=\"group.category\"\n      :open=\"group.category === 'basic'\"\n      class=\"config-category\"\n    >\n      <summary class=\"config-category-header\">\n        {{ getCategoryDisplayName(group.category) }}\n        <span class=\"config-category-count\">({{ group.items.length }})</span>\n        <a v-if=\"group.category === 'menu'\" href=\"./Menu\" class=\"config-category-link\">See full documentation</a>\n      </summary>\n      <table class=\"config-table\">\n        <thead>\n          <tr>\n            <th>Option</th>\n            <th>Description</th>\n          </tr>\n        </thead>\n        <tbody>\n          <template v-for=\"item in group.items\" :key=\"item.name\">\n            <tr>\n              <td class=\"config-option-cell\">\n                <a v-if=\"isDocumented(item.name)\" :href=\"'#' + getAnchor(item.name)\" class=\"config-link\" title=\"More details below\">\n                  <span v-if=\"hasChildren(item)\" class=\"config-has-children\" title=\"Has child options\">+</span>\n                  <code>{{ item.name }}</code>\n                  <span class=\"config-info-icon\">i</span>\n                </a>\n                <template v-else>\n                  <span v-if=\"hasChildren(item)\" class=\"config-has-children\" title=\"Has child options\">+</span>\n                  <code>{{ item.name }}</code>\n                </template>\n                <Badge type=\"info\">{{ getTypeIcon(item) }}{{ item.type }}</Badge>\n                <Badge v-if=\"item.required\" type=\"danger\">required</Badge>\n                <Badge v-else-if=\"item.recommended\" type=\"warning\">recommended</Badge>\n                <div v-if=\"hasDefault(item.default)\" type=\"tip\">=<code>{{ formatDefault(item.default) }}</code></div>\n              </td>\n              <td class=\"config-description\" v-html=\"renderDescription(item.description)\" />\n            </tr>\n            <!-- Children row (recursive) -->\n            <tr v-if=\"hasChildren(item)\" class=\"config-children-row\">\n              <td colspan=\"2\" class=\"config-children-cell\">\n                <details class=\"config-children-details\">\n                  <summary><code>{{ item.name }}</code> options</summary>\n                  <config-table\n                    :items=\"item.children\"\n                    :is-nested=\"true\"\n                    :option-to-anchor=\"optionToAnchor\"\n                    :parent-name=\"getQualifiedName(item.name)\"\n                  />\n                </details>\n              </td>\n            </tr>\n          </template>\n        </tbody>\n      </table>\n    </details>\n  </div>\n\n  <!-- Flat table (for nested tables or when no categories) -->\n  <table v-else :class=\"['config-table', { 'config-nested': isNested }]\">\n    <thead>\n      <tr>\n        <th>Option</th>\n        <th>Description</th>\n      </tr>\n    </thead>\n    <tbody>\n      <template v-for=\"item in items\" :key=\"item.name\">\n        <tr>\n          <td class=\"config-option-cell\">\n            <a v-if=\"isDocumented(item.name)\" :href=\"'#' + getAnchor(item.name)\" class=\"config-link\" title=\"More details below\">\n              <span v-if=\"hasChildren(item)\" class=\"config-has-children\" title=\"Has child options\">+</span>\n              <code>{{ item.name }}</code>\n              <span class=\"config-info-icon\">i</span>\n            </a>\n            <template v-else>\n              <span v-if=\"hasChildren(item)\" class=\"config-has-children\" title=\"Has child options\">+</span>\n              <code>{{ item.name }}</code>\n            </template>\n            <Badge type=\"info\">{{ item.type }}</Badge>\n            <Badge v-if=\"item.required\" type=\"danger\">required</Badge>\n            <Badge v-else-if=\"item.recommended\" type=\"warning\">recommended</Badge>\n            <div v-if=\"hasDefault(item.default)\" type=\"tip\">=<code>{{ formatDefault(item.default) }}</code></div>\n          </td>\n          <td class=\"config-description\" v-html=\"renderDescription(item.description)\" />\n        </tr>\n        <!-- Children row (recursive) -->\n        <tr v-if=\"hasChildren(item)\" class=\"config-children-row\">\n          <td colspan=\"2\" class=\"config-children-cell\">\n            <details class=\"config-children-details\">\n              <summary><code>{{ item.name }}</code> options</summary>\n              <config-table\n                :items=\"item.children\"\n                :is-nested=\"true\"\n                :option-to-anchor=\"optionToAnchor\"\n                :parent-name=\"getQualifiedName(item.name)\"\n              />\n            </details>\n          </td>\n        </tr>\n      </template>\n    </tbody>\n  </table>\n</template>\n\n<script>\nimport { hasChildren, hasDefault, formatDefault, renderDescription } from './configHelpers.js'\n\n// Category display order and names\nconst CATEGORY_ORDER = ['basic', 'menu', 'appearance', 'positioning', 'behavior', 'external_commands', 'templating', 'placement', 'advanced', 'overrides', '']\nconst CATEGORY_NAMES = {\n  'basic': 'Basic',\n  'menu': 'Menu',\n  'appearance': 'Appearance',\n  'positioning': 'Positioning',\n  'behavior': 'Behavior',\n  'external_commands': 'External commands',\n  'templating': 'Templating',\n  'placement': 'Placement',\n  'advanced': 'Advanced',\n  'overrides': 'Overrides',\n  '': 'Other'\n}\n\nexport default {\n  name: 'ConfigTable',\n  props: {\n    items: { type: Array, required: true },\n    isNested: { type: Boolean, default: false },\n    optionToAnchor: { type: Object, default: () => ({}) },\n    parentName: { type: String, default: '' }\n  },\n  computed: {\n    hasCategories() {\n      // Only group if there are multiple distinct categories\n      const categories = new Set(this.items.map(item => item.category || ''))\n      return categories.size > 1\n    },\n    groupedItems() {\n      // Group items by category\n      const groups = {}\n      for (const item of this.items) {\n        const category = item.category || ''\n        if (!groups[category]) {\n          groups[category] = []\n        }\n        groups[category].push(item)\n      }\n\n      // Sort groups by CATEGORY_ORDER\n      const result = []\n      for (const cat of CATEGORY_ORDER) {\n        if (groups[cat]) {\n          result.push({ category: cat, items: groups[cat] })\n          delete groups[cat]\n        }\n      }\n      // Add any remaining categories not in the order list\n      for (const cat of Object.keys(groups).sort()) {\n        result.push({ category: cat, items: groups[cat] })\n      }\n\n      return result\n    }\n  },\n  methods: {\n    hasChildren,\n    hasDefault,\n    formatDefault,\n    renderDescription,\n    getTypeIcon(item) {\n      const type = item.type || ''\n      if (type.includes('Path')) {\n        return item.is_directory ? '\\u{1F4C1} ' : '\\u{1F4C4} '\n      }\n      return ''\n    },\n    getCategoryDisplayName(category) {\n      return CATEGORY_NAMES[category] || category.charAt(0).toUpperCase() + category.slice(1)\n    },\n    getQualifiedName(name) {\n      const baseName = name.replace(/^\\[.*?\\]\\./, '')\n      return this.parentName ? `${this.parentName}.${baseName}` : baseName\n    },\n    isDocumented(name) {\n      if (Object.keys(this.optionToAnchor).length === 0) return false\n      const qualifiedName = this.getQualifiedName(name)\n      const anchorKey = qualifiedName.replace(/\\./g, '-')\n      return anchorKey in this.optionToAnchor || qualifiedName in this.optionToAnchor\n    },\n    getAnchor(name) {\n      const qualifiedName = this.getQualifiedName(name)\n      const anchorKey = qualifiedName.replace(/\\./g, '-')\n      return this.optionToAnchor[anchorKey] || this.optionToAnchor[qualifiedName] || ''\n    }\n  }\n}\n</script>\n\n<style scoped>\n.config-categories {\n  display: flex;\n  flex-direction: column;\n  gap: 0.5rem;\n}\n\n.config-category {\n  border: 1px solid var(--vp-c-divider);\n  border-radius: 8px;\n  overflow: hidden;\n}\n\n.config-category[open] {\n  border-color: var(--vp-c-brand);\n}\n\n.config-category-header {\n  display: flex;\n  align-items: center;\n  gap: 0.5rem;\n  padding: 0.75rem 1rem;\n  background: var(--vp-c-bg-soft);\n  font-weight: 600;\n  cursor: pointer;\n  user-select: none;\n}\n\n.config-category-header:hover {\n  background: var(--vp-c-bg-mute);\n}\n\n.config-category-count {\n  font-weight: 400;\n  color: var(--vp-c-text-2);\n  font-size: 0.875em;\n}\n\n.config-category-link {\n  margin-left: auto;\n  font-weight: 400;\n  font-size: 0.875em;\n  color: var(--vp-c-brand);\n  text-decoration: none;\n}\n\n.config-category-link:hover {\n  text-decoration: underline;\n}\n\n.config-category .config-table {\n  margin: 0;\n  border: none;\n  border-radius: 0;\n}\n\n.config-category .config-table thead {\n  background: var(--vp-c-bg-alt);\n}\n</style>\n"
  },
  {
    "path": "site/versions/3.2.1/components/EngineDefaults.vue",
    "content": "<template>\n  <div v-if=\"error\" class=\"data-error\">{{ error }}</div>\n  <table v-else-if=\"engineDefaults\" class=\"data-table\">\n    <thead>\n      <tr>\n        <th>Engine</th>\n        <th>Default Parameters</th>\n      </tr>\n    </thead>\n    <tbody>\n      <tr v-for=\"(params, engine) in engineDefaults\" :key=\"engine\">\n        <td><code>{{ engine }}</code></td>\n        <td><code>{{ params || '-' }}</code></td>\n      </tr>\n    </tbody>\n  </table>\n</template>\n\n<script setup>\nimport { computed } from 'vue'\nimport { getPluginData } from './jsonLoader.js'\n\nconst props = defineProps({\n  version: {\n    type: String,\n    default: null\n  }\n})\n\nconst data = computed(() => {\n  try {\n    return getPluginData('menu', props.version)\n  } catch (e) {\n    console.error('Failed to load menu data:', e)\n    return null\n  }\n})\n\nconst engineDefaults = computed(() => data.value?.engine_defaults || null)\nconst error = computed(() => data.value === null ? 'Failed to load engine defaults' : null)\n</script>\n"
  },
  {
    "path": "site/versions/3.2.1/components/EngineList.vue",
    "content": "<template>\n  <div v-if=\"error\" class=\"data-error\">{{ error }}</div>\n  <ul v-else class=\"engine-list\">\n    <li></li>\n    <li v-for=\"engine in engines\" :key=\"engine\">\n            <code>{{ engine }}</code>\n    </li>\n  </ul>\n</template>\n\n<script setup>\nimport { computed } from 'vue'\nimport { getPluginData } from './jsonLoader.js'\n\nconst props = defineProps({\n  version: {\n    type: String,\n    default: null\n  }\n})\n\nconst data = computed(() => {\n  try {\n    return getPluginData('menu', props.version)\n  } catch (e) {\n    console.error('Failed to load menu data:', e)\n    return null\n  }\n})\n\nconst engineDefaults = computed(() => data.value?.engine_defaults ?? {})\nconst engines = computed(() => Object.keys(engineDefaults.value))\nconst error = computed(() => (data.value === null ? 'Failed to load engine defaults' : null))\n</script>\n\n<style scoped>\n.engine-list {\n  list-style: none;\n  margin: 0;\n  padding: 0;\n  overflow: hidden;\n}\n\n.engine-list li {\n  float: left;\n  margin-right: 0.75rem;\n}\n</style>\n"
  },
  {
    "path": "site/versions/3.2.1/components/PluginCommands.vue",
    "content": "<template>\n  <div v-if=\"loading\" class=\"command-loading\">Loading commands...</div>\n  <div v-else-if=\"error\" class=\"command-error\">{{ error }}</div>\n  <div v-else-if=\"filteredCommands.length === 0\" class=\"command-empty\">\n    No commands are provided by this plugin.\n  </div>\n  <div v-else class=\"command-box\">\n    <ul class=\"command-list\">\n      <li v-for=\"command in filteredCommands\" :key=\"command.name\" class=\"command-item\">\n        <a v-if=\"isDocumented(command.name)\" :href=\"'#' + getAnchor(command.name)\" class=\"command-link\" title=\"More details below\">\n          <code class=\"command-name\">{{ command.name }}</code>\n          <span class=\"command-info-icon\">i</span>\n        </a>\n        <code v-else class=\"command-name\">{{ command.name }}</code>\n        <template v-for=\"(arg, idx) in command.args\" :key=\"idx\">\n          <code class=\"command-arg\">{{ formatArg(arg) }}</code>\n        </template>\n        <span class=\"command-desc\" v-html=\"renderDescription(command.short_description)\" />\n      </li>\n    </ul>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted } from 'vue'\nimport { renderDescription } from './configHelpers.js'\nimport { usePluginData } from './usePluginData.js'\nimport { getPluginData } from './jsonLoader.js'\n\nconst props = defineProps({\n  plugin: {\n    type: String,\n    required: true\n  },\n  filter: {\n    type: Array,\n    default: null\n  },\n  linkPrefix: {\n    type: String,\n    default: ''\n  },\n  version: {\n    type: String,\n    default: null\n  }\n})\n\nconst commandToAnchor = ref({})\n\nconst { data: commands, loading, error } = usePluginData(async () => {\n  const data = getPluginData(props.plugin, props.version)\n  if (!data) throw new Error(`Plugin data not found: ${props.plugin}`)\n  return data.commands || []\n})\n\nconst filteredCommands = computed(() => {\n  if (!props.filter || props.filter.length === 0) {\n    return commands.value\n  }\n  return commands.value.filter(cmd => props.filter.includes(cmd.name))\n})\n\nonMounted(() => {\n  if (props.linkPrefix) {\n    const anchors = document.querySelectorAll('h3[id], h4[id], h5[id]')\n    const mapping = {}\n    anchors.forEach(heading => {\n      mapping[heading.id] = heading.id\n      // Also extract command names from <code> elements\n      const codes = heading.querySelectorAll('code')\n      codes.forEach(code => {\n        mapping[code.textContent] = heading.id\n      })\n    })\n    commandToAnchor.value = mapping\n  }\n})\n\nfunction isDocumented(name) {\n  if (Object.keys(commandToAnchor.value).length === 0) return false\n  const anchorKey = `${props.linkPrefix}${name}`\n  return anchorKey in commandToAnchor.value || name in commandToAnchor.value\n}\n\nfunction getAnchor(name) {\n  const anchorKey = `${props.linkPrefix}${name}`\n  return commandToAnchor.value[anchorKey] || commandToAnchor.value[name] || ''\n}\n\nfunction formatArg(arg) {\n  return arg.required ? arg.value : `[${arg.value}]`\n}\n</script>\n"
  },
  {
    "path": "site/versions/3.2.1/components/PluginConfig.vue",
    "content": "<template>\n  <div v-if=\"loading\" class=\"config-loading\">Loading configuration...</div>\n  <div v-else-if=\"error\" class=\"config-error\">{{ error }}</div>\n  <div v-else-if=\"filteredConfig.length === 0\" class=\"config-empty\">No configuration options available.</div>\n  <config-table\n    v-else\n    :items=\"filteredConfig\"\n    :option-to-anchor=\"optionToAnchor\"\n  />\n</template>\n\n<script>\nimport ConfigTable from './ConfigTable.vue'\nimport { getPluginData } from './jsonLoader.js'\n\nexport default {\n  components: {\n    ConfigTable\n  },\n  props: {\n    plugin: {\n      type: String,\n      required: true\n    },\n    linkPrefix: {\n      type: String,\n      default: ''\n    },\n    filter: {\n      type: Array,\n      default: null\n    },\n    version: {\n      type: String,\n      default: null\n    }\n  },\n  data() {\n    return {\n      config: [],\n      loading: true,\n      error: null,\n      optionToAnchor: {}\n    }\n  },\n  computed: {\n    filteredConfig() {\n      if (!this.filter || this.filter.length === 0) {\n        return this.config\n      }\n      return this.config.filter(item => {\n        const baseName = item.name.replace(/^\\[.*?\\]\\./, '')\n        return this.filter.includes(baseName)\n      })\n    }\n  },\n  mounted() {\n    try {\n      const data = getPluginData(this.plugin, this.version)\n      if (!data) {\n        this.error = `Plugin data not found: ${this.plugin}`\n      } else {\n        this.config = data.config || []\n      }\n    } catch (e) {\n      this.error = `Failed to load configuration for plugin: ${this.plugin}`\n      console.error(e)\n    } finally {\n      this.loading = false\n    }\n    \n    // Scan page for documented option anchors (h3, h4, h5) and build option->anchor mapping\n    if (this.linkPrefix) {\n      const anchors = document.querySelectorAll('h3[id], h4[id], h5[id]')\n      const mapping = {}\n      anchors.forEach(heading => {\n        // Map by anchor ID directly (e.g., \"placement-scale\" -> \"placement-scale\")\n        // This allows qualified lookups like \"placement.scale\" -> \"placement-scale\"\n        mapping[heading.id] = heading.id\n        // Also extract option names from <code> elements for top-level matching\n        const codes = heading.querySelectorAll('code')\n        codes.forEach(code => {\n          mapping[code.textContent] = heading.id\n        })\n      })\n      this.optionToAnchor = mapping\n    }\n  }\n}\n</script>\n"
  },
  {
    "path": "site/versions/3.2.1/components/PluginList.vue",
    "content": "<template>\n    <div>\n        <h1>Built-in plugins</h1>\n        <div v-if=\"loading\">Loading plugins...</div>\n        <div v-else-if=\"error\">{{ error }}</div>\n        <div v-else v-for=\"plugin in sortedPlugins\" :key=\"plugin.name\" class=\"plugin-item\">\n            <div class=\"plugin-info\">\n                <h3>\n                    <a :href=\"plugin.name + '.html'\">{{ plugin.name }}</a>\n                    <span v-html=\"'&nbsp;' + getStars(plugin.stars)\"></span>\n                    <span v-if=\"plugin.multimon\">\n                        <Badge type=\"tip\" text=\"multi-monitor\" />\n                    </span>\n                    <span v-if=\"plugin.environments && plugin.environments.length\">\n                        <Badge v-for=\"env in plugin.environments\" :key=\"env\" type=\"tip\" :text=\"env\" style=\"margin-left: 5px;\" />\n                    </span>\n                </h3>\n                <p v-html=\"plugin.description\" />\n            </div>\n            <a v-if=\"plugin.demoVideoId\" :href=\"'https://www.youtube.com/watch?v=' + plugin.demoVideoId\"\n                class=\"plugin-video\">\n                <img :src=\"'https://img.youtube.com/vi/' + plugin.demoVideoId + '/1.jpg'\" alt=\"Demo video thumbnail\" />\n            </a>\n        </div>\n    </div>\n</template>\n\n<style scoped>\n.plugin-item {\n    display: flex;\n    align-items: flex-start;\n    margin-bottom: 1.5rem;\n}\n\n.plugin-info {\n    flex: 1;\n}\n\n.plugin-video {\n    margin-left: 1rem;\n}\n\n.plugin-video img {\n    max-width: 200px;\n    /* Adjust as needed */\n    height: auto;\n}\n</style>\n\n\n<script setup>\nimport { computed } from 'vue'\nimport { usePluginData } from './usePluginData.js'\nimport { getPluginData } from './jsonLoader.js'\n\nconst props = defineProps({\n    version: {\n        type: String,\n        default: null\n    }\n})\n\nconst { data: plugins, loading, error } = usePluginData(async () => {\n    const data = getPluginData('index', props.version)\n    if (!data) throw new Error('Plugin index not found')\n    // Filter out internal plugins like 'pyprland'\n    return (data.plugins || []).filter(p => p.name !== 'pyprland')\n})\n\nconst sortedPlugins = computed(() => {\n    if (!plugins.value?.length) return []\n    return plugins.value.slice().sort((a, b) => a.name.localeCompare(b.name))\n})\n\nfunction getStars(count) {\n    return count > 0 ? '&#11088;'.repeat(count) : ''\n}\n</script>\n"
  },
  {
    "path": "site/versions/3.2.1/components/configHelpers.js",
    "content": "/**\n * Shared helper functions for config table components.\n */\n\nimport MarkdownIt from 'markdown-it'\n\nconst md = new MarkdownIt({ html: true, linkify: true })\n\n/**\n * Check if a config item has children.\n * @param {Object} item - Config item\n * @returns {boolean}\n */\nexport function hasChildren(item) {\n  return item.children && item.children.length > 0\n}\n\n/**\n * Check if a value represents a meaningful default (not empty/null).\n * @param {*} value - Default value to check\n * @returns {boolean}\n */\nexport function hasDefault(value) {\n  if (value === null || value === undefined) return false\n  if (value === '') return false\n  if (Array.isArray(value) && value.length === 0) return false\n  if (typeof value === 'object' && Object.keys(value).length === 0) return false\n  return true\n}\n\n/**\n * Format a default value for display.\n * @param {*} value - Value to format\n * @returns {string}\n */\nexport function formatDefault(value) {\n  if (typeof value === 'boolean') {\n    return value ? 'true' : 'false'\n  }\n  if (typeof value === 'string') {\n    return `\"${value}\"`\n  }\n  if (Array.isArray(value)) {\n    return JSON.stringify(value)\n  }\n  return String(value)\n}\n\n/**\n * Render description text with markdown support.\n * Transforms <opt1|opt2|...> patterns to styled inline code blocks.\n * @param {string} text - Description text\n * @returns {string} - HTML string\n */\nexport function renderDescription(text) {\n  if (!text) return ''\n  // Transform <opt1|opt2|...> patterns to styled inline code blocks\n  text = text.replace(/<([^>|]+(?:\\|[^>|]+)+)>/g, (match, choices) => {\n    return choices.split('|').map(c => `\\`${c}\\``).join(' | ')\n  })\n  // Use render() to support links, then strip wrapping <p> tags\n  const html = md.render(text)\n  return html.replace(/^<p>/, '').replace(/<\\/p>\\n?$/, '')\n}\n"
  },
  {
    "path": "site/versions/3.2.1/components/jsonLoader.js",
    "content": "/**\n * JSON loader with glob imports for version-aware plugin data.\n *\n * Uses Vite's import.meta.glob to pre-bundle all JSON files at build time,\n * enabling runtime selection based on version.\n */\n\n// Pre-load all JSON files at build time\nconst currentJson = import.meta.glob('../generated/*.json', { eager: true })\nconst versionedJson = import.meta.glob('../versions/*/generated/*.json', { eager: true })\n\n/**\n * Get plugin data from the appropriate JSON file.\n *\n * @param {string} name - JSON filename without extension (e.g., 'scratchpads', 'index', 'menu')\n * @param {string|null} version - Version string (e.g., '3.0.0') or null for current\n * @returns {object|null} - Parsed JSON data or null if not found\n */\nexport function getPluginData(name, version = null) {\n  const filename = `${name}.json`\n\n  if (version) {\n    const key = `../versions/${version}/generated/${filename}`\n    const data = versionedJson[key]\n    return data?.default || data || null\n  }\n\n  const key = `../generated/${filename}`\n  const data = currentJson[key]\n  return data?.default || data || null\n}\n"
  },
  {
    "path": "site/versions/3.2.1/components/usePluginData.js",
    "content": "/**\n * Composable for loading plugin data with loading/error states.\n *\n * Provides a standardized pattern for async data loading in Vue components.\n */\n\nimport { ref, onMounted } from 'vue'\n\n/**\n * Load data asynchronously with loading and error state management.\n *\n * @param {Function} loader - Async function that returns the data\n * @returns {Object} - { data, loading, error } refs\n *\n * @example\n * // Load commands from a plugin JSON file\n * const { data: commands, loading, error } = usePluginData(async () => {\n *   const module = await import(`../generated/${props.plugin}.json`)\n *   return module.commands || []\n * })\n *\n * @example\n * // Load with default value\n * const { data: config, loading, error } = usePluginData(\n *   async () => {\n *     const module = await import('../generated/menu.json')\n *     return module.engine_defaults || {}\n *   }\n * )\n */\nexport function usePluginData(loader) {\n  const data = ref(null)\n  const loading = ref(true)\n  const error = ref(null)\n\n  onMounted(async () => {\n    try {\n      data.value = await loader()\n    } catch (e) {\n      error.value = e.message || 'Failed to load data'\n      console.error(e)\n    } finally {\n      loading.value = false\n    }\n  })\n\n  return { data, loading, error }\n}\n"
  },
  {
    "path": "site/versions/3.2.1/expose.md",
    "content": "---\n---\n\n# expose\n\nImplements the \"expose\" effect, showing every client window on the focused screen.\n\nFor a similar feature using a menu, try the [fetch_client_menu](./fetch_client_menu) plugin (less intrusive).\n\nSample `hyprland.conf`:\n\n```bash\n# Setup the key binding\nbind = $mainMod, B, exec, pypr expose\n\n# Add some style to the \"exposed\" workspace\nworkspace = special:exposed,gapsout:60,gapsin:30,bordersize:5,border:true,shadow:false\n```\n\n`MOD+B` will bring every client to the focused workspace, pressed again it will go to this workspace.\n\nCheck [workspace rules](https://wiki.hyprland.org/Configuring/Workspace-Rules/#rules) for styling options.\n\n> [!note]\n> If you are looking for `toggle_minimized`, check the [toggle_special](./toggle_special) plugin\n\n\n## Commands\n\n<PluginCommands plugin=\"expose\"  version=\"3.2.1\" />\n\n## Configuration\n\n<PluginConfig plugin=\"expose\" linkPrefix=\"config-\"  version=\"3.2.1\" />\n\n"
  },
  {
    "path": "site/versions/3.2.1/fcitx5_switcher.md",
    "content": "---\n---\n\n# fcitx5_switcher\n\nThis is a useful tool for CJK input method users.\n\nIt can automatically switch fcitx5 input method status based on window class and title.\n\n<details>\n<summary>Example</summary>\n\n```toml\n[fcitx5_switcher]\nactive_classes = [\"wechat\", \"QQ\", \"zoom\"]\ninactive_classes = [\n    \"code\",\n    \"kitty\",\n    \"google-chrome\",\n]\nactive_titles = []\ninactive_titles = []\n```\n\nIn this example, if the window class is \"wechat\" or \"QQ\" or \"zoom\", the input method will be activated. If the window class is \"code\" or \"kitty\" or \"google-chrome\", the input method will be inactivated.\n\n</details>\n\n## Commands\n\n<PluginCommands plugin=\"fcitx5_switcher\"  version=\"3.2.1\" />\n\n## Configuration\n\n<PluginConfig plugin=\"fcitx5_switcher\" linkPrefix=\"config-\"  version=\"3.2.1\" />\n\n"
  },
  {
    "path": "site/versions/3.2.1/fetch_client_menu.md",
    "content": "---\n---\n\n# fetch_client_menu\n\nBring any window to the active workspace using a menu.\n\nA bit like the [expose](./expose) plugin but using a menu instead (less intrusive).\n\nIt brings the window to the current workspace, while [expose](./expose) moves the currently focused screen to the application workspace.\n\n## Commands\n\n<PluginCommands plugin=\"fetch_client_menu\"  version=\"3.2.1\" />\n\n## Configuration\n\n<PluginConfig plugin=\"fetch_client_menu\" linkPrefix=\"config-\"  version=\"3.2.1\" />"
  },
  {
    "path": "site/versions/3.2.1/filters.md",
    "content": "# Text filters\n\nAt the moment there is only one filter, close match of [sed's \"s\" command](https://www.gnu.org/software/sed/manual/html_node/The-_0022s_0022-Command.html).\n\nThe only supported flag is \"g\"\n\n## Example\n\n```toml\nfilter = 's/foo/bar/'\nfilter = 's/.*started (.*)/\\1 has started/'\nfilter = 's#</?div>##g'\n```\n"
  },
  {
    "path": "site/versions/3.2.1/gamemode.md",
    "content": "---\n---\n\n# gamemode\n\nToggle game mode (automatically) for improved performance. When enabled, disables animations, blur, shadows, gaps, and rounding. When disabled, reloads the Hyprland config to restore original settings.\n\nThis is useful when gaming or running performance-intensive applications where visual effects may cause frame drops or input lag.\n\n<details>\n    <summary>Example</summary>\n\nSample `hyprland.conf`:\n\n```sh\nbind = $mainMod, G, exec, pypr gamemode\n```\n\n</details>\n\n## Commands\n\n<PluginCommands plugin=\"gamemode\"  version=\"3.2.1\" />\n\n## Configuration\n\n<PluginConfig plugin=\"gamemode\" linkPrefix=\"config-\"  version=\"3.2.1\" />\n\n### `auto` <ConfigBadges plugin=\"gamemode\" option=\"auto\"  version=\"3.2.1\" /> {#config-auto}\n\nEnable automatic game mode detection. When enabled, pyprland monitors window open/close events and automatically enables game mode when a window matching one of the configured patterns is detected. Game mode is disabled when all matching windows are closed.\n\n```toml\n[gamemode]\nauto = true\n```\n\n### `patterns` <ConfigBadges plugin=\"gamemode\" option=\"patterns\"  version=\"3.2.1\" /> {#config-patterns}\n\nList of glob patterns to match window class names for automatic game mode activation. Uses shell-style wildcards (`*`, `?`, `[seq]`, `[!seq]`).\n\nThe default pattern `steam_app_*` matches all Steam games, which have window classes like `steam_app_870780`.\n\n```toml\n[gamemode]\nauto = true\npatterns = [\"steam_app_*\", \"gamescope*\", \"lutris_*\"]\n```\n\nTo find the window class of a specific application, run:\n\n```sh\nhyprctl clients -j | jq '.[].class'\n```\n\n### `border_size` <ConfigBadges plugin=\"gamemode\" option=\"border_size\"  version=\"3.2.1\" /> {#config-border_size}\n\nBorder size to use when game mode is enabled. Since gaps are removed, a visible border helps distinguish window boundaries.\n\n### `notify` <ConfigBadges plugin=\"gamemode\" option=\"notify\"  version=\"3.2.1\" /> {#config-notify}\n\nWhether to show a notification when toggling game mode on or off.\n"
  },
  {
    "path": "site/versions/3.2.1/generated/expose.json",
    "content": "{\n  \"name\": \"expose\",\n  \"description\": \"Exposes all windows for a quick 'jump to' feature.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"expose\",\n      \"args\": [],\n      \"short_description\": \"Expose every client on the active workspace.\",\n      \"full_description\": \"Expose every client on the active workspace.\\n\\nIf expose is active restores everything and move to the focused window\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"include_special\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Include windows from special workspaces\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.2.1/generated/fcitx5_switcher.json",
    "content": "{\n  \"name\": \"fcitx5_switcher\",\n  \"description\": \"A plugin to auto-switch Fcitx5 input method status by window class/title.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [],\n  \"config\": [\n    {\n      \"name\": \"active_classes\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Window classes that should activate Fcitx5\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"activation\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"active_titles\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Window titles that should activate Fcitx5\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"activation\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"inactive_classes\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Window classes that should deactivate Fcitx5\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"deactivation\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"inactive_titles\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Window titles that should deactivate Fcitx5\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"deactivation\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.2.1/generated/fetch_client_menu.json",
    "content": "{\n  \"name\": \"fetch_client_menu\",\n  \"description\": \"Shows a menu to select and fetch a window to your active workspace.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"fetch_client_menu\",\n      \"args\": [],\n      \"short_description\": \"Select a client window and move it to the active workspace.\",\n      \"full_description\": \"Select a client window and move it to the active workspace.\"\n    },\n    {\n      \"name\": \"unfetch_client\",\n      \"args\": [],\n      \"short_description\": \"Return a window back to its origin.\",\n      \"full_description\": \"Return a window back to its origin.\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"engine\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Menu engine to use\",\n      \"choices\": [\n        \"fuzzel\",\n        \"tofi\",\n        \"rofi\",\n        \"wofi\",\n        \"bemenu\",\n        \"dmenu\",\n        \"anyrun\",\n        \"walker\",\n        \"vicinae\"\n      ],\n      \"children\": null,\n      \"category\": \"menu\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"parameters\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Extra parameters for the menu engine command\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"menu\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"separator\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"|\",\n      \"description\": \"Separator between window number and title\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"appearance\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"center_on_fetch\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Center the fetched window on the focused monitor (floating)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"margin\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 60,\n      \"description\": \"Margin from monitor edges in pixels when centering/resizing\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.2.1/generated/gamemode.json",
    "content": "{\n  \"name\": \"gamemode\",\n  \"description\": \"Toggle game mode (automatically) for improved performance.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"gamemode\",\n      \"args\": [],\n      \"short_description\": \"Toggle game mode (disables animations, blur, shadows, gaps, rounding).\",\n      \"full_description\": \"Toggle game mode (disables animations, blur, shadows, gaps, rounding).\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"border_size\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 1,\n      \"description\": \"Border size when game mode is enabled\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"notify\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Show notification when toggling\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"auto\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Automatically enable game mode when matching windows are detected\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"patterns\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [\n        \"steam_app_*\"\n      ],\n      \"description\": \"Glob patterns to match window class for auto mode\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.2.1/generated/index.json",
    "content": "{\n  \"plugins\": [\n    {\n      \"name\": \"fetch_client_menu\",\n      \"description\": \"Shows a menu to select and fetch a window to your active workspace.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"layout_center\",\n      \"description\": \"A workspace layout where one window is centered and maximized while others are in the background.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": \"vEr9eeSJYDc\",\n      \"multimon\": false\n    },\n    {\n      \"name\": \"magnify\",\n      \"description\": \"Toggles zooming of viewport or sets a specific scaling factor.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": \"yN-mhh9aDuo\",\n      \"multimon\": false\n    },\n    {\n      \"name\": \"menubar\",\n      \"description\": \"Improves multi-monitor handling of the status bar and restarts it on crashes.\",\n      \"environments\": [\n        \"hyprland\",\n        \"niri\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"monitors\",\n      \"description\": \"Allows relative placement and configuration of monitors.\",\n      \"environments\": [\n        \"hyprland\",\n        \"niri\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"scratchpads\",\n      \"description\": \"Makes your applications into dropdowns & togglable popups.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": \"ZOhv59VYqkc\",\n      \"multimon\": false\n    },\n    {\n      \"name\": \"shift_monitors\",\n      \"description\": \"Moves workspaces from monitor to monitor (carousel).\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": null,\n      \"multimon\": true\n    },\n    {\n      \"name\": \"shortcuts_menu\",\n      \"description\": \"A flexible way to make your own shortcuts menus & launchers.\",\n      \"environments\": [],\n      \"stars\": 3,\n      \"demoVideoId\": \"UCuS417BZK8\",\n      \"multimon\": false\n    },\n    {\n      \"name\": \"system_notifier\",\n      \"description\": \"Opens streams (eg: journal logs) and triggers notifications.\",\n      \"environments\": [],\n      \"stars\": 3,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"toggle_special\",\n      \"description\": \"Toggle switching the focused window to a special workspace.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": \"BNZCMqkwTOo\",\n      \"multimon\": false\n    },\n    {\n      \"name\": \"wallpapers\",\n      \"description\": \"Handles random wallpapers at regular intervals, with support for rounded corners and color scheme generation.\",\n      \"environments\": [],\n      \"stars\": 3,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"workspaces_follow_focus\",\n      \"description\": \"Makes non-visible workspaces available on the currently focused screen.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 3,\n      \"demoVideoId\": null,\n      \"multimon\": true\n    },\n    {\n      \"name\": \"expose\",\n      \"description\": \"Exposes all windows for a quick 'jump to' feature.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 2,\n      \"demoVideoId\": \"ce5HQZ3na8M\",\n      \"multimon\": false\n    },\n    {\n      \"name\": \"gamemode\",\n      \"description\": \"Toggle game mode (automatically) for improved performance.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 2,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"stash\",\n      \"description\": \"Stash and show windows in named groups using special workspaces.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 2,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"fcitx5_switcher\",\n      \"description\": \"A plugin to auto-switch Fcitx5 input method status by window class/title.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 1,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"lost_windows\",\n      \"description\": \"Brings lost floating windows (which are out of reach) to the current workspace.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 1,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"toggle_dpms\",\n      \"description\": \"Toggles the DPMS status of every plugged monitor.\",\n      \"environments\": [\n        \"hyprland\"\n      ],\n      \"stars\": 1,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    },\n    {\n      \"name\": \"pyprland\",\n      \"description\": \"Internal built-in plugin allowing caching states and implementing special commands.\",\n      \"environments\": [],\n      \"stars\": 0,\n      \"demoVideoId\": null,\n      \"multimon\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.2.1/generated/layout_center.json",
    "content": "{\n  \"name\": \"layout_center\",\n  \"description\": \"A workspace layout where one window is centered and maximized while others are in the background.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"layout_center\",\n      \"args\": [\n        {\n          \"value\": \"toggle|next|prev|next2|prev2\",\n          \"required\": true\n        }\n      ],\n      \"short_description\": \"turn on/off or change the active window.\",\n      \"full_description\": \"<toggle|next|prev|next2|prev2> turn on/off or change the active window.\\n\\nArgs:\\n    what: The action to perform\\n        - toggle: Enable/disable the centered layout\\n        - next/prev: Focus the next/previous window in the stack\\n        - next2/prev2: Alternative focus commands (configurable)\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"margin\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 60,\n      \"description\": \"Margin around the centered window in pixels\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"offset\",\n      \"type\": \"str or list or tuple\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [\n        0,\n        0\n      ],\n      \"description\": \"Offset of the centered window as 'X Y' or [X, Y]\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"style\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Window rules to apply to the centered window\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"captive_focus\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Keep focus on the centered window\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"on_new_client\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"focus\",\n      \"description\": \"Behavior when a new window opens\",\n      \"choices\": [\n        \"focus\",\n        \"background\",\n        \"close\"\n      ],\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"next\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Command to run when 'next' is called and layout is disabled\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"commands\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"prev\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Command to run when 'prev' is called and layout is disabled\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"commands\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"next2\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Alternative command for 'next'\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"commands\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"prev2\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Alternative command for 'prev'\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"commands\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.2.1/generated/lost_windows.json",
    "content": "{\n  \"name\": \"lost_windows\",\n  \"description\": \"Brings lost floating windows (which are out of reach) to the current workspace.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"attract_lost\",\n      \"args\": [],\n      \"short_description\": \"Brings lost floating windows to the current workspace.\",\n      \"full_description\": \"Brings lost floating windows to the current workspace.\"\n    }\n  ],\n  \"config\": []\n}\n"
  },
  {
    "path": "site/versions/3.2.1/generated/magnify.json",
    "content": "{\n  \"name\": \"magnify\",\n  \"description\": \"Toggles zooming of viewport or sets a specific scaling factor.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"zoom\",\n      \"args\": [\n        {\n          \"value\": \"factor\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"zooms to \\\"factor\\\" or toggles zoom level if factor is omitted.\",\n      \"full_description\": \"[factor] zooms to \\\"factor\\\" or toggles zoom level if factor is omitted.\\n\\nIf factor is omitted, it toggles between the configured zoom level and no zoom.\\nFactor can be relative (e.g. +0.5 or -0.5).\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"factor\",\n      \"type\": \"float\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 2.0,\n      \"description\": \"Zoom factor when toggling\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"duration\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 0,\n      \"description\": \"Animation duration in frames (0 to disable)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.2.1/generated/menu.json",
    "content": "{\n  \"name\": \"menu\",\n  \"description\": \"Shared configuration for menu-based plugins.\",\n  \"environments\": [],\n  \"commands\": [],\n  \"config\": [\n    {\n      \"name\": \"engine\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Menu engine to use\",\n      \"choices\": [\n        \"fuzzel\",\n        \"tofi\",\n        \"rofi\",\n        \"wofi\",\n        \"bemenu\",\n        \"dmenu\",\n        \"anyrun\",\n        \"walker\",\n        \"vicinae\"\n      ],\n      \"children\": null,\n      \"category\": \"menu\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"parameters\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Extra parameters for the menu engine command\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"menu\",\n      \"is_directory\": false\n    }\n  ],\n  \"engine_defaults\": {\n    \"fuzzel\": \"--match-mode=fuzzy -d -p '[prompt]'\",\n    \"tofi\": \"--prompt-text '[prompt]'\",\n    \"rofi\": \"-dmenu -i -p '[prompt]'\",\n    \"wofi\": \"-dmenu -i -p '[prompt]'\",\n    \"bemenu\": \"-c\",\n    \"dmenu\": \"-i\",\n    \"anyrun\": \"--plugins libstdin.so --show-results-immediately true\",\n    \"walker\": \"-d -k -p '[prompt]'\",\n    \"vicinae\": \"dmenu --no-quick-look\"\n  }\n}\n"
  },
  {
    "path": "site/versions/3.2.1/generated/menubar.json",
    "content": "{\n  \"name\": \"menubar\",\n  \"description\": \"Improves multi-monitor handling of the status bar and restarts it on crashes.\",\n  \"environments\": [\n    \"hyprland\",\n    \"niri\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"bar\",\n      \"args\": [\n        {\n          \"value\": \"restart|stop|toggle\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Start (default), restart, stop or toggle the menu bar.\",\n      \"full_description\": \"[restart|stop|toggle] Start (default), restart, stop or toggle the menu bar.\\n\\nArgs:\\n    args: The action to perform\\n        - (empty): Start the bar\\n        - restart: Stop and restart the bar\\n        - stop: Stop the bar\\n        - toggle: Toggle the bar on/off\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"command\",\n      \"type\": \"str\",\n      \"required\": true,\n      \"recommended\": false,\n      \"default\": \"uwsm app -- ashell\",\n      \"description\": \"Command to run the bar (supports [monitor] variable)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"monitors\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Preferred monitors list in order of priority\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.2.1/generated/monitors.json",
    "content": "{\n  \"name\": \"monitors\",\n  \"description\": \"Allows relative placement and configuration of monitors.\",\n  \"environments\": [\n    \"hyprland\",\n    \"niri\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"relayout\",\n      \"args\": [],\n      \"short_description\": \"Recompute & apply every monitors's layout.\",\n      \"full_description\": \"Recompute & apply every monitors's layout.\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"startup_relayout\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Relayout monitors on startup\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"relayout_on_config_change\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Relayout when Hyprland config is reloaded\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"new_monitor_delay\",\n      \"type\": \"float\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 1.0,\n      \"description\": \"Delay in seconds before handling new monitor\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"unknown\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Command to run when an unknown monitor is detected\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"external_commands\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"placement\",\n      \"type\": \"dict\",\n      \"required\": true,\n      \"recommended\": false,\n      \"default\": {},\n      \"description\": \"Monitor placement rules (pattern -> positioning rules)\",\n      \"choices\": null,\n      \"children\": [\n        {\n          \"name\": \"scale\",\n          \"type\": \"float\",\n          \"required\": false,\n          \"recommended\": false,\n          \"default\": null,\n          \"description\": \"UI scale factor\",\n          \"choices\": null,\n          \"children\": null,\n          \"category\": \"display\",\n          \"is_directory\": false\n        },\n        {\n          \"name\": \"rate\",\n          \"type\": \"int or float\",\n          \"required\": false,\n          \"recommended\": false,\n          \"default\": null,\n          \"description\": \"Refresh rate in Hz\",\n          \"choices\": null,\n          \"children\": null,\n          \"category\": \"display\",\n          \"is_directory\": false\n        },\n        {\n          \"name\": \"resolution\",\n          \"type\": \"str or list\",\n          \"required\": false,\n          \"recommended\": false,\n          \"default\": null,\n          \"description\": \"Display resolution (e.g., '2560x1440' or [2560, 1440])\",\n          \"choices\": null,\n          \"children\": null,\n          \"category\": \"display\",\n          \"is_directory\": false\n        },\n        {\n          \"name\": \"transform\",\n          \"type\": \"int\",\n          \"required\": false,\n          \"recommended\": false,\n          \"default\": null,\n          \"description\": \"Rotation/flip transform\",\n          \"choices\": [\n            0,\n            1,\n            2,\n            3,\n            4,\n            5,\n            6,\n            7\n          ],\n          \"children\": null,\n          \"category\": \"display\",\n          \"is_directory\": false\n        },\n        {\n          \"name\": \"disables\",\n          \"type\": \"list\",\n          \"required\": false,\n          \"recommended\": false,\n          \"default\": null,\n          \"description\": \"List of monitors to disable when this monitor is connected\",\n          \"choices\": null,\n          \"children\": null,\n          \"category\": \"behavior\",\n          \"is_directory\": false\n        }\n      ],\n      \"category\": \"placement\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"hotplug_commands\",\n      \"type\": \"dict\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": {},\n      \"description\": \"Commands to run when specific monitors are plugged (pattern -> command)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"external_commands\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"hotplug_command\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Command to run when any monitor is plugged\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"external_commands\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.2.1/generated/pyprland.json",
    "content": "{\n  \"name\": \"pyprland\",\n  \"description\": \"Internal built-in plugin allowing caching states and implementing special commands.\",\n  \"environments\": [],\n  \"commands\": [\n    {\n      \"name\": \"compgen\",\n      \"args\": [\n        {\n          \"value\": \"shell\",\n          \"required\": true\n        },\n        {\n          \"value\": \"default|path\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Generate shell completions.\",\n      \"full_description\": \"<shell> [default|path] Generate shell completions.\\n\\nUsage:\\n  pypr compgen <shell>            Output script to stdout\\n  pypr compgen <shell> default    Install to default user path\\n  pypr compgen <shell> ~/path     Install to home-relative path\\n  pypr compgen <shell> /abs/path  Install to absolute path\\n\\nExamples:\\n  pypr compgen zsh > ~/.zsh/completions/_pypr\\n  pypr compgen bash default\"\n    },\n    {\n      \"name\": \"doc\",\n      \"args\": [\n        {\n          \"value\": \"plugin.option\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Show plugin and configuration documentation.\",\n      \"full_description\": \"[plugin.option] Show plugin and configuration documentation.\\n\\nUsage:\\n  pypr doc                 List all plugins\\n  pypr doc <plugin>        Show plugin documentation\\n  pypr doc <plugin.option> Show config option details\\n  pypr doc <plugin> <opt>  Same as plugin.option\\n\\nExamples:\\n  pypr doc scratchpads\\n  pypr doc scratchpads.animation\\n  pypr doc wallpapers path\"\n    },\n    {\n      \"name\": \"dumpjson\",\n      \"args\": [],\n      \"short_description\": \"Dump the configuration in JSON format (after includes are processed).\",\n      \"full_description\": \"Dump the configuration in JSON format (after includes are processed).\"\n    },\n    {\n      \"name\": \"exit\",\n      \"args\": [],\n      \"short_description\": \"Terminate the pyprland daemon.\",\n      \"full_description\": \"Terminate the pyprland daemon.\"\n    },\n    {\n      \"name\": \"get\",\n      \"args\": [\n        {\n          \"value\": \"plugin.key\",\n          \"required\": true\n        }\n      ],\n      \"short_description\": \"Get a configuration value.\",\n      \"full_description\": \"<plugin.key> Get a configuration value.\\n\\nArgs:\\n    path: Dot-separated path (e.g., 'wallpapers.online_ratio')\\n\\nExamples:\\n    pypr get wallpapers.online_ratio\\n    pypr get scratchpads.term.command\"\n    },\n    {\n      \"name\": \"help\",\n      \"args\": [\n        {\n          \"value\": \"command\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Show available commands or detailed help.\",\n      \"full_description\": \"[command] Show available commands or detailed help.\\n\\nUsage:\\n  pypr help           List all commands\\n  pypr help <command> Show detailed help\"\n    },\n    {\n      \"name\": \"reload\",\n      \"args\": [],\n      \"short_description\": \"Reload the configuration file.\",\n      \"full_description\": \"Reload the configuration file.\\n\\nNew plugins will be loaded and configuration options will be updated.\\nMost plugins will use the new values on the next command invocation.\"\n    },\n    {\n      \"name\": \"set\",\n      \"args\": [\n        {\n          \"value\": \"plugin.key\",\n          \"required\": true\n        },\n        {\n          \"value\": \"value\",\n          \"required\": true\n        }\n      ],\n      \"short_description\": \"Set a configuration value.\",\n      \"full_description\": \"<plugin.key> <value> Set a configuration value.\\n\\nArgs:\\n    args: Path and value (e.g., 'wallpapers.online_ratio 0.5')\\n\\nUse 'None' to unset a non-required option.\\n\\nExamples:\\n    pypr set wallpapers.online_ratio 0.5\\n    pypr set wallpapers.path /new/path\\n    pypr set scratchpads.term.lazy true\\n    pypr set wallpapers.online_ratio None\"\n    },\n    {\n      \"name\": \"version\",\n      \"args\": [],\n      \"short_description\": \"Show the pyprland version.\",\n      \"full_description\": \"Show the pyprland version.\"\n    },\n    {\n      \"name\": \"edit\",\n      \"args\": [],\n      \"short_description\": \"Open the configuration file in $EDITOR, then reload.\",\n      \"full_description\": \"Open the configuration file in $EDITOR, then reload.\\n\\nOpens pyprland.toml in your preferred editor (EDITOR or VISUAL env var,\\ndefaults to vi). After the editor closes, the configuration is reloaded.\"\n    },\n    {\n      \"name\": \"validate\",\n      \"args\": [],\n      \"short_description\": \"Validate the configuration file.\",\n      \"full_description\": \"Validate the configuration file.\\n\\nChecks the configuration file for syntax errors and validates plugin\\nconfigurations against their schemas. Does not require the daemon.\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"plugins\",\n      \"type\": \"list\",\n      \"required\": true,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"List of plugins to load\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"include\",\n      \"type\": \"list[Path]\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Additional config files or folders to include\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": true\n    },\n    {\n      \"name\": \"plugins_paths\",\n      \"type\": \"list[Path]\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Additional paths to search for third-party plugins\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": true\n    },\n    {\n      \"name\": \"colored_handlers_log\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Enable colored log output for event handlers (debugging)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"notification_type\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"auto\",\n      \"description\": \"Notification method: 'auto', 'notify-send', or 'native'\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"variables\",\n      \"type\": \"dict\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": {},\n      \"description\": \"User-defined variables for string substitution (see Variables page)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"hyprland_version\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Override auto-detected Hyprland version (e.g., '0.40.0')\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"desktop\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Override auto-detected desktop environment (e.g., 'hyprland', 'niri'). Empty means auto-detect.\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.2.1/generated/scratchpads.json",
    "content": "{\n  \"name\": \"scratchpads\",\n  \"description\": \"Makes your applications into dropdowns & togglable popups.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"attach\",\n      \"args\": [],\n      \"short_description\": \"Attach the focused window to the last focused scratchpad.\",\n      \"full_description\": \"Attach the focused window to the last focused scratchpad.\"\n    },\n    {\n      \"name\": \"hide\",\n      \"args\": [\n        {\n          \"value\": \"name\",\n          \"required\": true\n        }\n      ],\n      \"short_description\": \"hides scratchpad \\\"name\\\" (accepts \\\"*\\\").\",\n      \"full_description\": \"<name> hides scratchpad \\\"name\\\" (accepts \\\"*\\\").\\n\\nArgs:\\n    uid: The scratchpad name, or \\\"*\\\" to hide all visible scratchpads\\n    flavor: Internal hide behavior flags (default: NONE)\"\n    },\n    {\n      \"name\": \"show\",\n      \"args\": [\n        {\n          \"value\": \"name\",\n          \"required\": true\n        }\n      ],\n      \"short_description\": \"shows scratchpad \\\"name\\\" (accepts \\\"*\\\").\",\n      \"full_description\": \"<name> shows scratchpad \\\"name\\\" (accepts \\\"*\\\").\\n\\nArgs:\\n    uid: The scratchpad name, or \\\"*\\\" to show all hidden scratchpads\"\n    },\n    {\n      \"name\": \"toggle\",\n      \"args\": [\n        {\n          \"value\": \"name\",\n          \"required\": true\n        }\n      ],\n      \"short_description\": \"toggles visibility of scratchpad \\\"name\\\" (supports multiple names).\",\n      \"full_description\": \"<name> toggles visibility of scratchpad \\\"name\\\" (supports multiple names).\\n\\nArgs:\\n    uid_or_uids: Space-separated scratchpad name(s)\\n\\nExample:\\n    pypr toggle term\\n    pypr toggle term music\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"[scratchpad].command\",\n      \"type\": \"str\",\n      \"required\": true,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Command to run (omit for unmanaged scratchpads)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].class\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": true,\n      \"default\": \"\",\n      \"description\": \"Window class for matching\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].animation\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"fromTop\",\n      \"description\": \"Animation type\",\n      \"choices\": [\n        \"\",\n        \"fromTop\",\n        \"fromBottom\",\n        \"fromLeft\",\n        \"fromRight\"\n      ],\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].size\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": true,\n      \"default\": \"80% 80%\",\n      \"description\": \"Window size (e.g. '80% 80%')\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].position\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Explicit position override\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"positioning\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].margin\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 60,\n      \"description\": \"Pixels from screen edge\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"positioning\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].offset\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"100%\",\n      \"description\": \"Hide animation distance\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"positioning\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].max_size\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Maximum window size\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"positioning\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].lazy\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Start on first use\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].pinned\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Sticky to monitor\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].multi\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Allow multiple windows\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].unfocus\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Action on unfocus ('hide' or empty)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].hysteresis\",\n      \"type\": \"float\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 0.4,\n      \"description\": \"Delay before unfocus hide\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].excludes\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Scratches to hide when shown\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].restore_excluded\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Restore excluded on hide\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].preserve_aspect\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Keep size/position across shows\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].hide_delay\",\n      \"type\": \"float\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 0.2,\n      \"description\": \"Delay before hide animation\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].force_monitor\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Always show on specific monitor\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].alt_toggle\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Alternative toggle for multi-monitor\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].allow_special_workspaces\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Allow over special workspaces\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].smart_focus\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Restore focus on hide\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].close_on_hide\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Close instead of hide\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].match_by\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"pid\",\n      \"description\": \"Match method: pid, class, initialClass, title, initialTitle\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].initialClass\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Match value when match_by='initialClass'\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].initialTitle\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Match value when match_by='initialTitle'\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].title\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Match value when match_by='title'\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].process_tracking\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Enable process management\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].skip_windowrules\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Rules to skip: aspect, float, workspace\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].use\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Inherit from another scratchpad definition\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"advanced\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"[scratchpad].monitor\",\n      \"type\": \"dict\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": {},\n      \"description\": \"Per-monitor config overrides\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"overrides\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.2.1/generated/shift_monitors.json",
    "content": "{\n  \"name\": \"shift_monitors\",\n  \"description\": \"Moves workspaces from monitor to monitor (carousel).\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"shift_monitors\",\n      \"args\": [\n        {\n          \"value\": \"direction\",\n          \"required\": true\n        }\n      ],\n      \"short_description\": \"Swaps monitors' workspaces in the given direction.\",\n      \"full_description\": \"<direction> Swaps monitors' workspaces in the given direction.\\n\\nArgs:\\n    arg: Integer direction (+1 or -1) to rotate workspaces across monitors\"\n    }\n  ],\n  \"config\": []\n}\n"
  },
  {
    "path": "site/versions/3.2.1/generated/shortcuts_menu.json",
    "content": "{\n  \"name\": \"shortcuts_menu\",\n  \"description\": \"A flexible way to make your own shortcuts menus & launchers.\",\n  \"environments\": [],\n  \"commands\": [\n    {\n      \"name\": \"menu\",\n      \"args\": [\n        {\n          \"value\": \"name\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Shows the menu, if \\\"name\\\" is provided, will only show this sub-menu.\",\n      \"full_description\": \"[name] Shows the menu, if \\\"name\\\" is provided, will only show this sub-menu.\\n\\nArgs:\\n    name: The menu name\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"entries\",\n      \"type\": \"dict\",\n      \"required\": true,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Menu entries structure (nested dict of commands)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"engine\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Menu engine to use\",\n      \"choices\": [\n        \"fuzzel\",\n        \"tofi\",\n        \"rofi\",\n        \"wofi\",\n        \"bemenu\",\n        \"dmenu\",\n        \"anyrun\",\n        \"walker\",\n        \"vicinae\"\n      ],\n      \"children\": null,\n      \"category\": \"menu\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"parameters\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Extra parameters for the menu engine command\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"menu\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"separator\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \" | \",\n      \"description\": \"Separator for menu display\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"appearance\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"command_start\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Prefix for command entries\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"appearance\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"command_end\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Suffix for command entries\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"appearance\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"submenu_start\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Prefix for submenu entries\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"appearance\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"submenu_end\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\\u279c\",\n      \"description\": \"Suffix for submenu entries\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"appearance\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"skip_single\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": true,\n      \"description\": \"Auto-select when only one option available\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.2.1/generated/stash.json",
    "content": "{\n  \"name\": \"stash\",\n  \"description\": \"Stash and show windows in named groups using special workspaces.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"stash\",\n      \"args\": [\n        {\n          \"value\": \"name\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Toggle stashing the focused window (default stash: \\\"default\\\").\",\n      \"full_description\": \"[name] Toggle stashing the focused window (default stash: \\\"default\\\").\\n\\nArgs:\\n    name: The stash group name\"\n    },\n    {\n      \"name\": \"stash_toggle\",\n      \"args\": [\n        {\n          \"value\": \"name\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Show or hide stash \\\"name\\\" as floating windows on the active workspace (default: \\\"default\\\").\",\n      \"full_description\": \"[name] Show or hide stash \\\"name\\\" as floating windows on the active workspace (default: \\\"default\\\").\\n\\nWhen showing, windows are moved from the hidden stash workspace to the\\nactive workspace and made floating.  When hiding, they are moved back.\\n\\nArgs:\\n    name: The stash group name\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"style\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Window rules to apply to shown stash windows\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.2.1/generated/system_notifier.json",
    "content": "{\n  \"name\": \"system_notifier\",\n  \"description\": \"Opens streams (eg: journal logs) and triggers notifications.\",\n  \"environments\": [],\n  \"commands\": [],\n  \"config\": [\n    {\n      \"name\": \"command\",\n      \"type\": \"dict\",\n      \"required\": false,\n      \"recommended\": true,\n      \"default\": {},\n      \"description\": \"This is the long-running command (eg: `tail -f <filename>`) returning the stream of text that will be updated.\\n            A common option is the system journal output (eg: `journalctl -u nginx`)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"parser\",\n      \"type\": \"dict\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": {},\n      \"description\": \"Sets the list of rules / parser to be used to extract lines of interest\\n            Must match a list of rules defined as `system_notifier.parsers.<parser_name>`.\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"parsers\",\n      \"type\": \"dict\",\n      \"required\": false,\n      \"recommended\": true,\n      \"default\": {},\n      \"description\": \"Custom parser definitions (name -> list of rules).\\n            Each rule has: pattern (required), filter, color (defaults to default_color), duration (defaults to 3 seconds)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"parsers\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"sources\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": true,\n      \"default\": [],\n      \"description\": \"Source definitions with command and parser\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"pattern\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": true,\n      \"default\": \"\",\n      \"description\": \"The pattern is any regular expression that should trigger a match.\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"default_color\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"#5555AA\",\n      \"description\": \"Default notification color\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"appearance\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"use_notify_send\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Use notify-send instead of Hyprland notifications\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"behavior\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.2.1/generated/toggle_dpms.json",
    "content": "{\n  \"name\": \"toggle_dpms\",\n  \"description\": \"Toggles the DPMS status of every plugged monitor.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"toggle_dpms\",\n      \"args\": [],\n      \"short_description\": \"Toggle dpms on/off for every monitor.\",\n      \"full_description\": \"Toggle dpms on/off for every monitor.\"\n    }\n  ],\n  \"config\": []\n}\n"
  },
  {
    "path": "site/versions/3.2.1/generated/toggle_special.json",
    "content": "{\n  \"name\": \"toggle_special\",\n  \"description\": \"Toggle switching the focused window to a special workspace.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"toggle_special\",\n      \"args\": [\n        {\n          \"value\": \"name\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Toggles switching the focused window to the special workspace \\\"name\\\" (default: minimized).\",\n      \"full_description\": \"[name] Toggles switching the focused window to the special workspace \\\"name\\\" (default: minimized).\\n\\nArgs:\\n    special_workspace: The special workspace name\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"name\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"minimized\",\n      \"description\": \"Default special workspace name\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.2.1/generated/wallpapers.json",
    "content": "{\n  \"name\": \"wallpapers\",\n  \"description\": \"Handles random wallpapers at regular intervals, with support for rounded corners and color scheme generation.\",\n  \"environments\": [],\n  \"commands\": [\n    {\n      \"name\": \"color\",\n      \"args\": [\n        {\n          \"value\": \"#RRGGBB\",\n          \"required\": true\n        },\n        {\n          \"value\": \"scheme\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Generate color palette from hex color.\",\n      \"full_description\": \"<#RRGGBB> [scheme] Generate color palette from hex color.\\n\\nArgs:\\n    arg: Hex color and optional scheme name\\n\\nSchemes: pastel, fluo, vibrant, mellow, neutral, earth\\n\\nExample:\\n    pypr color #ff5500 vibrant\"\n    },\n    {\n      \"name\": \"palette\",\n      \"args\": [\n        {\n          \"value\": \"color\",\n          \"required\": false\n        },\n        {\n          \"value\": \"json\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Show available color template variables.\",\n      \"full_description\": \"[color] [json] Show available color template variables.\\n\\nArgs:\\n    arg: Optional hex color and/or \\\"json\\\" flag\\n        - color: Hex color (#RRGGBB) to use for palette\\n        - json: Output in JSON format instead of human-readable\\n\\nExample:\\n    pypr palette\\n    pypr palette #ff5500\\n    pypr palette json\"\n    },\n    {\n      \"name\": \"wall cleanup\",\n      \"args\": [\n        {\n          \"value\": \"all\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Clean up rounded images cache.\",\n      \"full_description\": \"[all] Clean up rounded images cache.\\n\\nWithout arguments: removes orphaned files (source no longer exists).\\nWith 'all': removes ALL rounded cache files.\\n\\nExample:\\n    pypr wall cleanup\\n    pypr wall cleanup all\"\n    },\n    {\n      \"name\": \"wall clear\",\n      \"args\": [],\n      \"short_description\": \"Stop cycling and clear the current wallpaper.\",\n      \"full_description\": \"Stop cycling and clear the current wallpaper.\"\n    },\n    {\n      \"name\": \"wall info\",\n      \"args\": [\n        {\n          \"value\": \"json\",\n          \"required\": false\n        }\n      ],\n      \"short_description\": \"Show current wallpaper information.\",\n      \"full_description\": \"[json] Show current wallpaper information.\\n\\nArgs:\\n    arg: Optional \\\"json\\\" flag for JSON output\\n\\nExample:\\n    pypr wall info\\n    pypr wall info json\"\n    },\n    {\n      \"name\": \"wall next\",\n      \"args\": [],\n      \"short_description\": \"Switch to the next wallpaper immediately.\",\n      \"full_description\": \"Switch to the next wallpaper immediately.\"\n    },\n    {\n      \"name\": \"wall pause\",\n      \"args\": [],\n      \"short_description\": \"Pause automatic wallpaper cycling.\",\n      \"full_description\": \"Pause automatic wallpaper cycling.\"\n    },\n    {\n      \"name\": \"wall rm\",\n      \"args\": [],\n      \"short_description\": \"Remove the current online wallpaper and show next.\",\n      \"full_description\": \"Remove the current online wallpaper and show next.\\n\\nOnly removes online wallpapers (files in the online folder).\\nShows an error notification for local wallpapers.\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"path\",\n      \"type\": \"Path or list\",\n      \"required\": true,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Path(s) to wallpaper images or directories\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"interval\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 10,\n      \"description\": \"Minutes between wallpaper changes\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"extensions\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [\n        \"png\",\n        \"jpeg\",\n        \"jpg\"\n      ],\n      \"description\": \"File extensions to include (e.g., ['png', 'jpg'])\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"recurse\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Recursively search subdirectories\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"unique\",\n      \"type\": \"bool\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": false,\n      \"description\": \"Use different wallpaper per monitor\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"radius\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 0,\n      \"description\": \"Corner radius for rounded corners\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"appearance\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"command\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Custom command to set wallpaper ([file] and [output] variables)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"external_commands\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"post_command\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Command to run after setting wallpaper\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"external_commands\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"clear_command\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Command to run when clearing wallpaper\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"external_commands\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"color_scheme\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"\",\n      \"description\": \"Color scheme for palette generation\",\n      \"choices\": [\n        \"\",\n        \"pastel\",\n        \"fluo\",\n        \"vibrant\",\n        \"mellow\",\n        \"neutral\",\n        \"earth\",\n        \"fluorescent\"\n      ],\n      \"children\": null,\n      \"category\": \"templating\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"variant\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Color variant type for palette\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"templating\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"templates\",\n      \"type\": \"dict\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": null,\n      \"description\": \"Template files for color palette generation\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"templating\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"online_ratio\",\n      \"type\": \"float\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 0.0,\n      \"description\": \"Probability of fetching online (0.0-1.0)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"online\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"online_backends\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [\n        \"unsplash\",\n        \"picsum\",\n        \"wallhaven\",\n        \"reddit\"\n      ],\n      \"description\": \"Enabled online backends\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"online\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"online_keywords\",\n      \"type\": \"list\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": [],\n      \"description\": \"Keywords to filter online images\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"online\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"online_folder\",\n      \"type\": \"str\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": \"online\",\n      \"description\": \"Subfolder for downloaded online images\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"online\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"cache_days\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 0,\n      \"description\": \"Days to keep cached images (0 = forever)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"cache\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"cache_max_mb\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 100,\n      \"description\": \"Maximum cache size in MB (0 = unlimited)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"cache\",\n      \"is_directory\": false\n    },\n    {\n      \"name\": \"cache_max_images\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 0,\n      \"description\": \"Maximum number of cached images (0 = unlimited)\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"cache\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.2.1/generated/workspaces_follow_focus.json",
    "content": "{\n  \"name\": \"workspaces_follow_focus\",\n  \"description\": \"Makes non-visible workspaces available on the currently focused screen.\",\n  \"environments\": [\n    \"hyprland\"\n  ],\n  \"commands\": [\n    {\n      \"name\": \"change_workspace\",\n      \"args\": [\n        {\n          \"value\": \"direction\",\n          \"required\": true\n        }\n      ],\n      \"short_description\": \"Switch workspaces of current monitor, avoiding displayed workspaces.\",\n      \"full_description\": \"<direction> Switch workspaces of current monitor, avoiding displayed workspaces.\\n\\nArgs:\\n    direction: Integer offset to move (e.g., +1 for next, -1 for previous)\"\n    }\n  ],\n  \"config\": [\n    {\n      \"name\": \"max_workspaces\",\n      \"type\": \"int\",\n      \"required\": false,\n      \"recommended\": false,\n      \"default\": 10,\n      \"description\": \"Maximum number of workspaces to manage\",\n      \"choices\": null,\n      \"children\": null,\n      \"category\": \"basic\",\n      \"is_directory\": false\n    }\n  ]\n}\n"
  },
  {
    "path": "site/versions/3.2.1/index.md",
    "content": "---\n# https://vitepress.dev/reference/default-theme-home-page\nlayout: home\n\nhero:\n  text: \"Extensions for your desktop environment\"\n  tagline: Enhance your desktop experience with Pyprland\n  image:\n    src: /logo.svg\n    alt: logo\n  actions:\n    - theme: brand\n      text: Getting started\n      link: ./Getting-started\n    - theme: alt\n      text: Plugins\n      link: ./Plugins\n    - theme: alt\n      text: Report something\n      link: https://github.com/hyprland-community/pyprland/issues/new/choose\n\nfeatures:\n  - title: TOML format\n    details: Simple and flexible configuration file(s)\n  - title: Customizable\n    details: Create your own Hyprland experience\n  - title: Fast and easy\n    details: Designed for performance and simplicity\n---\n\n# What is Pyprland?\n\nIt's a software that extends the functionality of your desktop environment (Hyprland, Niri, etc...), adding new features and improving the existing ones.\n\nIt also enables a high degree of customization and automation, making it easier to adapt to your workflow.\n\nTo understand the potential of Pyprland, you can check the [plugins](./Plugins) page.\n\n# Major recent changes\n\n- The [Scratchpads](/monitors) got reworked to better satisfy current Hyprland version\n- New [Stash](/stash) plugin, allowing to park windows and show/hide them easily\n- Self documented using cli \"doc\" command\n- Schema validation and \"always in sync\" configurations and commands (doc and code)\n- Major rewrite of the [Monitors plugin](/monitors) delivers improved stability and functionality.\n- The [Wallpapers plugin](/wallpapers) now applies [rounded corners](/wallpapers#radius) per display and derives cohesive [color schemes from the background](/wallpapers#templates) (Matugen/Pywal-inspired).\n## Version 3.2.1 archive\n"
  },
  {
    "path": "site/versions/3.2.1/layout_center.md",
    "content": "---\n---\n# layout_center\n\nImplements a workspace layout where one window is bigger and centered,\nother windows are tiled as usual in the background.\n\nOn `toggle`, the active window is made floating and centered if the layout wasn't enabled, else reverts the floating status.\n\nWith `next` and `prev` you can cycle the active window, keeping the same layout type.\nIf the layout_center isn't active and `next` or `prev` is used, it will call the [next](#config-next) and [prev](#config-next) configuration options.\n\nTo allow full override of the focus keys, `next2` and `prev2` are provided, they do the same actions as `next` and `prev` but allow different fallback commands.\n\n<details>\n<summary>Configuration sample</summary>\n\n```toml\n[layout_center]\nmargin = 60\noffset = [0, 30]\nnext = \"movefocus r\"\nprev = \"movefocus l\"\nnext2 = \"movefocus d\"\nprev2 = \"movefocus u\"\n```\n\nusing the following in `hyprland.conf`:\n```sh\nbind = $mainMod, M, exec, pypr layout_center toggle # toggle the layout\n## focus change keys\nbind = $mainMod, left, exec, pypr layout_center prev\nbind = $mainMod, right, exec, pypr layout_center next\nbind = $mainMod, up, exec, pypr layout_center prev2\nbind = $mainMod, down, exec, pypr layout_center next2\n```\n\nYou can completely ignore `next2` and `prev2` if you are allowing focus change in a single direction (when the layout is enabled), eg:\n\n```sh\nbind = $mainMod, up, movefocus, u\nbind = $mainMod, down, movefocus, d\n```\n\n</details>\n\n\n## Commands\n\n<PluginCommands plugin=\"layout_center\"  version=\"3.2.1\" />\n\n## Configuration\n\n<PluginConfig plugin=\"layout_center\" linkPrefix=\"config-\"  version=\"3.2.1\" />\n\n### `style` <ConfigBadges plugin=\"layout_center\" option=\"style\"  version=\"3.2.1\" /> {#config-style}\n\nCustom Hyprland style rules applied to the centered window. Requires Hyprland > 0.40.0.\n\n```toml\nstyle = [\"opacity 1\", \"bordercolor rgb(FFFF00)\"]\n```\n\n### `on_new_client` <ConfigBadges plugin=\"layout_center\" option=\"on_new_client\"  version=\"3.2.1\" /> {#config-on-new-client}\n\nBehavior when a new window opens while layout is active:\n\n- `\"focus\"` (or `\"foreground\"`) - make the new window the main window\n- `\"background\"` - make the new window appear in the background  \n- `\"close\"` - stop the centered layout when a new window opens\n\n### `next` / `prev` <ConfigBadges plugin=\"layout_center\" option=\"next\"  version=\"3.2.1\" /> {#config-next}\n\nHyprland dispatcher command to run when layout_center isn't active:\n\n```toml\nnext = \"movefocus r\"\nprev = \"movefocus l\"\n```\n\n### `next2` / `prev2` <ConfigBadges plugin=\"layout_center\" option=\"next2\"  version=\"3.2.1\" /> {#config-next2}\n\nAlternative fallback commands for vertical navigation:\n\n```toml\nnext2 = \"movefocus d\"\nprev2 = \"movefocus u\"\n```\n\n### `offset` <ConfigBadges plugin=\"layout_center\" option=\"offset\"  version=\"3.2.1\" /> {#config-offset}\n\noffset in pixels applied to the main window position\n\nExample shift the main window 20px down:\n```toml\noffset = [0, 20]\n```\n\n\n### `margin` <ConfigBadges plugin=\"layout_center\" option=\"margin\"  version=\"3.2.1\" /> {#config-margin}\n\nmargin (in pixels) used when placing the center window, calculated from the border of the screen.\n\nExample to make the main window be 100px far from the monitor's limits:\n```toml\nmargin = 100\n```\nYou can also set a different margin for width and height by using a list:\n\n```toml\nmargin = [100, 100]\n```\n"
  },
  {
    "path": "site/versions/3.2.1/lost_windows.md",
    "content": "---\n---\n# lost_windows\n\nBring windows which are not reachable in the currently focused workspace.\n*Deprecated:* Was used to work around issues in Hyprland which have been fixed since then.\n\n## Commands\n\n<PluginCommands plugin=\"lost_windows\"  version=\"3.2.1\" />\n\n## Configuration\n\nThis plugin has no configuration options.\n"
  },
  {
    "path": "site/versions/3.2.1/magnify.md",
    "content": "---\n---\n# magnify\n\nZooms in and out with an optional animation.\n\n\n<details>\n    <summary>Example</summary>\n\n```sh\npypr zoom  # sets zoom to `factor`\npypr zoom +1  # will set zoom to 3x\npypr zoom  # will set zoom to 1x\npypr zoom 1 # will (also) set zoom to 1x - effectively doing nothing\n```\n\nSample `hyprland.conf`:\n\n```sh\nbind = $mainMod , Z, exec, pypr zoom ++0.5\nbind = $mainMod SHIFT, Z, exec, pypr zoom\n```\n\n</details>\n\n## Commands\n\n<PluginCommands plugin=\"magnify\"  version=\"3.2.1\" />\n\n### `zoom [factor]`\n\n#### unset / not specified\n\nWill zoom to [factor](#config-factor) if not zoomed, else will set the zoom to 1x.\n\n#### floating or integer value\n\nWill set the zoom to the provided value.\n\n#### +value / -value\n\nUpdate (increment or decrement) the current zoom level by the provided value.\n\n#### ++value / --value\n\nUpdate (increment or decrement) the current zoom level by a non-linear scale.\nIt _looks_ more linear changes than using a single + or -.\n\n> [!NOTE]\n>\n> The non-linear scale is calculated as powers of two, eg:\n>\n> - `zoom ++1` → 2x, 4x, 8x, 16x...\n> - `zoom ++0.7` → 1.6x, 2.6x, 4.3x, 7.0x, 11.3x, 18.4x...\n> - `zoom ++0.5` → 1.4x, 2x, 2.8x, 4x, 5.7x, 8x, 11.3x, 16x...\n\n## Configuration\n\n<PluginConfig plugin=\"magnify\" linkPrefix=\"config-\"  version=\"3.2.1\" />\n\n### `factor` <ConfigBadges plugin=\"magnify\" option=\"factor\"  version=\"3.2.1\" /> {#config-factor}\n\nThe zoom level to use when `pypr zoom` is called without arguments.\n\n### `duration` <ConfigBadges plugin=\"magnify\" option=\"duration\"  version=\"3.2.1\" /> {#config-duration}\n\nAnimation duration in seconds. Not needed with recent Hyprland versions - you can customize the animation in Hyprland config instead:\n\n```C\nanimations {\n    bezier = easeInOut,0.65, 0, 0.35, 1\n    animation = zoomFactor, 1, 4, easeInOut\n}\n```\n"
  },
  {
    "path": "site/versions/3.2.1/menubar.md",
    "content": "---\n---\n\n# menubar\n\nRuns your favorite bar app (gbar, ags / hyprpanel, waybar, ...) with option to pass the \"best\" monitor from a list of monitors.\n\n- Will take care of starting the command on startup (you must not run it from another source like `hyprland.conf`).\n- Automatically restarts the menu bar on crash\n- Checks which monitors are on and take the best one from a provided list\n\n<details>\n<summary>Example</summary>\n\n```toml\n[menubar]\ncommand = \"gBar bar [monitor]\"\nmonitors = [\"DP-1\", \"HDMI-1\", \"HDMI-1-A\"]\n```\n\n</details>\n\n> [!tip]\n> This plugin supports both Hyprland and Niri. It will automatically detect the environment and use the appropriate IPC commands.\n\n## Commands\n\n<PluginCommands plugin=\"menubar\"  version=\"3.2.1\" />\n\n## Configuration\n\n<PluginConfig plugin=\"menubar\" linkPrefix=\"config-\"  version=\"3.2.1\" />\n\n### `command` <ConfigBadges plugin=\"menubar\" option=\"command\"  version=\"3.2.1\" /> {#config-command}\n\nThe command to run the bar. Use `[monitor]` as a placeholder for the monitor name:\n\n```toml\ncommand = \"waybar -o [monitor]\"\n```\n"
  },
  {
    "path": "site/versions/3.2.1/monitors.md",
    "content": "---\n---\n\n# monitors\n\nAllows relative placement of monitors depending on the model (\"description\" returned by `hyprctl monitors`).\nUseful if you have multiple monitors connected to a video signal switch or using a laptop and plugging monitors having different relative positions.\n\n> [!Tip]\n> This plugin also supports Niri. It will automatically detect the environment and use `nirictl` to apply the layout.\n> Note that \"hotplug_commands\" and \"unknown\" commands may need adjustment for Niri (e.g. using `sh -c '...'` or Niri specific tools).\n\nSyntax:\n\n\n```toml\n[monitors.placement]\n\"description match\".placement = \"other description match\"\n```\n\n<details>\n    <summary>Example to set a Sony monitor on top of a BenQ monitor</summary>\n\n```toml\n[monitors.placement]\nSony.topOf = \"BenQ\"\n\n## Character case is ignored, \"_\" can be added\nSony.Top_Of = [\"BenQ\"]\n\n## Thanks to TOML format, complex configurations can use separate \"sections\" for clarity, eg:\n\n[monitors.placement.\"My monitor brand\"]\n## You can also use \"port\" names such as *HDMI-A-1*, *DP-1*, etc...\nleftOf = \"eDP-1\"\n\n## lists are possible on the right part of the assignment:\nrightOf = [\"Sony\", \"BenQ\"]\n\n# When multiple targets are specified, only the first connected monitor\n# matching a pattern is used as the reference.\n\n## > 2.3.2: you can also set scale, transform & rate for a given monitor\n[monitors.placement.Microstep]\nrate = 100\n```\n\nTry to keep the rules as simple as possible, but relatively complex scenarios are supported.\n\n> [!note]\n> Check [wlr layout UI](https://github.com/fdev31/wlr-layout-ui) which is a nice complement to configure your monitor settings.\n\n</details>\n\n## Commands\n\n<PluginCommands plugin=\"monitors\"  version=\"3.2.1\" />\n\n## Configuration\n\n<PluginConfig plugin=\"monitors\" linkPrefix=\"config-\"  version=\"3.2.1\" />\n\n### `placement` <ConfigBadges plugin=\"monitors\" option=\"placement\"  version=\"3.2.1\" /> {#config-placement}\n\nConfigure monitor settings and relative positioning. Each monitor is identified by a [pattern](#monitor-patterns) (port name or description substring) and can have both display settings and positioning rules.\n\n```toml\n[monitors.placement.\"My monitor\"]\n# Display settings\nscale = 1.25\ntransform = 1\nrate = 144\nresolution = \"2560x1440\"\n\n# Positioning\nleftOf = \"eDP-1\"\n```\n\n#### Monitor Settings\n\nThese settings control the display properties of a monitor.\n\n##### `scale` {#placement-scale}\n\nControls UI element size. Higher values make the UI larger (zoomed in), showing less content.\n\n| Scale Value | Content Visible |\n|---------------|-----------------|\n|`0.666667` | More (zoomed out) |\n|`0.833333` | More |\n| `1.0` | Native |\n| `1.25` | Less |\n| `1.6` | Less |\n| `2.0` | 25% (zoomed in) |\n\n> [!tip]\n> For HiDPI displays, use values like `1.5` or `2.0` to make UI elements larger and more readable at the cost of screen real estate.\n\n##### `transform` {#placement-transform}\n\nRotates and optionally flips the monitor.\n\n| Value | Rotation | Description |\n|-------|----------|-------------|\n| 0 | Normal | No rotation (landscape) |\n| 1 | 90° | Portrait (rotated right) |\n| 2 | 180° | Upside down |\n| 3 | 270° | Portrait (rotated left) |\n| 4 | Flipped | Mirrored horizontally |\n| 5 | Flipped 90° | Mirrored + 90° |\n| 6 | Flipped 180° | Mirrored + 180° |\n| 7 | Flipped 270° | Mirrored + 270° |\n\n##### `rate` {#placement-rate}\n\nRefresh rate in Hz.\n\n```toml\nrate = 144\n```\n\n> [!tip]\n> Run `hyprctl monitors` to see available refresh rates for each monitor.\n\n##### `resolution` {#placement-resolution}\n\nDisplay resolution. Can be specified as a string or array.\n\n```toml\nresolution = \"2560x1440\"\n# or\nresolution = [2560, 1440]\n```\n\n> [!tip]\n> Run `hyprctl monitors` to see available resolutions for each monitor.\n\n##### `disables` {#placement-disables}\n\nList of monitors to disable when this monitor is connected. This is useful for automatically turning off a laptop's built-in display when an external monitor is plugged in.\n\n```toml\n[monitors.placement.\"External Monitor\"]\ndisables = [\"eDP-1\"]  # Disable laptop screen when this monitor is connected\n```\n\nYou can disable multiple monitors and combine with positioning rules:\n\n```toml\n[monitors.placement.\"DELL U2722D\"]\nleftOf = \"DP-2\"\ndisables = [\"eDP-1\", \"HDMI-A-2\"]\n```\n\n> [!note]\n> Monitors specified in `disables` are excluded from layout calculations. They will be re-enabled on the next relayout if the disabling monitor is disconnected.\n\n#### Positioning Rules\n\nPosition monitors relative to each other using directional keywords.\n\n**Directions:**\n\n- `leftOf` / `rightOf` — horizontal placement\n- `topOf` / `bottomOf` — vertical placement\n\n**Alignment modifiers** (for different-sized monitors):\n\n- `start` (default) — align at top/left edge\n- `center` / `middle` — center alignment\n- `end` — align at bottom/right edge\n\nCombine direction + alignment: `topCenterOf`, `leftEndOf`, `right_middle_of`, etc.\n\nEverything is case insensitive; use `_` for readability (e.g., `top_center_of`).\n\n> [!important]\n> At least one monitor must have **no placement rule** to serve as the anchor/reference point.\n> Other monitors are positioned relative to this anchor.\n\nSee [Placement Examples](#placement-examples) for visual diagrams.\n\n#### Monitor Patterns {#monitor-patterns}\n\nBoth the monitor being configured and the target monitor can be specified using:\n\n1. **Port name** (exact match) — e.g., `eDP-1`, `HDMI-A-1`, `DP-1`\n2. **Description substring** (partial match) — e.g., `Hisense`, `BenQ`, `DELL P2417H`\n\nThe plugin first checks for an exact port name match, then searches monitor descriptions for a substring match. Descriptions typically contain the manufacturer, model, and serial number.\n\n```toml\n# Target by port name\n[monitors.placement.Sony]\ntopOf = \"eDP-1\"\n\n# Target by brand/model name\n[monitors.placement.Hisense]\ntop_middle_of = \"BenQ\"\n\n# Mix both approaches\n[monitors.placement.\"DELL P2417H\"]\nright_end_of = \"HDMI-A-1\"\n```\n\n> [!tip]\n> Run `hyprctl monitors` (or `nirictl outputs` for Niri) to see the full description of each connected monitor.\n\n### `startup_relayout` <ConfigBadges plugin=\"monitors\" option=\"startup_relayout\"  version=\"3.2.1\" /> {#config-startup-relayout}\n\nWhen set to `false`, do not initialize the monitor layout on startup or when configuration is reloaded.\n\n### `relayout_on_config_change` <ConfigBadges plugin=\"monitors\" option=\"relayout_on_config_change\"  version=\"3.2.1\" /> {#config-relayout-on-config-change}\n\nWhen set to `false`, do not relayout when Hyprland config is reloaded.\n\n### `new_monitor_delay` <ConfigBadges plugin=\"monitors\" option=\"new_monitor_delay\"  version=\"3.2.1\" /> {#config-new-monitor-delay}\n\nThe layout computation happens after this delay when a new monitor is detected, to let time for things to settle.\n\n### `hotplug_command` <ConfigBadges plugin=\"monitors\" option=\"hotplug_command\"  version=\"3.2.1\" /> {#config-hotplug-command}\n\nAllows to run a command when any monitor is plugged.\n\n```toml\n[monitors]\nhotplug_command = \"wlrlui -m\"\n```\n\n### `hotplug_commands` <ConfigBadges plugin=\"monitors\" option=\"hotplug_commands\"  version=\"3.2.1\" /> {#config-hotplug-commands}\n\nAllows to run a command when a specific monitor is plugged.\n\nExample to load a specific profile using [wlr layout ui](https://github.com/fdev31/wlr-layout-ui):\n\n```toml\n[monitors.hotplug_commands]\n\"DELL P2417H CJFH277Q3HCB\" = \"wlrlui rotated\"\n```\n\n### `unknown` <ConfigBadges plugin=\"monitors\" option=\"unknown\"  version=\"3.2.1\" /> {#config-unknown}\n\nAllows to run a command when no monitor layout has been changed (no rule applied).\n\n```toml\n[monitors]\nunknown = \"wlrlui\"\n```\n\n## Placement Examples {#placement-examples}\n\nThis section provides visual diagrams to help understand monitor placement rules.\n\n### Basic Positions\n\nThe four basic placement directions position a monitor relative to another:\n\n#### `topOf` - Monitor above another\n\n<img src=\"/images/monitors/basic-top-of.svg\" alt=\"Monitor A placed on top of Monitor B\" style=\"max-width: 36%\" />\n\n```toml\n[monitors.placement.A]\ntopOf = \"B\"\n```\n\n#### `bottomOf` - Monitor below another\n\n<img src=\"/images/monitors/basic-bottom-of.svg\" alt=\"Monitor A placed below Monitor B\" style=\"max-width: 36%\" />\n\n```toml\n[monitors.placement.A]\nbottomOf = \"B\"\n```\n\n#### `leftOf` - Monitor to the left\n\n<img src=\"/images/monitors/basic-left-of.svg\" alt=\"Monitor A placed to the left of Monitor B\" style=\"max-width: 68%\" />\n\n```toml\n[monitors.placement.A]\nleftOf = \"B\"\n```\n\n#### `rightOf` - Monitor to the right\n\n<img src=\"/images/monitors/basic-right-of.svg\" alt=\"Monitor A placed to the right of Monitor B\" style=\"max-width: 68%\" />\n\n```toml\n[monitors.placement.A]\nrightOf = \"B\"\n```\n\n### Alignment Modifiers\n\nWhen monitors have different sizes, alignment modifiers control where the smaller monitor aligns along the edge.\n\n#### Horizontal placement (`leftOf` / `rightOf`)\n\n**Start (default)** - Top edges align:\n\n<img src=\"/images/monitors/align-left-start.svg\" alt=\"Monitor A to the left of B, top edges aligned\" style=\"max-width: 57%\" />\n\n```toml\n[monitors.placement.A]\nleftOf = \"B\"  # same as leftStartOf\n```\n\n**Center / Middle** - Vertically centered:\n\n<img src=\"/images/monitors/align-left-center.svg\" alt=\"Monitor A to the left of B, vertically centered\" style=\"max-width: 57%\" />\n\n```toml\n[monitors.placement.A]\nleftCenterOf = \"B\"  # or leftMiddleOf\n```\n\n**End** - Bottom edges align:\n\n<img src=\"/images/monitors/align-left-end.svg\" alt=\"Monitor A to the left of B, bottom edges aligned\" style=\"max-width: 57%\" />\n\n```toml\n[monitors.placement.A]\nleftEndOf = \"B\"\n```\n\n#### Vertical placement (`topOf` / `bottomOf`)\n\n**Start (default)** - Left edges align:\n\n<img src=\"/images/monitors/align-top-start.svg\" alt=\"Monitor A on top of B, left edges aligned\" style=\"max-width: 36%\" />\n\n```toml\n[monitors.placement.A]\ntopOf = \"B\"  # same as topStartOf\n```\n\n**Center / Middle** - Horizontally centered:\n\n<img src=\"/images/monitors/align-top-center.svg\" alt=\"Monitor A on top of B, horizontally centered\" style=\"max-width: 36%\" />\n\n```toml\n[monitors.placement.A]\ntopCenterOf = \"B\"  # or topMiddleOf\n```\n\n**End** - Right edges align:\n\n<img src=\"/images/monitors/align-top-end.svg\" alt=\"Monitor A on top of B, right edges aligned\" style=\"max-width: 36%\" />\n\n```toml\n[monitors.placement.A]\ntopEndOf = \"B\"\n```\n\n### Common Setups\n\n#### Dual side-by-side\n\n<img src=\"/images/monitors/setup-dual.svg\" alt=\"Dual monitor setup: A and B side by side\" style=\"max-width: 68%\" />\n\n```toml\n[monitors.placement.A]\nleftOf = \"B\"\n```\n\n#### Triple horizontal\n\n<img src=\"/images/monitors/setup-triple.svg\" alt=\"Triple monitor setup: A, B, C in a row\" style=\"max-width: 100%\" />\n\n```toml\n[monitors.placement.A]\nleftOf = \"B\"\n\n[monitors.placement.C]\nrightOf = \"B\"\n```\n\n#### Stacked (vertical)\n\n<img src=\"/images/monitors/setup-stacked.svg\" alt=\"Stacked monitor setup: A on top, B in middle, C at bottom\" style=\"max-width: 36%\" />\n\n```toml\n[monitors.placement.A]\ntopOf = \"B\"\n\n[monitors.placement.C]\nbottomOf = \"B\"\n```\n\n### Real-World Example: L-Shape with Portrait Monitor\n\nThis example shows a complex 3-monitor setup combining portrait mode, corner alignment, and different-sized displays.\n\n**Layout:**\n\n<img src=\"/images/monitors/real-world-l-shape.svg\" alt=\"L-shape monitor setup with portrait monitor A, anchor B, and landscape C\" style=\"max-width: 57%\" />\n\nWhere:\n\n- **A** (HDMI-A-1) = Portrait monitor (transform=1), directly on top of B (blue)\n- **B** (eDP-1) = Main anchor monitor, landscape (green)\n- **C** = Landscape monitor, positioned at the bottom-right corner of A (orange)\n\n**Configuration:**\n\n```toml\n[monitors.placement.CJFH277Q3HCB]\ntop_of = \"eDP-1\"\ntransform = 1\nscale = 0.83\n\n[monitors.placement.CJFH27888CUB]\nright_end_of = \"HDMI-A-1\"\n```\n\n**Explanation:**\n\n1. **B (eDP-1)** has no placement rule, making it the anchor/reference point\n2. **A (CJFH277Q3HCB)** is placed on top of B with `top_of = \"eDP-1\"`, rotated to portrait with `transform = 1`, and scaled to 83%\n3. **C (CJFH27888CUB)** uses `right_end_of = \"HDMI-A-1\"` to position itself to the right of A with bottom edges aligned, creating the L-shape\n\nThe `right_end_of` placement is key here: it aligns C's bottom edge with A's bottom edge, tucking C into the corner rather than aligning at the top (which `rightOf` would do).\n"
  },
  {
    "path": "site/versions/3.2.1/scratchpads.md",
    "content": "---\n---\n# scratchpads\n\nEasily toggle the visibility of applications you use the most.\n\nConfigurable and flexible, while supporting complex setups it's easy to get started with:\n\n```toml\n[scratchpads.name]\ncommand = \"command to run\"\nclass = \"the window's class\"  # check: hyprctl clients | grep class\nsize = \"[width] [height]\"  # size of the window relative to the screen size\n```\n\n<details>\n<summary>Example</summary>\n\nAs an example, defining two scratchpads:\n\n- _term_ which would be a kitty terminal on upper part of the screen\n- _volume_ which would be a pavucontrol window on the right part of the screen\n\n\n```toml\n[scratchpads.term]\nanimation = \"fromTop\"\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nsize = \"75% 60%\"\nmax_size = \"1920px 100%\"\nmargin = 50\n\n[scratchpads.volume]\nanimation = \"fromRight\"\ncommand = \"pavucontrol\"\nclass = \"org.pulseaudio.pavucontrol\"\nsize = \"40% 90%\"\nunfocus = \"hide\"\nlazy = true\n```\n\nShortcuts are generally needed:\n\n```ini\nbind = $mainMod,V,exec,pypr toggle volume\nbind = $mainMod,A,exec,pypr toggle term\nbind = $mainMod,Y,exec,pypr attach\n```\n</details>\n\n- If you wish to have a more generic space for any application you may run, check [toggle_special](./toggle_special).\n- When you create a scratchpad called \"name\", it will be hidden in `special:scratch_<name>`.\n- Providing `class` allows a glitch free experience, mostly noticeable when using animations\n\n\n## Commands\n\n<PluginCommands plugin=\"scratchpads\"  version=\"3.2.1\" />\n\n> [!tip]\n> You can use `\"*\"` as a _scratchpad name_ to target every scratchpad when using `show` or `hide`.\n> You'll need to quote or escape the `*` character to avoid interpretation from your shell.\n\n## Configuration\n\n<PluginConfig plugin=\"scratchpads\" linkPrefix=\"config-\" :filter=\"['command', 'class', 'animation', 'size', 'position', 'margin', 'max_size', 'multi', 'lazy']\"  version=\"3.2.1\" />\n\n> [!tip]\n> Looking for more options? See:\n> - [Advanced Configuration](./scratchpads_advanced) - unfocus, excludes, monitor overrides, and more\n> - [Troubleshooting](./scratchpads_nonstandard) - PWAs, emacsclient, custom window matching\n\n### `command` <ConfigBadges plugin=\"scratchpads\" option=\"command\"  version=\"3.2.1\" /> {#config-command}\n\nThis is the command you wish to run in the scratchpad. It supports [variables](./Variables).\n\n### `class` <ConfigBadges plugin=\"scratchpads\" option=\"class\"  version=\"3.2.1\" /> {#config-class}\n\nAllows _Pyprland_ prepare the window for a correct animation and initial positioning.\nCheck your window's class with: `hyprctl clients | grep class`\n\n### `animation` <ConfigBadges plugin=\"scratchpads\" option=\"animation\"  version=\"3.2.1\" /> {#config-animation}\n\nType of animation to use:\n\n- `fromTop` (default) stays close to upper screen border\n- `fromBottom` stays close to lower screen border\n- `fromLeft` stays close to left screen border\n- `fromRight` stays close to right screen border\n- `null` / `\"\"` no sliding animation - also disables positioning relative to the border\n\nIt is recommended to set [position](#config-position) when disabling this configuration option.\n\n### `size` <ConfigBadges plugin=\"scratchpads\" option=\"size\"  version=\"3.2.1\" /> {#config-size}\n\nEach time scratchpad is shown, window will be resized according to the provided values.\n\nFor example on monitor of size `800x600` and `size= \"80% 80%\"` in config scratchpad always have size `640x480`,\nregardless of which monitor it was first launched on.\n\n> #### Format\n>\n> String with \"x y\" (or \"width height\") values using some units suffix:\n>\n> - **percents** relative to the focused screen size (`%` suffix), eg: `60% 30%`\n> - **pixels** for absolute values (`px` suffix), eg: `800px 600px`\n> - a mix is possible, eg: `800px 40%`\n\n### `position` <ConfigBadges plugin=\"scratchpads\" option=\"position\"  version=\"3.2.1\" /> {#config-position}\n\nOverrides the automatic margin-based position.\nSets the scratchpad client window position relative to the top-left corner.\n\nSame format as `size` (see above)\n\nExample of scratchpad that always sits on the top-right corner of the screen:\n\n```toml\n[scratchpads.term_quake]\ncommand = \"wezterm start --class term_quake\"\nposition = \"50% 0%\"\nsize = \"50% 50%\"\nclass = \"term_quake\"\n```\n\n> [!note]\n> If `position` is not provided, the window is placed according to `margin` on one axis and centered on the other.\n>\n> Hide animations then slide away from the configured coordinates — not from wherever the window was last manually moved or tiled.\n\n### `margin` <ConfigBadges plugin=\"scratchpads\" option=\"margin\"  version=\"3.2.1\" /> {#config-margin}\n\nPixels from the screen edge when using animations. Used to position the window along the animation axis.\n\n### `max_size` <ConfigBadges plugin=\"scratchpads\" option=\"max_size\"  version=\"3.2.1\" /> {#config-max-size}\n\nMaximum window size. Same format as `size`. Useful to prevent scratchpads from growing too large on big monitors.\n\n### `multi` <ConfigBadges plugin=\"scratchpads\" option=\"multi\"  version=\"3.2.1\" /> {#config-multi}\n\nWhen set to `false`, only one client window is supported for this scratchpad.\nOtherwise other matching windows will be **attach**ed to the scratchpad.\nAllows the `attach` command on the scratchpad.\n\n### `lazy` <ConfigBadges plugin=\"scratchpads\" option=\"lazy\"  version=\"3.2.1\" /> {#config-lazy}\n\nWhen `true`, the scratchpad command is only started on first use instead of at startup.\n\n## Advanced configuration\n\nTo go beyond the basic setup and have a look at every configuration item, you can read the following pages:\n\n- [Advanced](./scratchpads_advanced) contains options for fine-tuners or specific tastes (eg: i3 compatibility)\n- [Non-Standard](./scratchpads_nonstandard) contains options for \"broken\" applications\nlike progressive web apps (PWA) or emacsclient, use only if you can't get it to work otherwise\n"
  },
  {
    "path": "site/versions/3.2.1/scratchpads_advanced.md",
    "content": "---\n---\n# Fine tuning scratchpads\n\n> [!note]\n> For basic setup, see [Scratchpads](./scratchpads).\n\nAdvanced configuration options\n\n<PluginConfig plugin=\"scratchpads\" linkPrefix=\"config-\" :filter=\"['use', 'pinned', 'excludes', 'restore_excluded', 'unfocus', 'hysteresis', 'preserve_aspect', 'offset', 'hide_delay', 'force_monitor', 'alt_toggle', 'allow_special_workspaces', 'smart_focus', 'close_on_hide', 'monitor']\"  version=\"3.2.1\" />\n\n### `use` <ConfigBadges plugin=\"scratchpads\" option=\"use\"  version=\"3.2.1\" /> {#config-use}\n\nList of scratchpads (or single string) that will be used for the default values of this scratchpad.\nThink about *templates*:\n\n```toml\n[scratchpads.terminals]\nanimation = \"fromTop\"\nmargin = 50\nsize = \"75% 60%\"\nmax_size = \"1920px 100%\"\n\n[scratchpads.term]\ncommand = \"kitty --class kitty-dropterm\"\nclass = \"kitty-dropterm\"\nuse = \"terminals\"\n```\n\n### `pinned` <ConfigBadges plugin=\"scratchpads\" option=\"pinned\"  version=\"3.2.1\" /> {#config-pinned}\n\nMakes the scratchpad \"sticky\" to the monitor, following any workspace change.\n\n### `excludes` <ConfigBadges plugin=\"scratchpads\" option=\"excludes\"  version=\"3.2.1\" /> {#config-excludes}\n\nList of scratchpads to hide when this one is displayed, eg: `excludes = [\"term\", \"volume\"]`.\nIf you want to hide every displayed scratch you can set this to the string `\"*\"` instead of a list: `excludes = \"*\"`.\n\n### `restore_excluded` <ConfigBadges plugin=\"scratchpads\" option=\"restore_excluded\"  version=\"3.2.1\" /> {#config-restore-excluded}\n\nWhen enabled, will remember the scratchpads which have been closed due to `excludes` rules, so when the scratchpad is hidden, those previously hidden scratchpads will be shown again.\n\n### `unfocus` <ConfigBadges plugin=\"scratchpads\" option=\"unfocus\"  version=\"3.2.1\" /> {#config-unfocus}\n\nWhen set to `\"hide\"`, allow to hide the window when the focus is lost.\n\nUse `hysteresis` to change the reactivity\n\n### `hysteresis` <ConfigBadges plugin=\"scratchpads\" option=\"hysteresis\"  version=\"3.2.1\" /> {#config-hysteresis}\n\nControls how fast a scratchpad hiding on unfocus will react. Check `unfocus` option.\nSet to `0` to disable.\n\n> [!important]\n> Only relevant when `unfocus=\"hide\"` is used.\n\n### `preserve_aspect` <ConfigBadges plugin=\"scratchpads\" option=\"preserve_aspect\"  version=\"3.2.1\" /> {#config-preserve-aspect}\n\nWhen set to `true`, will preserve the size and position of the scratchpad when called repeatedly from the same monitor and workspace even though an `animation` , `position` or `size` is used (those will be used for the initial setting only).\n\nForces the `lazy` option.\n\n### `offset` <ConfigBadges plugin=\"scratchpads\" option=\"offset\"  version=\"3.2.1\" /> {#config-offset}\n\nNumber of pixels for the **hide** sliding animation (how far the window will go).\n\n> [!tip]\n> - It is also possible to set a string to express percentages of the client window\n> - `margin` is automatically added to the offset\n\n### `hide_delay` <ConfigBadges plugin=\"scratchpads\" option=\"hide_delay\"  version=\"3.2.1\" /> {#config-hide-delay}\n\nDelay (in seconds) after which the hide animation happens, before hiding the scratchpad.\n\nRule of thumb, if you have an animation with speed \"7\", as in:\n```bash\n    animation = windowsOut, 1, 7, easeInOut, popin 80%\n```\nYou can divide the value by two and round to the lowest value, here `3`, then divide by 10, leading to `hide_delay = 0.3`.\n\n### `force_monitor` <ConfigBadges plugin=\"scratchpads\" option=\"force_monitor\"  version=\"3.2.1\" /> {#config-force-monitor}\n\nIf set to some monitor name (eg: `\"DP-1\"`), it will always use this monitor to show the scratchpad.\n\n### `alt_toggle` <ConfigBadges plugin=\"scratchpads\" option=\"alt_toggle\"  version=\"3.2.1\" /> {#config-alt-toggle}\n\nWhen enabled, use an alternative `toggle` command logic for multi-screen setups.\nIt applies when the `toggle` command is triggered and the toggled scratchpad is visible on a screen which is not the focused one.\n\nInstead of moving the scratchpad to the focused screen, it will hide the scratchpad.\n\n### `allow_special_workspaces` <ConfigBadges plugin=\"scratchpads\" option=\"allow_special_workspaces\"  version=\"3.2.1\" /> {#config-allow-special-workspaces}\n\nWhen enabled, you can toggle a scratchpad over a special workspace.\nIt will always use the \"normal\" workspace otherwise.\n\n> [!note]\n> Can't be disabled when using *Hyprland* < 0.39 where this behavior can't be controlled.\n\n### `smart_focus` <ConfigBadges plugin=\"scratchpads\" option=\"smart_focus\"  version=\"3.2.1\" /> {#config-smart-focus}\n\nWhen enabled, the focus will be restored in a best effort way as an attempt to improve the user experience.\nIf you face issues such as spontaneous workspace changes, you can disable this feature.\n\n\n### `close_on_hide` <ConfigBadges plugin=\"scratchpads\" option=\"close_on_hide\"  version=\"3.2.1\" /> {#config-close-on-hide}\n\nWhen enabled, the window in the scratchpad is closed instead of hidden when `pypr hide <name>` is run.\nThis option implies `lazy = true`.\nThis can be useful on laptops where background apps may increase battery power draw.\n\nNote: Currently this option changes the hide animation to use hyprland's close window animation.\n\n### `monitor` <ConfigBadges plugin=\"scratchpads\" option=\"monitor\"  version=\"3.2.1\" /> {#config-monitor}\n\nPer-monitor configuration overrides. Most display-related attributes can be changed (not `command`, `class` or `process_tracking`).\n\nUse the `monitor.<monitor name>` configuration item to override values, eg:\n\n```toml\n[scratchpads.music.monitor.eDP-1]\nposition = \"30% 50%\"\nanimation = \"fromBottom\"\n```\n\nYou may want to inline it for simple cases:\n\n```toml\n[scratchpads.music]\nmonitor = {HDMI-A-1={size = \"30% 50%\"}}\n```\n"
  },
  {
    "path": "site/versions/3.2.1/scratchpads_nonstandard.md",
    "content": "---\n---\n# Troubleshooting scratchpads\n\n> [!note]\n> For basic setup, see [Scratchpads](./scratchpads).\n\nOptions that should only be used for applications that are not behaving in a \"standard\" way, such as `emacsclient` or progressive web apps.\n\n<PluginConfig plugin=\"scratchpads\" linkPrefix=\"config-\" :filter=\"['match_by', 'initialClass', 'initialTitle', 'title', 'process_tracking', 'skip_windowrules']\"  version=\"3.2.1\" />\n\n### `match_by` <ConfigBadges plugin=\"scratchpads\" option=\"match_by\"  version=\"3.2.1\" /> {#config-match-by}\n\nWhen set to a sensitive client property value (eg: `class`, `initialClass`, `title`, `initialTitle`), will match the client window using the provided property instead of the PID of the process.\n\nThis property must be set accordingly, eg:\n\n```toml\nmatch_by = \"class\"\nclass = \"my-web-app\"\n```\n\nor\n\n```toml\nmatch_by = \"initialClass\"\ninitialClass = \"my-web-app\"\n```\n\nYou can add the \"re:\" prefix to use a regular expression, eg:\n\n```toml\nmatch_by = \"title\"\ntitle = \"re:.*some string.*\"\n```\n\n> [!note]\n> Some apps may open the graphical client window in a \"complicated\" way, to work around this, it is possible to disable the process PID matching algorithm and simply rely on window's class.\n>\n> The `match_by` attribute can be used to achieve this, eg. for emacsclient:\n> ```toml\n> [scratchpads.emacs]\n> command = \"/usr/local/bin/emacsStart.sh\"\n> class = \"Emacs\"\n> match_by = \"class\"\n> ```\n\n### `process_tracking` <ConfigBadges plugin=\"scratchpads\" option=\"process_tracking\"  version=\"3.2.1\" /> {#config-process-tracking}\n\nAllows disabling the process management. Use only if running a progressive web app (Chrome based apps) or similar.\n\nThis will automatically force `lazy = true` and set `match_by=\"class\"` if no `match_by` rule is provided, to help with the fuzzy client window matching.\n\nIt requires defining a `class` option (or the option matching your `match_by` value).\n\n```toml\n## Chat GPT on Brave\n[scratchpads.gpt]\nanimation = \"fromTop\"\ncommand = \"brave --profile-directory=Default --app=https://chat.openai.com\"\nclass = \"brave-chat.openai.com__-Default\"\nsize = \"75% 60%\"\nprocess_tracking = false\n\n## Some chrome app\n[scratchpads.music]\ncommand = \"google-chrome --profile-directory=Default --app-id=cinhimbnkkaeohfgghhklpknlkffjgod\"\nclass = \"chrome-cinhimbnkkaeohfgghhklpknlkffjgod-Default\"\nsize = \"50% 50%\"\nprocess_tracking = false\n```\n\n> [!tip]\n> To list windows by class and title you can use:\n> - `hyprctl -j clients | jq '.[]|[.class,.title]'`\n> - or if you prefer a graphical tool: `rofi -show window`\n\n> [!note]\n> Progressive web apps will share a single process for every window.\n> On top of requiring the class based window tracking (using `match_by`),\n> the process can not be managed the same way as usual apps and the correlation\n> between the process and the client window isn't as straightforward and can lead to false matches in extreme cases.\n\n### `skip_windowrules` <ConfigBadges plugin=\"scratchpads\" option=\"skip_windowrules\"  version=\"3.2.1\" /> {#config-skip-windowrules}\n\nAllows you to skip the window rules for a specific scratchpad.\nAvailable rules are:\n\n- \"aspect\" controlling size and position\n- \"float\" controlling the floating state\n- \"workspace\" which moves the window to its own workspace\n\nIf you are using an application which can spawn multiple windows and you can't see them, you can skip rules made to improve the initial display of the window.\n\n```toml\n[scratchpads.filemanager]\nanimation = \"fromBottom\"\ncommand = \"nemo\"\nclass = \"nemo\"\nsize = \"60% 60%\"\nskip_windowrules = [\"aspect\", \"workspace\"]\n```\n"
  },
  {
    "path": "site/versions/3.2.1/shift_monitors.md",
    "content": "---\n---\n\n# shift_monitors\n\nSwaps the workspaces of every screen in the given direction.\n\n> [!Note]\n> the behavior can be hard to predict if you have more than 2 monitors (depending on your layout).\n> If you use this plugin with many monitors and have some ideas about a convenient configuration, you are welcome ;)\n\n> [!Tip]\n> On Niri, this plugin moves the active workspace to the adjacent monitor instead of swapping workspaces, as Niri workspaces are dynamic.\n\nExample usage in `hyprland.conf`:\n\n```sh\nbind = $mainMod, O, exec, pypr shift_monitors +1\nbind = $mainMod SHIFT, O, exec, pypr shift_monitors -1\n```\n\n## Commands\n\n<PluginCommands plugin=\"shift_monitors\"  version=\"3.2.1\" />\n\n## Configuration\n\nThis plugin has no configuration options.\n"
  },
  {
    "path": "site/versions/3.2.1/shortcuts_menu.md",
    "content": "---\n---\n\n# shortcuts_menu\n\nPresents some menu to run shortcut commands. Supports nested menus (aka categories / sub-menus).\n\n<details>\n   <summary>Configuration examples</summary>\n\n```toml\n[shortcuts_menu.entries]\n\n\"Open Jira ticket\" = 'open-jira-ticket \"$(wl-paste)\"'\nRelayout = \"pypr relayout\"\n\"Fetch window\" = \"pypr fetch_client_menu\"\n\"Hyprland socket\" = 'kitty  socat - \"UNIX-CONNECT:$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock\"'\n\"Hyprland logs\" = 'kitty tail -f $XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/hyprland.log'\n\n\"Serial USB Term\" = [\n    {name=\"device\", command=\"ls -1 /dev/ttyUSB*; ls -1 /dev/ttyACM*\"},\n    {name=\"speed\", options=[\"115200\", \"9600\", \"38400\", \"115200\", \"256000\", \"512000\"]},\n    \"kitty miniterm --raw --eol LF [device] [speed]\"\n]\n\n\"Color picker\" = [\n    {name=\"format\", options=[\"hex\", \"rgb\", \"hsv\", \"hsl\", \"cmyk\"]},\n    \"sleep 0.2; hyprpicker --format [format] | wl-copy\" # sleep to let the menu close before the picker opens\n]\n\nscreenshot = [\n    {name=\"what\", options=[\"output\", \"window\", \"region\", \"active\"]},\n    \"hyprshot -m [what] -o /tmp -f shot_[what].png\"\n]\n\nannotate = [\n    {name=\"fname\", command=\"ls /tmp/shot_*.png\"},\n    \"satty --filename '[fname]' --output-filename '/tmp/annotated.png'\"\n]\n\n\"Clipboard history\" = [\n    {name=\"entry\", command=\"cliphist list\", filter=\"s/\\t.*//\"},\n    \"cliphist decode '[entry]' | wl-copy\"\n]\n\n\"Copy password\" = [\n    {name=\"what\", command=\"gopass ls --flat\"},\n    \"gopass show -c [what]\"\n]\n\n\"Update/Change password\" = [\n    {name=\"what\", command=\"gopass ls --flat\"},\n    \"kitty -- gopass generate -s --strict -t '[what]' && gopass show -c '[what]'\"\n]\n```\n\n</details>\n\n\n## Commands\n\n<PluginCommands plugin=\"shortcuts_menu\"  version=\"3.2.1\" />\n\n> [!tip]\n> - If \"name\" is provided it will show the given sub-menu.\n> - You can use \".\" to reach any level of the configured menus.\n>\n>      Example to reach `shortcuts_menu.entries.utils.\"local commands\"`, use:\n>      ```sh\n>       pypr menu \"utils.local commands\"\n>      ```\n\n## Configuration\n\n<PluginConfig plugin=\"shortcuts_menu\" linkPrefix=\"config-\"  version=\"3.2.1\" />\n\n### `entries` <ConfigBadges plugin=\"shortcuts_menu\" option=\"entries\"  version=\"3.2.1\" /> {#config-entries}\n\n**Required.** Defines the menu entries. Supports [Variables](./Variables)\n\n```toml\n[shortcuts_menu.entries]\n\"entry 1\" = \"command to run\"\n\"entry 2\" = \"command to run\"\n```\n\nSubmenus can be defined too (there is no depth limit):\n\n```toml\n[shortcuts_menu.entries.\"My submenu\"]\n\"entry X\" = \"command\"\n\n[shortcuts_menu.entries.one.two.three.four.five]\nfoobar = \"ls\"\n```\n\n#### Advanced usage\n\nInstead of navigating a configured list of menu options and running a pre-defined command, you can collect various *variables* (either static list of options selected by the user, or generated from a shell command) and then run a command using those variables:\n\n```toml\n\"Play Video\" = [\n    {name=\"video_device\", command=\"ls -1 /dev/video*\"},\n    {name=\"player\", options=[\"mpv\", \"guvcview\"]},\n    \"[player] [video_device]\"\n]\n\n\"Ssh\" = [\n    {name=\"action\", options=[\"htop\", \"uptime\", \"sudo halt -p\"]},\n    {name=\"host\", options=[\"gamix\", \"gate\", \"idp\"]},\n    \"kitty --hold ssh [host] [action]\"\n]\n```\n\nYou must define a list of objects, containing:\n- `name`: the variable name\n- then the list of options, must be one of:\n    - `options` for a static list of options\n    - `command` to get the list of options from a shell command's output\n\n> [!tip]\n> You can apply post-filters to the `command` output, eg:\n> ```toml\n> {\n>   name = \"entry\",\n>   command = \"cliphist list\",\n>   filter = \"s/\\t.*//\"  # will remove everything after the TAB character\n> }\n> ```\n> Check the [filters](./filters) page for more details\n\nThe last item of the list must be a string which is the command to run. Variables can be used enclosed in `[]`.\n\n### `command_start` / `command_end` <ConfigBadges plugin=\"shortcuts_menu\" option=\"command_start\"  version=\"3.2.1\" /> {#config-command-start}\n\nAllow adding some text (eg: icon) before / after a menu entry for final commands.\n\n### `submenu_start` / `submenu_end` <ConfigBadges plugin=\"shortcuts_menu\" option=\"submenu_start\"  version=\"3.2.1\" /> {#config-submenu-start}\n\nAllow adding some text (eg: icon) before / after a menu entry leading to another menu.\n\nBy default `submenu_end` is set to a right arrow sign, while other attributes are not set.\n\n### `skip_single` <ConfigBadges plugin=\"shortcuts_menu\" option=\"skip_single\"  version=\"3.2.1\" /> {#config-skip-single}\n\nWhen disabled, shows the menu even for single options.\n\n## Hints\n\n### Multiple menus\n\nTo manage multiple distinct menus, always use a name when using the `pypr menu <name>` command.\n\nExample of a multi-menu configuration:\n\n```toml\n[shortcuts_menu.entries.\"Basic commands\"]\n\"entry X\" = \"command\"\n\"entry Y\" = \"command2\"\n\n[shortcuts_menu.entries.menu2]\n## ...\n```\n\nYou can then show the first menu using `pypr menu \"Basic commands\"`\n"
  },
  {
    "path": "site/versions/3.2.1/sidebar.json",
    "content": "{\n  \"main\": [\n    {\n      \"text\": \"Getting Started\",\n      \"link\": \"./Getting-started\"\n    },\n    {\n      \"text\": \"Configuration\",\n      \"link\": \"./Configuration\"\n    },\n    {\n      \"text\": \"Commands\",\n      \"link\": \"./Commands\"\n    },\n    {\n      \"text\": \"Plugins\",\n      \"link\": \"./Plugins\"\n    },\n    {\n      \"text\": \"Troubleshooting\",\n      \"link\": \"./Troubleshooting\"\n    },\n    {\n      \"text\": \"Development\",\n      \"link\": \"./Development\"\n    },\n    {\n      \"text\": \"Examples\",\n      \"link\": \"./Examples\"\n    },\n    {\n      \"text\": \"Architecture\",\n      \"link\": \"./Architecture\",\n      \"collapsed\": true,\n      \"items\": [\n        {\n          \"text\": \"Overview\",\n          \"link\": \"./Architecture_overview\"\n        },\n        {\n          \"text\": \"Core Components\",\n          \"link\": \"./Architecture_core\"\n        }\n      ]\n    }\n  ],\n  \"plugins\": {\n    \"text\": \"Featured plugins\",\n    \"collapsed\": false,\n    \"items\": [\n      {\n        \"text\": \"Expose\",\n        \"link\": \"./expose\"\n      },\n      {\n        \"text\": \"Fcitx5 Switcher\",\n        \"link\": \"./fcitx5_switcher\"\n      },\n      {\n        \"text\": \"Fetch client menu\",\n        \"link\": \"./fetch_client_menu\"\n      },\n      {\n        \"text\": \"Gamemode\",\n        \"link\": \"./gamemode\"\n      },\n      {\n        \"text\": \"Menubar\",\n        \"link\": \"./menubar\"\n      },\n      {\n        \"text\": \"Layout center\",\n        \"link\": \"./layout_center\"\n      },\n      {\n        \"text\": \"Lost windows\",\n        \"link\": \"./lost_windows\"\n      },\n      {\n        \"text\": \"Magnify\",\n        \"link\": \"./magnify\"\n      },\n      {\n        \"text\": \"Monitors\",\n        \"link\": \"./monitors\"\n      },\n      {\n        \"text\": \"Scratchpads\",\n        \"link\": \"./scratchpads\",\n        \"items\": [\n          {\n            \"text\": \"Advanced\",\n            \"link\": \"./scratchpads_advanced\"\n          },\n          {\n            \"text\": \"Special cases\",\n            \"link\": \"./scratchpads_nonstandard\"\n          }\n        ]\n      },\n      {\n        \"text\": \"Shift monitors\",\n        \"link\": \"./shift_monitors\"\n      },\n      {\n        \"text\": \"Shortcuts menu\",\n        \"link\": \"./shortcuts_menu\"\n      },\n      {\n        \"text\": \"Stash\",\n        \"link\": \"./stash\"\n      },\n      {\n        \"text\": \"System notifier\",\n        \"link\": \"./system_notifier\"\n      },\n      {\n        \"text\": \"Toggle dpms\",\n        \"link\": \"./toggle_dpms\"\n      },\n      {\n        \"text\": \"Toggle special\",\n        \"link\": \"./toggle_special\"\n      },\n      {\n        \"text\": \"Wallpapers\",\n        \"link\": \"./wallpapers\",\n        \"items\": [\n          {\n            \"text\": \"Online\",\n            \"link\": \"./wallpapers_online\"\n          },\n          {\n            \"text\": \"Templates\",\n            \"link\": \"./wallpapers_templates\"\n          }\n        ]\n      },\n      {\n        \"text\": \"Workspaces follow focus\",\n        \"link\": \"./workspaces_follow_focus\"\n      }\n    ]\n  }\n}\n"
  },
  {
    "path": "site/versions/3.2.1/stash.md",
    "content": "---\n---\n\n# stash\n\nStash and show windows in named groups using special workspaces.\n\nUnlike `toggle_special` which uses a single special workspace, `stash` supports multiple named stash groups. Windows can be quickly stashed away and retrieved later, appearing on whichever workspace you are currently on.\n\n## Usage\n\n```bash\nbind = $mainMod, S, exec, pypr stash          # toggle stash the focused window\nbind = $mainMod SHIFT, S, exec, pypr stash_toggle # show/hide stashed windows\n```\n\nFor multiple stash groups:\n\n```bash\nbind = $mainMod, S, exec, pypr stash default\nbind = $mainMod, W, exec, pypr stash work\nbind = $mainMod SHIFT, S, exec, pypr stash_toggle default\nbind = $mainMod SHIFT, W, exec, pypr stash_toggle work\n```\n\n## Commands\n\n<PluginCommands plugin=\"stash\"  version=\"3.2.1\" />\n\n## Configuration\n\n<PluginConfig plugin=\"stash\"  version=\"3.2.1\" />\n\n### Example\n\n```toml\n[stash]\nstyle = [\n    \"border_color rgb(ec8800)\",\n    \"border_size 3\",\n]\n```\n\nWhen `style` is set, shown stash windows are tagged with `stash` and the listed [window rules](https://wiki.hyprland.org/Configuring/Window-Rules/) are applied.\nThe tag is removed when windows are hidden or removed from the stash.\n"
  },
  {
    "path": "site/versions/3.2.1/system_notifier.md",
    "content": "---\n---\n# system_notifier\n\nThis plugin adds system notifications based on journal logs (or any program's output).\nIt monitors specified **sources** for log entries matching predefined **patterns** and generates notifications accordingly (after applying an optional **filter**).\n\nSources are commands that return a stream of text (eg: journal, mqtt, `tail -f`, ...) which is sent to a parser that will use a [regular expression pattern](https://en.wikipedia.org/wiki/Regular_expression) to detect lines of interest and optionally transform them before sending the notification.\n\n<details>\n    <summary>Minimal configuration</summary>\n\n```toml\n[[system_notifier.sources]]\ncommand = \"journalctl -fx\"\nparser = \"journal\"\n```\n\nNo **sources** are defined by default, so you will need to define at least one.\n\nIn general you will also need to define some **parsers**.\nBy default a **\"journal\"** parser is provided, otherwise you need to define your own rules.\nThis built-in configuration is close to this one, provided as an example:\n\n```toml\n[[system_notifier.parsers.journal]]\npattern = \"([a-z0-9]+): Link UP$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is active/\"\ncolor = \"#00aa00\"\n\n[[system_notifier.parsers.journal]]\npattern = \"([a-z0-9]+): Link DOWN$\"\nfilter = \"s/.*\\[\\d+\\]: ([a-z0-9]+): Link.*/\\1 is inactive/\"\ncolor = \"#ff8800\"\nduration = 15\n\n[[system_notifier.parsers.journal]]\npattern = \"Process \\d+ \\(.*\\) of .* dumped core.\"\nfilter = \"s/.*Process \\d+ \\((.*)\\) of .* dumped core./\\1 dumped core/\"\ncolor = \"#aa0000\"\n\n[[system_notifier.parsers.journal]]\npattern = \"usb \\d+-[0-9.]+: Product: \"\nfilter = \"s/.*usb \\d+-[0-9.]+: Product: (.*)/USB plugged: \\1/\"\n```\n</details>\n\n## Commands\n\n<PluginCommands plugin=\"system_notifier\"  version=\"3.2.1\" />\n\n## Configuration\n\n<PluginConfig plugin=\"system_notifier\" linkPrefix=\"config-\"  version=\"3.2.1\" />\n\n### `sources` <ConfigBadges plugin=\"system_notifier\" option=\"sources\"  version=\"3.2.1\" /> {#config-sources}\n\nList of sources to monitor. Each source must contain a `command` to run and a `parser` to use:\n\n```toml\n[[system_notifier.sources]]\ncommand = \"journalctl -fx\"\nparser = \"journal\"\n```\n\nYou can also use multiple parsers:\n\n```toml\n[[system_notifier.sources]]\ncommand = \"sudo journalctl -fkn\"\nparser = [\"journal\", \"custom_parser\"]\n```\n\n### `parsers` <ConfigBadges plugin=\"system_notifier\" option=\"parsers\"  version=\"3.2.1\" /> {#config-parsers}\n\nNamed parser configurations. Each parser rule contains:\n- `pattern`: regex to match lines of interest\n- `filter`: optional [filter](./filters) to transform text (e.g., `s/.*value: (\\d+)/Value=\\1/`)\n- `color`: optional color in `\"#hex\"` or `\"rgb()\"` format\n- `duration`: notification display time in seconds (default: 3)\n\n```toml\n[[system_notifier.parsers.custom_parser]]\npattern = 'special value:'\nfilter = \"s/.*special value: (\\d+)/Value=\\1/\"\ncolor = \"#FF5500\"\nduration = 10\n```\n\n### Built-in \"journal\" parser\n\nA `journal` parser is provided, detecting link up/down, core dumps, and USB plugs.\n\n### `use_notify_send` <ConfigBadges plugin=\"system_notifier\" option=\"use_notify_send\"  version=\"3.2.1\" /> {#config-use-notify-send}\n\nWhen enabled, forces use of `notify-send` command instead of the compositor's native notification system.\n"
  },
  {
    "path": "site/versions/3.2.1/toggle_dpms.md",
    "content": "---\n---\n\n# toggle_dpms\n\n## Commands\n\n<PluginCommands plugin=\"toggle_dpms\"  version=\"3.2.1\" />\n\n## Configuration\n\nThis plugin has no configuration options."
  },
  {
    "path": "site/versions/3.2.1/toggle_special.md",
    "content": "---\n---\n\n# toggle_special\n\nAllows moving the focused window to a special workspace and back (based on the visibility status of that workspace).\n\nIt's a companion of the `togglespecialworkspace` Hyprland's command which toggles a special workspace's visibility.\n\nYou most likely will need to configure the two commands for a complete user experience, eg:\n\n```bash\nbind = $mainMod SHIFT, N, togglespecialworkspace, stash # toggles \"stash\" special workspace visibility\nbind = $mainMod, N, exec, pypr toggle_special stash # moves window to/from the \"stash\" workspace\n```\n\nNo other configuration needed, here `MOD+SHIFT+N` will show every window in \"stash\" while `MOD+N` will move the focused window out of it/ to it.\n\n## Commands\n\n<PluginCommands plugin=\"toggle_special\"  version=\"3.2.1\" />\n\n## Configuration\n\n<PluginConfig plugin=\"toggle_special\" linkPrefix=\"config-\"  version=\"3.2.1\" />\n\n### `name` <ConfigBadges plugin=\"toggle_special\" option=\"name\"  version=\"3.2.1\" /> {#config-name}\n\nDefault special workspace name.\n"
  },
  {
    "path": "site/versions/3.2.1/wallpapers.md",
    "content": "---\n---\n\n\n# wallpapers\n\nSearch folders for images and sets the background image at a regular interval.\nPictures are selected randomly from the full list of images found.\n\nIt serves few purposes:\n\n- adding support for random images to any background setting tool\n- quickly testing different tools with a minimal effort\n- adding rounded corners to each wallpaper screen\n- generating a wallpaper-compliant color scheme usable to generate configurations for any application (matugen/pywal alike)\n\nIt allows \"zapping\" current backgrounds, clearing it to go distraction free and optionally make them different for each screen.\n\n> [!important]\n> On Hyprland, Pyprland uses **hyprpaper** by default, but you must start hyprpaper separately (e.g. `uwsm app -- hyprpaper`). For other environments, set the `command` option to launch your wallpaper application.\n\n> [!note]\n> On environments other than Hyprland and Niri, pyprland uses `wlr-randr` (Wayland) or `xrandr` (X11) for monitor detection.\n> This provides full wallpaper functionality but without automatic refresh on monitor hotplug.\n\nCached images (rounded corners, online downloads) are stored in subfolders within your configured `path` directory.\n\n<details>\n    <summary>Minimal example using defaults (requires <b>hyprpaper</b>)</summary>\n\n```toml\n[wallpapers]\npath = \"~/Pictures/wallpapers/\" # path to the folder with background images\n```\n\n</details>\n\n<details>\n<summary>More complete, using the custom <b>swww</b> command (not recommended because of its stability)</summary>\n\n```toml\n[wallpapers]\nunique = true # set a different wallpaper for each screen\npath = \"~/Pictures/wallpapers/\"\ninterval = 60 # change every hour\nextensions = [\"jpg\", \"jpeg\"]\nrecurse = true\nclear_command = \"swww clear\"\ncommand = \"swww img --outputs '[output]'  '[file]'\"\n\n```\n\nNote that for applications like `swww`, you'll need to start a daemon separately (eg: from `hyprland.conf`).\n</details>\n\n\n## Commands\n\n<PluginCommands plugin=\"wallpapers\"  version=\"3.2.1\" />\n\n> [!tip]\n> The `color` and `palette` commands are used for templating. See [Templates](./wallpapers_templates#commands) for details.\n\n## Configuration\n\n<PluginConfig plugin=\"wallpapers\" linkPrefix=\"config-\" :filter=\"['path', 'interval', 'command', 'clear_command', 'post_command', 'radius', 'extensions', 'recurse', 'unique']\"  version=\"3.2.1\" />\n\n### `path` <ConfigBadges plugin=\"wallpapers\" option=\"path\"  version=\"3.2.1\" /> {#config-path}\n\n**Required.** Path to a folder or list of folders that will be searched for wallpaper images.\n\n```toml\npath = [\"~/Pictures/Portraits/\", \"~/Pictures/Landscapes/\"]\n```\n\n### `interval` <ConfigBadges plugin=\"wallpapers\" option=\"interval\"  version=\"3.2.1\" /> {#config-interval}\n\nHow long (in minutes) a background should stay in place before changing.\n\n### `command` <ConfigBadges plugin=\"wallpapers\" option=\"command\"  version=\"3.2.1\" /> {#config-command}\n\nOverrides the default command to set the background image.\n\n> [!important]\n> **Required** for all environments except Hyprland.\n> On Hyprland, defaults to using hyprpaper if not specified.\n\n[Variables](./Variables) are replaced with the appropriate values. Use `[file]` for the image path and `[output]` for the monitor name:\n\n> [!note]\n> The `[output]` variable requires monitor detection (available on Hyprland, Niri, and fallback environments with `wlr-randr` or `xrandr`).\n\n```sh\nswaybg -i '[file]' -o '[output]'\n```\nor\n```sh\nswww img --outputs [output] [file]\n```\n\n### `clear_command` <ConfigBadges plugin=\"wallpapers\" option=\"clear_command\"  version=\"3.2.1\" /> {#config-clear-command}\n\nOverrides the default behavior which kills the `command` program.\nUse this to provide a command to clear the background:\n\n```toml\nclear_command = \"swaybg clear\"\n```\n\n### `post_command` <ConfigBadges plugin=\"wallpapers\" option=\"post_command\"  version=\"3.2.1\" /> {#config-post-command}\n\nExecutes a command after a wallpaper change. Can use `[file]`:\n\n```toml\npost_command = \"matugen image '[file]'\"\n```\n\n### `radius` <ConfigBadges plugin=\"wallpapers\" option=\"radius\"  version=\"3.2.1\" /> {#config-radius}\n\nWhen set, adds rounded borders to the wallpapers. Expressed in pixels. Disabled by default.\n\nRequires monitor detection (available on Hyprland, Niri, and fallback environments with `wlr-randr` or `xrandr`).\nFor this feature to work, you must use `[output]` in your `command` to specify the screen port name.\n\n```toml\nradius = 16\n```\n\n### `extensions` <ConfigBadges plugin=\"wallpapers\" option=\"extensions\"  version=\"3.2.1\" /> {#config-extensions}\n\nList of valid wallpaper image extensions.\n\n### `recurse` <ConfigBadges plugin=\"wallpapers\" option=\"recurse\"  version=\"3.2.1\" /> {#config-recurse}\n\nWhen enabled, will also search sub-directories recursively.\n\n### `unique` <ConfigBadges plugin=\"wallpapers\" option=\"unique\"  version=\"3.2.1\" /> {#config-unique}\n\nWhen enabled, will set a different wallpaper for each screen.\n\n> [!note]\n> Requires monitor detection (available on Hyprland, Niri, and fallback environments with `wlr-randr` or `xrandr`).\n> Usage with [templates](./wallpapers_templates) is not recommended.\n\nIf you are not using the default application, ensure you are using `[output]` in the [command](#config-command) template.\n\nExample for swaybg: `swaybg -o \"[output]\" -m fill -i \"[file]\"`\n\n## Online Wallpapers\n\nPyprland can fetch wallpapers from free online sources like Unsplash, Wallhaven, Reddit, and more. Downloaded images are stored locally and become part of your collection.\n\nSee [Online Wallpapers](./wallpapers_online) for configuration options and available backends.\n\n## Templates\n\nGenerate config files with colors extracted from your wallpaper - similar to matugen/pywal. Automatically theme your terminal, window borders, GTK apps, and more.\n\nSee [Templates](./wallpapers_templates) for full documentation including syntax, color reference, and examples.\n"
  },
  {
    "path": "site/versions/3.2.1/wallpapers_online.md",
    "content": "---\n---\n\n# Online Wallpapers\n\nPyprland can fetch wallpapers from free online sources without requiring API keys. When `online_ratio` is set, each wallpaper change has a chance to fetch a new image from the configured backends. If a fetch fails, it falls back to local images.\n\nDownloaded images are stored in the `online_folder` subfolder and become part of your local collection for future use.\n\n> [!note]\n> Online fetching requires `online_ratio > 0`. If `online_backends` is empty, online fetching is disabled.\n\n## Configuration\n\n<PluginConfig plugin=\"wallpapers\" linkPrefix=\"config-\" :filter=\"['online_ratio', 'online_backends', 'online_keywords', 'online_folder']\"  version=\"3.2.1\" />\n\n### `online_ratio` <ConfigBadges plugin=\"wallpapers\" option=\"online_ratio\"  version=\"3.2.1\" /> {#config-online-ratio}\n\nProbability (0.0 to 1.0) of fetching a wallpaper from online sources instead of local files. Set to `0.0` to disable online fetching or `1.0` to always fetch online.\n\n```toml\nonline_ratio = 0.3  # 30% chance of fetching online\n```\n\n### `online_backends` <ConfigBadges plugin=\"wallpapers\" option=\"online_backends\"  version=\"3.2.1\" /> {#config-online-backends}\n\nList of online backends to use. Defaults to all available backends. Set to an empty list to disable online fetching. See [Available Backends](#available-backends) for details.\n\n```toml\nonline_backends = [\"unsplash\", \"wallhaven\"]  # Use only these two\n```\n\n### `online_keywords` <ConfigBadges plugin=\"wallpapers\" option=\"online_keywords\"  version=\"3.2.1\" /> {#config-online-keywords}\n\nKeywords to filter online wallpaper searches. Not all backends support keywords.\n\n```toml\nonline_keywords = [\"nature\", \"landscape\", \"mountains\"]\n```\n\n### `online_folder` <ConfigBadges plugin=\"wallpapers\" option=\"online_folder\"  version=\"3.2.1\" /> {#config-online-folder}\n\nSubfolder name within `path` where downloaded online images are stored. These images persist and become part of your local collection.\n\n```toml\nonline_folder = \"online\"  # Stores in {path}/online/\n```\n\n## Cache Management\n\n<PluginConfig plugin=\"wallpapers\" linkPrefix=\"config-\" :filter=\"['cache_days', 'cache_max_mb', 'cache_max_images']\"  version=\"3.2.1\" />\n\n### `cache_days` <ConfigBadges plugin=\"wallpapers\" option=\"cache_days\"  version=\"3.2.1\" /> {#config-cache-days}\n\nDays to keep cached images before automatic cleanup. Set to `0` to keep images forever.\n\n```toml\ncache_days = 30  # Remove cached images older than 30 days\n```\n\n### `cache_max_mb` <ConfigBadges plugin=\"wallpapers\" option=\"cache_max_mb\"  version=\"3.2.1\" /> {#config-cache-max-mb}\n\nMaximum cache size in megabytes. When exceeded, oldest files are removed first. Set to `0` for unlimited.\n\n```toml\ncache_max_mb = 500  # Limit cache to 500 MB\n```\n\n### `cache_max_images` <ConfigBadges plugin=\"wallpapers\" option=\"cache_max_images\"  version=\"3.2.1\" /> {#config-cache-max-images}\n\nMaximum number of cached images. When exceeded, oldest files are removed first. Set to `0` for unlimited.\n\n```toml\ncache_max_images = 100  # Keep at most 100 cached images\n```\n\n## Available Backends\n\n| Backend | Keywords | Description |\n|---------|:--------:|-------------|\n| `unsplash` | ✓ | Unsplash Source - high quality photos |\n| `wallhaven` | ✓ | Wallhaven - curated wallpapers |\n| `reddit` | ✓ | Reddit - keywords map to wallpaper subreddits |\n| `picsum` | ✗ | Picsum Photos - random images |\n| `bing` | ✗ | Bing Daily Wallpaper |\n\n## Example Configuration\n\n```toml\n[wallpapers]\npath = \"~/Pictures/wallpapers/\"\nonline_ratio = 0.2  # 20% chance to fetch online\nonline_backends = [\"unsplash\", \"wallhaven\"]\nonline_keywords = [\"nature\", \"minimal\"]\n```\n"
  },
  {
    "path": "site/versions/3.2.1/wallpapers_templates.md",
    "content": "---\n---\n\n# Wallpaper Templates\n\nThe templates feature provides automatic theming for your desktop applications. When the wallpaper changes, pyprland:\n\n1. Extracts dominant colors from the wallpaper image\n2. Generates a Material Design-inspired color palette\n3. Processes your template files, replacing color placeholders with actual values\n4. Runs optional `post_hook` commands to apply the changes\n\nThis creates a unified color scheme across your terminal, window borders, GTK apps, and other tools - all derived from your wallpaper.\n\n> [!tip]\n> If you're migrating from *matugen* or *pywal*, your existing templates should work with minimal changes.\n\n## Commands\n\n<PluginCommands plugin=\"wallpapers\" :filter=\"['color', 'palette']\" linkPrefix=\"command-\"  version=\"3.2.1\" />\n\n### Using the `color` command {#command-color}\n\nThe `color` command allows testing the palette with a specific color instead of extracting from the wallpaper:\n\n- `pypr color \"#ff0000\"` - Re-generate the templates with the given color\n- `pypr color \"#ff0000\" neutral` - Re-generate the templates with the given color and [color scheme](#config-color-scheme) (color filter)\n\n### Using the `palette` command {#command-palette}\n\nThe `palette` command shows available color template variables:\n\n- `pypr palette` - Show palette using colors from current wallpaper\n- `pypr palette \"#ff0000\"` - Show palette for a specific color\n- `pypr palette json` - Output palette in JSON format\n\n## Configuration\n\n<PluginConfig plugin=\"wallpapers\" linkPrefix=\"config-\" :filter=\"['templates', 'color_scheme', 'variant']\"  version=\"3.2.1\" />\n\n### `templates` <ConfigBadges plugin=\"wallpapers\" option=\"templates\"  version=\"3.2.1\" /> {#config-templates}\n\nEnables automatic theming by generating config files from templates using colors extracted from the wallpaper.\n\n```toml\n[wallpapers.templates.hyprland]\ninput_path = \"~/color_configs/hyprlandcolors.sh\"\noutput_path = \"/tmp/hyprlandcolors.sh\"\npost_hook = \"sh /tmp/hyprlandcolors.sh\"\n```\n\n> [!tip]\n> Mostly compatible with *matugen* template syntax.\n\n### `color_scheme` <ConfigBadges plugin=\"wallpapers\" option=\"color_scheme\"  version=\"3.2.1\" /> {#config-color-scheme}\n\nOptional modification of the base color used in the templates. One of:\n\n- **pastel** - a bit more washed colors\n- **fluo** or **fluorescent** - for high color saturation\n- **neutral** - for low color saturation\n- **earth** - a bit more dark, a bit less blue\n- **vibrant** - for moderate to high saturation\n- **mellow** - for lower saturation\n\n### `variant` <ConfigBadges plugin=\"wallpapers\" option=\"variant\"  version=\"3.2.1\" /> {#config-variant}\n\nChanges the algorithm used to pick the primary, secondary and tertiary colors.\n\n- **islands** - uses the 3 most popular colors from the wallpaper image\n\nBy default it will pick the \"main\" color and shift the hue to get the secondary and tertiary colors.\n\n## Template Configuration\n\nEach template requires an `input_path` (template file with placeholders) and `output_path` (where to write the result):\n\n```toml\n[wallpapers.templates.hyprland]\ninput_path = \"~/color_configs/hyprlandcolors.sh\"\noutput_path = \"/tmp/hyprlandcolors.sh\"\npost_hook = \"sh /tmp/hyprlandcolors.sh\"  # optional: runs after this template\n```\n\n| Option | Required | Description |\n|--------|----------|-------------|\n| `input_path` | Yes | Path to template file containing <code v-pre>{{placeholders}}</code> |\n| `output_path` | Yes | Where to write the processed output |\n| `post_hook` | No | Command to run after this specific template is generated |\n\n> [!note]\n> **`post_hook` vs `post_command`**: The `post_hook` runs after each individual template is generated. The global [`post_command`](./wallpapers#config-post-command) runs once after the wallpaper is set and all templates are processed.\n\n## Template Syntax\n\nUse double curly braces to insert color values:\n\n```txt\n{{colors.<color_name>.<variant>.<format>}}\n```\n\n| Part | Options | Description |\n|------|---------|-------------|\n| `color_name` | See [color reference](#color-reference) | The color role (e.g., `primary`, `surface`) |\n| `variant` | `default`, `dark`, `light` | Which theme variant to use |\n| `format` | `hex`, `hex_stripped`, `rgb`, `rgba` | Output format |\n\n**Examples:**\n```txt\n{{colors.primary.default.hex}}           → #6495ED\n{{colors.primary.default.hex_stripped}}  → 6495ED\n{{colors.primary.dark.rgb}}              → rgb(100, 149, 237)\n{{colors.surface.light.rgba}}            → rgba(250, 248, 245, 1.0)\n```\n\n**Shorthand:** <code v-pre>{{colors.primary.default}}</code> is equivalent to <code v-pre>{{colors.primary.default.hex}}</code>\n\nThe `default` variant automatically selects `dark` or `light` based on [theme detection](#theme-detection).\n\n## Special Variables\n\nIn addition to colors, these variables are available in templates:\n\n| Variable | Description | Example Value |\n|----------|-------------|---------------|\n| <code v-pre>{{image}}</code> | Full path to the current wallpaper | `/home/user/Pictures/sunset.jpg` |\n| <code v-pre>{{scheme}}</code> | Detected theme | `dark` or `light` |\n\n## Color Formats\n\n| Format | Example | Typical Use |\n|--------|---------|-------------|\n| `hex` | `#6495ED` | Most applications, CSS |\n| `hex_stripped` | `6495ED` | Hyprland configs, apps that don't want `#` |\n| `rgb` | `rgb(100, 149, 237)` | CSS, GTK |\n| `rgba` | `rgba(100, 149, 237, 1.0)` | CSS with opacity |\n\n## Filters\n\nFilters modify color values. Use the pipe (`|`) syntax:\n\n```txt\n{{colors.primary.default.hex | filter_name: argument}}\n```\n\n**`set_alpha`** - Add transparency to a color\n\nConverts the color to RGBA format with the specified alpha value (0.0 to 1.0):\n\n```txt\nTemplate:  {{colors.primary.default.hex | set_alpha: 0.5}}\nOutput:    rgba(100, 149, 237, 0.5)\n\nTemplate:  {{colors.surface.default.hex | set_alpha: 0.8}}\nOutput:    rgba(26, 22, 18, 0.8)\n```\n\n**`set_lightness`** - Adjust color brightness\n\nChanges the lightness by a percentage (-100 to 100). Positive values lighten, negative values darken:\n\n```txt\nTemplate:  {{colors.primary.default.hex | set_lightness: 20}}\nOutput:    #8AB4F8  (20% lighter)\n\nTemplate:  {{colors.primary.default.hex | set_lightness: -20}}\nOutput:    #3A5980  (20% darker)\n```\n\n## Theme Detection {#theme-detection}\n\nThe `default` color variant automatically adapts to your system theme. Detection order:\n\n1. **gsettings** (GNOME/GTK): `gsettings get org.gnome.desktop.interface color-scheme`\n2. **darkman**: `darkman get`\n3. **Fallback**: defaults to `dark` if neither is available\n\nYou can check the detected theme using the <code v-pre>{{scheme}}</code> variable in your templates.\n\n## Color Reference {#color-reference}\n\nColors follow the Material Design 3 color system, organized by role:\n\n**Primary Colors** - Main accent color derived from the wallpaper\n\n| Color | Description |\n|-------|-------------|\n| `primary` | Main accent color |\n| `on_primary` | Text/icons displayed on primary color |\n| `primary_container` | Less prominent container using primary hue |\n| `on_primary_container` | Text/icons on primary container |\n| `primary_fixed` | Fixed primary that doesn't change with theme |\n| `primary_fixed_dim` | Dimmer variant of fixed primary |\n| `on_primary_fixed` | Text on fixed primary |\n| `on_primary_fixed_variant` | Variant text on fixed primary |\n\n**Secondary Colors** - Complementary accent (hue-shifted from primary)\n\n| Color | Description |\n|-------|-------------|\n| `secondary` | Secondary accent color |\n| `on_secondary` | Text/icons on secondary |\n| `secondary_container` | Container using secondary hue |\n| `on_secondary_container` | Text on secondary container |\n| `secondary_fixed`, `secondary_fixed_dim` | Fixed variants |\n| `on_secondary_fixed`, `on_secondary_fixed_variant` | Text on fixed |\n\n**Tertiary Colors** - Additional accent (hue-shifted opposite of secondary)\n\n| Color | Description |\n|-------|-------------|\n| `tertiary` | Tertiary accent color |\n| `on_tertiary` | Text/icons on tertiary |\n| `tertiary_container` | Container using tertiary hue |\n| `on_tertiary_container` | Text on tertiary container |\n| `tertiary_fixed`, `tertiary_fixed_dim` | Fixed variants |\n| `on_tertiary_fixed`, `on_tertiary_fixed_variant` | Text on fixed |\n\n**Surface Colors** - Backgrounds and containers\n\n| Color | Description |\n|-------|-------------|\n| `surface` | Default background |\n| `surface_bright` | Brighter surface variant |\n| `surface_dim` | Dimmer surface variant |\n| `surface_container_lowest` | Lowest emphasis container |\n| `surface_container_low` | Low emphasis container |\n| `surface_container` | Default container |\n| `surface_container_high` | High emphasis container |\n| `surface_container_highest` | Highest emphasis container |\n| `on_surface` | Text/icons on surface |\n| `surface_variant` | Alternative surface |\n| `on_surface_variant` | Text on surface variant |\n| `background` | App background |\n| `on_background` | Text on background |\n\n**Error Colors** - Error states and alerts\n\n| Color | Description |\n|-------|-------------|\n| `error` | Error color (red hue) |\n| `on_error` | Text on error |\n| `error_container` | Error container background |\n| `on_error_container` | Text on error container |\n\n**Utility Colors**\n\n| Color | Description |\n|-------|-------------|\n| `source` | Original extracted color (unmodified) |\n| `outline` | Borders and dividers |\n| `outline_variant` | Subtle borders |\n| `inverse_primary` | Primary for inverse surfaces |\n| `inverse_surface` | Inverse surface color |\n| `inverse_on_surface` | Text on inverse surface |\n| `surface_tint` | Tint overlay for elevation |\n| `scrim` | Overlay for modals/dialogs |\n| `shadow` | Shadow color |\n| `white` | Pure white |\n\n**ANSI Terminal Colors** - Standard terminal color palette\n\n| Color | Description |\n|-------|-------------|\n| `red` | ANSI red |\n| `green` | ANSI green |\n| `yellow` | ANSI yellow |\n| `blue` | ANSI blue |\n| `magenta` | ANSI magenta |\n| `cyan` | ANSI cyan |\n\n## Examples\n\n**Hyprland Window Borders**\n\nConfig (`pyprland.toml`):\n```toml\n[wallpapers.templates.hyprland]\ninput_path = \"~/color_configs/hyprlandcolors.sh\"\noutput_path = \"/tmp/hyprlandcolors.sh\"\npost_hook = \"sh /tmp/hyprlandcolors.sh\"\n```\n\nTemplate (`~/color_configs/hyprlandcolors.sh`):\n```txt\nhyprctl keyword general:col.active_border \"rgb({{colors.primary.default.hex_stripped}}) rgb({{colors.tertiary.default.hex_stripped}}) 30deg\"\nhyprctl keyword general:col.inactive_border \"rgb({{colors.surface_variant.default.hex_stripped}})\"\nhyprctl keyword decoration:shadow:color \"rgba({{colors.shadow.default.hex_stripped}}ee)\"\n```\n\nOutput (after processing with a blue-toned wallpaper):\n```sh\nhyprctl keyword general:col.active_border \"rgb(6495ED) rgb(ED6495) 30deg\"\nhyprctl keyword general:col.inactive_border \"rgb(3D3D3D)\"\nhyprctl keyword decoration:shadow:color \"rgba(000000ee)\"\n```\n\n**Kitty Terminal Theme**\n\nConfig:\n```toml\n[wallpapers.templates.kitty]\ninput_path = \"~/color_configs/kitty_theme.conf\"\noutput_path = \"~/.config/kitty/current-theme.conf\"\npost_hook = \"kill -SIGUSR1 $(pgrep kitty) 2>/dev/null || true\"\n```\n\nTemplate (`~/color_configs/kitty_theme.conf`):\n```sh\n# Auto-generated theme from wallpaper: {{image}}\n# Scheme: {{scheme}}\n\nforeground {{colors.on_background.default.hex}}\nbackground {{colors.background.default.hex}}\ncursor {{colors.primary.default.hex}}\ncursor_text_color {{colors.on_primary.default.hex}}\nselection_foreground {{colors.on_primary.default.hex}}\nselection_background {{colors.primary.default.hex}}\n\n# ANSI colors\ncolor0 {{colors.surface.default.hex}}\ncolor1 {{colors.red.default.hex}}\ncolor2 {{colors.green.default.hex}}\ncolor3 {{colors.yellow.default.hex}}\ncolor4 {{colors.blue.default.hex}}\ncolor5 {{colors.magenta.default.hex}}\ncolor6 {{colors.cyan.default.hex}}\ncolor7 {{colors.on_surface.default.hex}}\n```\n\n**GTK4 CSS Theme**\n\nConfig:\n```toml\n[wallpapers.templates.gtk4]\ninput_path = \"~/color_configs/gtk.css\"\noutput_path = \"~/.config/gtk-4.0/colors.css\"\n```\n\nTemplate:\n```css\n/* Auto-generated from wallpaper */\n@define-color accent_bg_color {{colors.primary.default.hex}};\n@define-color accent_fg_color {{colors.on_primary.default.hex}};\n@define-color window_bg_color {{colors.surface.default.hex}};\n@define-color window_fg_color {{colors.on_surface.default.hex}};\n@define-color headerbar_bg_color {{colors.surface_container.default.hex}};\n@define-color card_bg_color {{colors.surface_container_low.default.hex}};\n@define-color view_bg_color {{colors.background.default.hex}};\n@define-color popover_bg_color {{colors.surface_container_high.default.hex}};\n\n/* With transparency */\n@define-color sidebar_bg_color {{colors.surface_container.default.hex | set_alpha: 0.95}};\n```\n\n**JSON Export (for external tools)**\n\nConfig:\n```toml\n[wallpapers.templates.json]\ninput_path = \"~/color_configs/colors.json\"\noutput_path = \"~/.cache/current-colors.json\"\npost_hook = \"notify-send 'Theme Updated' 'New colors from wallpaper'\"\n```\n\nTemplate:\n```json\n{\n  \"scheme\": \"{{scheme}}\",\n  \"wallpaper\": \"{{image}}\",\n  \"colors\": {\n    \"primary\": \"{{colors.primary.default.hex}}\",\n    \"secondary\": \"{{colors.secondary.default.hex}}\",\n    \"tertiary\": \"{{colors.tertiary.default.hex}}\",\n    \"background\": \"{{colors.background.default.hex}}\",\n    \"surface\": \"{{colors.surface.default.hex}}\",\n    \"error\": \"{{colors.error.default.hex}}\"\n  }\n}\n```\n\n## Troubleshooting\n\nFor general pyprland issues, see the [Troubleshooting](./Troubleshooting) page.\n\n**Template not updating?**\n- Verify `input_path` exists and is readable\n- Check pyprland logs:\n  - **Systemd**: `journalctl --user -u pyprland -f`\n  - **exec-once**: Check your log file (e.g., `tail -f ~/pypr.log`)\n- Enable debug logging with `--debug` or `--debug <logfile>` (see [Getting Started](./Getting-started#running-the-daemon))\n- Ensure the wallpapers plugin is loaded in your config\n\n**Colors look wrong or washed out?**\n- Try different [`color_scheme`](#config-color-scheme) values: `vibrant`, `pastel`, `fluo`\n- Use [`variant = \"islands\"`](#config-variant) to pick colors from different areas of the image\n\n**Theme detection not working?**\n- Install `darkman` or ensure gsettings is available\n- Force a theme by using `.dark` or `.light` variants instead of `.default`\n\n**`post_hook` not running?**\n- Commands run asynchronously; check for errors in logs\n- Ensure the command is valid and executable\n- Enable debug logging to see command execution details\n"
  },
  {
    "path": "site/versions/3.2.1/workspaces_follow_focus.md",
    "content": "---\n---\n\n# workspaces_follow_focus\n\nMake non-visible workspaces follow the focused monitor.\nAlso provides commands to switch between workspaces while preserving the current monitor assignments:\n\nSyntax:\n```toml\n[workspaces_follow_focus]\nmax_workspaces = 4 # number of workspaces before cycling\n```\nExample usage in `hyprland.conf`:\n\n```ini\nbind = $mainMod, K, exec, pypr change_workspace +1\nbind = $mainMod, J, exec, pypr change_workspace -1\n ```\n\n## Commands\n\n<PluginCommands plugin=\"workspaces_follow_focus\"  version=\"3.2.1\" />\n\n## Configuration\n\n<PluginConfig plugin=\"workspaces_follow_focus\" linkPrefix=\"config-\"  version=\"3.2.1\" />\n\n"
  },
  {
    "path": "site/wallpapers.md",
    "content": "---\n---\n\n\n# wallpapers\n\nSearch folders for images and sets the background image at a regular interval.\nPictures are selected randomly from the full list of images found.\n\nIt serves few purposes:\n\n- adding support for random images to any background setting tool\n- quickly testing different tools with a minimal effort\n- adding rounded corners to each wallpaper screen\n- generating a wallpaper-compliant color scheme usable to generate configurations for any application (matugen/pywal alike)\n\nIt allows \"zapping\" current backgrounds, clearing it to go distraction free and optionally make them different for each screen.\n\n> [!important]\n> On Hyprland, Pyprland uses **hyprpaper** by default, but you must start hyprpaper separately (e.g. `uwsm app -- hyprpaper`). For other environments, set the `command` option to launch your wallpaper application.\n\n> [!note]\n> On environments other than Hyprland and Niri, pyprland uses `wlr-randr` (Wayland) or `xrandr` (X11) for monitor detection.\n> This provides full wallpaper functionality but without automatic refresh on monitor hotplug.\n\nCached images (rounded corners, online downloads) are stored in subfolders within your configured `path` directory.\n\n<details>\n    <summary>Minimal example using defaults (requires <b>hyprpaper</b>)</summary>\n\n```toml\n[wallpapers]\npath = \"~/Pictures/wallpapers/\" # path to the folder with background images\n```\n\n</details>\n\n<details>\n<summary>More complete, using the custom <b>swww</b> command (not recommended because of its stability)</summary>\n\n```toml\n[wallpapers]\nunique = true # set a different wallpaper for each screen\npath = \"~/Pictures/wallpapers/\"\ninterval = 60 # change every hour\nextensions = [\"jpg\", \"jpeg\"]\nrecurse = true\nclear_command = \"swww clear\"\ncommand = \"swww img --outputs '[output]'  '[file]'\"\n\n```\n\nNote that for applications like `swww`, you'll need to start a daemon separately (eg: from `hyprland.conf`).\n</details>\n\n\n## Commands\n\n<PluginCommands plugin=\"wallpapers\" />\n\n> [!tip]\n> The `color` and `palette` commands are used for templating. See [Templates](./wallpapers_templates#commands) for details.\n\n## Configuration\n\n<PluginConfig plugin=\"wallpapers\" linkPrefix=\"config-\" :filter=\"['path', 'interval', 'command', 'clear_command', 'post_command', 'radius', 'extensions', 'recurse', 'unique']\" />\n\n### `path` <ConfigBadges plugin=\"wallpapers\" option=\"path\" /> {#config-path}\n\n**Required.** Path to a folder or list of folders that will be searched for wallpaper images.\n\n```toml\npath = [\"~/Pictures/Portraits/\", \"~/Pictures/Landscapes/\"]\n```\n\n### `interval` <ConfigBadges plugin=\"wallpapers\" option=\"interval\" /> {#config-interval}\n\nHow long (in minutes) a background should stay in place before changing.\n\n### `command` <ConfigBadges plugin=\"wallpapers\" option=\"command\" /> {#config-command}\n\nOverrides the default command to set the background image.\n\n> [!important]\n> **Required** for all environments except Hyprland.\n> On Hyprland, defaults to using hyprpaper if not specified.\n\n[Variables](./Variables) are replaced with the appropriate values. Use `[file]` for the image path and `[output]` for the monitor name:\n\n> [!note]\n> The `[output]` variable requires monitor detection (available on Hyprland, Niri, and fallback environments with `wlr-randr` or `xrandr`).\n\n```sh\nswaybg -i '[file]' -o '[output]'\n```\nor\n```sh\nswww img --outputs [output] [file]\n```\n\n### `clear_command` <ConfigBadges plugin=\"wallpapers\" option=\"clear_command\" /> {#config-clear-command}\n\nOverrides the default behavior which kills the `command` program.\nUse this to provide a command to clear the background:\n\n```toml\nclear_command = \"swaybg clear\"\n```\n\n### `post_command` <ConfigBadges plugin=\"wallpapers\" option=\"post_command\" /> {#config-post-command}\n\nExecutes a command after a wallpaper change. Can use `[file]`:\n\n```toml\npost_command = \"matugen image '[file]'\"\n```\n\n### `radius` <ConfigBadges plugin=\"wallpapers\" option=\"radius\" /> {#config-radius}\n\nWhen set, adds rounded borders to the wallpapers. Expressed in pixels. Disabled by default.\n\nRequires monitor detection (available on Hyprland, Niri, and fallback environments with `wlr-randr` or `xrandr`).\nFor this feature to work, you must use `[output]` in your `command` to specify the screen port name.\n\n```toml\nradius = 16\n```\n\n### `extensions` <ConfigBadges plugin=\"wallpapers\" option=\"extensions\" /> {#config-extensions}\n\nList of valid wallpaper image extensions.\n\n### `recurse` <ConfigBadges plugin=\"wallpapers\" option=\"recurse\" /> {#config-recurse}\n\nWhen enabled, will also search sub-directories recursively.\n\n### `unique` <ConfigBadges plugin=\"wallpapers\" option=\"unique\" /> {#config-unique}\n\nWhen enabled, will set a different wallpaper for each screen.\n\n> [!note]\n> Requires monitor detection (available on Hyprland, Niri, and fallback environments with `wlr-randr` or `xrandr`).\n> Usage with [templates](./wallpapers_templates) is not recommended.\n\nIf you are not using the default application, ensure you are using `[output]` in the [command](#config-command) template.\n\nExample for swaybg: `swaybg -o \"[output]\" -m fill -i \"[file]\"`\n\n## Online Wallpapers\n\nPyprland can fetch wallpapers from free online sources like Unsplash, Wallhaven, Reddit, and more. Downloaded images are stored locally and become part of your collection.\n\nSee [Online Wallpapers](./wallpapers_online) for configuration options and available backends.\n\n## Templates\n\nGenerate config files with colors extracted from your wallpaper - similar to matugen/pywal. Automatically theme your terminal, window borders, GTK apps, and more.\n\nSee [Templates](./wallpapers_templates) for full documentation including syntax, color reference, and examples.\n"
  },
  {
    "path": "site/wallpapers_online.md",
    "content": "---\n---\n\n# Online Wallpapers\n\nPyprland can fetch wallpapers from free online sources without requiring API keys. When `online_ratio` is set, each wallpaper change has a chance to fetch a new image from the configured backends. If a fetch fails, it falls back to local images.\n\nDownloaded images are stored in the `online_folder` subfolder and become part of your local collection for future use.\n\n> [!note]\n> Online fetching requires `online_ratio > 0`. If `online_backends` is empty, online fetching is disabled.\n\n## Configuration\n\n<PluginConfig plugin=\"wallpapers\" linkPrefix=\"config-\" :filter=\"['online_ratio', 'online_backends', 'online_keywords', 'online_folder']\" />\n\n### `online_ratio` <ConfigBadges plugin=\"wallpapers\" option=\"online_ratio\" /> {#config-online-ratio}\n\nProbability (0.0 to 1.0) of fetching a wallpaper from online sources instead of local files. Set to `0.0` to disable online fetching or `1.0` to always fetch online.\n\n```toml\nonline_ratio = 0.3  # 30% chance of fetching online\n```\n\n### `online_backends` <ConfigBadges plugin=\"wallpapers\" option=\"online_backends\" /> {#config-online-backends}\n\nList of online backends to use. Defaults to all available backends. Set to an empty list to disable online fetching. See [Available Backends](#available-backends) for details.\n\n```toml\nonline_backends = [\"unsplash\", \"wallhaven\"]  # Use only these two\n```\n\n### `online_keywords` <ConfigBadges plugin=\"wallpapers\" option=\"online_keywords\" /> {#config-online-keywords}\n\nKeywords to filter online wallpaper searches. Not all backends support keywords.\n\n```toml\nonline_keywords = [\"nature\", \"landscape\", \"mountains\"]\n```\n\n### `online_folder` <ConfigBadges plugin=\"wallpapers\" option=\"online_folder\" /> {#config-online-folder}\n\nSubfolder name within `path` where downloaded online images are stored. These images persist and become part of your local collection.\n\n```toml\nonline_folder = \"online\"  # Stores in {path}/online/\n```\n\n## Cache Management\n\n<PluginConfig plugin=\"wallpapers\" linkPrefix=\"config-\" :filter=\"['cache_days', 'cache_max_mb', 'cache_max_images']\" />\n\n### `cache_days` <ConfigBadges plugin=\"wallpapers\" option=\"cache_days\" /> {#config-cache-days}\n\nDays to keep cached images before automatic cleanup. Set to `0` to keep images forever.\n\n```toml\ncache_days = 30  # Remove cached images older than 30 days\n```\n\n### `cache_max_mb` <ConfigBadges plugin=\"wallpapers\" option=\"cache_max_mb\" /> {#config-cache-max-mb}\n\nMaximum cache size in megabytes. When exceeded, oldest files are removed first. Set to `0` for unlimited.\n\n```toml\ncache_max_mb = 500  # Limit cache to 500 MB\n```\n\n### `cache_max_images` <ConfigBadges plugin=\"wallpapers\" option=\"cache_max_images\" /> {#config-cache-max-images}\n\nMaximum number of cached images. When exceeded, oldest files are removed first. Set to `0` for unlimited.\n\n```toml\ncache_max_images = 100  # Keep at most 100 cached images\n```\n\n## Available Backends\n\n| Backend | Keywords | Description |\n|---------|:--------:|-------------|\n| `unsplash` | ✓ | Unsplash Source - high quality photos |\n| `wallhaven` | ✓ | Wallhaven - curated wallpapers |\n| `reddit` | ✓ | Reddit - keywords map to wallpaper subreddits |\n| `picsum` | ✗ | Picsum Photos - random images |\n| `bing` | ✗ | Bing Daily Wallpaper |\n\n## Example Configuration\n\n```toml\n[wallpapers]\npath = \"~/Pictures/wallpapers/\"\nonline_ratio = 0.2  # 20% chance to fetch online\nonline_backends = [\"unsplash\", \"wallhaven\"]\nonline_keywords = [\"nature\", \"minimal\"]\n```\n"
  },
  {
    "path": "site/wallpapers_templates.md",
    "content": "---\n---\n\n# Wallpaper Templates\n\nThe templates feature provides automatic theming for your desktop applications. When the wallpaper changes, pyprland:\n\n1. Extracts dominant colors from the wallpaper image\n2. Generates a Material Design-inspired color palette\n3. Processes your template files, replacing color placeholders with actual values\n4. Runs optional `post_hook` commands to apply the changes\n\nThis creates a unified color scheme across your terminal, window borders, GTK apps, and other tools - all derived from your wallpaper.\n\n> [!tip]\n> If you're migrating from *matugen* or *pywal*, your existing templates should work with minimal changes.\n\n## Commands\n\n<PluginCommands plugin=\"wallpapers\" :filter=\"['color', 'palette']\" linkPrefix=\"command-\" />\n\n### Using the `color` command {#command-color}\n\nThe `color` command allows testing the palette with a specific color instead of extracting from the wallpaper:\n\n- `pypr color \"#ff0000\"` - Re-generate the templates with the given color\n- `pypr color \"#ff0000\" neutral` - Re-generate the templates with the given color and [color scheme](#config-color-scheme) (color filter)\n\n### Using the `palette` command {#command-palette}\n\nThe `palette` command shows available color template variables:\n\n- `pypr palette` - Show palette using colors from current wallpaper\n- `pypr palette \"#ff0000\"` - Show palette for a specific color\n- `pypr palette json` - Output palette in JSON format\n\n## Configuration\n\n<PluginConfig plugin=\"wallpapers\" linkPrefix=\"config-\" :filter=\"['templates', 'color_scheme', 'variant']\" />\n\n### `templates` <ConfigBadges plugin=\"wallpapers\" option=\"templates\" /> {#config-templates}\n\nEnables automatic theming by generating config files from templates using colors extracted from the wallpaper.\n\n```toml\n[wallpapers.templates.hyprland]\ninput_path = \"~/color_configs/hyprlandcolors.sh\"\noutput_path = \"/tmp/hyprlandcolors.sh\"\npost_hook = \"sh /tmp/hyprlandcolors.sh\"\n```\n\n> [!tip]\n> Mostly compatible with *matugen* template syntax.\n\n### `color_scheme` <ConfigBadges plugin=\"wallpapers\" option=\"color_scheme\" /> {#config-color-scheme}\n\nOptional modification of the base color used in the templates. One of:\n\n- **pastel** - a bit more washed colors\n- **fluo** or **fluorescent** - for high color saturation\n- **neutral** - for low color saturation\n- **earth** - a bit more dark, a bit less blue\n- **vibrant** - for moderate to high saturation\n- **mellow** - for lower saturation\n\n### `variant` <ConfigBadges plugin=\"wallpapers\" option=\"variant\" /> {#config-variant}\n\nChanges the algorithm used to pick the primary, secondary and tertiary colors.\n\n- **islands** - uses the 3 most popular colors from the wallpaper image\n\nBy default it will pick the \"main\" color and shift the hue to get the secondary and tertiary colors.\n\n## Template Configuration\n\nEach template requires an `input_path` (template file with placeholders) and `output_path` (where to write the result):\n\n```toml\n[wallpapers.templates.hyprland]\ninput_path = \"~/color_configs/hyprlandcolors.sh\"\noutput_path = \"/tmp/hyprlandcolors.sh\"\npost_hook = \"sh /tmp/hyprlandcolors.sh\"  # optional: runs after this template\n```\n\n| Option | Required | Description |\n|--------|----------|-------------|\n| `input_path` | Yes | Path to template file containing <code v-pre>{{placeholders}}</code> |\n| `output_path` | Yes | Where to write the processed output |\n| `post_hook` | No | Command to run after this specific template is generated |\n\n> [!note]\n> **`post_hook` vs `post_command`**: The `post_hook` runs after each individual template is generated. The global [`post_command`](./wallpapers#config-post-command) runs once after the wallpaper is set and all templates are processed.\n\n## Template Syntax\n\nUse double curly braces to insert color values:\n\n```txt\n{{colors.<color_name>.<variant>.<format>}}\n```\n\n| Part | Options | Description |\n|------|---------|-------------|\n| `color_name` | See [color reference](#color-reference) | The color role (e.g., `primary`, `surface`) |\n| `variant` | `default`, `dark`, `light` | Which theme variant to use |\n| `format` | `hex`, `hex_stripped`, `rgb`, `rgba` | Output format |\n\n**Examples:**\n```txt\n{{colors.primary.default.hex}}           → #6495ED\n{{colors.primary.default.hex_stripped}}  → 6495ED\n{{colors.primary.dark.rgb}}              → rgb(100, 149, 237)\n{{colors.surface.light.rgba}}            → rgba(250, 248, 245, 1.0)\n```\n\n**Shorthand:** <code v-pre>{{colors.primary.default}}</code> is equivalent to <code v-pre>{{colors.primary.default.hex}}</code>\n\nThe `default` variant automatically selects `dark` or `light` based on [theme detection](#theme-detection).\n\n## Special Variables\n\nIn addition to colors, these variables are available in templates:\n\n| Variable | Description | Example Value |\n|----------|-------------|---------------|\n| <code v-pre>{{image}}</code> | Full path to the current wallpaper | `/home/user/Pictures/sunset.jpg` |\n| <code v-pre>{{scheme}}</code> | Detected theme | `dark` or `light` |\n\n## Color Formats\n\n| Format | Example | Typical Use |\n|--------|---------|-------------|\n| `hex` | `#6495ED` | Most applications, CSS |\n| `hex_stripped` | `6495ED` | Hyprland configs, apps that don't want `#` |\n| `rgb` | `rgb(100, 149, 237)` | CSS, GTK |\n| `rgba` | `rgba(100, 149, 237, 1.0)` | CSS with opacity |\n\n## Filters\n\nFilters modify color values. Use the pipe (`|`) syntax:\n\n```txt\n{{colors.primary.default.hex | filter_name: argument}}\n```\n\n**`set_alpha`** - Add transparency to a color\n\nConverts the color to RGBA format with the specified alpha value (0.0 to 1.0):\n\n```txt\nTemplate:  {{colors.primary.default.hex | set_alpha: 0.5}}\nOutput:    rgba(100, 149, 237, 0.5)\n\nTemplate:  {{colors.surface.default.hex | set_alpha: 0.8}}\nOutput:    rgba(26, 22, 18, 0.8)\n```\n\n**`set_lightness`** - Adjust color brightness\n\nChanges the lightness by a percentage (-100 to 100). Positive values lighten, negative values darken:\n\n```txt\nTemplate:  {{colors.primary.default.hex | set_lightness: 20}}\nOutput:    #8AB4F8  (20% lighter)\n\nTemplate:  {{colors.primary.default.hex | set_lightness: -20}}\nOutput:    #3A5980  (20% darker)\n```\n\n## Theme Detection {#theme-detection}\n\nThe `default` color variant automatically adapts to your system theme. Detection order:\n\n1. **gsettings** (GNOME/GTK): `gsettings get org.gnome.desktop.interface color-scheme`\n2. **darkman**: `darkman get`\n3. **Fallback**: defaults to `dark` if neither is available\n\nYou can check the detected theme using the <code v-pre>{{scheme}}</code> variable in your templates.\n\n## Color Reference {#color-reference}\n\nColors follow the Material Design 3 color system, organized by role:\n\n**Primary Colors** - Main accent color derived from the wallpaper\n\n| Color | Description |\n|-------|-------------|\n| `primary` | Main accent color |\n| `on_primary` | Text/icons displayed on primary color |\n| `primary_container` | Less prominent container using primary hue |\n| `on_primary_container` | Text/icons on primary container |\n| `primary_fixed` | Fixed primary that doesn't change with theme |\n| `primary_fixed_dim` | Dimmer variant of fixed primary |\n| `on_primary_fixed` | Text on fixed primary |\n| `on_primary_fixed_variant` | Variant text on fixed primary |\n\n**Secondary Colors** - Complementary accent (hue-shifted from primary)\n\n| Color | Description |\n|-------|-------------|\n| `secondary` | Secondary accent color |\n| `on_secondary` | Text/icons on secondary |\n| `secondary_container` | Container using secondary hue |\n| `on_secondary_container` | Text on secondary container |\n| `secondary_fixed`, `secondary_fixed_dim` | Fixed variants |\n| `on_secondary_fixed`, `on_secondary_fixed_variant` | Text on fixed |\n\n**Tertiary Colors** - Additional accent (hue-shifted opposite of secondary)\n\n| Color | Description |\n|-------|-------------|\n| `tertiary` | Tertiary accent color |\n| `on_tertiary` | Text/icons on tertiary |\n| `tertiary_container` | Container using tertiary hue |\n| `on_tertiary_container` | Text on tertiary container |\n| `tertiary_fixed`, `tertiary_fixed_dim` | Fixed variants |\n| `on_tertiary_fixed`, `on_tertiary_fixed_variant` | Text on fixed |\n\n**Surface Colors** - Backgrounds and containers\n\n| Color | Description |\n|-------|-------------|\n| `surface` | Default background |\n| `surface_bright` | Brighter surface variant |\n| `surface_dim` | Dimmer surface variant |\n| `surface_container_lowest` | Lowest emphasis container |\n| `surface_container_low` | Low emphasis container |\n| `surface_container` | Default container |\n| `surface_container_high` | High emphasis container |\n| `surface_container_highest` | Highest emphasis container |\n| `on_surface` | Text/icons on surface |\n| `surface_variant` | Alternative surface |\n| `on_surface_variant` | Text on surface variant |\n| `background` | App background |\n| `on_background` | Text on background |\n\n**Error Colors** - Error states and alerts\n\n| Color | Description |\n|-------|-------------|\n| `error` | Error color (red hue) |\n| `on_error` | Text on error |\n| `error_container` | Error container background |\n| `on_error_container` | Text on error container |\n\n**Utility Colors**\n\n| Color | Description |\n|-------|-------------|\n| `source` | Original extracted color (unmodified) |\n| `outline` | Borders and dividers |\n| `outline_variant` | Subtle borders |\n| `inverse_primary` | Primary for inverse surfaces |\n| `inverse_surface` | Inverse surface color |\n| `inverse_on_surface` | Text on inverse surface |\n| `surface_tint` | Tint overlay for elevation |\n| `scrim` | Overlay for modals/dialogs |\n| `shadow` | Shadow color |\n| `white` | Pure white |\n\n**ANSI Terminal Colors** - Standard terminal color palette\n\n| Color | Description |\n|-------|-------------|\n| `red` | ANSI red |\n| `green` | ANSI green |\n| `yellow` | ANSI yellow |\n| `blue` | ANSI blue |\n| `magenta` | ANSI magenta |\n| `cyan` | ANSI cyan |\n\n## Examples\n\n**Hyprland Window Borders**\n\nConfig (`pyprland.toml`):\n```toml\n[wallpapers.templates.hyprland]\ninput_path = \"~/color_configs/hyprlandcolors.sh\"\noutput_path = \"/tmp/hyprlandcolors.sh\"\npost_hook = \"sh /tmp/hyprlandcolors.sh\"\n```\n\nTemplate (`~/color_configs/hyprlandcolors.sh`):\n```txt\nhyprctl keyword general:col.active_border \"rgb({{colors.primary.default.hex_stripped}}) rgb({{colors.tertiary.default.hex_stripped}}) 30deg\"\nhyprctl keyword general:col.inactive_border \"rgb({{colors.surface_variant.default.hex_stripped}})\"\nhyprctl keyword decoration:shadow:color \"rgba({{colors.shadow.default.hex_stripped}}ee)\"\n```\n\nOutput (after processing with a blue-toned wallpaper):\n```sh\nhyprctl keyword general:col.active_border \"rgb(6495ED) rgb(ED6495) 30deg\"\nhyprctl keyword general:col.inactive_border \"rgb(3D3D3D)\"\nhyprctl keyword decoration:shadow:color \"rgba(000000ee)\"\n```\n\n**Kitty Terminal Theme**\n\nConfig:\n```toml\n[wallpapers.templates.kitty]\ninput_path = \"~/color_configs/kitty_theme.conf\"\noutput_path = \"~/.config/kitty/current-theme.conf\"\npost_hook = \"kill -SIGUSR1 $(pgrep kitty) 2>/dev/null || true\"\n```\n\nTemplate (`~/color_configs/kitty_theme.conf`):\n```sh\n# Auto-generated theme from wallpaper: {{image}}\n# Scheme: {{scheme}}\n\nforeground {{colors.on_background.default.hex}}\nbackground {{colors.background.default.hex}}\ncursor {{colors.primary.default.hex}}\ncursor_text_color {{colors.on_primary.default.hex}}\nselection_foreground {{colors.on_primary.default.hex}}\nselection_background {{colors.primary.default.hex}}\n\n# ANSI colors\ncolor0 {{colors.surface.default.hex}}\ncolor1 {{colors.red.default.hex}}\ncolor2 {{colors.green.default.hex}}\ncolor3 {{colors.yellow.default.hex}}\ncolor4 {{colors.blue.default.hex}}\ncolor5 {{colors.magenta.default.hex}}\ncolor6 {{colors.cyan.default.hex}}\ncolor7 {{colors.on_surface.default.hex}}\n```\n\n**GTK4 CSS Theme**\n\nConfig:\n```toml\n[wallpapers.templates.gtk4]\ninput_path = \"~/color_configs/gtk.css\"\noutput_path = \"~/.config/gtk-4.0/colors.css\"\n```\n\nTemplate:\n```css\n/* Auto-generated from wallpaper */\n@define-color accent_bg_color {{colors.primary.default.hex}};\n@define-color accent_fg_color {{colors.on_primary.default.hex}};\n@define-color window_bg_color {{colors.surface.default.hex}};\n@define-color window_fg_color {{colors.on_surface.default.hex}};\n@define-color headerbar_bg_color {{colors.surface_container.default.hex}};\n@define-color card_bg_color {{colors.surface_container_low.default.hex}};\n@define-color view_bg_color {{colors.background.default.hex}};\n@define-color popover_bg_color {{colors.surface_container_high.default.hex}};\n\n/* With transparency */\n@define-color sidebar_bg_color {{colors.surface_container.default.hex | set_alpha: 0.95}};\n```\n\n**JSON Export (for external tools)**\n\nConfig:\n```toml\n[wallpapers.templates.json]\ninput_path = \"~/color_configs/colors.json\"\noutput_path = \"~/.cache/current-colors.json\"\npost_hook = \"notify-send 'Theme Updated' 'New colors from wallpaper'\"\n```\n\nTemplate:\n```json\n{\n  \"scheme\": \"{{scheme}}\",\n  \"wallpaper\": \"{{image}}\",\n  \"colors\": {\n    \"primary\": \"{{colors.primary.default.hex}}\",\n    \"secondary\": \"{{colors.secondary.default.hex}}\",\n    \"tertiary\": \"{{colors.tertiary.default.hex}}\",\n    \"background\": \"{{colors.background.default.hex}}\",\n    \"surface\": \"{{colors.surface.default.hex}}\",\n    \"error\": \"{{colors.error.default.hex}}\"\n  }\n}\n```\n\n## Troubleshooting\n\nFor general pyprland issues, see the [Troubleshooting](./Troubleshooting) page.\n\n**Template not updating?**\n- Verify `input_path` exists and is readable\n- Check pyprland logs:\n  - **Systemd**: `journalctl --user -u pyprland -f`\n  - **exec-once**: Check your log file (e.g., `tail -f ~/pypr.log`)\n- Enable debug logging with `--debug` or `--debug <logfile>` (see [Getting Started](./Getting-started#running-the-daemon))\n- Ensure the wallpapers plugin is loaded in your config\n\n**Colors look wrong or washed out?**\n- Try different [`color_scheme`](#config-color-scheme) values: `vibrant`, `pastel`, `fluo`\n- Use [`variant = \"islands\"`](#config-variant) to pick colors from different areas of the image\n\n**Theme detection not working?**\n- Install `darkman` or ensure gsettings is available\n- Force a theme by using `.dark` or `.light` variants instead of `.default`\n\n**`post_hook` not running?**\n- Commands run asynchronously; check for errors in logs\n- Ensure the command is valid and executable\n- Enable debug logging to see command execution details\n"
  },
  {
    "path": "site/workspaces_follow_focus.md",
    "content": "---\n---\n\n# workspaces_follow_focus\n\nMake non-visible workspaces follow the focused monitor.\nAlso provides commands to switch between workspaces while preserving the current monitor assignments:\n\nSyntax:\n```toml\n[workspaces_follow_focus]\nmax_workspaces = 4 # number of workspaces before cycling\n```\nExample usage in `hyprland.conf`:\n\n```ini\nbind = $mainMod, K, exec, pypr change_workspace +1\nbind = $mainMod, J, exec, pypr change_workspace -1\n ```\n\n## Commands\n\n<PluginCommands plugin=\"workspaces_follow_focus\" />\n\n## Configuration\n\n<PluginConfig plugin=\"workspaces_follow_focus\" linkPrefix=\"config-\" />\n\n"
  },
  {
    "path": "systemd-unit/pyprland.service",
    "content": "[Unit]\nDescription=Starts pyprland daemon\nAfter=graphical-session.target\nWants=graphical-session.target\n#Wants=hyprpaper.service\nStartLimitIntervalSec=600\nStartLimitBurst=5\n\n[Service]\nType=simple\nExecStartPre=/bin/sh -c '[ \"$XDG_CURRENT_DESKTOP\" = \"Hyprland\" ] || exit 0'\nExecStart=pypr --debug \"${XDG_STATE_HOME}/tmp/pypr.log\"\nRestart=always\nRestartSec=2\n\n\n[Install]\nWantedBy=graphical-session.target\n"
  },
  {
    "path": "tests/__init__.py",
    "content": "# __init__.py\n"
  },
  {
    "path": "tests/conftest.py",
    "content": "\"\"\"generic fixtures.\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport logging\nimport os\nfrom copy import deepcopy\nfrom dataclasses import dataclass, field\nfrom typing import TYPE_CHECKING, Callable\nfrom unittest.mock import AsyncMock, MagicMock, Mock\n\nimport pytest\nimport tomllib\nfrom pytest_asyncio import fixture\n\nfrom pyprland.common import SharedState\n\nfrom .testtools import MockReader, MockWriter\n\nif TYPE_CHECKING:\n    from pyprland.manager import Pyprland\n\nos.environ[\"HYPRLAND_INSTANCE_SIGNATURE\"] = \"ABCD\"\n\nCONFIG_1 = tomllib.load(open(\"tests/sample_config.toml\", \"rb\"))\n\n\n@pytest.fixture\ndef test_logger():\n    \"\"\"Provide a silent logger for tests.\"\"\"\n    logger = logging.getLogger(\"test\")\n    logger.handlers.clear()\n    logger.addHandler(logging.NullHandler())\n    logger.propagate = False\n    return logger\n\n\n# Error patterns to detect in stderr during tests\nERROR_PATTERNS = [\n    \"failed:\",\n    \"Error:\",\n    \"ERROR\",\n    \"Exception\",\n    \"Traceback\",\n]\n\n# Patterns to ignore (false positives)\nERROR_IGNORE_PATTERNS = [\n    \"notify_error\",  # Method name, not an actual error\n    \"ConnectionResetError\",  # Expected in some cleanup scenarios\n    \"BrokenPipeError\",  # Expected in some cleanup scenarios\n    \"DeprecationWarning\",  # Python deprecation warnings\n    \"Config error for\",  # Validation errors from run_validate command (expected in tests)\n]\n\n\ndef pytest_configure():\n    \"\"\"Runs once before all.\"\"\"\n    os.environ[\"PYPRLAND_STRICT_ERRORS\"] = \"1\"\n    from pyprland.common import init_logger\n\n    init_logger(\"/dev/null\", force_debug=True)\n\n\ndef _contains_error(text: str) -> str | None:\n    \"\"\"Check if text contains error patterns, returns the matching line or None.\"\"\"\n    for line in text.split(\"\\n\"):\n        # Skip ignored patterns\n        if any(ignore in line for ignore in ERROR_IGNORE_PATTERNS):\n            continue\n        # Check for error patterns\n        for pattern in ERROR_PATTERNS:\n            if pattern in line:\n                return line.strip()\n    return None\n\n\n@pytest.hookimpl(hookwrapper=True)\ndef pytest_runtest_makereport(item, call):\n    \"\"\"Check captured stderr for error patterns after each test.\"\"\"\n    outcome = yield\n    report = outcome.get_result()\n\n    # Only check after the test call phase (not setup/teardown)\n    if call.when == \"call\" and report.passed:\n        # Check captured output sections\n        for section_name, content in report.sections:\n            if \"stderr\" in section_name.lower() or \"captured\" in section_name.lower():\n                error_line = _contains_error(content)\n                if error_line:\n                    report.outcome = \"failed\"\n                    report.longrepr = f\"Error detected in captured output:\\n{error_line}\"\n                    return\n\n\n# Mocks\n\n\n@dataclass\nclass GlobalMocks:\n    hyprevt: tuple[MockReader, MockWriter] = None\n    pyprctrl: tuple[MockReader, MockWriter] = None\n    subprocess_call: MagicMock = None\n    hyprctl: AsyncMock = None\n\n    json_commands_result: dict[str, list | dict] = field(default_factory=dict)\n\n    _pypr_command_reader: Callable = None\n\n    # Test-only instance tracking (replaces Pyprland.instance singleton)\n    pyprland_instance: Pyprland | None = None\n\n    def reset(self):\n        \"\"\"Resets not standard mocks.\"\"\"\n        self.json_commands_result.clear()\n        self.pyprland_instance = None\n\n    async def pypr(self, cmd):\n        \"\"\"Simulates the pypr command.\"\"\"\n        assert self.pyprctrl\n        await self.pyprctrl[0].q.put(b\"%s\\n\" % cmd.encode(\"utf-8\"))\n        await self._pypr_command_reader(*self.pyprctrl)\n\n    async def wait_queues(self):\n        \"\"\"Wait for all plugin queues to be empty.\n\n        This ensures background tasks have finished processing.\n        \"\"\"\n        if self.pyprland_instance is None:\n            return\n        for _ in range(100):  # max 10 seconds\n            all_empty = all(q.empty() for q in self.pyprland_instance.queues.values())\n            if all_empty:\n                # Give one more tick for any pending task to complete\n                await asyncio.sleep(0.01)\n                return\n            await asyncio.sleep(0.1)\n\n    async def send_event(self, cmd):\n        \"\"\"Simulates receiving a Hyprland event.\"\"\"\n        assert self.hyprevt\n        await self.hyprevt[0].q.put(b\"%s\\n\" % cmd.encode(\"utf-8\"))\n\n\nmocks = GlobalMocks()\n\n\ndef make_extension(plugin_class, name: str | None = None, *, logger=None, **overrides):\n    \"\"\"Factory function for creating mocked plugin extensions.\n\n    Creates a plugin extension with common mocks pre-configured:\n    - backend: AsyncMock with execute, execute_json, get_monitors, etc.\n    - state: SharedState with active_workspace=\"1\" and active_window=\"0x123\"\n    - hyprctl: alias to backend.execute\n    - hyprctl_json: alias to backend.execute_json\n    - get_clients: AsyncMock returning []\n    - log: Mock for logging\n\n    Args:\n        plugin_class: The Extension class to instantiate\n        name: Plugin name (defaults to class module name)\n        logger: Logger instance for Configuration (optional)\n        **overrides: Additional attributes to set on the extension.\n            - state=Mock(): Replace SharedState with a custom state object\n            - config={...}: If plugin has config_schema, creates Configuration with schema\n            - state_* prefix: Sets state attributes (e.g. state_active_workspace=\"2\")\n            - Any other key: Sets as attribute directly (methods, mocks, etc.)\n\n    Returns:\n        Configured plugin extension instance\n\n    Example:\n        ext = make_extension(Extension, config={\"margin\": 50})\n        ext = make_extension(Extension, state_active_workspace=\"2\")\n        ext = make_extension(Extension, get_config_bool=Mock(return_value=False))\n        ext = make_extension(Extension, menu=AsyncMock(), _menu_configured=True)\n        ext = make_extension(Extension, state=Mock())  # Use a Mock for state\n    \"\"\"\n    # Default name from module if not provided\n    if name is None:\n        # Extract plugin name from module path like pyprland.plugins.expose\n        module = plugin_class.__module__\n        name = module.split(\".\")[-1] if \".\" in module else \"test\"\n\n    ext = plugin_class(name)\n\n    # Setup common mocks\n    ext.backend = AsyncMock()\n    ext.log = Mock()\n\n    # Handle state: use provided state or create SharedState with defaults\n    if \"state\" in overrides:\n        ext.state = overrides[\"state\"]\n    else:\n        ext.state = SharedState()\n        ext.state.active_workspace = \"1\"\n        ext.state.active_window = \"0x123\"\n\n    # Common aliases used in tests\n    ext.hyprctl = ext.backend.execute\n    ext.hyprctl_json = ext.backend.execute_json\n    ext.get_clients = AsyncMock(return_value=[])\n\n    # Apply overrides\n    for key, value in overrides.items():\n        if key == \"state\":\n            # Already handled above\n            continue\n        elif key.startswith(\"state_\"):\n            # Handle state_* prefix for setting state attributes\n            state_attr = key[6:]  # Remove \"state_\" prefix\n            setattr(ext.state, state_attr, value)\n        elif key == \"config\":\n            # Auto-detect config_schema and use Configuration when present\n            if isinstance(value, dict):\n                if hasattr(ext, \"config_schema\") and ext.config_schema:\n                    from pyprland.config import Configuration\n\n                    ext.config = Configuration(value, logger=logger, schema=ext.config_schema)\n                else:\n                    ext.config.update(value)\n            else:\n                ext.config = value  # Allow passing Configuration directly\n        else:\n            setattr(ext, key, value)\n\n    return ext\n\n\nasync def mocked_unix_server(command_reader, *_):\n    mocks._pypr_command_reader = command_reader\n    server = AsyncMock()\n    server.close = Mock()\n    return server\n\n\nasync def mocked_unix_connection(path):\n    \"\"\"Return a mocked reader & writer.\"\"\"\n    if path.endswith(\".socket2.sock\"):\n        return mocks.hyprevt\n    raise ValueError()\n\n\n@fixture\nasync def empty_config(monkeypatch):\n    \"\"\"Runs with no config.\"\"\"\n    monkeypatch.setattr(\"tomllib.load\", lambda x: {\"pyprland\": {\"plugins\": []}})\n    yield\n\n\n@fixture\nasync def third_monitor(monkeypatch):\n    \"\"\"Adds a third monitor.\"\"\"\n    MONITORS.append(EXTRA_MON)\n    yield\n    MONITORS[:] = MONITORS[:-1]\n\n\n@fixture\nasync def sample1_config(monkeypatch):\n    \"\"\"Runs with config n°1.\"\"\"\n    monkeypatch.setattr(\"tomllib.load\", lambda x: deepcopy(CONFIG_1))\n    yield\n\n\nasync def mocked_hyprctl_json(self, command, *, log=None, **kwargs):\n    if command in mocks.json_commands_result:\n        return mocks.json_commands_result[command]\n    if command.startswith(\"monitors\"):\n        return deepcopy(MONITORS)\n    if command == \"activeworkspace\":\n        return {\"name\": \"1\", \"id\": 1}\n    if command == \"version\":\n        return {\n            \"branch\": \"\",\n            \"commit\": \"fe7b748eb668136dd0558b7c8279bfcd7ab4d759\",\n            \"dirty\": False,\n            \"commit_message\": \"props: bump version to 0.39.1\",\n            \"commit_date\": \"Tue Apr 16 16:01:03 2024\",\n            \"tag\": \"v0.39.1\",\n            \"commits\": 4460,\n            \"flags\": [],\n        }\n    raise NotImplementedError()\n\n\n@fixture\ndef subprocess_shell_mock(mocker):\n    # Mocking the asyncio.create_subprocess_shell function\n    mocked_subprocess_shell = mocker.patch(\"asyncio.create_subprocess_shell\", name=\"mocked_shell_command\")\n    mocked_process = MagicMock(spec=\"subprocess.Process\", name=\"mocked_subprocess\")\n    mocked_subprocess_shell.return_value = mocked_process\n    mocked_process.pid = 1  # init always exists\n    mocked_process.stderr = AsyncMock(return_code=\"\")\n    mocked_process.stdout = AsyncMock(return_code=\"\")\n    mocked_process.terminate = Mock()\n    mocked_process.wait = AsyncMock()\n    mocked_process.kill = Mock()\n    mocked_process.return_code = 0\n    return mocked_subprocess_shell, mocked_process\n\n\n@fixture\nasync def server_fixture(monkeypatch, mocker):\n    \"\"\"Handle server setup boilerplate.\"\"\"\n    mocks.hyprctl = AsyncMock(return_value=True)\n    mocks.hyprevt = (MockReader(), MockWriter())\n    mocks.pyprctrl = (MockReader(), MockWriter())\n    mocks.subprocess_call = MagicMock(return_value=0)\n\n    monkeypatch.setenv(\"XDG_RUNTIME_DIR\", \"/tmp\")\n    monkeypatch.setenv(\"HYPRLAND_INSTANCE_SIGNATURE\", \"/tmp/will_not_be_used/\")\n\n    monkeypatch.setattr(\"asyncio.open_unix_connection\", mocked_unix_connection)\n    monkeypatch.setattr(\"asyncio.start_unix_server\", mocked_unix_server)\n\n    from pyprland.adapters.hyprland import HyprlandBackend\n\n    monkeypatch.setattr(HyprlandBackend, \"execute_json\", mocked_hyprctl_json)\n    monkeypatch.setattr(HyprlandBackend, \"execute\", mocks.hyprctl)\n\n    from pyprland import ipc\n    from pyprland.manager import Pyprland\n    from pyprland.pypr_daemon import run_daemon\n\n    # Capture the Pyprland instance when it's created\n    original_init = Pyprland.__init__\n\n    def patched_init(self):\n        original_init(self)\n        mocks.pyprland_instance = self\n\n    monkeypatch.setattr(Pyprland, \"__init__\", patched_init)\n\n    ipc.init()\n\n    server_task = asyncio.create_task(run_daemon())\n\n    # spy on Pyprland.run using mocker\n    run_spi = mocker.spy(Pyprland, \"run\")\n\n    for _ in range(10):\n        if run_spi.call_count:\n            break\n        await asyncio.sleep(0.1)\n\n    # Reset hyprctl call count after server setup (clears any startup notifications like config migration)\n    mocks.hyprctl.reset_mock()\n\n    yield  # Run the test\n    await mocks.hyprctl(\"exit\")\n    server_task.cancel()\n    await asyncio.sleep(0.01)\n    mocks.reset()\n\n\nEXTRA_MON = {\n    \"id\": 1,\n    \"name\": \"eDP-1\",\n    \"description\": \"Sony (eDP-1)\",\n    \"make\": \"Sony\",\n    \"model\": \"XXX\",\n    \"serial\": \"YYY\",\n    \"width\": 640,\n    \"height\": 480,\n    \"refreshRate\": 59.99900,\n    \"x\": 0,\n    \"y\": 0,\n    \"activeWorkspace\": {\"id\": 2, \"name\": \"2\"},\n    \"specialWorkspace\": {\"id\": 0, \"name\": \"\"},\n    \"reserved\": [0, 50, 0, 0],\n    \"scale\": 1.00,\n    \"transform\": 0,\n    \"focused\": True,\n    \"dpmsStatus\": True,\n    \"vrr\": False,\n    \"activelyTearing\": False,\n}\n\nMONITORS = [\n    {\n        \"id\": 1,\n        \"name\": \"DP-1\",\n        \"description\": \"Microstep MAG342CQPV DB6H513700137 (DP-1)\",\n        \"make\": \"Microstep\",\n        \"model\": \"MAG342CQPV\",\n        \"serial\": \"DB6H513700137\",\n        \"width\": 3440,\n        \"height\": 1440,\n        \"refreshRate\": 59.99900,\n        \"x\": 0,\n        \"y\": 1080,\n        \"activeWorkspace\": {\"id\": 1, \"name\": \"1\"},\n        \"specialWorkspace\": {\"id\": 0, \"name\": \"\"},\n        \"reserved\": [0, 50, 0, 0],\n        \"scale\": 1.00,\n        \"transform\": 0,\n        \"focused\": True,\n        \"dpmsStatus\": True,\n        \"vrr\": False,\n        \"activelyTearing\": False,\n    },\n    {\n        \"id\": 0,\n        \"name\": \"HDMI-A-1\",\n        \"description\": \"BNQ BenQ PJ 0x01010101 (HDMI-A-1)\",\n        \"make\": \"BNQ\",\n        \"model\": \"BenQ PJ\",\n        \"serial\": \"0x01010101\",\n        \"width\": 1920,\n        \"height\": 1080,\n        \"refreshRate\": 60.00000,\n        \"x\": 0,\n        \"y\": 0,\n        \"activeWorkspace\": {\"id\": 4, \"name\": \"4\"},\n        \"specialWorkspace\": {\"id\": 0, \"name\": \"\"},\n        \"reserved\": [0, 50, 0, 0],\n        \"scale\": 1.00,\n        \"transform\": 0,\n        \"focused\": False,\n        \"dpmsStatus\": True,\n        \"vrr\": False,\n        \"activelyTearing\": False,\n    },\n]\n"
  },
  {
    "path": "tests/sample_config.toml",
    "content": "[pyprland]\nplugins = [\n  \"monitors\",\n]\n\n[monitors]\nstartup_relayout = false\nnew_monitor_delay = 0\n\n[monitors.placement]\n\"(eDP-1)\".rightOf = \"(DP-1)\"\n\"(DP-1)\".rightOf = [\"(HDMI-A-1)\"]\n"
  },
  {
    "path": "tests/test_adapters_fallback.py",
    "content": "\"\"\"Tests for fallback adapters (wayland, xorg).\"\"\"\n\nimport logging\nimport pytest\nfrom pyprland.adapters.wayland import WaylandBackend\nfrom pyprland.adapters.xorg import XorgBackend\nfrom pyprland.common import SharedState\n\n\n@pytest.fixture\ndef test_log():\n    \"\"\"Provide a silent logger for tests.\"\"\"\n    logger = logging.getLogger(\"test_adapters\")\n    logger.handlers.clear()\n    logger.addHandler(logging.NullHandler())\n    logger.propagate = False\n    return logger\n\n\n@pytest.fixture\ndef mock_state():\n    \"\"\"Provide a mock SharedState for tests.\"\"\"\n    return SharedState()\n\n\nclass TestWaylandBackend:\n    \"\"\"Tests for WaylandBackend wlr-randr parsing.\"\"\"\n\n    def test_parse_single_monitor(self, test_log, mock_state):\n        \"\"\"Test parsing a single monitor output.\"\"\"\n        output = \"\"\"DP-1 \"Dell Inc. DELL U2415 ABC123\"\n  Enabled: yes\n  Modes:\n    1920x1200 px, 59.950 Hz (preferred, current)\n    1920x1080 px, 60.000 Hz\n  Position: 0,0\n  Transform: normal\n  Scale: 1.000000\n\"\"\"\n        backend = WaylandBackend(mock_state)\n        monitors = backend._parse_wlr_randr_output(output, False, test_log)\n\n        assert len(monitors) == 1\n        assert monitors[0][\"name\"] == \"DP-1\"\n        assert monitors[0][\"width\"] == 1920\n        assert monitors[0][\"height\"] == 1200\n        assert monitors[0][\"x\"] == 0\n        assert monitors[0][\"y\"] == 0\n        assert monitors[0][\"scale\"] == 1.0\n        assert monitors[0][\"transform\"] == 0\n        assert monitors[0][\"refreshRate\"] == 59.95\n\n    def test_parse_multiple_monitors(self, test_log, mock_state):\n        \"\"\"Test parsing multiple monitors.\"\"\"\n        output = \"\"\"DP-1 \"Primary Monitor\"\n  Enabled: yes\n  Modes:\n    1920x1080 px, 60.000 Hz (preferred, current)\n  Position: 0,0\n  Transform: normal\n  Scale: 1.000000\n\nHDMI-A-1 \"Secondary Monitor\"\n  Enabled: yes\n  Modes:\n    2560x1440 px, 75.000 Hz (preferred, current)\n  Position: 1920,0\n  Transform: normal\n  Scale: 1.500000\n\"\"\"\n        backend = WaylandBackend(mock_state)\n        monitors = backend._parse_wlr_randr_output(output, False, test_log)\n\n        assert len(monitors) == 2\n        assert monitors[0][\"name\"] == \"DP-1\"\n        assert monitors[0][\"x\"] == 0\n        assert monitors[1][\"name\"] == \"HDMI-A-1\"\n        assert monitors[1][\"x\"] == 1920\n        assert monitors[1][\"scale\"] == 1.5\n        assert monitors[1][\"refreshRate\"] == 75.0\n\n    def test_parse_rotated_monitor(self, test_log, mock_state):\n        \"\"\"Test parsing a rotated monitor.\"\"\"\n        output = \"\"\"eDP-1 \"Laptop Display\"\n  Enabled: yes\n  Modes:\n    1920x1080 px, 60.000 Hz (preferred, current)\n  Position: 0,0\n  Transform: 90\n  Scale: 1.000000\n\"\"\"\n        backend = WaylandBackend(mock_state)\n        monitors = backend._parse_wlr_randr_output(output, False, test_log)\n\n        assert len(monitors) == 1\n        assert monitors[0][\"transform\"] == 1  # 90 degrees\n\n    def test_parse_disabled_monitor_excluded(self, test_log, mock_state):\n        \"\"\"Test that disabled monitors are excluded by default.\"\"\"\n        output = \"\"\"DP-1 \"Primary\"\n  Enabled: yes\n  Modes:\n    1920x1080 px, 60.000 Hz (current)\n  Position: 0,0\n  Transform: normal\n  Scale: 1.000000\n\nDP-2 \"Disabled\"\n  Enabled: no\n  Modes:\n    1920x1080 px, 60.000 Hz (current)\n  Position: 0,0\n  Transform: normal\n  Scale: 1.000000\n\"\"\"\n        backend = WaylandBackend(mock_state)\n        monitors = backend._parse_wlr_randr_output(output, False, test_log)\n\n        assert len(monitors) == 1\n        assert monitors[0][\"name\"] == \"DP-1\"\n\n    def test_parse_disabled_monitor_included(self, test_log, mock_state):\n        \"\"\"Test that disabled monitors are included when requested.\"\"\"\n        output = \"\"\"DP-1 \"Primary\"\n  Enabled: yes\n  Modes:\n    1920x1080 px, 60.000 Hz (current)\n  Position: 0,0\n  Transform: normal\n  Scale: 1.000000\n\nDP-2 \"Disabled\"\n  Enabled: no\n  Modes:\n    1920x1080 px, 60.000 Hz (current)\n  Position: 0,0\n  Transform: normal\n  Scale: 1.000000\n\"\"\"\n        backend = WaylandBackend(mock_state)\n        monitors = backend._parse_wlr_randr_output(output, True, test_log)\n\n        assert len(monitors) == 2\n\n    def test_parse_no_mode_skipped(self, test_log, mock_state):\n        \"\"\"Test that outputs without a current mode are skipped.\"\"\"\n        output = \"\"\"DP-1 \"No Mode\"\n  Enabled: yes\n  Modes:\n    1920x1080 px, 60.000 Hz (preferred)\n  Position: 0,0\n  Transform: normal\n  Scale: 1.000000\n\"\"\"\n        backend = WaylandBackend(mock_state)\n        monitors = backend._parse_wlr_randr_output(output, False, test_log)\n\n        # No \"(current)\" mode, should be skipped\n        assert len(monitors) == 0\n\n    def test_parse_empty_output(self, test_log, mock_state):\n        \"\"\"Test parsing empty output.\"\"\"\n        backend = WaylandBackend(mock_state)\n        monitors = backend._parse_wlr_randr_output(\"\", False, test_log)\n\n        assert len(monitors) == 0\n\n\nclass TestXorgBackend:\n    \"\"\"Tests for XorgBackend xrandr parsing.\"\"\"\n\n    def test_parse_single_monitor(self, test_log, mock_state):\n        \"\"\"Test parsing a single monitor output.\"\"\"\n        output = \"\"\"Screen 0: minimum 8 x 8, current 1920 x 1080, maximum 32767 x 32767\nDP-1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 527mm x 296mm\n   1920x1080     60.00*+\n   1680x1050     59.95\n\"\"\"\n        backend = XorgBackend(mock_state)\n        monitors = backend._parse_xrandr_output(output, False, test_log)\n\n        assert len(monitors) == 1\n        assert monitors[0][\"name\"] == \"DP-1\"\n        assert monitors[0][\"width\"] == 1920\n        assert monitors[0][\"height\"] == 1080\n        assert monitors[0][\"x\"] == 0\n        assert monitors[0][\"y\"] == 0\n        assert monitors[0][\"transform\"] == 0\n\n    def test_parse_multiple_monitors(self, test_log, mock_state):\n        \"\"\"Test parsing multiple monitors with offsets.\"\"\"\n        output = \"\"\"Screen 0: minimum 8 x 8, current 4480 x 1440, maximum 32767 x 32767\nDP-1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 527mm x 296mm\n   1920x1080     60.00*+\nHDMI-1 connected 2560x1440+1920+0 (normal left inverted right x axis y axis) 597mm x 336mm\n   2560x1440     59.95*+\n\"\"\"\n        backend = XorgBackend(mock_state)\n        monitors = backend._parse_xrandr_output(output, False, test_log)\n\n        assert len(monitors) == 2\n        assert monitors[0][\"name\"] == \"DP-1\"\n        assert monitors[0][\"x\"] == 0\n        assert monitors[1][\"name\"] == \"HDMI-1\"\n        assert monitors[1][\"width\"] == 2560\n        assert monitors[1][\"height\"] == 1440\n        assert monitors[1][\"x\"] == 1920\n\n    def test_parse_rotated_monitor(self, test_log, mock_state):\n        \"\"\"Test parsing a rotated (left) monitor.\"\"\"\n        output = \"\"\"Screen 0: minimum 8 x 8, current 1080 x 1920, maximum 32767 x 32767\nDP-1 connected primary 1920x1080+0+0 left (normal left inverted right x axis y axis) 296mm x 527mm\n   1920x1080     60.00*+\n\"\"\"\n        backend = XorgBackend(mock_state)\n        monitors = backend._parse_xrandr_output(output, False, test_log)\n\n        assert len(monitors) == 1\n        assert monitors[0][\"transform\"] == 1  # left = 90 degrees\n\n    def test_parse_inverted_monitor(self, test_log, mock_state):\n        \"\"\"Test parsing an inverted (180 deg) monitor.\"\"\"\n        output = \"\"\"Screen 0: minimum 8 x 8, current 1920 x 1080, maximum 32767 x 32767\nDP-1 connected primary 1920x1080+0+0 inverted (normal left inverted right x axis y axis) 527mm x 296mm\n   1920x1080     60.00*+\n\"\"\"\n        backend = XorgBackend(mock_state)\n        monitors = backend._parse_xrandr_output(output, False, test_log)\n\n        assert len(monitors) == 1\n        assert monitors[0][\"transform\"] == 2  # inverted = 180 degrees\n\n    def test_parse_disconnected_excluded(self, test_log, mock_state):\n        \"\"\"Test that disconnected monitors are excluded by default.\"\"\"\n        output = \"\"\"Screen 0: minimum 8 x 8, current 1920 x 1080, maximum 32767 x 32767\nDP-1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 527mm x 296mm\n   1920x1080     60.00*+\nVGA-1 disconnected (normal left inverted right x axis y axis)\n\"\"\"\n        backend = XorgBackend(mock_state)\n        monitors = backend._parse_xrandr_output(output, False, test_log)\n\n        assert len(monitors) == 1\n        assert monitors[0][\"name\"] == \"DP-1\"\n\n    def test_parse_disconnected_included(self, test_log, mock_state):\n        \"\"\"Test that disconnected monitors can be included.\"\"\"\n        output = \"\"\"Screen 0: minimum 8 x 8, current 1920 x 1080, maximum 32767 x 32767\nDP-1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 527mm x 296mm\n   1920x1080     60.00*+\nVGA-1 disconnected (normal left inverted right x axis y axis)\n\"\"\"\n        backend = XorgBackend(mock_state)\n        monitors = backend._parse_xrandr_output(output, True, test_log)\n\n        assert len(monitors) == 2\n        assert monitors[1][\"name\"] == \"VGA-1\"\n        assert monitors[1][\"width\"] == 0  # Disconnected has no resolution\n\n    def test_parse_empty_output(self, test_log, mock_state):\n        \"\"\"Test parsing empty output.\"\"\"\n        backend = XorgBackend(mock_state)\n        monitors = backend._parse_xrandr_output(\"\", False, test_log)\n\n        assert len(monitors) == 0\n\n    def test_parse_connected_no_mode(self, test_log, mock_state):\n        \"\"\"Test that connected but inactive outputs are skipped.\"\"\"\n        output = \"\"\"Screen 0: minimum 8 x 8, current 1920 x 1080, maximum 32767 x 32767\nDP-1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 527mm x 296mm\n   1920x1080     60.00*+\nDP-2 connected (normal left inverted right x axis y axis)\n   1920x1080     60.00+\n\"\"\"\n        backend = XorgBackend(mock_state)\n        monitors = backend._parse_xrandr_output(output, False, test_log)\n\n        # DP-2 is connected but has no active mode (no +X+Y), should be skipped\n        assert len(monitors) == 1\n        assert monitors[0][\"name\"] == \"DP-1\"\n"
  },
  {
    "path": "tests/test_ansi.py",
    "content": "\"\"\"Tests for the ansi module.\"\"\"\n\nimport os\nfrom io import StringIO\nfrom unittest.mock import patch\n\nfrom pyprland.ansi import (\n    BOLD,\n    DIM,\n    RED,\n    RESET,\n    YELLOW,\n    HandlerStyles,\n    LogStyles,\n    colorize,\n    make_style,\n    should_colorize,\n)\n\n\ndef test_colorize_single_code():\n    \"\"\"Test colorize with a single ANSI code.\"\"\"\n    result = colorize(\"hello\", RED)\n    assert result == \"\\x1b[31mhello\\x1b[0m\"\n\n\ndef test_colorize_multiple_codes():\n    \"\"\"Test colorize with multiple ANSI codes.\"\"\"\n    result = colorize(\"hello\", RED, BOLD)\n    assert result == \"\\x1b[31;1mhello\\x1b[0m\"\n\n\ndef test_colorize_no_codes():\n    \"\"\"Test colorize with no codes returns text unchanged.\"\"\"\n    result = colorize(\"hello\")\n    assert result == \"hello\"\n\n\ndef test_make_style():\n    \"\"\"Test make_style returns correct prefix and suffix.\"\"\"\n    prefix, suffix = make_style(YELLOW, DIM)\n    assert prefix == \"\\x1b[33;2m\"\n    assert suffix == RESET\n\n\ndef test_make_style_no_codes():\n    \"\"\"Test make_style with no codes returns empty prefix.\"\"\"\n    prefix, suffix = make_style()\n    assert prefix == \"\"\n    assert suffix == RESET\n\n\ndef test_should_colorize_respects_no_color():\n    \"\"\"Test that NO_COLOR environment variable disables colors.\"\"\"\n    with patch.dict(os.environ, {\"NO_COLOR\": \"1\"}, clear=False):\n        assert should_colorize() is False\n\n\ndef test_should_colorize_respects_force_color():\n    \"\"\"Test that FORCE_COLOR environment variable forces colors.\"\"\"\n    # Create a non-TTY stream\n    stream = StringIO()\n    with patch.dict(os.environ, {\"FORCE_COLOR\": \"1\", \"NO_COLOR\": \"\"}, clear=False):\n        assert should_colorize(stream) is True\n\n\ndef test_should_colorize_non_tty():\n    \"\"\"Test that non-TTY streams don't get colors by default.\"\"\"\n    stream = StringIO()\n    with patch.dict(os.environ, {\"NO_COLOR\": \"\", \"FORCE_COLOR\": \"\"}, clear=False):\n        assert should_colorize(stream) is False\n\n\ndef test_log_styles():\n    \"\"\"Test LogStyles contains expected style tuples.\"\"\"\n    assert LogStyles.WARNING == (YELLOW, DIM)\n    assert LogStyles.ERROR == (RED, DIM)\n    assert LogStyles.CRITICAL == (RED, BOLD)\n\n\ndef test_handler_styles():\n    \"\"\"Test HandlerStyles contains expected style tuples.\"\"\"\n    assert HandlerStyles.COMMAND == (YELLOW, BOLD)\n    assert HandlerStyles.EVENT == (\"30\", BOLD)  # BLACK = \"30\"\n\n\ndef test_constants():\n    \"\"\"Test that ANSI constants have expected values.\"\"\"\n    assert RESET == \"\\x1b[0m\"\n    assert BOLD == \"1\"\n    assert DIM == \"2\"\n    assert RED == \"31\"\n    assert YELLOW == \"33\"\n"
  },
  {
    "path": "tests/test_command.py",
    "content": "import pytest\nfrom unittest.mock import Mock, patch, AsyncMock, MagicMock\nimport asyncio\nimport sys\nimport os\nimport tempfile\nfrom pathlib import Path\nfrom pyprland.manager import Pyprland\nfrom pyprland.models import ExitCode, PyprError\nfrom pyprland.validate_cli import run_validate, _load_plugin_module\n\n\n@pytest.fixture\ndef pyprland_app():\n    \"\"\"Fixture to create a Pyprland instance with mocked dependencies.\"\"\"\n    with patch(\"pyprland.manager.get_logger\", return_value=Mock()):\n        app = Pyprland()\n        app.server = AsyncMock()\n        app.event_reader = AsyncMock()\n        app.log_handler = Mock()  # Required for _run_plugin_handler\n        return app\n\n\n@pytest.mark.asyncio\nasync def test_load_config_toml(pyprland_app):\n    \"\"\"Test loading a TOML configuration.\"\"\"\n    mock_toml = {\"pyprland\": {\"plugins\": [\"test_plug\"]}}\n\n    with (\n        patch(\"pyprland.config_loader.aiexists\", AsyncMock(return_value=True)),\n        patch(\"pathlib.Path.exists\", return_value=True),\n        patch(\"pathlib.Path.open\", new_callable=MagicMock),\n        patch(\"tomllib.load\", return_value=mock_toml),\n        patch(\"pyprland.ipc._state.log\", new=Mock()),  # Mock the logger used in ipc module\n    ):\n        # We also need to mock plugin loading to avoid import errors\n        # And ensure 'pyprland' key exists in plugins map if we want to avoid KeyError inside load loop\n        # But wait, the KeyError 'pyprland' in test failure comes from accessing plugins[name].load_config\n        # because the mocked _load_single_plugin doesn't actually put anything in self.plugins for 'pyprland'\n\n        async def mock_load_plugin(name, init):\n            # minimal side effect to pretend plugin loaded\n            pyprland_app.plugins[name] = Mock()\n            pyprland_app.plugins[name].load_config = AsyncMock()\n            pyprland_app.plugins[name].on_reload = AsyncMock()\n            pyprland_app.plugins[name].validate_config = Mock(return_value=[])\n            return True\n\n        with patch.object(pyprland_app, \"_load_single_plugin\", side_effect=mock_load_plugin) as mock_load:\n            await pyprland_app.load_config(init=True)\n\n            assert pyprland_app.config == mock_toml\n            # \"pyprland\" is always loaded first\n            # \"test_plug\" is in the list\n            mock_load.assert_any_call(\"test_plug\", True)\n\n\n@pytest.mark.asyncio\nasync def test_load_config_toml_with_notify(pyprland_app):\n    \"\"\"Test loading a TOML configuration.\"\"\"\n    mock_toml = {\"pyprland\": {\"plugins\": [\"test_plug\"]}}\n\n    with (\n        patch(\"pyprland.config_loader.aiexists\", AsyncMock(return_value=True)),\n        patch(\"pathlib.Path.exists\", return_value=True),\n        patch(\"pathlib.Path.open\", new_callable=MagicMock),\n        patch(\"tomllib.load\", return_value=mock_toml),\n    ):\n        pyprland_app.backend.notify_info = AsyncMock()\n\n        # Mock _load_single_plugin to side-effect populate plugins\n        async def mock_load_plugin(name, init):\n            plug = Mock()\n            plug.load_config = AsyncMock()\n            plug.on_reload = AsyncMock()\n            plug.validate_config = Mock(return_value=[])\n            pyprland_app.plugins[name] = plug\n            return True\n\n        with patch.object(pyprland_app, \"_load_single_plugin\", side_effect=mock_load_plugin):\n            await pyprland_app.load_config(init=True)\n\n            assert pyprland_app.config == mock_toml\n            assert \"test_plug\" in pyprland_app.plugins\n            assert \"pyprland\" in pyprland_app.plugins\n\n\n@pytest.mark.asyncio\nasync def test_load_config_json_fallback(pyprland_app):\n    \"\"\"Test fallback to JSON if TOML doesn't exist.\"\"\"\n    mock_json = {\"pyprland\": {\"plugins\": []}}\n\n    # Async exists checks in _open_config:\n    # 1. CONFIG_FILE exists? -> False\n    # 2. LEGACY_CONFIG_FILE exists? -> False\n    # 3. OLD_CONFIG_FILE exists? -> True (triggers warning)\n    async_side_effects = [False, False, True]\n\n    # Sync exists checks in _load_config_file:\n    # 4. fname (CONFIG_FILE) exists? -> False\n    # 5. OLD_CONFIG_FILE exists? -> True\n    sync_side_effects = [False, True]\n\n    with (\n        patch(\"pyprland.config_loader.aiexists\", AsyncMock(side_effect=async_side_effects)),\n        patch.object(Path, \"exists\", side_effect=sync_side_effects),\n        patch.object(Path, \"open\", new_callable=MagicMock),\n        patch(\"json.loads\", return_value=mock_json),\n    ):\n        pyprland_app.backend.notify_info = AsyncMock()\n\n        # Mock _load_single_plugin same as above\n        async def mock_load_plugin(name, init):\n            plug = Mock()\n            plug.load_config = AsyncMock()\n            plug.validate_config = Mock(return_value=[])\n            pyprland_app.plugins[name] = plug\n            return True\n\n        with patch.object(pyprland_app, \"_load_single_plugin\", side_effect=mock_load_plugin):\n            await pyprland_app.load_config(init=False)\n            assert pyprland_app.config == mock_json\n\n\n@pytest.mark.asyncio\nasync def test_load_config_missing(pyprland_app):\n    \"\"\"Test error raised when no config found.\"\"\"\n    with (\n        patch(\"pyprland.config_loader.aiexists\", AsyncMock(return_value=False)),\n        patch.object(Path, \"exists\", return_value=False),\n    ):\n        with pytest.raises(PyprError):\n            await pyprland_app.load_config()\n\n\n@pytest.mark.asyncio\nasync def test_run_plugin_handler_success(pyprland_app):\n    \"\"\"Test successful execution of a plugin handler.\"\"\"\n    mock_plugin = Mock()\n    mock_plugin.name = \"test_plugin\"\n    mock_plugin.test_method = AsyncMock()\n\n    # Mock the log handler since it's called inside _run_plugin_handler\n    pyprland_app.log_handler = Mock()\n\n    await pyprland_app._run_plugin_handler(mock_plugin, \"test_method\", (\"arg1\",))\n\n    mock_plugin.test_method.assert_called_once_with(\"arg1\")\n\n\n@pytest.mark.asyncio\nasync def test_run_plugin_handler_exception(pyprland_app, monkeypatch):\n    \"\"\"Test that plugin exceptions are caught and logged.\"\"\"\n    # Disable strict mode for this test - we're testing the resilient behavior\n    monkeypatch.delenv(\"PYPRLAND_STRICT_ERRORS\", raising=False)\n\n    mock_plugin = Mock()\n    mock_plugin.name = \"test_plugin\"\n    mock_plugin.test_method = AsyncMock(side_effect=Exception(\"Boom\"))\n\n    pyprland_app.log_handler = Mock()\n    pyprland_app.backend.notify_error = AsyncMock()\n\n    # Should not raise\n    await pyprland_app._run_plugin_handler(mock_plugin, \"test_method\", ())\n\n    pyprland_app.backend.notify_error.assert_called()\n    pyprland_app.log.exception.assert_called()\n\n\n@pytest.mark.asyncio\nasync def test_call_handler_dispatch(pyprland_app):\n    \"\"\"Test dispatching commands to plugins.\"\"\"\n    # Setup two plugins\n    p1 = Mock()\n    p1.name = \"p1\"\n    p1.aborted = False\n\n    p2 = Mock()\n    p2.name = \"p2\"\n    p2.aborted = False\n    # Only p2 has the method\n    p2.cmd_do_something = AsyncMock()\n\n    pyprland_app.plugins = {\"p1\": p1, \"p2\": p2}\n    pyprland_app.queues = {\"p1\": asyncio.Queue(), \"p2\": asyncio.Queue()}\n\n    # \"cmd_\" prefix is usually stripped or added depending on context,\n    # but _call_handler takes the full name \"event_...\" or \"run_...\"\n    # The code checks `if hasattr(plugin, full_name)`\n\n    p2.run_mycommand = AsyncMock()\n\n    with patch(\"pyprland.manager.partial\") as mock_partial:\n        handled, success, msg = await pyprland_app._call_handler(\"run_mycommand\", \"arg1\")\n\n        assert handled is True\n        assert success is True\n        assert msg == \"\"\n        # Verify it was queued for p2\n        assert pyprland_app.queues[\"p2\"].qsize() == 1\n\n\n@pytest.mark.asyncio\nasync def test_call_handler_dispatch_with_wait(pyprland_app):\n    \"\"\"Test dispatching commands to plugins with wait=True.\"\"\"\n    p1 = Mock()\n    p1.name = \"p1\"\n    p1.aborted = False\n\n    pyprland_app.plugins = {\"p1\": p1}\n    pyprland_app.queues = {\"p1\": asyncio.Queue()}\n    pyprland_app.log_handler = Mock()\n    pyprland_app.pyprland_mutex_event = asyncio.Event()\n    pyprland_app.pyprland_mutex_event.set()\n    pyprland_app.stopped = False\n\n    p1.run_mycommand = AsyncMock()\n\n    # Start a background task to process the queue (simulates _plugin_runner_loop)\n    async def queue_processor():\n        q = pyprland_app.queues[\"p1\"]\n        while True:\n            task = await q.get()\n            if task is None:\n                break\n            await task()\n\n    processor_task = asyncio.create_task(queue_processor())\n\n    try:\n        handled, success, msg = await pyprland_app._call_handler(\"run_mycommand\", \"arg1\", wait=True)\n\n        assert handled is True\n        assert success is True\n        assert msg == \"\"\n        p1.run_mycommand.assert_called_once_with(\"arg1\")\n    finally:\n        # Stop the processor\n        await pyprland_app.queues[\"p1\"].put(None)\n        await processor_task\n\n\n@pytest.mark.asyncio\nasync def test_call_handler_unknown_command(pyprland_app):\n    \"\"\"Test handling unknown command.\"\"\"\n    pyprland_app.plugins = {}\n    pyprland_app.backend.notify_info = AsyncMock()\n\n    handled, success, msg = await pyprland_app._call_handler(\"run_nonexistent\", notify=\"nonexistent\")\n\n    assert handled is False\n    assert success is False\n    assert \"Unknown command\" in msg\n    pyprland_app.backend.notify_info.assert_called()\n\n\n@pytest.mark.asyncio\nasync def test_read_command_socket(pyprland_app):\n    \"\"\"Test reading commands from the socket.\"\"\"\n    reader = AsyncMock()\n    writer = AsyncMock()\n    # writer.write and writer.close are synchronous methods on StreamWriter\n    writer.write = Mock()\n    writer.close = Mock()\n\n    # Simulate receiving \"reload\"\n    reader.readline.return_value = b\"reload\\n\"\n\n    with patch.object(pyprland_app, \"_call_handler\", new_callable=AsyncMock) as mock_call:\n        # Mock returns (handled=True, success=True, msg=\"\")\n        mock_call.return_value = (True, True, \"\")\n        await pyprland_app.read_command(reader, writer)\n\n        mock_call.assert_called_with(\"run_reload\", notify=\"reload\", wait=True)\n        writer.write.assert_called()\n        writer.close.assert_called()\n        await writer.wait_closed()\n\n\n@pytest.mark.asyncio\nasync def test_read_command_socket_error(pyprland_app):\n    \"\"\"Test reading commands from the socket with error response.\"\"\"\n    reader = AsyncMock()\n    writer = AsyncMock()\n    writer.write = Mock()\n    writer.close = Mock()\n\n    reader.readline.return_value = b\"failing_command\\n\"\n\n    with patch.object(pyprland_app, \"_call_handler\", new_callable=AsyncMock) as mock_call:\n        # Mock returns (handled=True, success=False, msg=\"error message\")\n        mock_call.return_value = (True, False, \"test_plugin::run_failing: Exception occurred\")\n        await pyprland_app.read_command(reader, writer)\n\n        # Verify ERROR response was sent\n        calls = writer.write.call_args_list\n        assert any(b\"ERROR:\" in call[0][0] for call in calls)\n        writer.close.assert_called()\n\n\n@pytest.mark.asyncio\nasync def test_read_command_socket_unknown(pyprland_app):\n    \"\"\"Test reading unknown command from the socket.\"\"\"\n    reader = AsyncMock()\n    writer = AsyncMock()\n    writer.write = Mock()\n    writer.close = Mock()\n\n    reader.readline.return_value = b\"unknown_cmd\\n\"\n\n    with patch.object(pyprland_app, \"_call_handler\", new_callable=AsyncMock) as mock_call:\n        # Mock returns (handled=False, success=False, msg=\"Unknown command\")\n        mock_call.return_value = (False, False, 'Unknown command \"unknown_cmd\". Try \"help\" for available commands.')\n        await pyprland_app.read_command(reader, writer)\n\n        # Verify ERROR response was sent\n        calls = writer.write.call_args_list\n        assert any(b\"ERROR:\" in call[0][0] for call in calls)\n        writer.close.assert_called()\n\n\n@pytest.mark.asyncio\nasync def test_read_command_exit(pyprland_app):\n    \"\"\"Test the exit command.\"\"\"\n    reader = AsyncMock()\n    writer = AsyncMock()\n    # writer.write and writer.close are synchronous methods on StreamWriter\n    writer.write = Mock()\n    writer.close = Mock()\n\n    reader.readline.return_value = b\"exit\\n\"\n\n    # Set up the pyprland plugin with manager reference so run_exit works\n    from pyprland.plugins.pyprland import Extension\n\n    pyprland_plugin = Extension(\"pyprland\")\n    pyprland_plugin.manager = pyprland_app\n    pyprland_app.plugins[\"pyprland\"] = pyprland_plugin\n\n    with patch.object(pyprland_app, \"_abort_plugins\", new_callable=AsyncMock) as mock_abort:\n        await pyprland_app.read_command(reader, writer)\n\n        assert pyprland_app.stopped is True\n        # exit command now writes OK response before triggering abort\n        writer.write.assert_called()\n        await writer.wait_closed()\n\n\ndef test_load_plugin_module_builtin():\n    \"\"\"Test loading a built-in plugin module.\"\"\"\n    extension_class = _load_plugin_module(\"magnify\")\n    assert extension_class is not None\n    assert hasattr(extension_class, \"config_schema\")\n\n\ndef test_load_plugin_module_not_found():\n    \"\"\"Test loading a non-existent plugin module.\"\"\"\n    extension_class = _load_plugin_module(\"nonexistent_plugin_xyz\")\n    assert extension_class is None\n\n\ndef test_run_validate_valid_config():\n    \"\"\"Test validate command with a valid config.\"\"\"\n    with tempfile.TemporaryDirectory() as tmpdir:\n        config_dir = os.path.join(tmpdir, \"hypr\")\n        os.makedirs(config_dir)\n        config_file = Path(config_dir) / \"pyprland.toml\"\n\n        # Write a valid config\n        with open(config_file, \"w\") as f:\n            f.write(\"\"\"\n[pyprland]\nplugins = [\"magnify\"]\n\n[magnify]\nfactor = 2.5\nduration = 10\n\"\"\")\n\n        # Patch the constants to use our temp paths\n        with (\n            patch(\"pyprland.validate_cli.CONFIG_FILE\", Path(tmpdir) / \"pypr\" / \"config.toml\"),\n            patch(\"pyprland.validate_cli.LEGACY_CONFIG_FILE\", config_file),\n            patch(\"pyprland.validate_cli.OLD_CONFIG_FILE\", Path(tmpdir) / \"hypr\" / \"pyprland.json\"),\n        ):\n            with pytest.raises(SystemExit) as exc_info:\n                run_validate()\n            assert exc_info.value.code == ExitCode.SUCCESS\n\n\ndef test_run_validate_missing_required_field():\n    \"\"\"Test validate command with missing required field.\"\"\"\n    with tempfile.TemporaryDirectory() as tmpdir:\n        config_dir = os.path.join(tmpdir, \"hypr\")\n        os.makedirs(config_dir)\n        config_file = Path(config_dir) / \"pyprland.toml\"\n\n        # Write a config with missing required \"path\" field for wallpapers\n        with open(config_file, \"w\") as f:\n            f.write(\"\"\"\n[pyprland]\nplugins = [\"wallpapers\"]\n\n[wallpapers]\ninterval = 10\n\"\"\")\n\n        # Patch the constants to use our temp paths\n        with (\n            patch(\"pyprland.validate_cli.CONFIG_FILE\", Path(tmpdir) / \"pypr\" / \"config.toml\"),\n            patch(\"pyprland.validate_cli.LEGACY_CONFIG_FILE\", config_file),\n            patch(\"pyprland.validate_cli.OLD_CONFIG_FILE\", Path(tmpdir) / \"hypr\" / \"pyprland.json\"),\n        ):\n            with pytest.raises(SystemExit) as exc_info:\n                run_validate()\n            # Should fail with USAGE_ERROR due to missing required field\n            assert exc_info.value.code == ExitCode.USAGE_ERROR\n\n\ndef test_run_validate_config_not_found():\n    \"\"\"Test validate command when config file doesn't exist.\"\"\"\n    with tempfile.TemporaryDirectory() as tmpdir:\n        # Patch the constants to use non-existent paths in temp directory\n        with (\n            patch(\"pyprland.validate_cli.CONFIG_FILE\", Path(tmpdir) / \"pypr\" / \"config.toml\"),\n            patch(\"pyprland.validate_cli.LEGACY_CONFIG_FILE\", Path(tmpdir) / \"hypr\" / \"pyprland.toml\"),\n            patch(\"pyprland.validate_cli.OLD_CONFIG_FILE\", Path(tmpdir) / \"hypr\" / \"pyprland.json\"),\n        ):\n            with pytest.raises(SystemExit) as exc_info:\n                run_validate()\n            assert exc_info.value.code == ExitCode.ENV_ERROR\n"
  },
  {
    "path": "tests/test_command_registry.py",
    "content": "\"\"\"Tests for command registry.\"\"\"\n\nimport pytest\n\nfrom pyprland.commands.discovery import extract_commands_from_object, get_client_commands\nfrom pyprland.commands.models import CommandArg, CommandInfo\nfrom pyprland.commands.parsing import parse_docstring\nfrom pyprland.commands.tree import build_command_tree, get_display_name, get_parent_prefixes\n\n\nclass TestParseDocstring:\n    \"\"\"Tests for parse_docstring function.\"\"\"\n\n    def test_required_arg(self):\n        \"\"\"Test parsing a required argument.\"\"\"\n        args, short, full = parse_docstring(\"<name> Toggle a scratchpad\")\n        assert len(args) == 1\n        assert args[0].value == \"name\"\n        assert args[0].required is True\n        assert short == \"Toggle a scratchpad\"\n        assert full == \"<name> Toggle a scratchpad\"\n\n    def test_optional_arg(self):\n        \"\"\"Test parsing an optional argument.\"\"\"\n        args, short, full = parse_docstring(\"[command] Show help\")\n        assert len(args) == 1\n        assert args[0].value == \"command\"\n        assert args[0].required is False\n        assert short == \"Show help\"\n\n    def test_mixed_args(self):\n        \"\"\"Test parsing mixed required and optional arguments.\"\"\"\n        args, short, full = parse_docstring(\"<shell> [path] Generate completions\")\n        assert len(args) == 2\n        assert args[0].value == \"shell\"\n        assert args[0].required is True\n        assert args[1].value == \"path\"\n        assert args[1].required is False\n        assert short == \"Generate completions\"\n\n    def test_no_args(self):\n        \"\"\"Test parsing docstring with no arguments.\"\"\"\n        args, short, full = parse_docstring(\"Show the version\")\n        assert args == []\n        assert short == \"Show the version\"\n        assert full == \"Show the version\"\n\n    def test_pipe_choices(self):\n        \"\"\"Test parsing argument with pipe-separated choices.\"\"\"\n        args, short, _ = parse_docstring(\"<next|prev|clear> Control playback\")\n        assert len(args) == 1\n        assert args[0].value == \"next|prev|clear\"\n        assert args[0].required is True\n        assert short == \"Control playback\"\n\n    def test_empty_docstring(self):\n        \"\"\"Test parsing empty docstring.\"\"\"\n        args, short, full = parse_docstring(\"\")\n        assert args == []\n        assert short == \"No description available.\"\n        assert full == \"\"\n\n    def test_multiline_docstring(self):\n        \"\"\"Test parsing multiline docstring.\"\"\"\n        doc = \"\"\"<arg> Short description.\n\n        Detailed explanation here.\n        More details.\"\"\"\n        args, short, full = parse_docstring(doc)\n        assert len(args) == 1\n        assert args[0].value == \"arg\"\n        assert short == \"Short description.\"\n        assert \"Detailed explanation\" in full\n\n    def test_arg_only_no_description(self):\n        \"\"\"Test docstring with only an argument, no description.\"\"\"\n        args, short, full = parse_docstring(\"<name>\")\n        assert len(args) == 1\n        assert args[0].value == \"name\"\n        assert short == \"<name>\"  # Falls back to full first line\n\n    def test_multiple_optional_args(self):\n        \"\"\"Test parsing multiple optional arguments.\"\"\"\n        args, short, _ = parse_docstring(\"[arg1] [arg2] [arg3] Do something\")\n        assert len(args) == 3\n        assert all(not arg.required for arg in args)\n        assert short == \"Do something\"\n\n\nclass TestExtractCommandsFromObject:\n    \"\"\"Tests for extract_commands_from_object function.\"\"\"\n\n    def test_extract_from_class(self):\n        \"\"\"Test extracting commands from a class.\"\"\"\n\n        class FakePlugin:\n            def run_test(self):\n                \"\"\"Do a test.\"\"\"\n\n            def run_other(self, arg):\n                \"\"\"<arg> Other command.\"\"\"\n\n            def not_a_command(self):\n                \"\"\"This is not a command.\"\"\"\n\n        cmds = extract_commands_from_object(FakePlugin, source=\"fake\")\n        assert len(cmds) == 2\n        names = {c.name for c in cmds}\n        assert names == {\"test\", \"other\"}\n\n    def test_extract_from_instance(self):\n        \"\"\"Test extracting commands from an instance.\"\"\"\n\n        class FakePlugin:\n            def run_hello(self):\n                \"\"\"Say hello.\"\"\"\n\n        instance = FakePlugin()\n        cmds = extract_commands_from_object(instance, source=\"test\")\n        assert len(cmds) == 1\n        assert cmds[0].name == \"hello\"\n        assert cmds[0].short_description == \"Say hello.\"\n\n    def test_source_preserved(self):\n        \"\"\"Test that source is correctly preserved.\"\"\"\n\n        class FakePlugin:\n            def run_cmd(self):\n                \"\"\"A command.\"\"\"\n\n        cmds = extract_commands_from_object(FakePlugin, source=\"myplugin\")\n        assert cmds[0].source == \"myplugin\"\n\n    def test_args_extracted(self):\n        \"\"\"Test that arguments are correctly extracted.\"\"\"\n\n        class FakePlugin:\n            def run_toggle(self, name):\n                \"\"\"<name> Toggle something.\"\"\"\n\n        cmds = extract_commands_from_object(FakePlugin, source=\"test\")\n        assert len(cmds[0].args) == 1\n        assert cmds[0].args[0].value == \"name\"\n        assert cmds[0].args[0].required is True\n\n    def test_no_commands(self):\n        \"\"\"Test class with no run_ methods.\"\"\"\n\n        class EmptyPlugin:\n            def do_something(self):\n                \"\"\"Not a command.\"\"\"\n\n        cmds = extract_commands_from_object(EmptyPlugin, source=\"empty\")\n        assert cmds == []\n\n    def test_method_without_docstring(self):\n        \"\"\"Test method without docstring.\"\"\"\n\n        class FakePlugin:\n            def run_nodoc(self):\n                pass\n\n        cmds = extract_commands_from_object(FakePlugin, source=\"test\")\n        assert len(cmds) == 1\n        assert cmds[0].short_description == \"No description available.\"\n\n\nclass TestGetClientCommands:\n    \"\"\"Tests for get_client_commands function.\"\"\"\n\n    def test_returns_edit_and_validate(self):\n        \"\"\"Test that edit and validate commands are returned.\"\"\"\n        cmds = get_client_commands()\n        names = {c.name for c in cmds}\n        assert \"edit\" in names\n        assert \"validate\" in names\n\n    def test_source_is_client(self):\n        \"\"\"Test that source is set to 'client'.\"\"\"\n        cmds = get_client_commands()\n        for cmd in cmds:\n            assert cmd.source == \"client\"\n\n    def test_has_descriptions(self):\n        \"\"\"Test that client commands have descriptions.\"\"\"\n        cmds = get_client_commands()\n        for cmd in cmds:\n            assert cmd.short_description\n            assert cmd.full_description\n\n\nclass TestCommandInfoDataclass:\n    \"\"\"Tests for CommandInfo dataclass.\"\"\"\n\n    def test_create_command_info(self):\n        \"\"\"Test creating a CommandInfo instance.\"\"\"\n        cmd = CommandInfo(\n            name=\"test\",\n            args=[CommandArg(value=\"arg1\", required=True)],\n            short_description=\"Short\",\n            full_description=\"Full description\",\n            source=\"plugin\",\n        )\n        assert cmd.name == \"test\"\n        assert len(cmd.args) == 1\n        assert cmd.source == \"plugin\"\n\n\nclass TestCommandArgDataclass:\n    \"\"\"Tests for CommandArg dataclass.\"\"\"\n\n    def test_create_required_arg(self):\n        \"\"\"Test creating a required argument.\"\"\"\n        arg = CommandArg(value=\"name\", required=True)\n        assert arg.value == \"name\"\n        assert arg.required is True\n\n    def test_create_optional_arg(self):\n        \"\"\"Test creating an optional argument.\"\"\"\n        arg = CommandArg(value=\"option\", required=False)\n        assert arg.value == \"option\"\n        assert arg.required is False\n\n\ndef _make_cmd(name: str, source: str) -> CommandInfo:\n    \"\"\"Helper to create a minimal CommandInfo for tree tests.\"\"\"\n    return CommandInfo(\n        name=name,\n        args=[],\n        short_description=f\"{name} command\",\n        full_description=f\"{name} command\",\n        source=source,\n    )\n\n\nclass TestGetParentPrefixes:\n    \"\"\"Tests for get_parent_prefixes function.\"\"\"\n\n    def test_same_source_groups(self):\n        \"\"\"Commands from the same source sharing a prefix are grouped.\"\"\"\n        commands = {\n            \"wall_next\": \"wallpapers\",\n            \"wall_pause\": \"wallpapers\",\n            \"wall_clear\": \"wallpapers\",\n        }\n        prefixes = get_parent_prefixes(commands)\n        assert \"wall\" in prefixes\n\n    def test_different_sources_not_grouped(self):\n        \"\"\"Commands from different sources sharing a prefix are NOT grouped.\"\"\"\n        commands = {\n            \"toggle\": \"scratchpads\",\n            \"toggle_dpms\": \"toggle_dpms\",\n            \"toggle_special\": \"toggle_special\",\n        }\n        prefixes = get_parent_prefixes(commands)\n        assert \"toggle\" not in prefixes\n\n    def test_mixed_same_and_different_sources(self):\n        \"\"\"Only same-source prefixes are grouped; cross-plugin ones are not.\"\"\"\n        commands = {\n            \"wall_next\": \"wallpapers\",\n            \"wall_pause\": \"wallpapers\",\n            \"toggle\": \"scratchpads\",\n            \"toggle_dpms\": \"toggle_dpms\",\n        }\n        prefixes = get_parent_prefixes(commands)\n        assert \"wall\" in prefixes\n        assert \"toggle\" not in prefixes\n\n    def test_single_command_no_grouping(self):\n        \"\"\"A lone command with underscores does not create a parent prefix.\"\"\"\n        commands = {\n            \"layout_center\": \"layout_center\",\n        }\n        prefixes = get_parent_prefixes(commands)\n        assert \"layout\" not in prefixes\n\n    def test_legacy_iterable_groups_all(self):\n        \"\"\"Legacy iterable input (no source info) groups by prefix count only.\"\"\"\n        commands = [\"toggle_dpms\", \"toggle_special\"]\n        prefixes = get_parent_prefixes(commands)\n        # Without source info, both share empty source, so \"toggle\" is grouped\n        assert \"toggle\" in prefixes\n\n\nclass TestBuildCommandTree:\n    \"\"\"Tests for build_command_tree function.\"\"\"\n\n    def test_cross_plugin_commands_stay_flat(self):\n        \"\"\"Commands from different plugins with shared prefix remain separate root entries.\"\"\"\n        commands = {\n            \"toggle\": _make_cmd(\"toggle\", \"scratchpads\"),\n            \"toggle_dpms\": _make_cmd(\"toggle_dpms\", \"toggle_dpms\"),\n            \"toggle_special\": _make_cmd(\"toggle_special\", \"toggle_special\"),\n        }\n        tree = build_command_tree(commands)\n\n        # Each command should be its own root node, not nested\n        assert \"toggle\" in tree\n        assert \"toggle_dpms\" in tree\n        assert \"toggle_special\" in tree\n\n        # toggle should have no children\n        assert tree[\"toggle\"].children == {}\n        assert tree[\"toggle_dpms\"].children == {}\n        assert tree[\"toggle_special\"].children == {}\n\n    def test_same_plugin_commands_grouped(self):\n        \"\"\"Commands from the same plugin with shared prefix are grouped.\"\"\"\n        commands = {\n            \"wall_next\": _make_cmd(\"wall_next\", \"wallpapers\"),\n            \"wall_pause\": _make_cmd(\"wall_pause\", \"wallpapers\"),\n            \"wall_clear\": _make_cmd(\"wall_clear\", \"wallpapers\"),\n        }\n        tree = build_command_tree(commands)\n\n        # Should have a single root \"wall\" with children\n        assert \"wall\" in tree\n        assert \"wall_next\" not in tree\n        assert \"wall_pause\" not in tree\n        assert \"wall_clear\" not in tree\n\n        wall = tree[\"wall\"]\n        assert \"next\" in wall.children\n        assert \"pause\" in wall.children\n        assert \"clear\" in wall.children\n\n    def test_mixed_plugins_correct_grouping(self):\n        \"\"\"Same-plugin commands group while cross-plugin ones stay flat.\"\"\"\n        commands = {\n            \"wall_next\": _make_cmd(\"wall_next\", \"wallpapers\"),\n            \"wall_pause\": _make_cmd(\"wall_pause\", \"wallpapers\"),\n            \"toggle\": _make_cmd(\"toggle\", \"scratchpads\"),\n            \"toggle_dpms\": _make_cmd(\"toggle_dpms\", \"toggle_dpms\"),\n        }\n        tree = build_command_tree(commands)\n\n        # wall commands grouped\n        assert \"wall\" in tree\n        assert \"next\" in tree[\"wall\"].children\n        assert \"pause\" in tree[\"wall\"].children\n\n        # toggle commands NOT grouped\n        assert \"toggle\" in tree\n        assert \"toggle_dpms\" in tree\n        assert tree[\"toggle\"].children == {}\n\n    def test_root_command_with_same_source_children(self):\n        \"\"\"A root command that is also a prefix keeps its info and gets children.\"\"\"\n        commands = {\n            \"wall\": _make_cmd(\"wall\", \"wallpapers\"),\n            \"wall_next\": _make_cmd(\"wall_next\", \"wallpapers\"),\n            \"wall_pause\": _make_cmd(\"wall_pause\", \"wallpapers\"),\n        }\n        tree = build_command_tree(commands)\n\n        assert \"wall\" in tree\n        wall = tree[\"wall\"]\n        assert wall.info is not None\n        assert wall.info.source == \"wallpapers\"\n        assert \"next\" in wall.children\n        assert \"pause\" in wall.children\n\n\nclass TestGetDisplayName:\n    \"\"\"Tests for get_display_name function.\"\"\"\n\n    def test_hierarchical_command(self):\n        \"\"\"Hierarchical command gets space-separated display name.\"\"\"\n        parent_prefixes = {\"wall\"}\n        assert get_display_name(\"wall_next\", parent_prefixes) == \"wall next\"\n\n    def test_non_hierarchical_command(self):\n        \"\"\"Non-hierarchical command keeps underscore name.\"\"\"\n        parent_prefixes = {\"wall\"}\n        assert get_display_name(\"toggle_dpms\", parent_prefixes) == \"toggle_dpms\"\n\n    def test_no_parent_prefixes(self):\n        \"\"\"With no parent prefixes, all commands keep underscore names.\"\"\"\n        parent_prefixes: set[str] = set()\n        assert get_display_name(\"toggle_dpms\", parent_prefixes) == \"toggle_dpms\"\n        assert get_display_name(\"wall_next\", parent_prefixes) == \"wall_next\"\n"
  },
  {
    "path": "tests/test_common_types.py",
    "content": "from pyprland.models import VersionInfo\n\n\ndef test_version_info_init():\n    v = VersionInfo(1, 2, 3)\n    assert v.major == 1\n    assert v.minor == 2\n    assert v.micro == 3\n\n\ndef test_version_info_defaults():\n    v = VersionInfo()\n    assert v.major == 0\n    assert v.minor == 0\n    assert v.micro == 0\n\n\ndef test_version_info_compare_major():\n    v1 = VersionInfo(1, 0, 0)\n    v2 = VersionInfo(0, 9, 9)\n    assert v1 > v2\n    assert v2 < v1\n\n\ndef test_version_info_compare_minor():\n    v1 = VersionInfo(0, 10, 0)\n    v2 = VersionInfo(0, 9, 9)\n    assert v1 > v2\n    assert v2 < v1\n\n\ndef test_version_info_compare_micro():\n    v1 = VersionInfo(0, 0, 2)\n    v2 = VersionInfo(0, 0, 1)\n    assert v1 > v2\n    assert v2 < v1\n\n\ndef test_version_info_equality():\n    v1 = VersionInfo(1, 2, 3)\n    v2 = VersionInfo(1, 2, 3)\n    v3 = VersionInfo(1, 2, 4)\n    assert v1 == v2\n    assert v1 != v3\n"
  },
  {
    "path": "tests/test_common_utils.py",
    "content": "import pytest\nfrom pyprland.common import merge, apply_filter, is_rotated\n\n\ndef test_merge_dicts():\n    d1 = {\"a\": 1, \"b\": {\"x\": 10}}\n    d2 = {\"b\": {\"y\": 20}, \"c\": 3}\n    expected = {\"a\": 1, \"b\": {\"x\": 10, \"y\": 20}, \"c\": 3}\n    assert merge(d1, d2) == expected\n\n\ndef test_merge_lists():\n    d1 = {\"a\": [1, 2]}\n    d2 = {\"a\": [3, 4]}\n    expected = {\"a\": [1, 2, 3, 4]}\n    assert merge(d1, d2) == expected\n\n\ndef test_merge_overwrite():\n    d1 = {\"a\": 1}\n    d2 = {\"a\": 2}\n    expected = {\"a\": 2}\n    assert merge(d1, d2) == expected\n\n\ndef test_apply_filter_empty():\n    assert apply_filter(\"hello\", \"\") == \"hello\"\n\n\ndef test_apply_filter_substitute():\n    assert apply_filter(\"hello world\", \"s/world/there/\") == \"hello there\"\n    assert apply_filter(\"hello world\", \"s|world|there|\") == \"hello there\"\n\n\ndef test_apply_filter_substitute_global():\n    assert apply_filter(\"foo bar foo\", \"s/foo/baz/g\") == \"baz bar baz\"\n    assert apply_filter(\"foo bar foo\", \"s/foo/baz/\") == \"baz bar foo\"\n\n\ndef test_apply_filter_malformed():\n    # Should not crash\n    assert apply_filter(\"hello\", \"s/incomplete\") == \"hello\"\n    assert apply_filter(\"hello\", \"invalid\") == \"hello\"\n\n\ndef test_is_rotated():\n    assert is_rotated({\"transform\": 1}) is True\n    assert is_rotated({\"transform\": 3}) is True\n    assert is_rotated({\"transform\": 5}) is True\n    assert is_rotated({\"transform\": 7}) is True\n\n    assert is_rotated({\"transform\": 0}) is False\n    assert is_rotated({\"transform\": 2}) is False\n    assert is_rotated({\"transform\": 4}) is False\n    assert is_rotated({\"transform\": 6}) is False\n\n"
  },
  {
    "path": "tests/test_completions.py",
    "content": "\"\"\"Tests for shell completion generators.\n\nTests use the generated JSON files from site/generated/ as source of truth\nand validate completion scripts with real shells when available.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport shutil\nimport subprocess\nfrom pathlib import Path\n\nimport pytest\n\nfrom pyprland.commands.models import CommandArg, CommandInfo\nfrom pyprland.commands.tree import build_command_tree\nfrom pyprland.completions.discovery import _build_command_from_node, _classify_arg\nfrom pyprland.completions.generators.bash import generate_bash\nfrom pyprland.completions.generators.fish import generate_fish\nfrom pyprland.completions.generators.zsh import generate_zsh\nfrom pyprland.completions.models import CommandCompletion, CompletionArg\n\nGENERATED_DIR = Path(__file__).parent.parent / \"site\" / \"generated\"\n\n\ndef _load_commands_from_json() -> dict[str, CommandInfo]:\n    \"\"\"Load all commands from generated JSON files into CommandInfo objects.\"\"\"\n    commands: dict[str, CommandInfo] = {}\n\n    for json_file in GENERATED_DIR.glob(\"*.json\"):\n        if json_file.stem in (\"index\", \"generted_files\"):\n            continue\n\n        data = json.loads(json_file.read_text())\n        plugin_name = data[\"name\"]\n\n        for cmd in data.get(\"commands\", []):\n            args = [CommandArg(value=arg[\"value\"], required=arg[\"required\"]) for arg in cmd.get(\"args\", [])]\n            commands[cmd[\"name\"]] = CommandInfo(\n                name=cmd[\"name\"],\n                args=args,\n                short_description=cmd.get(\"short_description\", \"\"),\n                full_description=cmd.get(\"full_description\", \"\"),\n                source=plugin_name,\n            )\n\n    return commands\n\n\ndef _build_completions_from_json() -> dict[str, CommandCompletion]:\n    \"\"\"Build CommandCompletion objects from generated JSON files.\"\"\"\n    all_commands = _load_commands_from_json()\n    command_tree = build_command_tree(all_commands)\n\n    # Build completions from tree (simplified, no scratchpad names)\n    completions: dict[str, CommandCompletion] = {}\n    for root_name, node in command_tree.items():\n        completions[root_name] = _build_command_from_node(root_name, node, [])\n\n    # Apply help command override (simplified version)\n    all_cmd_names = sorted(completions.keys())\n    help_subcommands: dict[str, CommandCompletion] = {}\n    for cmd_name, cmd in completions.items():\n        if cmd.subcommands:\n            help_subcommands[cmd_name] = CommandCompletion(\n                name=cmd_name,\n                args=[\n                    CompletionArg(\n                        position=1,\n                        completion_type=\"choices\",\n                        values=sorted(cmd.subcommands.keys()),\n                        required=False,\n                        description=\"subcommand\",\n                    )\n                ],\n                description=f\"Subcommands of {cmd_name}\",\n            )\n\n    if \"help\" in completions:\n        completions[\"help\"] = CommandCompletion(\n            name=\"help\",\n            args=[\n                CompletionArg(\n                    position=1,\n                    completion_type=\"choices\",\n                    values=all_cmd_names,\n                    required=False,\n                    description=\"command\",\n                )\n            ],\n            description=\"Show available commands or detailed help\",\n            subcommands=help_subcommands,\n        )\n\n    return completions\n\n\n@pytest.fixture(scope=\"module\")\ndef commands_from_json() -> dict[str, CommandCompletion]:\n    \"\"\"Build CommandCompletion objects from generated JSON files.\"\"\"\n    return _build_completions_from_json()\n\n\n@pytest.fixture(scope=\"module\")\ndef zsh_script(commands_from_json: dict[str, CommandCompletion]) -> str:\n    \"\"\"Generate zsh completion script.\"\"\"\n    return generate_zsh(commands_from_json)\n\n\n@pytest.fixture(scope=\"module\")\ndef bash_script(commands_from_json: dict[str, CommandCompletion]) -> str:\n    \"\"\"Generate bash completion script.\"\"\"\n    return generate_bash(commands_from_json)\n\n\n@pytest.fixture(scope=\"module\")\ndef fish_script(commands_from_json: dict[str, CommandCompletion]) -> str:\n    \"\"\"Generate fish completion script.\"\"\"\n    return generate_fish(commands_from_json)\n\n\n# --- Syntax validation with real shells ---\n\n\n@pytest.mark.skipif(not shutil.which(\"zsh\"), reason=\"zsh not installed\")\nclass TestZshSyntax:\n    \"\"\"Test zsh completion script syntax.\"\"\"\n\n    def test_syntax_valid(self, zsh_script: str) -> None:\n        \"\"\"Zsh completion script should have valid syntax.\"\"\"\n        result = subprocess.run(\n            [\"zsh\", \"-n\", \"-c\", zsh_script],\n            capture_output=True,\n            text=True,\n        )\n        assert result.returncode == 0, f\"Zsh syntax error: {result.stderr}\"\n\n\n@pytest.mark.skipif(not shutil.which(\"bash\"), reason=\"bash not installed\")\nclass TestBashSyntax:\n    \"\"\"Test bash completion script syntax.\"\"\"\n\n    def test_syntax_valid(self, bash_script: str) -> None:\n        \"\"\"Bash completion script should have valid syntax.\"\"\"\n        result = subprocess.run(\n            [\"bash\", \"-n\", \"-c\", bash_script],\n            capture_output=True,\n            text=True,\n        )\n        assert result.returncode == 0, f\"Bash syntax error: {result.stderr}\"\n\n\n@pytest.mark.skipif(not shutil.which(\"fish\"), reason=\"fish not installed\")\nclass TestFishSyntax:\n    \"\"\"Test fish completion script syntax.\"\"\"\n\n    def test_syntax_valid(self, fish_script: str, tmp_path: Path) -> None:\n        \"\"\"Fish completion script should have valid syntax.\"\"\"\n        script_file = tmp_path / \"completions.fish\"\n        script_file.write_text(fish_script)\n        result = subprocess.run(\n            [\"fish\", \"--no-execute\", str(script_file)],\n            capture_output=True,\n            text=True,\n        )\n        assert result.returncode == 0, f\"Fish syntax error: {result.stderr}\"\n\n\n# --- Command presence tests ---\n\n\nclass TestZshCommandPresence:\n    \"\"\"Test that all commands appear in zsh completion.\"\"\"\n\n    def test_all_commands_present(self, zsh_script: str, commands_from_json: dict[str, CommandCompletion]) -> None:\n        \"\"\"All commands should appear in zsh completion.\"\"\"\n        for cmd_name in commands_from_json:\n            assert cmd_name in zsh_script, f\"Command '{cmd_name}' missing from zsh\"\n\n    def test_help_case_exists(self, zsh_script: str) -> None:\n        \"\"\"Help command should have a case statement.\"\"\"\n        assert \"help)\" in zsh_script\n\n    def test_help_uses_commands_array(self, zsh_script: str) -> None:\n        \"\"\"Help command should use the commands array for completion.\"\"\"\n        assert \"_describe 'command' commands\" in zsh_script\n\n\nclass TestBashCommandPresence:\n    \"\"\"Test that all commands appear in bash completion.\"\"\"\n\n    def test_all_commands_present(self, bash_script: str, commands_from_json: dict[str, CommandCompletion]) -> None:\n        \"\"\"All commands should appear in bash completion.\"\"\"\n        for cmd_name in commands_from_json:\n            assert cmd_name in bash_script, f\"Command '{cmd_name}' missing from bash\"\n\n    def test_help_case_exists(self, bash_script: str) -> None:\n        \"\"\"Help command should have a case statement.\"\"\"\n        assert \"help)\" in bash_script\n\n\nclass TestFishCommandPresence:\n    \"\"\"Test that all commands appear in fish completion.\"\"\"\n\n    def test_all_commands_present(self, fish_script: str, commands_from_json: dict[str, CommandCompletion]) -> None:\n        \"\"\"All commands should appear in fish completion.\"\"\"\n        for cmd_name in commands_from_json:\n            assert cmd_name in fish_script, f\"Command '{cmd_name}' missing from fish\"\n\n    def test_help_completion_exists(self, fish_script: str) -> None:\n        \"\"\"Help command should have completion rules.\"\"\"\n        assert \"__fish_seen_subcommand_from help\" in fish_script\n\n\n# --- Subcommand tests ---\n\n\nclass TestSubcommandCompletions:\n    \"\"\"Test that subcommands are properly handled.\"\"\"\n\n    def test_zsh_wall_subcommands(self, zsh_script: str) -> None:\n        \"\"\"Wall subcommands should appear in zsh completion.\"\"\"\n        assert \"wall)\" in zsh_script\n        # Check for at least some known subcommands\n        for subcmd in [\"next\", \"pause\", \"clear\"]:\n            assert subcmd in zsh_script, f\"Wall subcommand '{subcmd}' missing\"\n\n    def test_bash_wall_subcommands(self, bash_script: str) -> None:\n        \"\"\"Wall subcommands should appear in bash completion.\"\"\"\n        assert \"wall)\" in bash_script\n        for subcmd in [\"next\", \"pause\", \"clear\"]:\n            assert subcmd in bash_script, f\"Wall subcommand '{subcmd}' missing\"\n\n    def test_fish_wall_subcommands(self, fish_script: str) -> None:\n        \"\"\"Wall subcommands should appear in fish completion.\"\"\"\n        for subcmd in [\"next\", \"pause\", \"clear\"]:\n            assert subcmd in fish_script, f\"Wall subcommand '{subcmd}' missing\"\n\n    def test_zsh_help_wall_subcommands(self, zsh_script: str) -> None:\n        \"\"\"Help wall should complete with wall's subcommands.\"\"\"\n        # The help case should have a nested case for wall\n        assert \"wall) compadd\" in zsh_script or (\"wall)\" in zsh_script and \"compadd\" in zsh_script)\n\n    def test_bash_help_wall_subcommands(self, bash_script: str) -> None:\n        \"\"\"Help wall should complete with wall's subcommands.\"\"\"\n        # Check that help case handles wall subcommands\n        assert \"COMP_WORDS[2]\" in bash_script  # Used for subcommand detection\n\n    def test_fish_help_wall_subcommands(self, fish_script: str) -> None:\n        \"\"\"Help wall should complete with wall's subcommands.\"\"\"\n        assert \"contains wall\" in fish_script\n"
  },
  {
    "path": "tests/test_config.py",
    "content": "from pyprland.config import Configuration\nfrom pyprland.validation import ConfigField, ConfigValidator, _find_similar_key, format_config_error\n\n\ndef test_config_access(test_logger):\n    conf = Configuration({\"a\": 1, \"b\": \"test\"}, logger=test_logger)\n    assert conf[\"a\"] == 1\n    assert conf.get(\"b\") == \"test\"\n    assert conf.get(\"c\", 3) == 3\n\n\ndef test_get_bool(test_logger):\n    conf = Configuration(\n        {\n            \"t1\": True,\n            \"t2\": \"true\",\n            \"t3\": \"yes\",\n            \"t4\": \"on\",\n            \"t5\": \"1\",\n            \"f1\": False,\n            \"f2\": \"false\",\n            \"f3\": \"no\",\n            \"f4\": \"off\",\n            \"f5\": \"0\",\n            \"invalid\": \"foo\",\n            \"empty\": \"\",\n        },\n        logger=test_logger,\n    )\n\n    assert conf.get_bool(\"t1\") is True\n    assert conf.get_bool(\"t2\") is True\n    assert conf.get_bool(\"t3\") is True\n    assert conf.get_bool(\"t4\") is True\n    assert conf.get_bool(\"t5\") is True\n\n    assert conf.get_bool(\"f1\") is False\n    assert conf.get_bool(\"f2\") is False\n    assert conf.get_bool(\"f3\") is False\n    assert conf.get_bool(\"f4\") is False\n    assert conf.get_bool(\"f5\") is False\n\n    # Non-empty unrecognized strings are truthy (blacklist approach)\n    assert conf.get_bool(\"invalid\") is True\n\n    # Empty string is always falsy\n    assert conf.get_bool(\"empty\") is False\n\n    assert conf.get_bool(\"missing\", default=True) is True\n    assert conf.get_bool(\"missing\", default=False) is False\n\n\ndef test_get_int(test_logger):\n    conf = Configuration({\"a\": 1, \"b\": \"2\", \"c\": \"invalid\"}, logger=test_logger)\n    assert conf.get_int(\"a\") == 1\n    assert conf.get_int(\"b\") == 2\n    assert conf.get_int(\"c\", default=10) == 10\n    assert conf.get_int(\"missing\", default=5) == 5\n\n\ndef test_get_float(test_logger):\n    conf = Configuration({\"a\": 1.5, \"b\": \"2.5\", \"c\": \"invalid\"}, logger=test_logger)\n    assert conf.get_float(\"a\") == 1.5\n    assert conf.get_float(\"b\") == 2.5\n    assert conf.get_float(\"c\", default=10.0) == 10.0\n    assert conf.get_float(\"missing\", default=5.5) == 5.5\n\n\ndef test_get_str(test_logger):\n    conf = Configuration({\"a\": \"text\", \"b\": 123}, logger=test_logger)\n    assert conf.get_str(\"a\") == \"text\"\n    assert conf.get_str(\"b\") == \"123\"\n    assert conf.get_str(\"missing\", \"default\") == \"default\"\n\n\ndef test_iter_subsections(test_logger):\n    conf = Configuration(\n        {\n            \"global_opt\": \"value\",\n            \"debug\": True,\n            \"scratchpad1\": {\"command\": \"cmd1\"},\n            \"scratchpad2\": {\"command\": \"cmd2\"},\n            \"nested\": {\"sub\": \"val\"},\n        },\n        logger=test_logger,\n    )\n\n    subsections = dict(conf.iter_subsections())\n\n    assert len(subsections) == 3\n    assert \"scratchpad1\" in subsections\n    assert \"scratchpad2\" in subsections\n    assert \"nested\" in subsections\n    assert \"global_opt\" not in subsections\n    assert \"debug\" not in subsections\n    assert subsections[\"scratchpad1\"] == {\"command\": \"cmd1\"}\n\n\n# Config Validation Tests\n\n\ndef test_config_field_defaults():\n    \"\"\"Test ConfigField default values.\"\"\"\n    field = ConfigField(\"test\")\n    assert field.name == \"test\"\n    assert field.field_type is str\n    assert field.required is False\n    assert field.default is None\n    assert field.description == \"\"\n    assert field.choices is None\n\n\ndef test_config_field_with_values():\n    \"\"\"Test ConfigField with custom values.\"\"\"\n    field = ConfigField(\n        \"margin\",\n        int,\n        required=True,\n        default=60,\n        description=\"Window margin\",\n        choices=[30, 60, 90],\n    )\n    assert field.name == \"margin\"\n    assert field.field_type is int\n    assert field.required is True\n    assert field.default == 60\n    assert field.description == \"Window margin\"\n    assert field.choices == [30, 60, 90]\n\n\ndef test_find_similar_key():\n    \"\"\"Test fuzzy key matching.\"\"\"\n    known_keys = [\"command\", \"class\", \"animation\", \"margin\"]\n\n    # Exact match shouldn't happen (would be found first)\n    # Close typos\n    assert _find_similar_key(\"comand\", known_keys) == \"command\"\n    assert _find_similar_key(\"comandd\", known_keys) == \"command\"\n    assert _find_similar_key(\"animaton\", known_keys) == \"animation\"\n    assert _find_similar_key(\"margn\", known_keys) == \"margin\"\n\n    # Too far - no match\n    assert _find_similar_key(\"xyz\", known_keys) is None\n    assert _find_similar_key(\"foobar\", known_keys) is None\n\n\ndef test_format_config_error():\n    \"\"\"Test error message formatting.\"\"\"\n    msg = format_config_error(\"scratchpads\", \"command\", \"Missing required field\")\n    assert \"[scratchpads]\" in msg\n    assert \"command\" in msg\n    assert \"Missing required field\" in msg\n\n    msg_with_suggestion = format_config_error(\"scratchpads\", \"command\", \"Missing required field\", 'Add command = \"value\"')\n    assert 'Add command = \"value\"' in msg_with_suggestion\n\n\ndef test_config_validator_required_fields(test_logger):\n    \"\"\"Test validation of required fields.\"\"\"\n    schema = [\n        ConfigField(\"command\", str, required=True),\n        ConfigField(\"class\", str, required=False),\n    ]\n\n    # Missing required field\n    validator = ConfigValidator({}, \"test_plugin\", test_logger)\n    errors = validator.validate(schema)\n    assert len(errors) == 1\n    assert \"command\" in errors[0]\n    assert \"Missing required field\" in errors[0]\n\n    # Required field present\n    validator = ConfigValidator({\"command\": \"kitty\"}, \"test_plugin\", test_logger)\n    errors = validator.validate(schema)\n    assert len(errors) == 0\n\n\ndef test_config_validator_type_checking(test_logger):\n    \"\"\"Test type validation.\"\"\"\n    schema = [\n        ConfigField(\"count\", int),\n        ConfigField(\"factor\", float),\n        ConfigField(\"enabled\", bool),\n        ConfigField(\"name\", str),\n        ConfigField(\"items\", list),\n        ConfigField(\"options\", dict),\n    ]\n\n    # All correct types\n    config = {\n        \"count\": 10,\n        \"factor\": 2.5,\n        \"enabled\": True,\n        \"name\": \"test\",\n        \"items\": [1, 2, 3],\n        \"options\": {\"a\": 1},\n    }\n    validator = ConfigValidator(config, \"test_plugin\", test_logger)\n    errors = validator.validate(schema)\n    assert len(errors) == 0\n\n    # Wrong types\n    config_bad = {\n        \"count\": \"not a number\",\n        \"factor\": \"not a float\",\n        \"enabled\": \"maybe\",  # Invalid bool string\n        \"name\": 123,  # Wrong - expected str\n        \"items\": \"not a list\",\n        \"options\": \"not a dict\",\n    }\n    validator = ConfigValidator(config_bad, \"test_plugin\", test_logger)\n    errors = validator.validate(schema)\n    # All 6 fields should fail with wrong types\n    assert len(errors) == 6\n\n\ndef test_config_validator_choices(test_logger):\n    \"\"\"Test validation of choice fields.\"\"\"\n    schema = [\n        ConfigField(\"animation\", str, choices=[\"fromTop\", \"fromBottom\", \"fromLeft\", \"fromRight\"]),\n    ]\n\n    # Valid choice\n    validator = ConfigValidator({\"animation\": \"fromTop\"}, \"test_plugin\", test_logger)\n    errors = validator.validate(schema)\n    assert len(errors) == 0\n\n    # Invalid choice\n    validator = ConfigValidator({\"animation\": \"fromUp\"}, \"test_plugin\", test_logger)\n    errors = validator.validate(schema)\n    assert len(errors) == 1\n    assert \"fromUp\" in errors[0]\n    assert \"Valid options\" in errors[0]\n\n\ndef test_config_validator_unknown_keys(test_logger):\n    \"\"\"Test warning for unknown configuration keys.\"\"\"\n    schema = [\n        ConfigField(\"command\", str),\n        ConfigField(\"class\", str),\n    ]\n\n    config = {\n        \"command\": \"kitty\",\n        \"class\": \"kitty\",\n        \"comandd\": \"typo\",  # Unknown key, similar to 'command'\n        \"foobar\": \"value\",  # Unknown key, no similar match\n    }\n\n    validator = ConfigValidator(config, \"test_plugin\", test_logger)\n    warnings = validator.warn_unknown_keys(schema)\n\n    assert len(warnings) == 2\n    # Check for typo suggestion\n    assert any(\"comandd\" in w and \"command\" in w for w in warnings)\n    # Check for unknown key without suggestion\n    assert any(\"foobar\" in w for w in warnings)\n\n\ndef test_config_validator_numeric_strings(test_logger):\n    \"\"\"Test that numeric strings are accepted for int/float fields.\"\"\"\n    schema = [\n        ConfigField(\"count\", int),\n        ConfigField(\"factor\", float),\n    ]\n\n    # String numbers should be accepted\n    config = {\"count\": \"42\", \"factor\": \"2.5\"}\n    validator = ConfigValidator(config, \"test_plugin\", test_logger)\n    errors = validator.validate(schema)\n    assert len(errors) == 0\n\n\ndef test_config_validator_optional_fields(test_logger):\n    \"\"\"Test that optional fields don't trigger errors when missing.\"\"\"\n    schema = [\n        ConfigField(\"optional1\", str),\n        ConfigField(\"optional2\", int, default=10),\n    ]\n\n    # Empty config should be valid\n    validator = ConfigValidator({}, \"test_plugin\", test_logger)\n    errors = validator.validate(schema)\n    assert len(errors) == 0\n\n\ndef test_config_validator_union_types(test_logger):\n    \"\"\"Test validation of fields that accept multiple types (union types).\"\"\"\n    schema = [\n        ConfigField(\"path\", (str, list), required=True, description=\"Path or list of paths\"),\n        ConfigField(\"setting\", (int, str), description=\"Can be int or string\"),\n    ]\n\n    # String value for path should work\n    validator = ConfigValidator({\"path\": \"/some/path\"}, \"test_plugin\", test_logger)\n    errors = validator.validate(schema)\n    assert len(errors) == 0\n\n    # List value for path should work\n    validator = ConfigValidator({\"path\": [\"/path1\", \"/path2\"]}, \"test_plugin\", test_logger)\n    errors = validator.validate(schema)\n    assert len(errors) == 0\n\n    # Int value for setting should work\n    validator = ConfigValidator({\"path\": \"/some/path\", \"setting\": 42}, \"test_plugin\", test_logger)\n    errors = validator.validate(schema)\n    assert len(errors) == 0\n\n    # String value for setting should work\n    validator = ConfigValidator({\"path\": \"/some/path\", \"setting\": \"auto\"}, \"test_plugin\", test_logger)\n    errors = validator.validate(schema)\n    assert len(errors) == 0\n\n    # Wrong type for path (dict) should fail\n    validator = ConfigValidator({\"path\": {\"key\": \"value\"}}, \"test_plugin\", test_logger)\n    errors = validator.validate(schema)\n    assert len(errors) == 1\n    assert \"str or list\" in errors[0]\n\n    # Wrong type for setting (list) should fail\n    validator = ConfigValidator({\"path\": \"/some/path\", \"setting\": [1, 2, 3]}, \"test_plugin\", test_logger)\n    errors = validator.validate(schema)\n    assert len(errors) == 1\n    assert \"int or str\" in errors[0]\n\n\ndef test_config_validator_children_schema(test_logger):\n    \"\"\"Test validation of dict fields with children schema.\"\"\"\n    from pyprland.validation import ConfigItems\n\n    child_schema = ConfigItems(\n        ConfigField(\"scale\", float),\n        ConfigField(\"enabled\", bool),\n    )\n\n    schema = ConfigItems(\n        ConfigField(\"settings\", dict, children=child_schema),\n    )\n\n    # Valid nested config\n    config = {\"settings\": {\"item1\": {\"scale\": 1.5, \"enabled\": True}}}\n    validator = ConfigValidator(config, \"test\", test_logger)\n    errors = validator.validate(schema)\n    assert len(errors) == 0\n\n\ndef test_config_validator_children_type_errors(test_logger):\n    \"\"\"Test children schema catches type errors.\"\"\"\n    from pyprland.validation import ConfigItems\n\n    child_schema = ConfigItems(\n        ConfigField(\"scale\", float),\n    )\n\n    schema = ConfigItems(\n        ConfigField(\"settings\", dict, children=child_schema),\n    )\n\n    # Wrong type in nested config\n    config = {\"settings\": {\"item1\": {\"scale\": \"not-a-float\"}}}\n    validator = ConfigValidator(config, \"test\", test_logger)\n    errors = validator.validate(schema)\n    assert len(errors) == 1\n    assert \"float\" in errors[0]\n\n\ndef test_config_validator_children_unknown_keys(test_logger):\n    \"\"\"Test children schema warns about unknown keys.\"\"\"\n    from pyprland.validation import ConfigItems\n\n    child_schema = ConfigItems(\n        ConfigField(\"scale\", float),\n    )\n\n    schema = ConfigItems(\n        ConfigField(\"settings\", dict, children=child_schema),\n    )\n\n    # Unknown key in nested config\n    config = {\"settings\": {\"item1\": {\"scale\": 1.5, \"unknown_key\": \"value\"}}}\n    validator = ConfigValidator(config, \"test\", test_logger)\n    errors = validator.validate(schema)\n    # Unknown keys show up as warnings/errors from children validation\n    assert any(\"unknown\" in str(e).lower() for e in errors)\n\n\ndef test_config_validator_children_collects_all_errors(test_logger):\n    \"\"\"Test that all children errors are collected, not just first.\"\"\"\n    from pyprland.validation import ConfigItems\n\n    child_schema = ConfigItems(\n        ConfigField(\"a\", int),\n        ConfigField(\"b\", int),\n    )\n\n    schema = ConfigItems(\n        ConfigField(\"settings\", dict, children=child_schema),\n    )\n\n    # Multiple errors across multiple children\n    config = {\n        \"settings\": {\n            \"item1\": {\"a\": \"wrong\", \"b\": \"wrong\"},\n            \"item2\": {\"a\": \"wrong\"},\n        }\n    }\n    validator = ConfigValidator(config, \"test\", test_logger)\n    errors = validator.validate(schema)\n    # All errors are joined into a single string with newlines\n    # Check that all 3 error contexts are present\n    assert len(errors) == 1\n    error_text = errors[0]\n    assert \"item1\" in error_text\n    assert \"item2\" in error_text\n    assert error_text.count(\"Expected int\") == 3\n\n\ndef test_config_validator_children_non_dict_value(test_logger):\n    \"\"\"Test children validation handles non-dict values gracefully.\"\"\"\n    from pyprland.validation import ConfigItems\n\n    child_schema = ConfigItems(\n        ConfigField(\"scale\", float),\n    )\n\n    schema = ConfigItems(\n        ConfigField(\"settings\", dict, children=child_schema),\n    )\n\n    # Child value is not a dict\n    config = {\"settings\": {\"item1\": \"not-a-dict\"}}\n    validator = ConfigValidator(config, \"test\", test_logger)\n    errors = validator.validate(schema)\n    assert len(errors) == 1\n    assert \"Expected dict\" in errors[0]\n\n\ndef test_config_validator_nested_children_schema(test_logger):\n    \"\"\"Test validation of deeply nested children schemas (recursive).\"\"\"\n    from pyprland.validation import ConfigItems\n\n    # Grandchild schema\n    grandchild_schema = ConfigItems(\n        ConfigField(\"value\", int),\n    )\n\n    # Child schema with its own children\n    child_schema = ConfigItems(\n        ConfigField(\"name\", str),\n        ConfigField(\"nested\", dict, children=grandchild_schema),\n    )\n\n    # Parent schema\n    schema = ConfigItems(\n        ConfigField(\"settings\", dict, children=child_schema),\n    )\n\n    # Valid deeply nested config\n    config = {\"settings\": {\"item1\": {\"name\": \"test\", \"nested\": {\"sub1\": {\"value\": 42}}}}}\n    validator = ConfigValidator(config, \"test\", test_logger)\n    errors = validator.validate(schema)\n    assert len(errors) == 0\n\n    # Invalid type in grandchild\n    config_invalid = {\"settings\": {\"item1\": {\"name\": \"test\", \"nested\": {\"sub1\": {\"value\": \"not-an-int\"}}}}}\n    validator = ConfigValidator(config_invalid, \"test\", test_logger)\n    errors = validator.validate(schema)\n    assert len(errors) == 1\n    assert \"int\" in errors[0]\n    assert \"sub1\" in errors[0]  # Error path includes nested key\n\n\n# ---------------------------------------------------------------------------\n#  Scratchpad template detection helpers\n# ---------------------------------------------------------------------------\n\nfrom pyprland.plugins.scratchpads.schema import (\n    get_template_names,\n    is_pure_template,\n    validate_scratchpad_config,\n)\n\n\ndef test_get_template_names_single_use():\n    config = {\n        \"common\": {\"animation\": \"fromTop\", \"size\": \"80% 80%\"},\n        \"term\": {\"command\": \"kitty\", \"use\": \"common\"},\n    }\n    assert get_template_names(config) == {\"common\"}\n\n\ndef test_get_template_names_list_use():\n    config = {\n        \"base\": {\"lazy\": True},\n        \"style\": {\"animation\": \"fromTop\"},\n        \"term\": {\"command\": \"kitty\", \"use\": [\"base\", \"style\"]},\n    }\n    assert get_template_names(config) == {\"base\", \"style\"}\n\n\ndef test_get_template_names_no_use():\n    config = {\n        \"term\": {\"command\": \"kitty\"},\n        \"music\": {\"command\": \"spotify\", \"class\": \"spotify\"},\n    }\n    assert get_template_names(config) == set()\n\n\ndef test_get_template_names_skips_non_dict_and_dotted():\n    config = {\n        \"common\": {\"animation\": \"fromTop\"},\n        \"term\": {\"command\": \"kitty\", \"use\": \"common\"},\n        \"term.monitor.DP-1\": {\"size\": \"50% 50%\"},  # dotted key, should be skipped\n        \"scalar_value\": \"not a dict\",  # non-dict, should be skipped\n    }\n    assert get_template_names(config) == {\"common\"}\n\n\ndef test_is_pure_template_true():\n    config = {\n        \"common\": {\"animation\": \"fromTop\"},\n        \"term\": {\"command\": \"kitty\", \"use\": \"common\"},\n    }\n    templates = get_template_names(config)\n    assert is_pure_template(\"common\", config, templates) is True\n\n\ndef test_is_pure_template_false_when_has_command():\n    \"\"\"A section with a command is a real scratchpad even if referenced by use.\"\"\"\n    config = {\n        \"base_term\": {\"command\": \"kitty\", \"animation\": \"fromTop\"},\n        \"term\": {\"command\": \"kitty --class drop\", \"use\": \"base_term\"},\n    }\n    templates = get_template_names(config)\n    assert is_pure_template(\"base_term\", config, templates) is False\n\n\ndef test_is_pure_template_false_when_not_referenced():\n    \"\"\"A section without command that is NOT referenced by use is not a template.\"\"\"\n    config = {\n        \"orphan\": {\"animation\": \"fromTop\"},\n        \"term\": {\"command\": \"kitty\"},\n    }\n    templates = get_template_names(config)\n    assert is_pure_template(\"orphan\", config, templates) is False\n\n\ndef test_is_pure_template_false_for_nonexistent():\n    config = {\"term\": {\"command\": \"kitty\"}}\n    assert is_pure_template(\"missing\", config, set()) is False\n\n\ndef test_validate_pure_template_no_errors():\n    \"\"\"A pure template (no command, referenced via use) should validate without errors.\"\"\"\n    config = {\n        \"common\": {\"animation\": \"fromTop\", \"size\": \"80% 80%\"},\n        \"term\": {\"command\": \"kitty\", \"class\": \"kitty-drop\", \"use\": \"common\"},\n    }\n    templates = get_template_names(config)\n\n    # Validate the template section -- should produce no errors\n    errors = validate_scratchpad_config(\"common\", config[\"common\"], is_template=is_pure_template(\"common\", config, templates))\n    assert errors == []\n\n\ndef test_validate_real_scratchpad_without_command_errors():\n    \"\"\"A section without command that is NOT a template should still error.\"\"\"\n    config = {\n        \"orphan\": {\"animation\": \"fromTop\"},\n        \"term\": {\"command\": \"kitty\"},\n    }\n    templates = get_template_names(config)\n\n    errors = validate_scratchpad_config(\"orphan\", config[\"orphan\"], is_template=is_pure_template(\"orphan\", config, templates))\n    # Should require 'command' (missing required field)\n    assert any(\"command\" in e.lower() or \"required\" in e.lower() for e in errors)\n\n\ndef test_validate_static_with_templates():\n    \"\"\"End-to-end: validate_config_static should not error on pure templates.\"\"\"\n    from pyprland.plugins.scratchpads import Extension\n\n    config = {\n        \"common\": {\"animation\": \"fromTop\", \"size\": \"80% 80%\", \"margin\": 50},\n        \"term\": {\"command\": \"kitty --class kitty-drop\", \"class\": \"kitty-drop\", \"use\": \"common\"},\n    }\n    errors = Extension.validate_config_static(\"scratchpads\", config)\n    assert errors == []\n"
  },
  {
    "path": "tests/test_event_signatures.py",
    "content": "\"\"\"Validate event handler signatures match Protocol definitions.\n\nThis test ensures all event_* methods in plugins conform to the expected\nsignatures defined in the HyprlandEvents and NiriEvents Protocols.\n\"\"\"\n\nimport importlib\nimport inspect\nfrom typing import get_type_hints\n\nimport pytest\n\nfrom pyprland.plugins.protocols import HyprlandEvents, NiriEvents\n\n\n# Modules containing classes with event handlers\nPLUGIN_MODULES = [\n    \"pyprland.plugins.pyprland.hyprland_core\",\n    \"pyprland.plugins.pyprland.niri_core\",\n    \"pyprland.plugins.monitors\",\n    \"pyprland.plugins.scratchpads\",\n    \"pyprland.plugins.layout_center\",\n    \"pyprland.plugins.shift_monitors\",\n    \"pyprland.plugins.workspaces_follow_focus\",\n    \"pyprland.plugins.wallpapers\",\n    \"pyprland.plugins.menubar\",\n    \"pyprland.plugins.fcitx5_switcher\",\n]\n\n\ndef get_protocol_signatures(protocol_cls: type) -> dict[str, inspect.Signature]:\n    \"\"\"Extract method signatures from a Protocol class.\"\"\"\n    return {\n        name: inspect.signature(getattr(protocol_cls, name))\n        for name in dir(protocol_cls)\n        if not name.startswith(\"_\") and callable(getattr(protocol_cls, name))\n    }\n\n\ndef signatures_compatible(actual: inspect.Signature, expected: inspect.Signature) -> tuple[bool, str]:\n    \"\"\"Check if actual signature is compatible with expected.\n\n    Compatible means:\n    - Same number of parameters (excluding self)\n    - Parameters can accept the same types (checking defaults for optional params)\n\n    Returns:\n        Tuple of (is_compatible, error_message)\n    \"\"\"\n    actual_params = list(actual.parameters.values())\n    expected_params = list(expected.parameters.values())\n\n    # Filter out 'self' parameter\n    actual_params = [p for p in actual_params if p.name != \"self\"]\n    expected_params = [p for p in expected_params if p.name != \"self\"]\n\n    # Check parameter count compatibility\n    # Actual can have defaults where expected requires a param, but not vice versa\n    actual_required = sum(1 for p in actual_params if p.default is inspect.Parameter.empty)\n    expected_required = sum(1 for p in expected_params if p.default is inspect.Parameter.empty)\n\n    # The actual method must accept at least as many required params as expected\n    # and the total param count should match\n    if len(actual_params) != len(expected_params):\n        return False, f\"parameter count mismatch: expected {len(expected_params)}, got {len(actual_params)}\"\n\n    # For event handlers, the key check is that methods accepting an optional param\n    # (with default) are compatible with being called with that param\n    # The bug we're catching: method has 0 params but event passes 1\n    if actual_required > expected_required:\n        return False, f\"requires {actual_required} params but event provides {expected_required}\"\n\n    return True, \"\"\n\n\ndef test_hyprland_event_signatures():\n    \"\"\"Verify all Hyprland event_* methods match Protocol signatures.\"\"\"\n    protocol_methods = get_protocol_signatures(HyprlandEvents)\n    errors = []\n\n    for module_name in PLUGIN_MODULES:\n        try:\n            module = importlib.import_module(module_name)\n        except ImportError:\n            continue\n\n        for cls_name, cls in inspect.getmembers(module, inspect.isclass):\n            # Skip imported classes (only check classes defined in this module)\n            if cls.__module__ != module_name:\n                continue\n\n            for method_name in dir(cls):\n                if not method_name.startswith(\"event_\"):\n                    continue\n\n                if method_name not in protocol_methods:\n                    # Event not in Protocol - that's OK, Protocol may not cover all events\n                    continue\n\n                method = getattr(cls, method_name)\n                if not callable(method):\n                    continue\n\n                try:\n                    actual_sig = inspect.signature(method)\n                except (ValueError, TypeError):\n                    continue\n\n                expected_sig = protocol_methods[method_name]\n                is_compatible, error_msg = signatures_compatible(actual_sig, expected_sig)\n\n                if not is_compatible:\n                    errors.append(\n                        f\"{module_name}:{cls_name}.{method_name}: {error_msg}\\n  Expected: {expected_sig}\\n  Got:      {actual_sig}\"\n                    )\n\n    assert not errors, \"Event handler signature mismatches:\\n\" + \"\\n\\n\".join(errors)\n\n\ndef test_niri_event_signatures():\n    \"\"\"Verify all Niri niri_* methods match Protocol signatures.\"\"\"\n    protocol_methods = get_protocol_signatures(NiriEvents)\n    errors = []\n\n    for module_name in PLUGIN_MODULES:\n        try:\n            module = importlib.import_module(module_name)\n        except ImportError:\n            continue\n\n        for cls_name, cls in inspect.getmembers(module, inspect.isclass):\n            if cls.__module__ != module_name:\n                continue\n\n            for method_name in dir(cls):\n                if not method_name.startswith(\"niri_\"):\n                    continue\n\n                if method_name not in protocol_methods:\n                    continue\n\n                method = getattr(cls, method_name)\n                if not callable(method):\n                    continue\n\n                try:\n                    actual_sig = inspect.signature(method)\n                except (ValueError, TypeError):\n                    continue\n\n                expected_sig = protocol_methods[method_name]\n                is_compatible, error_msg = signatures_compatible(actual_sig, expected_sig)\n\n                if not is_compatible:\n                    errors.append(\n                        f\"{module_name}:{cls_name}.{method_name}: {error_msg}\\n  Expected: {expected_sig}\\n  Got:      {actual_sig}\"\n                    )\n\n    assert not errors, \"Niri event handler signature mismatches:\\n\" + \"\\n\\n\".join(errors)\n\n\ndef test_protocol_methods_documented():\n    \"\"\"Ensure all Protocol methods have docstrings.\"\"\"\n    for protocol_cls in [HyprlandEvents, NiriEvents]:\n        for name in dir(protocol_cls):\n            if name.startswith(\"_\"):\n                continue\n            method = getattr(protocol_cls, name)\n            if callable(method):\n                assert method.__doc__, f\"{protocol_cls.__name__}.{name} missing docstring\"\n"
  },
  {
    "path": "tests/test_external_plugins.py",
    "content": "import sys\n\nimport pytest\nimport tomllib\nfrom pytest_asyncio import fixture\n\nfrom .conftest import mocks as tst\n\nsys.path.append(\"sample_extension\")\n\n\n@fixture\nasync def external_plugin_config(monkeypatch):\n    \"\"\"External config.\"\"\"\n    config = \"\"\"\n[pyprland]\nplugins = [\"pypr_examples.focus_counter\"]\n\"\"\"\n    monkeypatch.setattr(\"tomllib.load\", lambda x: tomllib.loads(config))\n    yield\n\n\n@pytest.mark.usefixtures(\"external_plugin_config\", \"server_fixture\")\n@pytest.mark.asyncio\nasync def test_ext_plugin():\n    await tst.pypr(\"counter\")\n    await tst.wait_queues()\n    # The plugin should successfully call notify_info, which invokes hyprctl\n    assert tst.hyprctl.call_count == 1, \"notify_info should be called once\"\n    # Check that the notification was an info notification (not an error)\n    call_args = tst.hyprctl.call_args[0][0]\n    assert \"Focus changed\" in call_args, \"Should contain focus change message\"\n"
  },
  {
    "path": "tests/test_http.py",
    "content": "\"\"\"Tests for pyprland.httpclient fallback implementation.\"\"\"\n\nimport json\nimport warnings\nfrom http.client import HTTPResponse\nfrom io import BytesIO\nfrom unittest.mock import MagicMock, patch\nfrom urllib.error import HTTPError, URLError\n\nimport pytest\nfrom pyprland.httpclient import (\n    FallbackClientError,\n    FallbackClientSession,\n    FallbackClientTimeout,\n    FallbackResponse,\n    reset_fallback_warning,\n)\n\n# HTTP status codes for tests\nHTTP_OK = 200\nHTTP_REDIRECT = 302\nHTTP_NOT_FOUND = 404\nHTTP_SERVER_ERROR = 500\n\n# Timeout values for tests\nDEFAULT_TIMEOUT = 30\nCUSTOM_TIMEOUT = 60\n\n\nclass TestFallbackClientTimeout:\n    \"\"\"Tests for FallbackClientTimeout.\"\"\"\n\n    def test_default_timeout(self) -> None:\n        \"\"\"Test default timeout value.\"\"\"\n        timeout = FallbackClientTimeout()\n        assert timeout.total == DEFAULT_TIMEOUT\n\n    def test_custom_timeout(self) -> None:\n        \"\"\"Test custom timeout value.\"\"\"\n        timeout = FallbackClientTimeout(total=CUSTOM_TIMEOUT)\n        assert timeout.total == CUSTOM_TIMEOUT\n\n\nclass TestFallbackResponse:\n    \"\"\"Tests for FallbackResponse.\"\"\"\n\n    def test_status_and_url(self) -> None:\n        \"\"\"Test status and url attributes.\"\"\"\n        response = FallbackResponse(status=HTTP_OK, url=\"https://example.com\", data=b\"test\")\n        assert response.status == HTTP_OK\n        assert response.url == \"https://example.com\"\n\n    @pytest.mark.asyncio\n    async def test_json(self) -> None:\n        \"\"\"Test JSON parsing.\"\"\"\n        data = {\"key\": \"value\", \"number\": 42}\n        response = FallbackResponse(status=HTTP_OK, url=\"https://example.com\", data=json.dumps(data).encode())\n        result = await response.json()\n        assert result == data\n\n    @pytest.mark.asyncio\n    async def test_read(self) -> None:\n        \"\"\"Test reading binary data.\"\"\"\n        data = b\"binary content\"\n        response = FallbackResponse(status=HTTP_OK, url=\"https://example.com\", data=data)\n        result = await response.read()\n        assert result == data\n\n    def test_raise_for_status_ok(self) -> None:\n        \"\"\"Test raise_for_status with OK status.\"\"\"\n        response = FallbackResponse(status=HTTP_OK, url=\"https://example.com\", data=b\"\")\n        response.raise_for_status()  # Should not raise\n\n    def test_raise_for_status_redirect(self) -> None:\n        \"\"\"Test raise_for_status with redirect status (should not raise).\"\"\"\n        response = FallbackResponse(status=HTTP_REDIRECT, url=\"https://example.com\", data=b\"\")\n        response.raise_for_status()  # Should not raise\n\n    def test_raise_for_status_client_error(self) -> None:\n        \"\"\"Test raise_for_status with 4xx status.\"\"\"\n        response = FallbackResponse(status=HTTP_NOT_FOUND, url=\"https://example.com\", data=b\"\")\n        with pytest.raises(FallbackClientError, match=\"HTTP 404\"):\n            response.raise_for_status()\n\n    def test_raise_for_status_server_error(self) -> None:\n        \"\"\"Test raise_for_status with 5xx status.\"\"\"\n        response = FallbackResponse(status=HTTP_SERVER_ERROR, url=\"https://example.com\", data=b\"\")\n        with pytest.raises(FallbackClientError, match=\"HTTP 500\"):\n            response.raise_for_status()\n\n\nclass TestFallbackClientSession:\n    \"\"\"Tests for FallbackClientSession.\"\"\"\n\n    @pytest.fixture(autouse=True)\n    def reset_warning(self) -> None:\n        \"\"\"Reset fallback warning before each test.\"\"\"\n        reset_fallback_warning()\n\n    def _mock_response(self, data: bytes, status: int = HTTP_OK, url: str = \"https://example.com\") -> MagicMock:\n        \"\"\"Create a mock urllib response.\"\"\"\n        mock = MagicMock(spec=HTTPResponse)\n        mock.status = status\n        mock.url = url\n        mock.read.return_value = data\n        return mock\n\n    @pytest.mark.asyncio\n    async def test_get_simple(self) -> None:\n        \"\"\"Test simple GET request.\"\"\"\n        with patch(\"pyprland.httpclient.urlopen\") as mock_urlopen:\n            mock_urlopen.return_value = self._mock_response(b'{\"result\": \"ok\"}')\n\n            session = FallbackClientSession()\n            async with session.get(\"https://example.com/api\") as response:\n                assert response.status == HTTP_OK\n                data = await response.json()\n                assert data == {\"result\": \"ok\"}\n\n    @pytest.mark.asyncio\n    async def test_get_with_params(self) -> None:\n        \"\"\"Test GET request with query parameters.\"\"\"\n        with patch(\"pyprland.httpclient.urlopen\") as mock_urlopen:\n            mock_urlopen.return_value = self._mock_response(b\"ok\")\n\n            session = FallbackClientSession()\n            async with session.get(\"https://example.com/search\", params={\"q\": \"test\", \"page\": \"1\"}) as response:\n                assert response.status == HTTP_OK\n\n            # Verify URL was built with params\n            call_args = mock_urlopen.call_args\n            request = call_args[0][0]\n            assert \"q=test\" in request.full_url\n            assert \"page=1\" in request.full_url\n\n    @pytest.mark.asyncio\n    async def test_get_with_headers(self) -> None:\n        \"\"\"Test GET request with custom headers.\"\"\"\n        with patch(\"pyprland.httpclient.urlopen\") as mock_urlopen:\n            mock_urlopen.return_value = self._mock_response(b\"ok\")\n\n            session = FallbackClientSession(headers={\"User-Agent\": \"test-agent\"})\n            async with session.get(\"https://example.com\", headers={\"X-Custom\": \"value\"}) as response:\n                assert response.status == HTTP_OK\n\n            # Verify headers were set\n            call_args = mock_urlopen.call_args\n            request = call_args[0][0]\n            assert request.get_header(\"User-agent\") == \"test-agent\"\n            assert request.get_header(\"X-custom\") == \"value\"\n\n    @pytest.mark.asyncio\n    async def test_get_with_timeout(self) -> None:\n        \"\"\"Test GET request with timeout configuration.\"\"\"\n        with patch(\"pyprland.httpclient.urlopen\") as mock_urlopen:\n            mock_urlopen.return_value = self._mock_response(b\"ok\")\n\n            session = FallbackClientSession(timeout=FallbackClientTimeout(total=CUSTOM_TIMEOUT))\n            async with session.get(\"https://example.com\") as response:\n                assert response.status == HTTP_OK\n\n            # Verify timeout was passed\n            call_args = mock_urlopen.call_args\n            assert call_args[1][\"timeout\"] == CUSTOM_TIMEOUT\n\n    @pytest.mark.asyncio\n    async def test_http_error_returns_response(self) -> None:\n        \"\"\"Test that HTTP errors return a response with error status.\"\"\"\n        with patch(\"pyprland.httpclient.urlopen\") as mock_urlopen:\n            error_response = BytesIO(b\"Not Found\")\n            mock_urlopen.side_effect = HTTPError(\n                url=\"https://example.com\",\n                code=HTTP_NOT_FOUND,\n                msg=\"Not Found\",\n                hdrs={},  # type: ignore[arg-type]\n                fp=error_response,\n            )\n\n            session = FallbackClientSession()\n            async with session.get(\"https://example.com\") as response:\n                assert response.status == HTTP_NOT_FOUND\n\n    @pytest.mark.asyncio\n    async def test_network_error_raises_client_error(self) -> None:\n        \"\"\"Test that network errors raise ClientError.\"\"\"\n        with patch(\"pyprland.httpclient.urlopen\") as mock_urlopen:\n            mock_urlopen.side_effect = URLError(\"Connection refused\")\n\n            session = FallbackClientSession()\n            with pytest.raises(FallbackClientError, match=\"Connection refused\"):\n                async with session.get(\"https://example.com\"):\n                    pass\n\n    @pytest.mark.asyncio\n    async def test_timeout_raises_client_error(self) -> None:\n        \"\"\"Test that timeout raises ClientError.\"\"\"\n        with patch(\"pyprland.httpclient.urlopen\") as mock_urlopen:\n            mock_urlopen.side_effect = TimeoutError()\n\n            session = FallbackClientSession()\n            with pytest.raises(FallbackClientError, match=\"timed out\"):\n                async with session.get(\"https://example.com\"):\n                    pass\n\n    @pytest.mark.asyncio\n    async def test_session_context_manager(self) -> None:\n        \"\"\"Test session as async context manager.\"\"\"\n        with patch(\"pyprland.httpclient.urlopen\") as mock_urlopen:\n            mock_urlopen.return_value = self._mock_response(b\"ok\")\n\n            async with FallbackClientSession() as session:\n                assert not session.closed\n                async with session.get(\"https://example.com\") as response:\n                    assert response.status == HTTP_OK\n\n            assert session.closed\n\n    @pytest.mark.asyncio\n    async def test_close(self) -> None:\n        \"\"\"Test session close.\"\"\"\n        session = FallbackClientSession()\n        assert not session.closed\n        await session.close()\n        assert session.closed\n\n    def test_warns_on_first_use(self) -> None:\n        \"\"\"Test that warning is emitted on first use.\"\"\"\n        with pytest.warns(UserWarning, match=\"aiohttp not installed\"):\n            FallbackClientSession()\n\n    def test_warns_only_once(self) -> None:\n        \"\"\"Test that warning is only emitted once.\"\"\"\n        with pytest.warns(UserWarning, match=\"aiohttp not installed\"):\n            FallbackClientSession()\n\n        # Second instantiation should not warn\n        with warnings.catch_warnings():\n            warnings.simplefilter(\"error\")\n            FallbackClientSession()  # Should not raise\n\n\nclass TestFallbackClientError:\n    \"\"\"Tests for FallbackClientError.\"\"\"\n\n    def test_is_exception(self) -> None:\n        \"\"\"Test that FallbackClientError is an Exception.\"\"\"\n        assert issubclass(FallbackClientError, Exception)\n\n    def test_message(self) -> None:\n        \"\"\"Test error message.\"\"\"\n        error = FallbackClientError(\"Something went wrong\")\n        assert str(error) == \"Something went wrong\"\n"
  },
  {
    "path": "tests/test_interface.py",
    "content": "import pytest\nfrom unittest.mock import Mock, AsyncMock, patch\nfrom pyprland.plugins.interface import Plugin\nfrom pyprland.config import Configuration\n\n\nclass ConcretePlugin(Plugin):\n    \"\"\"A concrete implementation of Plugin for testing.\"\"\"\n\n    pass\n\n\n@pytest.fixture\ndef plugin():\n    plugin = ConcretePlugin(\"test_plugin\")\n    # Manually attach mocks for methods used in get_clients\n    plugin.hyprctl_json = AsyncMock()\n    plugin.state = Mock()\n    plugin.state.environment = \"hyprland\"\n\n    # Mock the backend\n    plugin.backend = Mock()\n    plugin.backend.get_clients = AsyncMock()\n    plugin.backend.execute = AsyncMock()\n    plugin.backend.execute_json = AsyncMock()\n\n    return plugin\n\n\n@pytest.mark.asyncio\nasync def test_plugin_init(plugin):\n    assert plugin.name == \"test_plugin\"\n    assert isinstance(plugin.config, Configuration)\n    # Ensure init methods exist and are callable (even if empty)\n    await plugin.init()\n    await plugin.on_reload()\n    await plugin.exit()\n\n\n@pytest.mark.asyncio\nasync def test_load_config(plugin):\n    config = {\"test_plugin\": {\"option1\": \"value1\", \"option2\": 123}, \"other_plugin\": {\"ignore\": \"me\"}}\n\n    await plugin.load_config(config)\n\n    assert plugin.config[\"option1\"] == \"value1\"\n    assert plugin.config[\"option2\"] == 123\n    assert \"ignore\" not in plugin.config\n\n\n@pytest.mark.asyncio\nasync def test_get_clients_filter(plugin):\n    clients_data = [\n        {\"mapped\": True, \"workspace\": {\"name\": \"1\"}, \"address\": \"0x1\"},\n        {\"mapped\": False, \"workspace\": {\"name\": \"2\"}, \"address\": \"0x2\"},\n        {\"mapped\": True, \"workspace\": {\"name\": \"2\"}, \"address\": \"0x3\"},\n        {\"mapped\": True, \"workspace\": {\"name\": \"3\"}, \"address\": \"0x4\"},\n    ]\n\n    # Simulate backend behavior since we are testing the interface delegation,\n    # but strictly speaking if the logic moved to backend, this test should test backend or integration.\n    # However, to fix the test and verify delegation:\n\n    async def mock_backend_get_clients(mapped=True, workspace=None, workspace_bl=None):\n        # Replicate old logic for the sake of verifying the plugin method calls backend correctly\n        # Or simpler: verify backend is called with correct args.\n        # But verify result requires mocking the return.\n\n        # Let's filter manually here to mimic what backend should do\n        filtered = []\n        for client in clients_data:\n            if mapped and not client[\"mapped\"]:\n                continue\n            if workspace and client[\"workspace\"][\"name\"] != workspace:\n                continue\n            if workspace_bl and client[\"workspace\"][\"name\"] == workspace_bl:\n                continue\n            filtered.append(client)\n        return filtered\n\n    plugin.backend.get_clients.side_effect = mock_backend_get_clients\n\n    # 1. Default: mapped=True, no workspace filter\n    clients = await plugin.get_clients()\n    plugin.backend.get_clients.assert_called_with(True, None, None)\n    assert len(clients) == 3\n    assert \"0x2\" not in [c[\"address\"] for c in clients]\n\n    # 2. mapped=False (should return all?)\n    clients = await plugin.get_clients(mapped=False)\n    plugin.backend.get_clients.assert_called_with(False, None, None)\n    assert len(clients) == 4\n\n    # 3. Filter by workspace\n    clients = await plugin.get_clients(workspace=\"2\")\n    plugin.backend.get_clients.assert_called_with(True, \"2\", None)\n    assert len(clients) == 1\n    assert clients[0][\"address\"] == \"0x3\"\n\n    # 4. Filter by workspace blacklist\n    clients = await plugin.get_clients(workspace_bl=\"1\")\n    plugin.backend.get_clients.assert_called_with(True, None, \"1\")\n    assert len(clients) == 2\n    addresses = [c[\"address\"] for c in clients]\n    assert \"0x3\" in addresses\n    assert \"0x4\" in addresses\n"
  },
  {
    "path": "tests/test_ipc.py",
    "content": "import asyncio\nimport json\nimport logging\nimport pytest\nfrom unittest.mock import Mock, AsyncMock, patch\nfrom pyprland import ipc\nfrom pyprland.models import PyprError\nfrom pyprland.adapters.hyprland import HyprlandBackend\n\n\n@pytest.fixture\ndef test_log():\n    \"\"\"Provide a silent logger for tests.\"\"\"\n    logger = logging.getLogger(\"test_ipc\")\n    logger.handlers.clear()\n    logger.addHandler(logging.NullHandler())\n    logger.propagate = False\n    return logger\n\n\n@pytest.fixture\ndef mock_open_connection(mocker):\n    reader = AsyncMock()\n    # StreamWriter methods write and close are synchronous, drain and wait_closed are async\n    writer = Mock()\n    writer.drain = AsyncMock()\n    writer.wait_closed = AsyncMock()\n\n    mock_connect = mocker.patch(\"asyncio.open_unix_connection\", return_value=(reader, writer))\n    return mock_connect, reader, writer\n\n\n@pytest.mark.asyncio\nasync def test_hyprctl_connection_context_manager(mock_open_connection):\n    mock_connect, reader, writer = mock_open_connection\n    logger = Mock()\n\n    async with ipc.hyprctl_connection(logger) as (r, w):\n        assert r == reader\n        assert w == writer\n\n    writer.close.assert_called_once()\n    writer.wait_closed.assert_awaited_once()\n\n\n@pytest.mark.asyncio\nasync def test_hyprctl_connection_error(mocker):\n    mocker.patch(\"asyncio.open_unix_connection\", side_effect=FileNotFoundError)\n    logger = Mock()\n\n    with pytest.raises(PyprError):\n        async with ipc.hyprctl_connection(logger):\n            pass\n\n    logger.critical.assert_called_with(\"hyprctl socket not found! is it running ?\")\n\n\n@pytest.mark.asyncio\nasync def test_get_response(mock_open_connection):\n    mock_connect, reader, writer = mock_open_connection\n    logger = Mock()\n    reader.read.return_value = b'{\"status\": \"ok\"}'\n\n    result = await ipc.get_response(b\"command\", logger)\n\n    assert result == {\"status\": \"ok\"}\n    writer.write.assert_called_with(b\"command\")\n    writer.drain.assert_awaited_once()\n\n\n@pytest.mark.asyncio\nasync def test_hyprland_backend_execute_success(mock_open_connection, test_log):\n    mock_connect, reader, writer = mock_open_connection\n    reader.read.return_value = b\"ok\"\n\n    state = Mock()\n    backend = HyprlandBackend(state)\n\n    result = await backend.execute(\"some_command\", log=test_log)\n\n    assert result is True\n    writer.write.assert_called_with(b\"/dispatch some_command\")\n\n\n@pytest.mark.asyncio\nasync def test_hyprland_backend_execute_failure(mock_open_connection, test_log):\n    mock_connect, reader, writer = mock_open_connection\n    reader.read.return_value = b\"err\"\n\n    state = Mock()\n    backend = HyprlandBackend(state)\n\n    result = await backend.execute(\"some_command\", log=test_log)\n\n    assert result is False\n    # Check if logged error? Need to spy on logger or check side effects if implemented\n\n\n@pytest.mark.asyncio\nasync def test_hyprland_backend_execute_batch(mock_open_connection, test_log):\n    mock_connect, reader, writer = mock_open_connection\n    reader.read.return_value = b\"okok\"\n\n    state = Mock()\n    backend = HyprlandBackend(state)\n\n    cmds = [\"cmd1\", \"cmd2\"]\n    result = await backend.execute(cmds, log=test_log)\n\n    assert result is True\n    # Verify the batch format string\n    call_args = writer.write.call_args[0][0]\n    assert b\"[[BATCH]]\" in call_args\n    assert b\"dispatch cmd1\" in call_args\n    assert b\"dispatch cmd2\" in call_args\n\n\n@pytest.mark.asyncio\nasync def test_backend_get_client_props(mock_open_connection, test_log):\n    # Mock execute_json on the backend instead of ipc.hyprctl_json\n    state = Mock()\n    backend = HyprlandBackend(state)\n\n    clients = [{\"address\": \"0x123\", \"class\": \"Term\"}, {\"address\": \"0x456\", \"class\": \"Browser\"}]\n\n    # We mock execute_json on the instance\n    backend.execute_json = AsyncMock(return_value=clients)\n\n    # By address\n    client = await backend.get_client_props(addr=\"0x123\", log=test_log)\n    assert client == clients[0]\n\n    # By class\n    client = await backend.get_client_props(cls=\"Browser\", log=test_log)\n    assert client == clients[1]\n\n    # By custom prop\n    client = await backend.get_client_props(title=\"Something\", log=test_log)  # Not found\n    assert client is None\n"
  },
  {
    "path": "tests/test_load_all.py",
    "content": "import pytest\nimport tomllib\nfrom pytest_asyncio import fixture\n\n\n@fixture\nasync def load_all_config(monkeypatch):\n    \"\"\"External config.\"\"\"\n    config = \"\"\"\n[pyprland]\nplugins = [\n    \"expose\",\n    \"fetch_client_menu\",\n    \"layout_center\",\n    \"lost_windows\",\n    \"magnify\",\n    \"monitors\",\n    \"scratchpads\",\n    \"shift_monitors\",\n    \"shortcuts_menu\",\n    \"toggle_dpms\",\n    \"toggle_special\",\n    \"workspaces_follow_focus\",\n]\n\n\"\"\"\n    monkeypatch.setattr(\"tomllib.load\", lambda x: tomllib.loads(config))\n    yield\n\n\n@pytest.mark.usefixtures(\"load_all_config\", \"server_fixture\")\n@pytest.mark.asyncio\nasync def test_load_all(subprocess_shell_mock):\n    assert True\n"
  },
  {
    "path": "tests/test_monitors_commands.py",
    "content": "\"\"\"Unit tests for pyprland.plugins.monitors.commands module.\"\"\"\n\nimport pytest\n\nfrom pyprland.plugins.monitors.commands import (\n    NIRI_TRANSFORM_NAMES,\n    build_hyprland_command,\n    build_niri_disable_action,\n    build_niri_position_action,\n    build_niri_scale_action,\n    build_niri_transform_action,\n)\n\n\nclass TestBuildHyprlandCommand:\n    \"\"\"Tests for build_hyprland_command function.\"\"\"\n\n    def test_basic_command(self):\n        \"\"\"Test basic command generation with defaults from monitor.\"\"\"\n        monitor = {\n            \"name\": \"DP-1\",\n            \"width\": 1920,\n            \"height\": 1080,\n            \"refreshRate\": 60,\n            \"scale\": 1.0,\n            \"x\": 0,\n            \"y\": 0,\n            \"transform\": 0,\n        }\n        config = {}\n\n        result = build_hyprland_command(monitor, config)\n\n        assert result == \"monitor DP-1,1920x1080@60,0x0,1.0,transform,0\"\n\n    def test_with_custom_resolution_string(self):\n        \"\"\"Test with custom resolution as string.\"\"\"\n        monitor = {\n            \"name\": \"HDMI-A-1\",\n            \"width\": 1920,\n            \"height\": 1080,\n            \"refreshRate\": 60,\n            \"scale\": 1.0,\n            \"x\": 100,\n            \"y\": 200,\n            \"transform\": 0,\n        }\n        config = {\"resolution\": \"2560x1440\"}\n\n        result = build_hyprland_command(monitor, config)\n\n        assert result == \"monitor HDMI-A-1,2560x1440@60,100x200,1.0,transform,0\"\n\n    def test_with_custom_resolution_list(self):\n        \"\"\"Test with custom resolution as list.\"\"\"\n        monitor = {\n            \"name\": \"DP-2\",\n            \"width\": 1920,\n            \"height\": 1080,\n            \"refreshRate\": 60,\n            \"scale\": 1.0,\n            \"x\": 0,\n            \"y\": 0,\n            \"transform\": 0,\n        }\n        config = {\"resolution\": [3840, 2160]}\n\n        result = build_hyprland_command(monitor, config)\n\n        assert result == \"monitor DP-2,3840x2160@60,0x0,1.0,transform,0\"\n\n    def test_with_custom_scale(self):\n        \"\"\"Test with custom scale.\"\"\"\n        monitor = {\n            \"name\": \"DP-1\",\n            \"width\": 3840,\n            \"height\": 2160,\n            \"refreshRate\": 60,\n            \"scale\": 1.0,\n            \"x\": 0,\n            \"y\": 0,\n            \"transform\": 0,\n        }\n        config = {\"scale\": 2.0}\n\n        result = build_hyprland_command(monitor, config)\n\n        assert result == \"monitor DP-1,3840x2160@60,0x0,2.0,transform,0\"\n\n    def test_with_custom_rate(self):\n        \"\"\"Test with custom refresh rate.\"\"\"\n        monitor = {\n            \"name\": \"DP-1\",\n            \"width\": 1920,\n            \"height\": 1080,\n            \"refreshRate\": 60,\n            \"scale\": 1.0,\n            \"x\": 0,\n            \"y\": 0,\n            \"transform\": 0,\n        }\n        config = {\"rate\": 144}\n\n        result = build_hyprland_command(monitor, config)\n\n        assert result == \"monitor DP-1,1920x1080@144,0x0,1.0,transform,0\"\n\n    def test_with_transform(self):\n        \"\"\"Test with transform.\"\"\"\n        monitor = {\n            \"name\": \"DP-1\",\n            \"width\": 1920,\n            \"height\": 1080,\n            \"refreshRate\": 60,\n            \"scale\": 1.0,\n            \"x\": 0,\n            \"y\": 0,\n            \"transform\": 0,\n        }\n        config = {\"transform\": 1}\n\n        result = build_hyprland_command(monitor, config)\n\n        assert result == \"monitor DP-1,1920x1080@60,0x0,1.0,transform,1\"\n\n\nclass TestBuildNiriPositionAction:\n    \"\"\"Tests for build_niri_position_action function.\"\"\"\n\n    def test_basic_position(self):\n        \"\"\"Test basic position action.\"\"\"\n        result = build_niri_position_action(\"DP-1\", 100, 200)\n\n        assert result == {\n            \"Output\": {\n                \"output\": \"DP-1\",\n                \"action\": {\"Position\": {\"Specific\": {\"x\": 100, \"y\": 200}}},\n            }\n        }\n\n    def test_zero_position(self):\n        \"\"\"Test zero position.\"\"\"\n        result = build_niri_position_action(\"HDMI-A-1\", 0, 0)\n\n        assert result[\"Output\"][\"action\"][\"Position\"][\"Specific\"][\"x\"] == 0\n        assert result[\"Output\"][\"action\"][\"Position\"][\"Specific\"][\"y\"] == 0\n\n\nclass TestBuildNiriScaleAction:\n    \"\"\"Tests for build_niri_scale_action function.\"\"\"\n\n    def test_scale_1(self):\n        \"\"\"Test scale of 1.\"\"\"\n        result = build_niri_scale_action(\"DP-1\", 1.0)\n\n        assert result == {\"Output\": {\"output\": \"DP-1\", \"action\": {\"Scale\": {\"Specific\": 1.0}}}}\n\n    def test_scale_2(self):\n        \"\"\"Test scale of 2.\"\"\"\n        result = build_niri_scale_action(\"DP-1\", 2.0)\n\n        assert result[\"Output\"][\"action\"][\"Scale\"][\"Specific\"] == 2.0\n\n    def test_scale_fractional(self):\n        \"\"\"Test fractional scale.\"\"\"\n        result = build_niri_scale_action(\"DP-1\", 1.5)\n\n        assert result[\"Output\"][\"action\"][\"Scale\"][\"Specific\"] == 1.5\n\n\nclass TestBuildNiriTransformAction:\n    \"\"\"Tests for build_niri_transform_action function.\"\"\"\n\n    def test_transform_0(self):\n        \"\"\"Test transform 0 (Normal).\"\"\"\n        result = build_niri_transform_action(\"DP-1\", 0)\n\n        assert result == {\n            \"Output\": {\n                \"output\": \"DP-1\",\n                \"action\": {\"Transform\": {\"transform\": \"Normal\"}},\n            }\n        }\n\n    def test_transform_1(self):\n        \"\"\"Test transform 1 (90 degrees).\"\"\"\n        result = build_niri_transform_action(\"DP-1\", 1)\n\n        assert result[\"Output\"][\"action\"][\"Transform\"][\"transform\"] == \"90\"\n\n    def test_transform_4(self):\n        \"\"\"Test transform 4 (Flipped).\"\"\"\n        result = build_niri_transform_action(\"DP-1\", 4)\n\n        assert result[\"Output\"][\"action\"][\"Transform\"][\"transform\"] == \"Flipped\"\n\n    def test_transform_string(self):\n        \"\"\"Test transform as string (passthrough).\"\"\"\n        result = build_niri_transform_action(\"DP-1\", \"Flipped90\")\n\n        assert result[\"Output\"][\"action\"][\"Transform\"][\"transform\"] == \"Flipped90\"\n\n    def test_transform_out_of_range(self):\n        \"\"\"Test transform out of range uses string.\"\"\"\n        result = build_niri_transform_action(\"DP-1\", 99)\n\n        assert result[\"Output\"][\"action\"][\"Transform\"][\"transform\"] == \"99\"\n\n\nclass TestBuildNiriDisableAction:\n    \"\"\"Tests for build_niri_disable_action function.\"\"\"\n\n    def test_disable(self):\n        \"\"\"Test disable action.\"\"\"\n        result = build_niri_disable_action(\"DP-1\")\n\n        assert result == {\"Output\": {\"output\": \"DP-1\", \"action\": \"Off\"}}\n\n\nclass TestNiriTransformNames:\n    \"\"\"Tests for NIRI_TRANSFORM_NAMES constant.\"\"\"\n\n    def test_length(self):\n        \"\"\"Test there are 8 transform names.\"\"\"\n        assert len(NIRI_TRANSFORM_NAMES) == 8\n\n    def test_values(self):\n        \"\"\"Test transform names values.\"\"\"\n        assert NIRI_TRANSFORM_NAMES[0] == \"Normal\"\n        assert NIRI_TRANSFORM_NAMES[1] == \"90\"\n        assert NIRI_TRANSFORM_NAMES[2] == \"180\"\n        assert NIRI_TRANSFORM_NAMES[3] == \"270\"\n        assert NIRI_TRANSFORM_NAMES[4] == \"Flipped\"\n"
  },
  {
    "path": "tests/test_monitors_layout.py",
    "content": "\"\"\"Unit tests for pyprland.plugins.monitors.layout module.\"\"\"\n\nimport pytest\n\nfrom pyprland.plugins.monitors.layout import (\n    MAX_CYCLE_PATH_LENGTH,\n    MONITOR_PROPS,\n    build_graph,\n    compute_positions,\n    compute_xy,\n    find_cycle_path,\n    get_dims,\n)\n\n\ndef make_monitor(name, width=1920, height=1080, x=0, y=0, scale=1.0, transform=0):\n    \"\"\"Helper to create a monitor dict.\"\"\"\n    return {\n        \"name\": name,\n        \"description\": f\"{name} Monitor Description\",\n        \"width\": width,\n        \"height\": height,\n        \"x\": x,\n        \"y\": y,\n        \"scale\": scale,\n        \"transform\": transform,\n        \"refreshRate\": 60,\n    }\n\n\nclass TestGetDims:\n    \"\"\"Tests for get_dims function.\"\"\"\n\n    def test_basic_dimensions(self):\n        \"\"\"Test basic dimensions without config.\"\"\"\n        monitor = make_monitor(\"DP-1\", width=1920, height=1080)\n\n        width, height = get_dims(monitor)\n\n        assert width == 1920\n        assert height == 1080\n\n    def test_with_scale(self):\n        \"\"\"Test dimensions with scale applied.\"\"\"\n        monitor = make_monitor(\"DP-1\", width=3840, height=2160, scale=2.0)\n\n        width, height = get_dims(monitor)\n\n        assert width == 1920\n        assert height == 1080\n\n    def test_with_config_scale(self):\n        \"\"\"Test dimensions with config scale override.\"\"\"\n        monitor = make_monitor(\"DP-1\", width=3840, height=2160, scale=1.0)\n        config = {\"scale\": 2.0}\n\n        width, height = get_dims(monitor, config)\n\n        assert width == 1920\n        assert height == 1080\n\n    def test_with_config_resolution_string(self):\n        \"\"\"Test dimensions with config resolution as string.\"\"\"\n        monitor = make_monitor(\"DP-1\", width=1920, height=1080)\n        config = {\"resolution\": \"2560x1440\"}\n\n        width, height = get_dims(monitor, config)\n\n        assert width == 2560\n        assert height == 1440\n\n    def test_with_config_resolution_list(self):\n        \"\"\"Test dimensions with config resolution as list.\"\"\"\n        monitor = make_monitor(\"DP-1\", width=1920, height=1080)\n        config = {\"resolution\": [2560, 1440]}\n\n        width, height = get_dims(monitor, config)\n\n        assert width == 2560\n        assert height == 1440\n\n    def test_with_transform_90(self):\n        \"\"\"Test dimensions with 90 degree transform (swaps width/height).\"\"\"\n        monitor = make_monitor(\"DP-1\", width=1920, height=1080, transform=1)\n\n        width, height = get_dims(monitor)\n\n        assert width == 1080\n        assert height == 1920\n\n    def test_with_transform_270(self):\n        \"\"\"Test dimensions with 270 degree transform (swaps width/height).\"\"\"\n        monitor = make_monitor(\"DP-1\", width=1920, height=1080, transform=3)\n\n        width, height = get_dims(monitor)\n\n        assert width == 1080\n        assert height == 1920\n\n    def test_with_transform_180(self):\n        \"\"\"Test dimensions with 180 degree transform (no swap).\"\"\"\n        monitor = make_monitor(\"DP-1\", width=1920, height=1080, transform=2)\n\n        width, height = get_dims(monitor)\n\n        assert width == 1920\n        assert height == 1080\n\n\nclass TestComputeXy:\n    \"\"\"Tests for compute_xy function.\"\"\"\n\n    def test_left(self):\n        \"\"\"Test left placement.\"\"\"\n        ref_rect = (100, 100, 1920, 1080)  # x, y, width, height\n        mon_dim = (1920, 1080)  # width, height\n\n        x, y = compute_xy(ref_rect, mon_dim, \"left\")\n\n        assert x == -1820  # 100 - 1920\n        assert y == 100\n\n    def test_right(self):\n        \"\"\"Test right placement.\"\"\"\n        ref_rect = (0, 0, 1920, 1080)\n        mon_dim = (1920, 1080)\n\n        x, y = compute_xy(ref_rect, mon_dim, \"right\")\n\n        assert x == 1920\n        assert y == 0\n\n    def test_top(self):\n        \"\"\"Test top placement.\"\"\"\n        ref_rect = (0, 1080, 1920, 1080)\n        mon_dim = (1920, 1080)\n\n        x, y = compute_xy(ref_rect, mon_dim, \"top\")\n\n        assert x == 0\n        assert y == 0\n\n    def test_bottom(self):\n        \"\"\"Test bottom placement.\"\"\"\n        ref_rect = (0, 0, 1920, 1080)\n        mon_dim = (1920, 1080)\n\n        x, y = compute_xy(ref_rect, mon_dim, \"bottom\")\n\n        assert x == 0\n        assert y == 1080\n\n    def test_left_center(self):\n        \"\"\"Test left-center placement.\"\"\"\n        ref_rect = (0, 0, 1920, 1080)\n        mon_dim = (1920, 500)\n\n        x, y = compute_xy(ref_rect, mon_dim, \"left-center\")\n\n        assert x == -1920\n        assert y == 290  # (1080 - 500) // 2\n\n    def test_right_center(self):\n        \"\"\"Test right-center placement.\"\"\"\n        ref_rect = (0, 0, 1920, 1080)\n        mon_dim = (1920, 500)\n\n        x, y = compute_xy(ref_rect, mon_dim, \"rightCenter\")\n\n        assert x == 1920\n        assert y == 290\n\n    def test_top_center(self):\n        \"\"\"Test top-center placement.\"\"\"\n        ref_rect = (0, 1080, 1920, 1080)\n        mon_dim = (1000, 1080)\n\n        x, y = compute_xy(ref_rect, mon_dim, \"top_center\")\n\n        assert x == 460  # (1920 - 1000) // 2\n        assert y == 0\n\n    def test_bottom_end(self):\n        \"\"\"Test bottom-end placement.\"\"\"\n        ref_rect = (0, 0, 1920, 1080)\n        mon_dim = (1000, 1080)\n\n        x, y = compute_xy(ref_rect, mon_dim, \"bottom-end\")\n\n        assert x == 920  # 1920 - 1000\n        assert y == 1080\n\n    def test_unknown_rule_returns_ref_position(self):\n        \"\"\"Test that unknown rule returns reference position.\"\"\"\n        ref_rect = (100, 200, 1920, 1080)\n        mon_dim = (1920, 1080)\n\n        x, y = compute_xy(ref_rect, mon_dim, \"unknown\")\n\n        assert x == 100\n        assert y == 200\n\n\nclass TestBuildGraph:\n    \"\"\"Tests for build_graph function.\"\"\"\n\n    def test_basic_graph(self):\n        \"\"\"Test basic graph building.\"\"\"\n        monitors_by_name = {\n            \"DP-1\": make_monitor(\"DP-1\"),\n            \"DP-2\": make_monitor(\"DP-2\"),\n        }\n        config = {\n            \"DP-2\": {\"rightOf\": [\"DP-1\"]},\n        }\n\n        tree, in_degree, multi_target_info = build_graph(config, monitors_by_name)\n\n        assert (\"DP-2\", \"rightOf\") in tree[\"DP-1\"]\n        assert in_degree[\"DP-1\"] == 0\n        assert in_degree[\"DP-2\"] == 1\n        assert multi_target_info == []\n\n    def test_multiple_targets_reported(self):\n        \"\"\"Test that multiple targets are reported.\"\"\"\n        monitors_by_name = {\n            \"DP-1\": make_monitor(\"DP-1\"),\n            \"DP-2\": make_monitor(\"DP-2\"),\n            \"DP-3\": make_monitor(\"DP-3\"),\n        }\n        config = {\n            \"DP-2\": {\"rightOf\": [\"DP-1\", \"DP-3\"]},\n        }\n\n        tree, in_degree, multi_target_info = build_graph(config, monitors_by_name)\n\n        assert len(multi_target_info) == 1\n        assert multi_target_info[0] == (\"DP-2\", \"rightOf\", [\"DP-1\", \"DP-3\"])\n        # Should only use first target\n        assert (\"DP-2\", \"rightOf\") in tree[\"DP-1\"]\n        assert (\"DP-2\", \"rightOf\") not in tree[\"DP-3\"]\n\n    def test_ignores_monitor_props(self):\n        \"\"\"Test that monitor props are ignored.\"\"\"\n        monitors_by_name = {\n            \"DP-1\": make_monitor(\"DP-1\"),\n            \"DP-2\": make_monitor(\"DP-2\"),\n        }\n        config = {\n            \"DP-2\": {\"rightOf\": [\"DP-1\"], \"scale\": 2.0, \"resolution\": \"1920x1080\"},\n        }\n\n        tree, in_degree, _ = build_graph(config, monitors_by_name)\n\n        # Only rightOf should create a dependency\n        assert in_degree[\"DP-2\"] == 1\n\n    def test_ignores_disables(self):\n        \"\"\"Test that disables is ignored in graph building.\"\"\"\n        monitors_by_name = {\n            \"DP-1\": make_monitor(\"DP-1\"),\n            \"DP-2\": make_monitor(\"DP-2\"),\n        }\n        config = {\n            \"DP-1\": {\"disables\": [\"DP-3\"]},\n        }\n\n        tree, in_degree, _ = build_graph(config, monitors_by_name)\n\n        assert in_degree[\"DP-1\"] == 0\n        assert in_degree[\"DP-2\"] == 0\n\n\nclass TestComputePositions:\n    \"\"\"Tests for compute_positions function.\"\"\"\n\n    def test_chain_layout(self):\n        \"\"\"Test a chain of monitors: DP-1 -> DP-2 -> DP-3.\"\"\"\n        monitors = {\n            \"DP-1\": make_monitor(\"DP-1\", x=0, y=0),\n            \"DP-2\": make_monitor(\"DP-2\", x=0, y=0),\n            \"DP-3\": make_monitor(\"DP-3\", x=0, y=0),\n        }\n        config = {\n            \"DP-2\": {\"rightOf\": [\"DP-1\"]},\n            \"DP-3\": {\"rightOf\": [\"DP-2\"]},\n        }\n        tree, in_degree, _ = build_graph(config, monitors)\n\n        positions, unprocessed = compute_positions(monitors, tree, in_degree, config)\n\n        assert positions[\"DP-1\"] == (0, 0)\n        assert positions[\"DP-2\"] == (1920, 0)\n        assert positions[\"DP-3\"] == (3840, 0)\n        assert unprocessed == []\n\n    def test_circular_dependency(self):\n        \"\"\"Test that circular dependencies are detected.\"\"\"\n        monitors = {\n            \"DP-1\": make_monitor(\"DP-1\", x=0, y=0),\n            \"DP-2\": make_monitor(\"DP-2\", x=0, y=0),\n        }\n        config = {\n            \"DP-1\": {\"rightOf\": [\"DP-2\"]},\n            \"DP-2\": {\"rightOf\": [\"DP-1\"]},\n        }\n        tree, in_degree, _ = build_graph(config, monitors)\n\n        positions, unprocessed = compute_positions(monitors, tree, in_degree, config)\n\n        assert positions == {}\n        assert set(unprocessed) == {\"DP-1\", \"DP-2\"}\n\n    def test_anchor_monitor(self):\n        \"\"\"Test that anchor monitor (no placement rule) is processed first.\"\"\"\n        monitors = {\n            \"anchor\": make_monitor(\"anchor\", x=100, y=200),\n            \"DP-2\": make_monitor(\"DP-2\", x=0, y=0),\n        }\n        config = {\n            \"DP-2\": {\"rightOf\": [\"anchor\"]},\n        }\n        tree, in_degree, _ = build_graph(config, monitors)\n\n        positions, unprocessed = compute_positions(monitors, tree, in_degree, config)\n\n        assert positions[\"anchor\"] == (100, 200)\n        assert positions[\"DP-2\"] == (2020, 200)  # 100 + 1920\n        assert unprocessed == []\n\n\nclass TestFindCyclePath:\n    \"\"\"Tests for find_cycle_path function.\"\"\"\n\n    def test_simple_cycle(self):\n        \"\"\"Test simple A -> B -> A cycle.\"\"\"\n        config = {\n            \"A\": {\"rightOf\": [\"B\"]},\n            \"B\": {\"rightOf\": [\"A\"]},\n        }\n        unprocessed = [\"A\", \"B\"]\n\n        result = find_cycle_path(config, unprocessed)\n\n        assert \"A\" in result\n        assert \"B\" in result\n        assert \"->\" in result\n\n    def test_three_way_cycle(self):\n        \"\"\"Test A -> B -> C -> A cycle.\"\"\"\n        config = {\n            \"A\": {\"rightOf\": [\"B\"]},\n            \"B\": {\"rightOf\": [\"C\"]},\n            \"C\": {\"rightOf\": [\"A\"]},\n        }\n        unprocessed = [\"A\", \"B\", \"C\"]\n\n        result = find_cycle_path(config, unprocessed)\n\n        assert \"A\" in result\n        assert \"B\" in result\n        assert \"C\" in result\n\n    def test_no_clear_cycle(self):\n        \"\"\"Test when no clear cycle is found.\"\"\"\n        config = {\n            \"A\": {\"scale\": 1.0},  # No dependency\n            \"B\": {\"scale\": 1.0},  # No dependency\n        }\n        unprocessed = [\"A\", \"B\"]\n\n        result = find_cycle_path(config, unprocessed)\n\n        assert \"unpositioned monitors\" in result\n        assert \"A\" in result\n        assert \"B\" in result\n\n\nclass TestConstants:\n    \"\"\"Tests for module constants.\"\"\"\n\n    def test_monitor_props(self):\n        \"\"\"Test MONITOR_PROPS contains expected values.\"\"\"\n        assert \"resolution\" in MONITOR_PROPS\n        assert \"rate\" in MONITOR_PROPS\n        assert \"scale\" in MONITOR_PROPS\n        assert \"transform\" in MONITOR_PROPS\n\n    def test_max_cycle_path_length(self):\n        \"\"\"Test MAX_CYCLE_PATH_LENGTH is reasonable.\"\"\"\n        assert MAX_CYCLE_PATH_LENGTH > 0\n        assert MAX_CYCLE_PATH_LENGTH <= 20\n"
  },
  {
    "path": "tests/test_monitors_resolution.py",
    "content": "\"\"\"Unit tests for pyprland.plugins.monitors.resolution module.\"\"\"\n\nimport pytest\n\nfrom pyprland.plugins.monitors.resolution import (\n    get_monitor_by_pattern,\n    resolve_placement_config,\n)\n\n\ndef make_monitor(name, description=None, width=1920, height=1080, x=0, y=0, scale=1.0, transform=0):\n    \"\"\"Helper to create a monitor dict.\"\"\"\n    return {\n        \"name\": name,\n        \"description\": description or f\"{name} Monitor Description\",\n        \"width\": width,\n        \"height\": height,\n        \"x\": x,\n        \"y\": y,\n        \"scale\": scale,\n        \"transform\": transform,\n        \"refreshRate\": 60,\n    }\n\n\nclass TestGetMonitorByPattern:\n    \"\"\"Tests for get_monitor_by_pattern function.\"\"\"\n\n    def test_match_by_name(self):\n        \"\"\"Test matching by exact name.\"\"\"\n        monitors = [\n            make_monitor(\"DP-1\", \"Dell Monitor\"),\n            make_monitor(\"DP-2\", \"Samsung Monitor\"),\n        ]\n        description_db = {m[\"description\"]: m for m in monitors}\n        name_db = {m[\"name\"]: m for m in monitors}\n\n        result = get_monitor_by_pattern(\"DP-1\", description_db, name_db)\n\n        assert result is not None\n        assert result[\"name\"] == \"DP-1\"\n\n    def test_match_by_description_substring(self):\n        \"\"\"Test matching by description substring.\"\"\"\n        monitors = [\n            make_monitor(\"DP-1\", \"Dell U2720Q Monitor\"),\n            make_monitor(\"DP-2\", \"Samsung Odyssey G9\"),\n        ]\n        description_db = {m[\"description\"]: m for m in monitors}\n        name_db = {m[\"name\"]: m for m in monitors}\n\n        result = get_monitor_by_pattern(\"Dell\", description_db, name_db)\n\n        assert result is not None\n        assert result[\"name\"] == \"DP-1\"\n\n    def test_no_match(self):\n        \"\"\"Test when no monitor matches.\"\"\"\n        monitors = [\n            make_monitor(\"DP-1\", \"Dell Monitor\"),\n        ]\n        description_db = {m[\"description\"]: m for m in monitors}\n        name_db = {m[\"name\"]: m for m in monitors}\n\n        result = get_monitor_by_pattern(\"BenQ\", description_db, name_db)\n\n        assert result is None\n\n    def test_cache_hit(self):\n        \"\"\"Test that cache is used.\"\"\"\n        monitors = [\n            make_monitor(\"DP-1\", \"Dell Monitor\"),\n        ]\n        description_db = {m[\"description\"]: m for m in monitors}\n        name_db = {m[\"name\"]: m for m in monitors}\n        cache = {\"DP-1\": monitors[0]}\n\n        result = get_monitor_by_pattern(\"DP-1\", description_db, name_db, cache)\n\n        assert result is monitors[0]\n\n    def test_cache_populated(self):\n        \"\"\"Test that cache is populated after lookup.\"\"\"\n        monitors = [\n            make_monitor(\"DP-1\", \"Dell Monitor\"),\n        ]\n        description_db = {m[\"description\"]: m for m in monitors}\n        name_db = {m[\"name\"]: m for m in monitors}\n        cache = {}\n\n        result = get_monitor_by_pattern(\"DP-1\", description_db, name_db, cache)\n\n        assert result is not None\n        assert \"DP-1\" in cache\n        assert cache[\"DP-1\"] == result\n\n    def test_name_takes_precedence(self):\n        \"\"\"Test that exact name match takes precedence over description.\"\"\"\n        monitors = [\n            make_monitor(\"Dell\", \"Samsung Monitor\"),  # Name is Dell\n            make_monitor(\"DP-1\", \"Dell Monitor\"),  # Description contains Dell\n        ]\n        description_db = {m[\"description\"]: m for m in monitors}\n        name_db = {m[\"name\"]: m for m in monitors}\n\n        result = get_monitor_by_pattern(\"Dell\", description_db, name_db)\n\n        assert result is not None\n        assert result[\"name\"] == \"Dell\"\n\n\nclass TestResolvePlacementConfig:\n    \"\"\"Tests for resolve_placement_config function.\"\"\"\n\n    def test_basic_resolution(self):\n        \"\"\"Test basic pattern resolution.\"\"\"\n        monitors = [\n            make_monitor(\"DP-1\", \"Dell Monitor\"),\n            make_monitor(\"DP-2\", \"Samsung Monitor\"),\n        ]\n        placement_config = {\n            \"DP-2\": {\"rightOf\": \"DP-1\"},\n        }\n\n        result = resolve_placement_config(placement_config, monitors)\n\n        assert \"DP-2\" in result\n        assert result[\"DP-2\"][\"rightOf\"] == [\"DP-1\"]\n\n    def test_description_pattern_resolution(self):\n        \"\"\"Test resolution using description patterns.\"\"\"\n        monitors = [\n            make_monitor(\"DP-1\", \"Dell U2720Q Monitor\"),\n            make_monitor(\"DP-2\", \"Samsung Odyssey G9\"),\n        ]\n        placement_config = {\n            \"Samsung\": {\"rightOf\": \"Dell\"},\n        }\n\n        result = resolve_placement_config(placement_config, monitors)\n\n        assert \"DP-2\" in result\n        assert result[\"DP-2\"][\"rightOf\"] == [\"DP-1\"]\n\n    def test_preserves_monitor_props(self):\n        \"\"\"Test that monitor properties are preserved.\"\"\"\n        monitors = [\n            make_monitor(\"DP-1\", \"Dell Monitor\"),\n        ]\n        placement_config = {\n            \"DP-1\": {\n                \"scale\": 2.0,\n                \"resolution\": \"3840x2160\",\n                \"rate\": 144,\n                \"transform\": 1,\n            },\n        }\n\n        result = resolve_placement_config(placement_config, monitors)\n\n        assert result[\"DP-1\"][\"scale\"] == 2.0\n        assert result[\"DP-1\"][\"resolution\"] == \"3840x2160\"\n        assert result[\"DP-1\"][\"rate\"] == 144\n        assert result[\"DP-1\"][\"transform\"] == 1\n\n    def test_multiple_targets_as_list(self):\n        \"\"\"Test that multiple targets are resolved as list.\"\"\"\n        monitors = [\n            make_monitor(\"DP-1\", \"Dell Monitor\"),\n            make_monitor(\"DP-2\", \"Samsung Monitor\"),\n            make_monitor(\"DP-3\", \"BenQ Monitor\"),\n        ]\n        placement_config = {\n            \"DP-3\": {\"rightOf\": [\"DP-1\", \"DP-2\"]},\n        }\n\n        result = resolve_placement_config(placement_config, monitors)\n\n        assert result[\"DP-3\"][\"rightOf\"] == [\"DP-1\", \"DP-2\"]\n\n    def test_unmatched_pattern_ignored(self):\n        \"\"\"Test that unmatched patterns are ignored.\"\"\"\n        monitors = [\n            make_monitor(\"DP-1\", \"Dell Monitor\"),\n        ]\n        placement_config = {\n            \"NonExistent\": {\"rightOf\": \"DP-1\"},\n            \"DP-1\": {\"scale\": 1.5},\n        }\n\n        result = resolve_placement_config(placement_config, monitors)\n\n        assert \"NonExistent\" not in result\n        assert \"DP-1\" in result\n\n    def test_unmatched_target_ignored(self):\n        \"\"\"Test that unmatched targets are ignored.\"\"\"\n        monitors = [\n            make_monitor(\"DP-1\", \"Dell Monitor\"),\n            make_monitor(\"DP-2\", \"Samsung Monitor\"),\n        ]\n        placement_config = {\n            \"DP-2\": {\"rightOf\": [\"DP-1\", \"NonExistent\"]},\n        }\n\n        result = resolve_placement_config(placement_config, monitors)\n\n        assert result[\"DP-2\"][\"rightOf\"] == [\"DP-1\"]\n\n    def test_uses_provided_cache(self):\n        \"\"\"Test that provided cache is used and updated.\"\"\"\n        monitors = [\n            make_monitor(\"DP-1\", \"Dell Monitor\"),\n            make_monitor(\"DP-2\", \"Samsung Monitor\"),\n        ]\n        placement_config = {\n            \"DP-2\": {\"rightOf\": \"DP-1\"},\n        }\n        cache = {}\n\n        resolve_placement_config(placement_config, monitors, cache)\n\n        # Cache should now contain the looked-up monitors\n        assert \"DP-1\" in cache or \"DP-2\" in cache\n\n    def test_empty_config(self):\n        \"\"\"Test with empty configuration.\"\"\"\n        monitors = [\n            make_monitor(\"DP-1\", \"Dell Monitor\"),\n        ]\n        placement_config = {}\n\n        result = resolve_placement_config(placement_config, monitors)\n\n        assert result == {}\n\n    def test_empty_monitors(self):\n        \"\"\"Test with no monitors.\"\"\"\n        monitors = []\n        placement_config = {\n            \"DP-1\": {\"rightOf\": \"DP-2\"},\n        }\n\n        result = resolve_placement_config(placement_config, monitors)\n\n        assert result == {}\n\n    def test_mixed_props_and_rules(self):\n        \"\"\"Test config with both props and placement rules.\"\"\"\n        monitors = [\n            make_monitor(\"DP-1\", \"Dell Monitor\"),\n            make_monitor(\"DP-2\", \"Samsung Monitor\"),\n        ]\n        placement_config = {\n            \"DP-2\": {\n                \"rightOf\": \"DP-1\",\n                \"scale\": 1.5,\n                \"rate\": 120,\n            },\n        }\n\n        result = resolve_placement_config(placement_config, monitors)\n\n        assert result[\"DP-2\"][\"rightOf\"] == [\"DP-1\"]\n        assert result[\"DP-2\"][\"scale\"] == 1.5\n        assert result[\"DP-2\"][\"rate\"] == 120\n"
  },
  {
    "path": "tests/test_plugin_expose.py",
    "content": "import pytest\nfrom unittest.mock import Mock\nfrom pyprland.plugins.expose import Extension\nfrom tests.conftest import make_extension\n\n\n@pytest.fixture\ndef sample_clients():\n    return [\n        {\"address\": \"0x1\", \"workspace\": {\"id\": 1, \"name\": \"1\"}, \"class\": \"App1\"},\n        {\"address\": \"0x2\", \"workspace\": {\"id\": 2, \"name\": \"2\"}, \"class\": \"App2\"},\n        {\"address\": \"0x3\", \"workspace\": {\"id\": -99, \"name\": \"special:scratch\"}, \"class\": \"SpecialApp\"},\n    ]\n\n\n@pytest.fixture\ndef extension():\n    # Note: expose.py uses self.get_config_bool() which goes through Plugin.get_config()\n    # We mock the plugin method, not config.get_bool\n    return make_extension(Extension, get_config_bool=Mock(return_value=False))\n\n\n@pytest.mark.asyncio\nasync def test_exposed_clients_filtering(extension, sample_clients):\n    extension.exposed = sample_clients\n\n    # Test default: include_special=False (should exclude ID <= 0)\n    filtered = extension.exposed_clients\n    assert len(filtered) == 2\n    assert all(c[\"workspace\"][\"id\"] > 0 for c in filtered)\n\n    # Test include_special=True\n    extension.get_config_bool.return_value = True\n    all_clients = extension.exposed_clients\n    assert len(all_clients) == 3\n\n\n@pytest.mark.asyncio\nasync def test_run_expose_enable(extension, sample_clients):\n    # Setup\n    extension.exposed = []\n    # Mock returning only normal clients for simplicity\n    normal_clients = sample_clients[:2]\n    extension.get_clients.return_value = normal_clients\n\n    extension.state.active_workspace = \"1\"\n\n    await extension.run_expose()\n\n    # Verify state was captured\n    assert extension.exposed == normal_clients\n\n    # Verify commands\n    calls = extension.hyprctl.call_args[0][0]\n    # Should have 2 moves + 1 toggle\n    assert len(calls) == 3\n    assert \"movetoworkspacesilent special:exposed,address:0x1\" in calls\n    assert \"movetoworkspacesilent special:exposed,address:0x2\" in calls\n    assert \"togglespecialworkspace exposed\" in calls\n\n\n@pytest.mark.asyncio\nasync def test_run_expose_disable(extension, sample_clients):\n    # Setup\n    normal_clients = sample_clients[:2]\n    extension.exposed = normal_clients\n\n    extension.state.active_window = \"0x1\"\n\n    await extension.run_expose()\n\n    # Verify state was cleared\n    assert extension.exposed == []\n\n    # Verify commands\n    calls = extension.hyprctl.call_args[0][0]\n    # Should have 2 moves (restore) + 1 toggle + 1 focus\n    assert len(calls) == 4\n    # Check restoration to original workspaces\n    assert \"movetoworkspacesilent 1,address:0x1\" in calls\n    assert \"movetoworkspacesilent 2,address:0x2\" in calls\n    assert \"togglespecialworkspace exposed\" in calls\n    assert \"focuswindow address:0x1\" in calls\n\n\n@pytest.mark.asyncio\nasync def test_run_expose_empty_workspace(extension):\n    # Setup\n    extension.exposed = []\n    extension.get_clients.return_value = []\n\n    extension.state.active_workspace = \"1\"\n\n    await extension.run_expose()\n\n    # Exposed should be empty\n    assert extension.exposed == []\n\n    # Verify commands - likely just the toggle if logic persists\n    calls = extension.hyprctl.call_args[0][0]\n    assert len(calls) == 1\n    assert calls[0] == \"togglespecialworkspace exposed\"\n"
  },
  {
    "path": "tests/test_plugin_fetch_client_menu.py",
    "content": "import pytest\nfrom unittest.mock import AsyncMock\nfrom pyprland.plugins.fetch_client_menu import Extension\nfrom tests.conftest import make_extension\nfrom tests.testtools import get_executed_commands\n\n\n@pytest.fixture\ndef extension():\n    ext = make_extension(\n        Extension,\n        config={\"separator\": \"|\", \"center_on_fetch\": True, \"margin\": 60},\n        menu=AsyncMock(),\n        _windows_origins={},\n        ensure_menu_configured=AsyncMock(),\n    )\n    # Mock monitor and client props for centering\n    ext.get_focused_monitor_or_warn = AsyncMock(return_value={\"x\": 0, \"y\": 0, \"width\": 1920, \"height\": 1080, \"scale\": 1.0, \"transform\": 0})\n    ext.backend.get_client_props = AsyncMock(return_value={\"address\": \"0xDEF\", \"size\": [800, 600], \"floating\": False})\n    return ext\n\n\n@pytest.mark.asyncio\nasync def test_run_unfetch_client_success(extension):\n    extension._windows_origins = {\"0x123\": \"2\"}\n\n    await extension.run_unfetch_client()\n\n    extension.backend.move_window_to_workspace.assert_called_with(\"0x123\", \"2\")\n\n\n@pytest.mark.asyncio\nasync def test_run_unfetch_client_unknown(extension):\n    extension._windows_origins = {}\n\n    await extension.run_unfetch_client()\n\n    extension.backend.notify_error.assert_called_with(\"unknown window origin\")\n    assert get_executed_commands(extension.backend.execute) == []\n\n\n@pytest.mark.asyncio\nasync def test_run_fetch_client_menu(extension):\n    clients = [\n        {\"address\": \"0xABC\", \"title\": \"Window 1\", \"workspace\": {\"name\": \"2\"}},\n        {\"address\": \"0xDEF\", \"title\": \"Window 2\", \"workspace\": {\"name\": \"3\"}},\n    ]\n    extension.get_clients.return_value = clients\n    extension.menu.run.return_value = \"2 | Window 2\"  # User selects second item\n\n    await extension.run_fetch_client_menu()\n\n    # Check menu call options\n    extension.menu.run.assert_called()\n    options = extension.menu.run.call_args[0][0]\n    assert \"1 | Window 1\" in options\n    assert \"2 | Window 2\" in options\n\n    # Verify action\n    # Should save origin\n    assert extension._windows_origins[\"0xDEF\"] == \"3\"\n    # Should move window (non-silent since we want to follow the window)\n    extension.backend.move_window_to_workspace.assert_called_with(\"0xDEF\", extension.state.active_workspace, silent=False)\n\n\n@pytest.mark.asyncio\nasync def test_run_fetch_client_menu_cancel(extension):\n    extension.get_clients.return_value = []\n    extension.menu.run.return_value = None  # User cancelled\n\n    await extension.run_fetch_client_menu()\n\n    extension.backend.move_window_to_workspace.assert_not_called()\n\n\n@pytest.mark.asyncio\nasync def test_center_window_on_monitor_floats_and_centers(extension):\n    \"\"\"Test that fetching a window floats it and centers on monitor.\"\"\"\n    clients = [\n        {\"address\": \"0xABC\", \"title\": \"Window 1\", \"workspace\": {\"name\": \"2\"}},\n    ]\n    extension.get_clients.return_value = clients\n    extension.menu.run.return_value = \"1 | Window 1\"\n    extension.backend.get_client_props = AsyncMock(return_value={\"address\": \"0xABC\", \"size\": [800, 600], \"floating\": False})\n\n    await extension.run_fetch_client_menu()\n\n    # Should toggle floating since window is not floating\n    extension.backend.toggle_floating.assert_called_with(\"0xABC\")\n    # Should move to centered position: (1920-800)/2=560, (1080-600)/2=240\n    extension.backend.move_window.assert_called_with(\"0xABC\", 560, 240)\n\n\n@pytest.mark.asyncio\nasync def test_center_window_already_floating(extension):\n    \"\"\"Test that already floating windows don't get toggle_floating called.\"\"\"\n    clients = [\n        {\"address\": \"0xABC\", \"title\": \"Window 1\", \"workspace\": {\"name\": \"2\"}},\n    ]\n    extension.get_clients.return_value = clients\n    extension.menu.run.return_value = \"1 | Window 1\"\n    extension.backend.get_client_props = AsyncMock(return_value={\"address\": \"0xABC\", \"size\": [800, 600], \"floating\": True})\n\n    await extension.run_fetch_client_menu()\n\n    # Should NOT toggle floating since window is already floating\n    extension.backend.toggle_floating.assert_not_called()\n    # Should still center\n    extension.backend.move_window.assert_called_with(\"0xABC\", 560, 240)\n\n\n@pytest.mark.asyncio\nasync def test_center_window_resizes_if_too_large(extension):\n    \"\"\"Test that large windows are resized to fit within margin.\"\"\"\n    clients = [\n        {\"address\": \"0xABC\", \"title\": \"Window 1\", \"workspace\": {\"name\": \"2\"}},\n    ]\n    extension.get_clients.return_value = clients\n    extension.menu.run.return_value = \"1 | Window 1\"\n    # Window larger than monitor - 2*margin (1920-120=1800, 1080-120=960)\n    extension.backend.get_client_props = AsyncMock(return_value={\"address\": \"0xABC\", \"size\": [2000, 1200], \"floating\": True})\n\n    await extension.run_fetch_client_menu()\n\n    # Should resize to available space: 1920-120=1800, 1080-120=960\n    extension.backend.resize_window.assert_called_with(\"0xABC\", 1800, 960)\n    # Then center with new size: (1920-1800)/2=60, (1080-960)/2=60\n    extension.backend.move_window.assert_called_with(\"0xABC\", 60, 60)\n\n\n@pytest.mark.asyncio\nasync def test_center_window_with_rotated_monitor(extension):\n    \"\"\"Test that rotated monitors swap width/height.\"\"\"\n    clients = [\n        {\"address\": \"0xABC\", \"title\": \"Window 1\", \"workspace\": {\"name\": \"2\"}},\n    ]\n    extension.get_clients.return_value = clients\n    extension.menu.run.return_value = \"1 | Window 1\"\n    # Rotated monitor (transform=1 = 90 degrees)\n    extension.get_focused_monitor_or_warn = AsyncMock(\n        return_value={\"x\": 0, \"y\": 0, \"width\": 1920, \"height\": 1080, \"scale\": 1.0, \"transform\": 1}\n    )\n    extension.backend.get_client_props = AsyncMock(return_value={\"address\": \"0xABC\", \"size\": [400, 600], \"floating\": True})\n\n    await extension.run_fetch_client_menu()\n\n    # Rotated: effective dimensions are 1080x1920\n    # Center: (1080-400)/2=340, (1920-600)/2=660\n    extension.backend.move_window.assert_called_with(\"0xABC\", 340, 660)\n\n\n@pytest.mark.asyncio\nasync def test_center_window_disabled(extension):\n    \"\"\"Test that centering can be disabled via config.\"\"\"\n    clients = [\n        {\"address\": \"0xABC\", \"title\": \"Window 1\", \"workspace\": {\"name\": \"2\"}},\n    ]\n    extension.get_clients.return_value = clients\n    extension.menu.run.return_value = \"1 | Window 1\"\n    extension.config[\"center_on_fetch\"] = False\n\n    await extension.run_fetch_client_menu()\n\n    # Should not call any centering-related methods\n    extension.backend.toggle_floating.assert_not_called()\n    extension.backend.move_window.assert_not_called()\n    extension.backend.resize_window.assert_not_called()\n\n\n@pytest.mark.asyncio\nasync def test_center_window_with_scaled_monitor(extension):\n    \"\"\"Test centering with scaled monitor (e.g., HiDPI).\"\"\"\n    clients = [\n        {\"address\": \"0xABC\", \"title\": \"Window 1\", \"workspace\": {\"name\": \"2\"}},\n    ]\n    extension.get_clients.return_value = clients\n    extension.menu.run.return_value = \"1 | Window 1\"\n    # 2x scaled monitor\n    extension.get_focused_monitor_or_warn = AsyncMock(\n        return_value={\"x\": 0, \"y\": 0, \"width\": 3840, \"height\": 2160, \"scale\": 2.0, \"transform\": 0}\n    )\n    extension.backend.get_client_props = AsyncMock(return_value={\"address\": \"0xABC\", \"size\": [800, 600], \"floating\": True})\n\n    await extension.run_fetch_client_menu()\n\n    # Scaled: effective dimensions are 1920x1080\n    # Center: (1920-800)/2=560, (1080-600)/2=240\n    extension.backend.move_window.assert_called_with(\"0xABC\", 560, 240)\n"
  },
  {
    "path": "tests/test_plugin_layout_center.py",
    "content": "import pytest\nfrom unittest.mock import AsyncMock\nfrom pyprland.plugins.layout_center import Extension\nfrom tests.conftest import make_extension\n\n\n@pytest.fixture\ndef extension():\n    return make_extension(\n        Extension,\n        state_active_window=\"0x1\",\n        config={\"margin\": 50, \"offset\": \"10 20\"},\n        notify_error=AsyncMock(),\n        workspace_info={\"1\": {\"enabled\": True, \"addr\": \"0x1\"}},\n    )\n\n\n@pytest.mark.asyncio\nasync def test_sanity_check_fails(extension):\n    extension.get_clients.return_value = [{\"address\": \"0x1\"}]  # Only 1 client\n    extension.unprepare_window = AsyncMock()\n\n    assert await extension._sanity_check() is False\n    assert extension.enabled is False\n    extension.unprepare_window.assert_awaited()\n\n\n@pytest.mark.asyncio\nasync def test_sanity_check_passes(extension):\n    extension.get_clients.return_value = [{\"address\": \"0x1\"}, {\"address\": \"0x2\"}]\n\n    assert await extension._sanity_check() is True\n    assert extension.enabled is True\n\n\n@pytest.mark.asyncio\nasync def test_calculate_geometry(extension):\n    monitor = {\"name\": \"DP-1\", \"focused\": True, \"scale\": 1.0, \"width\": 1920, \"height\": 1080, \"x\": 0, \"y\": 0, \"transform\": 0}\n    extension.backend.get_monitor_props.return_value = monitor\n\n    # margin 50, offset 10 20\n    # width = 1920 - 100 = 1820\n    # height = 1080 - 100 = 980\n    # x = 0 + 50 + 10 = 60\n    # y = 0 + 50 + 20 = 70\n\n    x, y, w, h = await extension._calculate_centered_geometry(50, (10, 20))\n    assert x == 60\n    assert y == 70\n    assert w == 1820\n    assert h == 980\n\n\n@pytest.mark.asyncio\nasync def test_change_focus_next(extension):\n    clients = [{\"address\": \"0x1\", \"floating\": False}, {\"address\": \"0x2\", \"floating\": False}, {\"address\": \"0x3\", \"floating\": False}]\n    extension.get_clients.return_value = clients\n    extension.main_window_addr = \"0x1\"\n    extension.unprepare_window = AsyncMock()\n    extension.prepare_window = AsyncMock()\n\n    # Next from 0x1 (index 0) -> 0x2 (index 1)\n    await extension._run_changefocus(1)\n\n    assert extension.main_window_addr == \"0x2\"\n    extension.backend.focus_window.assert_called_with(\"0x2\")\n\n\n@pytest.mark.asyncio\nasync def test_change_focus_prev_wrap(extension):\n    clients = [{\"address\": \"0x1\", \"floating\": False}, {\"address\": \"0x2\", \"floating\": False}, {\"address\": \"0x3\", \"floating\": False}]\n    extension.get_clients.return_value = clients\n    extension.main_window_addr = \"0x1\"\n    extension.unprepare_window = AsyncMock()\n    extension.prepare_window = AsyncMock()\n\n    # Prev from 0x1 (index 0) -> 0x3 (index 2) - Wrap around\n    await extension._run_changefocus(-1)\n\n    assert extension.main_window_addr == \"0x3\"\n    extension.backend.focus_window.assert_called_with(\"0x3\")\n"
  },
  {
    "path": "tests/test_plugin_lost_windows.py",
    "content": "import pytest\nfrom pyprland.plugins.lost_windows import Extension, contains\nfrom tests.conftest import make_extension\n\n\n@pytest.fixture\ndef extension():\n    return make_extension(Extension)\n\n\ndef test_contains():\n    monitor = {\"x\": 0, \"y\": 0, \"width\": 1920, \"height\": 1080}\n\n    # Inside\n    assert contains(monitor, {\"at\": [100, 100]}) is True\n    assert contains(monitor, {\"at\": [1919, 1079]}) is True\n    assert contains(monitor, {\"at\": [0, 0]}) is True\n\n    # Outside\n    assert contains(monitor, {\"at\": [-10, 100]}) is False\n    assert contains(monitor, {\"at\": [100, -10]}) is False\n    assert contains(monitor, {\"at\": [1920, 100]}) is False\n    assert contains(monitor, {\"at\": [100, 1080]}) is False\n\n\n@pytest.mark.asyncio\nasync def test_run_attract_lost(extension):\n    monitor = {\"id\": 1, \"name\": \"DP-1\", \"width\": 1920, \"height\": 1080, \"x\": 0, \"y\": 0, \"focused\": True, \"activeWorkspace\": {\"id\": 1}}\n    monitors = [monitor]\n\n    # One window inside, one lost\n    clients = [\n        {\"pid\": 1, \"floating\": True, \"at\": [100, 100], \"class\": \"ok\"},\n        {\"pid\": 2, \"floating\": True, \"at\": [3000, 3000], \"class\": \"lost\"},\n        {\"pid\": 3, \"floating\": False, \"at\": [3000, 3000], \"class\": \"tiled_lost_ignored\"},\n    ]\n\n    extension.backend.get_monitors.return_value = monitors\n    extension.backend.get_monitor_props.return_value = monitor\n    extension.get_clients.return_value = clients\n\n    await extension.run_attract_lost()\n\n    extension.hyprctl.assert_called_once()\n    calls = extension.hyprctl.call_args[0][0]\n\n    # Should only move the lost floating window (pid 2)\n    assert len(calls) == 2\n    assert \"pid:2\" in calls[0]\n    assert \"pid:2\" in calls[1]\n    assert \"movetoworkspacesilent 1\" in calls[0]\n    assert \"movewindowpixel exact\" in calls[1]\n"
  },
  {
    "path": "tests/test_plugin_magnify.py",
    "content": "import pytest\nfrom pytest_asyncio import fixture\n\nfrom .conftest import mocks\nfrom .testtools import wait_called\n\n\n@fixture\nasync def magnify_config(monkeypatch):\n    d = {\"pyprland\": {\"plugins\": [\"magnify\"]}, \"magnify\": {\"factor\": 2.0}}\n    monkeypatch.setattr(\"tomllib.load\", lambda x: d)\n    yield\n\n\n@pytest.mark.asyncio\nasync def test_magnify(magnify_config, server_fixture):\n    # Test enabling zoom (default factor 2.0)\n    await mocks.pypr(\"zoom\")\n    await wait_called(mocks.hyprctl)\n\n    # Check if hyprctl was called.\n    # Note: The actual keyword might be \"misc:cursor_zoom_factor\" or \"cursor:zoom_factor\"\n    # depending on the mocked version state, but we can check if it ends with the correct value.\n    cmd = mocks.hyprctl.call_args[0][0]\n    assert \"zoom_factor 2.0\" in cmd\n\n    # Test toggling off (reset to 1)\n    await mocks.pypr(\"zoom\")\n    await wait_called(mocks.hyprctl, count=2)\n    cmd = mocks.hyprctl.call_args[0][0]\n    assert \"zoom_factor 1.0\" in cmd\n\n    # Test specific factor\n    await mocks.pypr(\"zoom 3\")\n    await wait_called(mocks.hyprctl, count=3)\n    cmd = mocks.hyprctl.call_args[0][0]\n    assert \"zoom_factor 3.0\" in cmd\n"
  },
  {
    "path": "tests/test_plugin_menubar.py",
    "content": "import pytest\nfrom unittest.mock import Mock, AsyncMock, patch\nfrom pyprland.plugins.menubar import get_pid_from_layers_hyprland, is_bar_in_layers_niri, is_bar_alive, Extension\nfrom tests.conftest import make_extension\n\n\ndef test_get_pid_from_layers_hyprland():\n    layers = {\n        \"DP-1\": {\n            \"levels\": {\n                \"0\": [\n                    {\"namespace\": \"wallpaper\", \"pid\": 1111},\n                ],\n                \"1\": [\n                    {\"namespace\": \"bar-123\", \"pid\": 1234},\n                ],\n            }\n        }\n    }\n    assert get_pid_from_layers_hyprland(layers) == 1234\n\n    layers_no_bar = {\n        \"DP-1\": {\n            \"levels\": {\n                \"0\": [\n                    {\"namespace\": \"wallpaper\", \"pid\": 1111},\n                ]\n            }\n        }\n    }\n    assert get_pid_from_layers_hyprland(layers_no_bar) is False\n\n\ndef test_is_bar_in_layers_niri():\n    # Bar exists\n    layers = [\n        {\"namespace\": \"waybar\", \"output\": \"DP-1\", \"layer\": \"Top\"},\n        {\"namespace\": \"bar-123\", \"output\": \"DP-1\", \"layer\": \"Top\"},\n    ]\n    assert is_bar_in_layers_niri(layers) is True\n\n    # No bar\n    layers_no_bar = [\n        {\"namespace\": \"waybar\", \"output\": \"DP-1\", \"layer\": \"Top\"},\n        {\"namespace\": \"notifications\", \"output\": \"DP-1\", \"layer\": \"Overlay\"},\n    ]\n    assert is_bar_in_layers_niri(layers_no_bar) is False\n\n    # Empty\n    assert is_bar_in_layers_niri([]) is False\n\n\n@pytest.mark.asyncio\nasync def test_is_bar_alive_hyprland():\n    backend = Mock()\n    backend.execute_json = AsyncMock()\n\n    # Case 1: Process exists in /proc\n    with patch(\"pyprland.plugins.menubar.aiexists\", AsyncMock(return_value=True)):\n        assert await is_bar_alive(1234, backend, \"hyprland\") == 1234\n        backend.execute_json.assert_not_called()\n\n    # Case 2: Process not in /proc, but found in layers\n    with patch(\"pyprland.plugins.menubar.aiexists\", AsyncMock(return_value=False)):\n        backend.execute_json.return_value = {\"DP-1\": {\"levels\": {\"0\": [{\"namespace\": \"bar-1\", \"pid\": 5678}]}}}\n        assert await is_bar_alive(1234, backend, \"hyprland\") == 5678\n        backend.execute_json.assert_called_with(\"layers\")\n\n    # Case 3: Process not found anywhere\n    with patch(\"pyprland.plugins.menubar.aiexists\", AsyncMock(return_value=False)):\n        backend.execute_json.return_value = {}\n        assert await is_bar_alive(1234, backend, \"hyprland\") is False\n\n\n@pytest.mark.asyncio\nasync def test_is_bar_alive_niri():\n    backend = Mock()\n    backend.execute_json = AsyncMock()\n\n    # Case 1: Process exists in /proc\n    with patch(\"pyprland.plugins.menubar.aiexists\", AsyncMock(return_value=True)):\n        assert await is_bar_alive(1234, backend, \"niri\") == 1234\n        backend.execute_json.assert_not_called()\n\n    # Case 2: Process not in /proc, but found in layers (returns True, not PID)\n    with patch(\"pyprland.plugins.menubar.aiexists\", AsyncMock(return_value=False)):\n        backend.execute_json.return_value = [{\"namespace\": \"bar-1\", \"output\": \"DP-1\", \"layer\": \"Top\"}]\n        result = await is_bar_alive(1234, backend, \"niri\")\n        assert result is True  # Niri can only detect presence, not recover PID\n        backend.execute_json.assert_called_with(\"Layers\")\n\n    # Case 3: Process not found anywhere\n    with patch(\"pyprland.plugins.menubar.aiexists\", AsyncMock(return_value=False)):\n        backend.execute_json.return_value = []\n        assert await is_bar_alive(1234, backend, \"niri\") is False\n\n\n@pytest.fixture\ndef extension():\n    # menubar needs state to be a Mock because it accesses active_monitors\n    # which is a computed property on SharedState\n    state = Mock()\n    state.monitors = [\"DP-1\", \"HDMI-A-1\", \"eDP-1\"]\n    state.active_monitors = [\"DP-1\", \"HDMI-A-1\", \"eDP-1\"]\n    state.environment = \"hyprland\"\n    return make_extension(\n        Extension,\n        config={\"monitors\": [\"DP-1\", \"HDMI-A-1\"]},\n        state=state,\n    )\n\n\n@pytest.mark.asyncio\nasync def test_get_best_monitor_hyprland(extension):\n    # Setup monitors return for Hyprland\n    extension.backend.get_monitors.return_value = [\n        {\"name\": \"eDP-1\", \"currentFormat\": \"1920x1080\"},\n        {\"name\": \"HDMI-A-1\", \"currentFormat\": \"1920x1080\"},\n    ]\n\n    # \"DP-1\" is preferred but not available. \"HDMI-A-1\" is second preferred and available.\n    best = await extension.get_best_monitor()\n    assert best == \"HDMI-A-1\"\n\n    # Now let's make DP-1 available\n    extension.backend.get_monitors.return_value = [\n        {\"name\": \"eDP-1\", \"currentFormat\": \"1920x1080\"},\n        {\"name\": \"HDMI-A-1\", \"currentFormat\": \"1920x1080\"},\n        {\"name\": \"DP-1\", \"currentFormat\": \"2560x1440\"},\n    ]\n    best = await extension.get_best_monitor()\n    assert best == \"DP-1\"\n\n    # No preferred monitor available\n    extension.config[\"monitors\"] = [\"Other\"]\n    best = await extension.get_best_monitor()\n    assert best == \"\"\n\n\n@pytest.mark.asyncio\nasync def test_get_best_monitor_niri(extension):\n    extension.state.environment = \"niri\"\n\n    # Setup outputs return for Niri (dict format, current_mode indicates enabled)\n    extension.backend.execute_json.return_value = {\n        \"eDP-1\": {\"current_mode\": 0, \"modes\": []},\n        \"HDMI-A-1\": {\"current_mode\": 0, \"modes\": []},\n    }\n\n    # \"DP-1\" is preferred but not available. \"HDMI-A-1\" is second preferred and available.\n    best = await extension.get_best_monitor()\n    assert best == \"HDMI-A-1\"\n\n    # Now let's make DP-1 available\n    extension.backend.execute_json.return_value = {\n        \"eDP-1\": {\"current_mode\": 0, \"modes\": []},\n        \"HDMI-A-1\": {\"current_mode\": 0, \"modes\": []},\n        \"DP-1\": {\"current_mode\": 0, \"modes\": []},\n    }\n    best = await extension.get_best_monitor()\n    assert best == \"DP-1\"\n\n    # Disabled monitor (current_mode is None)\n    extension.config[\"monitors\"] = [\"DP-2\", \"DP-1\"]\n    extension.backend.execute_json.return_value = {\n        \"DP-1\": {\"current_mode\": 0, \"modes\": []},\n        \"DP-2\": {\"current_mode\": None, \"modes\": []},  # Disabled\n    }\n    best = await extension.get_best_monitor()\n    assert best == \"DP-1\"  # DP-2 is disabled, so DP-1 is selected\n\n\n@pytest.mark.asyncio\nasync def test_set_best_monitor(extension):\n    extension.get_best_monitor = AsyncMock(return_value=\"DP-1\")\n    await extension.set_best_monitor()\n    assert extension.cur_monitor == \"DP-1\"\n\n    # Fallback to state.monitors if get_best_monitor returns empty\n    extension.get_best_monitor.return_value = \"\"\n    await extension.set_best_monitor()\n    assert extension.cur_monitor == \"DP-1\"  # first in state.monitors mock\n    extension.backend.notify_info.assert_called()\n\n\n@pytest.mark.asyncio\nasync def test_event_monitoradded(extension):\n    extension.cur_monitor = \"HDMI-A-1\"  # Index 1 in config\n    extension.stop = AsyncMock()\n    extension.on_reload = AsyncMock()\n\n    # Add a less preferred monitor\n    await extension.event_monitoradded(\"eDP-1\")  # Not in config\n    extension.on_reload.assert_not_called()\n\n    # Add a more preferred monitor (DP-1 is index 0)\n    await extension.event_monitoradded(\"DP-1\")\n    extension.stop.assert_called()\n    extension.on_reload.assert_called()\n\n\n@pytest.mark.asyncio\nasync def test_run_bar(extension):\n    extension.stop = AsyncMock()\n    extension.on_reload = AsyncMock()\n\n    await extension.run_bar(\"start\")\n    extension.stop.assert_called()\n    extension.on_reload.assert_called()\n\n    extension.stop.reset_mock()\n    extension.on_reload.reset_mock()\n\n    await extension.run_bar(\"stop\")\n    extension.stop.assert_called()\n    extension.on_reload.assert_not_called()\n"
  },
  {
    "path": "tests/test_plugin_monitor.py",
    "content": "import pytest\nimport tomllib\nfrom pytest_asyncio import fixture\n\nfrom .conftest import mocks as tst\nfrom .testtools import wait_called\n\n\n@fixture\nasync def shapeL_config(monkeypatch):\n    \"\"\"L shape.\"\"\"\n    config = \"\"\"\n[pyprland]\nplugins = [\"monitors\"]\n\n[monitors]\nstartup_relayout = false\nnew_monitor_delay = 0\n\n[monitors.placement]\n\"Sony\".topOf = [\"BenQ\"]\n\"Microstep\".rightOf = [\"BenQ\"]\n    \"\"\"\n    monkeypatch.setattr(\"tomllib.load\", lambda x: tomllib.loads(config))\n    yield\n\n\n@fixture\nasync def flipped_shapeL_config(monkeypatch):\n    \"\"\"Flipped L shape.\"\"\"\n    config = \"\"\"\n[pyprland]\nplugins = [\"monitors\"]\n\n[monitors]\nstartup_relayout = false\nnew_monitor_delay = 0\n\n[monitors.placement]\n\"Sony\".bottomOf = \"BenQ\"\n\"Microstep\".rightOf = \"Sony\"\n    \"\"\"\n    monkeypatch.setattr(\"tomllib.load\", lambda x: tomllib.loads(config))\n    yield\n\n\n@fixture\nasync def descr_config(monkeypatch):\n    \"\"\"Runs with config n°1.\"\"\"\n    config = \"\"\"\n[pyprland]\nplugins = [\"monitors\"]\n\n[monitors]\nstartup_relayout = false\nnew_monitor_delay = 0\n\n[monitors.placement]\n\"Sony\".rightCenterOf = \"Microstep\"\n\"Microstep\".rightCenterOf = [\"BenQ\"]\n    \"\"\"\n    monkeypatch.setattr(\"tomllib.load\", lambda x: tomllib.loads(config))\n    yield\n\n\n@fixture\nasync def topdown_config(monkeypatch):\n    \"\"\"Runs with config n°1.\"\"\"\n    config = \"\"\"\n[pyprland]\nplugins = [\"monitors\"]\n\n[monitors]\nstartup_relayout = false\nnew_monitor_delay = 0\n\n[monitors.placement]\n\"eDP-1\".topOf = \"DP-1\"\n\"DP-1\".topOf = \"HDMI-A-1\"\n    \"\"\"\n    monkeypatch.setattr(\"tomllib.load\", lambda x: tomllib.loads(config))\n    yield\n\n\n@fixture\nasync def bottomup_config(monkeypatch):\n    \"\"\"Runs with config n°1.\"\"\"\n    config = \"\"\"\n[pyprland]\nplugins = [\"monitors\"]\n\n[monitors]\nstartup_relayout = false\nnew_monitor_delay = 0\n\n[monitors.placement]\n\"eDP-1\".bottomCenterOf = \"DP-1\"\n\"DP-1\".bottomCenterOf = \"HDMI-A-1\"\n    \"\"\"\n    monkeypatch.setattr(\"tomllib.load\", lambda x: tomllib.loads(config))\n    yield\n\n\ndef get_xrandr_calls(mock):\n    return {al[0][0] for al in mock.call_args_list}\n\n\n@fixture\nasync def reversed_config(monkeypatch):\n    \"\"\"Runs with config n°1.\"\"\"\n    config = \"\"\"\n[pyprland]\nplugins = [\"monitors\"]\n\n[monitors]\nstartup_relayout = false\nnew_monitor_delay = 0\n\n[monitors.placement]\n\"eDP-1\".leftOf = \"DP-1\"\n\"DP-1\".leftOf = \"HDMI-A-1\"\n    \"\"\"\n    monkeypatch.setattr(\"tomllib.load\", lambda x: tomllib.loads(config))\n    yield\n\n\ndef assert_modes(call_list, expected=None, allow_empty=False):\n    if expected is None:\n        expected = []\n    ref_str = {x[0][0] for x in call_list}\n    for e in expected:\n        ref_str.remove(e)\n\n    if not allow_empty:\n        assert len(list(ref_str)) == 0\n\n\n@pytest.mark.usefixtures(\"sample1_config\", \"server_fixture\")\n@pytest.mark.asyncio\nasync def test_relayout():\n    await tst.pypr(\"relayout\")\n    await wait_called(tst.hyprctl)\n    assert_modes(\n        tst.hyprctl.call_args_list,\n        [\n            \"monitor HDMI-A-1,1920x1080@60.0,0x0,1.0,transform,0\",\n            \"monitor DP-1,3440x1440@59.999,1920x0,1.0,transform,0\",\n        ],\n    )\n\n\n@pytest.mark.usefixtures(\"third_monitor\", \"sample1_config\", \"server_fixture\")\n@pytest.mark.asyncio\nasync def test_3screens_relayout():\n    await tst.pypr(\"relayout\")\n    await wait_called(tst.hyprctl)\n    assert_modes(\n        tst.hyprctl.call_args_list,\n        [\n            \"monitor HDMI-A-1,1920x1080@60.0,0x0,1.0,transform,0\",\n            \"monitor DP-1,3440x1440@59.999,1920x0,1.0,transform,0\",\n            \"monitor eDP-1,640x480@59.999,5360x0,1.0,transform,0\",\n        ],\n    )\n\n\n@pytest.mark.usefixtures(\"third_monitor\", \"bottomup_config\", \"server_fixture\")\n@pytest.mark.asyncio\nasync def test_3screens_relayout_b():\n    await tst.pypr(\"relayout\")\n    await wait_called(tst.hyprctl)\n    assert_modes(\n        tst.hyprctl.call_args_list,\n        [\n            \"monitor HDMI-A-1,1920x1080@60.0,760x0,1.0,transform,0\",\n            \"monitor DP-1,3440x1440@59.999,0x1080,1.0,transform,0\",\n            \"monitor eDP-1,640x480@59.999,1400x2520,1.0,transform,0\",\n        ],\n    )\n\n\n@pytest.mark.usefixtures(\"third_monitor\", \"shapeL_config\", \"server_fixture\")\n@pytest.mark.asyncio\nasync def test_shape_l():\n    await tst.pypr(\"relayout\")\n    await wait_called(tst.hyprctl)\n    assert_modes(\n        tst.hyprctl.call_args_list,\n        [\n            \"monitor HDMI-A-1,1920x1080@60.0,0x480,1.0,transform,0\",\n            \"monitor DP-1,3440x1440@59.999,1920x480,1.0,transform,0\",\n            \"monitor eDP-1,640x480@59.999,0x0,1.0,transform,0\",\n        ],\n    )\n\n\n@pytest.mark.usefixtures(\"third_monitor\", \"flipped_shapeL_config\", \"server_fixture\")\n@pytest.mark.asyncio\nasync def test_flipped_shape_l():\n    await tst.pypr(\"relayout\")\n    await wait_called(tst.hyprctl)\n    assert_modes(\n        tst.hyprctl.call_args_list,\n        [\n            \"monitor HDMI-A-1,1920x1080@60.0,0x0,1.0,transform,0\",\n            \"monitor DP-1,3440x1440@59.999,640x1080,1.0,transform,0\",\n            \"monitor eDP-1,640x480@59.999,0x1080,1.0,transform,0\",\n        ],\n    )\n\n\n@pytest.mark.usefixtures(\"third_monitor\", \"reversed_config\", \"server_fixture\")\n@pytest.mark.asyncio\nasync def test_3screens_rev_relayout():\n    await tst.pypr(\"relayout\")\n    await wait_called(tst.hyprctl)\n    assert_modes(\n        tst.hyprctl.call_args_list,\n        [\n            \"monitor HDMI-A-1,1920x1080@60.0,4080x0,1.0,transform,0\",\n            \"monitor DP-1,3440x1440@59.999,640x0,1.0,transform,0\",\n            \"monitor eDP-1,640x480@59.999,0x0,1.0,transform,0\",\n        ],\n    )\n\n\n@pytest.mark.usefixtures(\"sample1_config\", \"server_fixture\")\n@pytest.mark.asyncio\nasync def test_events():\n    await tst.send_event(\"monitoradded>>DP-1\")\n    await wait_called(tst.hyprctl)\n    assert_modes(\n        tst.hyprctl.call_args_list,\n        [\n            \"monitor HDMI-A-1,1920x1080@60.0,0x0,1.0,transform,0\",\n            \"monitor DP-1,3440x1440@59.999,1920x0,1.0,transform,0\",\n        ],\n    )\n\n\n@pytest.mark.usefixtures(\"descr_config\", \"server_fixture\")\n@pytest.mark.asyncio\nasync def test_events_d():\n    await tst.send_event(\"monitoradded>>DP-1\")\n    await wait_called(tst.hyprctl)\n\n    assert_modes(\n        tst.hyprctl.call_args_list,\n        [\n            \"monitor HDMI-A-1,1920x1080@60.0,0x180,1.0,transform,0\",\n            \"monitor DP-1,3440x1440@59.999,1920x0,1.0,transform,0\",\n        ],\n    )\n\n\n@pytest.mark.usefixtures(\"reversed_config\", \"server_fixture\")\n@pytest.mark.asyncio\nasync def test_events2():\n    await tst.send_event(\"monitoradded>>DP-1\")\n\n    await wait_called(tst.hyprctl)\n\n    assert_modes(\n        tst.hyprctl.call_args_list,\n        [\n            \"monitor HDMI-A-1,1920x1080@60.0,3440x0,1.0,transform,0\",\n            \"monitor DP-1,3440x1440@59.999,0x0,1.0,transform,0\",\n        ],\n    )\n\n\n@pytest.mark.usefixtures(\"topdown_config\", \"server_fixture\")\n@pytest.mark.asyncio\nasync def test_events3():\n    await tst.send_event(\"monitoradded>>DP-1\")\n\n    await wait_called(tst.hyprctl)\n\n    assert_modes(\n        tst.hyprctl.call_args_list,\n        [\n            \"monitor HDMI-A-1,1920x1080@60.0,0x1440,1.0,transform,0\",\n            \"monitor DP-1,3440x1440@59.999,0x0,1.0,transform,0\",\n        ],\n    )\n\n\n@pytest.mark.usefixtures(\"sample1_config\", \"server_fixture\")\n@pytest.mark.asyncio\nasync def test_events3b():\n    await tst.send_event(\"monitoradded>>HDMI-A-1\")\n\n    await wait_called(tst.hyprctl)\n\n    assert_modes(\n        tst.hyprctl.call_args_list,\n        [\n            \"monitor HDMI-A-1,1920x1080@60.0,0x0,1.0,transform,0\",\n            \"monitor DP-1,3440x1440@59.999,1920x0,1.0,transform,0\",\n        ],\n    )\n\n\n@pytest.mark.usefixtures(\"bottomup_config\", \"server_fixture\")\n@pytest.mark.asyncio\nasync def test_events4():\n    await tst.send_event(\"monitoradded>>DP-1\")\n\n    await wait_called(tst.hyprctl)\n\n    assert_modes(\n        tst.hyprctl.call_args_list,\n        [\n            \"monitor HDMI-A-1,1920x1080@60.0,760x0,1.0,transform,0\",\n            \"monitor DP-1,3440x1440@59.999,0x1080,1.0,transform,0\",\n        ],\n    )\n\n\n@pytest.mark.usefixtures(\"empty_config\", \"server_fixture\")\n@pytest.mark.asyncio\nasync def test_nothing():\n    await tst.pypr(\"inexistent\")\n    assert tst.hyprctl.call_count == 1\n    # Check that notify was called with base_command=\"notify\"\n    assert tst.hyprctl.call_args.kwargs.get(\"base_command\") == \"notify\"\n    assert \"Unknown command\" in tst.hyprctl.call_args[0][0]\n\n\nimport pytest\nimport tomllib\nfrom pytest_asyncio import fixture\n\nfrom .conftest import mocks as tst\nfrom .testtools import wait_called\n\n\n@fixture\nasync def disables_config(monkeypatch):\n    \"\"\"Test disables config.\"\"\"\n    config = \"\"\"\n[pyprland]\nplugins = [\"monitors\"]\n\n[monitors]\nstartup_relayout = false\nnew_monitor_delay = 0\n\n[monitors.placement]\n\"Microstep\".topOf = \"BenQ\"\n\"Microstep\".disables = [\"eDP-1\"]\n    \"\"\"\n    monkeypatch.setattr(\"tomllib.load\", lambda x: tomllib.loads(config))\n    yield\n\n\n@fixture\nasync def disables_list_config(monkeypatch):\n    \"\"\"Test disables config with list.\"\"\"\n    config = \"\"\"\n[pyprland]\nplugins = [\"monitors\"]\n\n[monitors]\nstartup_relayout = false\nnew_monitor_delay = 0\n\n[monitors.placement]\n\"Microstep\".topOf = \"BenQ\"\n\"Microstep\".disables = [\"eDP-1\", \"HDMI-A-1\"]\n    \"\"\"\n    monkeypatch.setattr(\"tomllib.load\", lambda x: tomllib.loads(config))\n    yield\n\n\ndef assert_modes(call_list, expected=None, allow_empty=False):\n    if expected is None:\n        expected = []\n    ref_str = {x[0][0] for x in call_list}\n    for e in expected:\n        ref_str.remove(e)\n\n    if not allow_empty:\n        assert len(list(ref_str)) == 0\n\n\n@pytest.mark.usefixtures(\"third_monitor\", \"disables_config\", \"server_fixture\")\n@pytest.mark.asyncio\nasync def test_disables_monitor():\n    await tst.pypr(\"relayout\")\n    await wait_called(tst.hyprctl)\n    assert_modes(\n        tst.hyprctl.call_args_list,\n        [\n            \"monitor HDMI-A-1,1920x1080@60.0,0x1440,1.0,transform,0\",\n            \"monitor DP-1,3440x1440@59.999,0x0,1.0,transform,0\",\n            \"monitor eDP-1,disable\",\n        ],\n    )\n\n\n@pytest.mark.usefixtures(\"third_monitor\", \"disables_list_config\", \"server_fixture\")\n@pytest.mark.asyncio\nasync def test_disables_monitor_list():\n    await tst.pypr(\"relayout\")\n    await wait_called(tst.hyprctl)\n    assert_modes(\n        tst.hyprctl.call_args_list,\n        [\n            \"monitor DP-1,3440x1440@59.999,0x0,1.0,transform,0\",\n            \"monitor eDP-1,disable\",\n            \"monitor HDMI-A-1,disable\",\n        ],\n    )\n"
  },
  {
    "path": "tests/test_plugin_scratchpads.py",
    "content": "\"\"\"Scratchpad plugin (smoke) tests.\"\"\"\n\nimport asyncio\n\nimport pytest\nfrom pytest_asyncio import fixture\n\nfrom .conftest import mocks\nfrom .testtools import wait_called\n\n\n@fixture\ndef scratchpads(monkeypatch, mocker):\n    d = {\n        \"pyprland\": {\"plugins\": [\"scratchpads\"]},\n        \"scratchpads\": {\n            \"term\": {\n                \"command\": \"ls\",\n                \"lazy\": True,\n            }\n        },\n    }\n    monkeypatch.setattr(\"tomllib.load\", lambda x: d)\n\n\n@fixture(params=[\"fromTop\", \"fromBottom\", \"fromLeft\", \"fromRight\"])\ndef animated_scratchpads(request, monkeypatch, mocker):\n    d = {\n        \"pyprland\": {\"plugins\": [\"scratchpads\"]},\n        \"scratchpads\": {\n            \"term\": {\n                \"command\": \"ls\",\n                \"lazy\": True,\n                \"class\": \"kitty-dropterm\",\n                \"animation\": request.param,\n            }\n        },\n    }\n    monkeypatch.setattr(\"tomllib.load\", lambda x: d)\n    return request.param\n\n\n@fixture\ndef no_proc_scratchpads(request, monkeypatch, mocker):\n    d = {\n        \"pyprland\": {\"plugins\": [\"scratchpads\"]},\n        \"scratchpads\": {\n            \"term\": {\n                \"command\": \"ls\",\n                \"lazy\": True,\n                \"class\": \"kitty-dropterm\",\n                \"process_tracking\": False,\n                \"animation\": \"fromLeft\",\n            }\n        },\n    }\n    monkeypatch.setattr(\"tomllib.load\", lambda x: d)\n\n\n@pytest.mark.asyncio\nasync def test_not_found(scratchpads, subprocess_shell_mock, server_fixture):\n    await mocks.pypr(\"toggle foobar\")\n    await wait_called(mocks.hyprctl)\n\n    # Check for notification in kwargs\n    found = False\n    for call in mocks.hyprctl.call_args_list:\n        if call.kwargs.get(\"base_command\") == \"notify\":\n            found = True\n            break\n    assert found\n\n\ndef gen_call_set(call_list: list) -> set[str]:\n    \"\"\"Generate a set of calls from a list of calls.\"\"\"\n    call_set: set[str] = set()\n    for item in call_list:\n        if isinstance(item, str):\n            call_set.add(item)\n        else:\n            call_set.update(gen_call_set(item))\n    return call_set\n\n\nasync def _send_window_events(address=\"12345677890\", klass=\"kitty-dropterm\", title=\"my fake terminal\"):\n    await mocks.send_event(f\"openwindow>>address:0x{address},1,{klass},{title}\")\n    await mocks.send_event(\"activewindowv2>>44444677890\")\n    await mocks.send_event(f\"activewindowv2>>{address}\")\n\n\n@pytest.mark.asyncio\nasync def test_std(scratchpads, subprocess_shell_mock, server_fixture):\n    mocks.json_commands_result[\"clients\"] = CLIENT_CONFIG\n    await mocks.pypr(\"toggle term\")\n    await wait_called(mocks.hyprctl, count=3)\n    await _send_window_events()\n    await asyncio.sleep(0.1)\n    await wait_called(mocks.hyprctl, count=3)\n    call_set = gen_call_set(mocks.hyprctl.call_args_list)\n    for expected in {\n        \"movetoworkspacesilent special:S-term,address:0x12345677890\",\n        \"moveworkspacetomonitor special:S-term DP-1\",\n        \"alterzorder top,address:0x12345677890\",\n        \"focuswindow address:0x12345677890\",\n        \"movetoworkspacesilent 1,address:0x12345677890\",\n    }:\n        assert expected in call_set\n\n    # check if it matches the hide calls\n    call_set = gen_call_set(mocks.hyprctl.call_args_list)\n    call_set.remove(\"movetoworkspacesilent special:S-term,address:0x12345677890\")\n    await mocks.pypr(\"toggle term\")\n    await wait_called(mocks.hyprctl, count=4)\n    call_set = gen_call_set(mocks.hyprctl.call_args_list)\n    call_set.remove(\"movetoworkspacesilent special:S-term,address:0x12345677890\")\n    await mocks.send_event(\"activewindowv2>>44444677890\")\n    await asyncio.sleep(0.1)\n    call_set = gen_call_set(mocks.hyprctl.call_args_list)\n    for expected in {\n        \"moveworkspacetomonitor special:S-term DP-1\",\n        \"alterzorder top,address:0x12345677890\",\n        \"focuswindow address:0x12345677890\",\n        \"movetoworkspacesilent special:S-term,address:0x12345677890\",\n        \"movetoworkspacesilent 1,address:0x12345677890\",\n    }:\n        assert expected in call_set\n\n\n@pytest.mark.asyncio\nasync def test_animated(animated_scratchpads, subprocess_shell_mock, server_fixture):\n    mocks.json_commands_result[\"clients\"] = CLIENT_CONFIG\n    mocks.hyprctl.reset_mock()\n    await mocks.pypr(\"toggle term\")\n    await wait_called(mocks.hyprctl, count=2)\n    call_set = gen_call_set(mocks.hyprctl.call_args_list)\n    call_set.remove(\"movetoworkspacesilent 1,address:0x12345677890\")\n    call_set.remove(\"focuswindow address:0x12345677890\")\n    await _send_window_events()\n    await wait_called(mocks.hyprctl, count=4)\n    call_set = gen_call_set(mocks.hyprctl.call_args_list)\n    #     assert expected in call_set\n    mocks.hyprctl.reset_mock()\n    await asyncio.sleep(0.2)\n    await mocks.pypr(\"toggle term\")\n    await wait_called(mocks.hyprctl, count=2)\n    await asyncio.sleep(0.2)\n    call_set = gen_call_set(mocks.hyprctl.call_args_list)\n    call_set.remove(\"movetoworkspacesilent special:S-term,address:0x12345677890\")\n    assert any(x.startswith(\"movewindowpixel\") for x in call_set)\n    await _send_window_events(\"7777745\", \"plop\", \"notthat\")\n    await wait_called(mocks.hyprctl, count=2)\n\n    # Test attach\n    mocks.hyprctl.reset_mock()\n    await mocks.pypr(\"attach\")\n    await wait_called(mocks.hyprctl, count=1)\n    mocks.hyprctl.reset_mock()\n    await mocks.pypr(\"toggle term\")\n    await wait_called(mocks.hyprctl, count=3)\n    mocks.hyprctl.reset_mock()\n    await mocks.pypr(\"toggle term\")\n    await wait_called(mocks.hyprctl, count=2)\n\n\n@pytest.mark.asyncio\nasync def test_no_proc(no_proc_scratchpads, subprocess_shell_mock, server_fixture):\n    mocks.hyprctl.reset_mock()\n    mocks.json_commands_result[\"clients\"] = CLIENT_CONFIG\n    await mocks.pypr(\"toggle term\")\n    await wait_called(mocks.hyprctl, count=2)\n\n    await _send_window_events()\n    await wait_called(mocks.hyprctl, count=4)\n    await asyncio.sleep(0.2)\n    call_set = gen_call_set(mocks.hyprctl.call_args_list)\n    call_set.remove(\"movetoworkspacesilent special:S-term,address:0x12345677890\")\n\n    mocks.hyprctl.reset_mock()\n    await mocks.pypr(\"toggle term\")\n    await wait_called(mocks.hyprctl, count=3)\n    call_set = gen_call_set(mocks.hyprctl.call_args_list)\n    call_set.remove(\"movetoworkspacesilent special:S-term,address:0x12345677890\")\n    assert any(x.startswith(\"movewindowpixel\") for x in call_set)\n    await _send_window_events(\"745\", \"plop\", \"notthat\")\n    await wait_called(mocks.hyprctl, count=2)\n    await asyncio.sleep(0.1)\n\n\n@pytest.mark.asyncio\nasync def test_attach_sanity_checks(scratchpads, subprocess_shell_mock, server_fixture):\n    \"\"\"Ensure attaching windows behaves sanely.\"\"\"\n    mocks.json_commands_result[\"clients\"] = CLIENT_CONFIG\n\n    # 1. Start scratchpad\n    await mocks.pypr(\"toggle term\")\n    await wait_called(mocks.hyprctl, count=3)\n    await _send_window_events()\n    await asyncio.sleep(0.1)\n\n    # 2. Try to attach scratchpad to itself (should fail/warn)\n\n    # We need to simulate the \"active window\" being the scratchpad window.\n    # The fixture sets up the event loop and state.\n    # Calling run_attach reads from self.state.active_window.\n    # self.state.active_window is updated via event_activewindowv2\n\n    # Ensure the scratchpad window address is \"0x12345677890\" (default from _send_window_events)\n    # So we send an event saying that window is active.\n    await mocks.send_event(\"activewindowv2>>12345677890\")\n    await asyncio.sleep(0.05)\n\n    # Clear mocks before calling attach\n    mocks.hyprctl.reset_mock()\n    mocks.hyprctl.return_value = True\n\n    await mocks.pypr(\"attach\")\n    await wait_called(mocks.hyprctl)  # Should call notify_info or error\n\n    # Verify notification about self-attach\n    found_notification = False\n    for call in mocks.hyprctl.call_args_list:\n        if call.kwargs.get(\"base_command\") == \"notify\":\n            args = call[0][0]\n            if \"can't attach or detach to itself\" in args:\n                found_notification = True\n            if \"Scratchpad 'term' not found\" in args:\n                pass\n\n    assert found_notification, \"Should notify when attaching scratchpad to itself\"\n\n\n@pytest.mark.asyncio\nasync def test_attach_workspace_sanity(scratchpads, subprocess_shell_mock, server_fixture):\n    \"\"\"Ensure attaching doesn't move window to wrong workspace.\"\"\"\n    mocks.json_commands_result[\"clients\"] = CLIENT_CONFIG\n\n    # 1. Start scratchpad and show it\n    await mocks.pypr(\"toggle term\")\n    await _send_window_events()\n    await asyncio.sleep(0.1)\n\n    # 2. Focus another window (candidate to be attached)\n    other_window_addr = \"99999\"\n    await mocks.send_event(f\"activewindowv2>>{other_window_addr}\")\n    await asyncio.sleep(0.05)\n\n    mocks.hyprctl.reset_mock()\n\n    # 3. Attach the other window\n    await mocks.pypr(\"attach\")\n    await asyncio.sleep(0.1)\n\n    # 4. Toggle visibility (hide)\n\n    # When hiding, `_hide_scratch` calls `await scratch.update_client_info(clients=clients)`\n    # AND `await self._handle_multiwindow(scratch, clients)`\n    # AND loop over `extra_addr`:\n    #   await self.hyprctl(f\"movetoworkspacesilent {mk_scratch_name(scratch.uid)},address:{addr}\")\n\n    # So we just need to verify the hyprctl call is made.\n\n    mocks.hyprctl.reset_mock()\n    await mocks.pypr(\"toggle term\")\n    await asyncio.sleep(0.1)\n\n    call_set = gen_call_set(mocks.hyprctl.call_args_list)\n\n    # Check if the attached window is moved to silent workspace\n    moved_attached = False\n    for call in call_set:\n        if f\"movetoworkspacesilent special:S-term,address:0x{other_window_addr}\" in call:\n            moved_attached = True\n\n    assert moved_attached, \"Attached window should be moved to scratchpad workspace on hide\"\n\n\nCLIENT_CONFIG = [\n    {\n        \"address\": \"0x12345677890\",\n        \"mapped\": True,\n        \"hidden\": False,\n        \"at\": [2355, 54],\n        \"size\": [768, 972],\n        \"workspace\": {\"id\": 1, \"name\": \"1\"},\n        \"floating\": False,\n        \"monitor\": 0,\n        \"class\": \"kitty-dropterm\",\n        \"title\": \"my fake terminal\",\n        \"initialClass\": \"kitty\",\n        \"initialTitle\": \"blah\",\n        \"pid\": 1,\n        \"xwayland\": False,\n        \"pinned\": False,\n        \"fullscreen\": False,\n        \"fullscreenMode\": 0,\n        \"fakeFullscreen\": False,\n        \"grouped\": [],\n        \"swallowing\": \"0x0\",\n        \"focusHistoryID\": 5,\n    },\n]\n\n\nBROWSER_CLIENT = {\n    \"address\": \"0xBROWSER123\",\n    \"mapped\": True,\n    \"hidden\": False,\n    \"at\": [100, 100],\n    \"size\": [800, 600],\n    \"workspace\": {\"id\": 1, \"name\": \"1\"},\n    \"floating\": False,\n    \"monitor\": 0,\n    \"class\": \"firefox\",\n    \"title\": \"Firefox\",\n    \"initialClass\": \"firefox\",\n    \"initialTitle\": \"Firefox\",\n    \"pid\": 2,\n    \"xwayland\": False,\n    \"pinned\": False,\n    \"fullscreen\": False,\n    \"fullscreenMode\": 0,\n    \"fakeFullscreen\": False,\n    \"grouped\": [],\n    \"swallowing\": \"0x0\",\n    \"focusHistoryID\": 6,\n}\n\n\n@fixture\ndef exclude_scratchpads(monkeypatch, mocker):\n    \"\"\"Config with two scratchpads where one excludes the other.\"\"\"\n    d = {\n        \"pyprland\": {\"plugins\": [\"scratchpads\"]},\n        \"scratchpads\": {\n            \"term\": {\n                \"command\": \"ls\",\n                \"lazy\": True,\n                \"class\": \"kitty-dropterm\",\n            },\n            \"browser\": {\n                \"command\": \"ls\",\n                \"lazy\": True,\n                \"class\": \"firefox\",\n                \"excludes\": [\"term\"],\n                \"restore_excluded\": True,\n            },\n        },\n    }\n    monkeypatch.setattr(\"tomllib.load\", lambda x: d)\n\n\n@pytest.mark.asyncio\nasync def test_excluded_scratches_isolation(exclude_scratchpads, subprocess_shell_mock, server_fixture):\n    \"\"\"Verify excluded_scratches is per-instance, not shared across Scratch objects.\"\"\"\n    # Setup clients\n    mocks.json_commands_result[\"clients\"] = CLIENT_CONFIG + [BROWSER_CLIENT]\n\n    # 1. Show term scratchpad first\n    await mocks.pypr(\"toggle term\")\n    await _send_window_events()\n    await asyncio.sleep(0.1)\n\n    # 2. Show browser scratchpad (should hide term and track it in browser.excluded_scratches)\n    await mocks.pypr(\"toggle browser\")\n    await _send_window_events(address=\"BROWSER123\", klass=\"firefox\", title=\"Firefox\")\n    await asyncio.sleep(0.1)\n\n    # 3. Access the plugin to verify internal state\n    plugin = mocks.pyprland_instance.plugins[\"scratchpads\"]\n    term_scratch = plugin.scratches.get(\"term\")\n    browser_scratch = plugin.scratches.get(\"browser\")\n\n    # Key assertion: browser should have \"term\" in its excluded list\n    assert \"term\" in browser_scratch.excluded_scratches, \"browser should track that it excluded term\"\n\n    # Key assertion: term should have empty excluded list (not shared!)\n    assert term_scratch.excluded_scratches == [], \"term should have its own empty excluded_scratches list\"\n\n    # 4. Hide browser - should restore term\n    mocks.hyprctl.reset_mock()\n    await mocks.pypr(\"toggle browser\")\n    await asyncio.sleep(0.2)\n\n    # After hide, browser.excluded_scratches should be cleared\n    assert browser_scratch.excluded_scratches == [], \"browser.excluded_scratches should be cleared after hide\"\n\n\n@fixture\ndef exclude_wildcard_scratchpads(monkeypatch, mocker):\n    \"\"\"Config with two scratchpads where one uses excludes = [\"*\"] wildcard.\"\"\"\n    d = {\n        \"pyprland\": {\"plugins\": [\"scratchpads\"]},\n        \"scratchpads\": {\n            \"term\": {\n                \"command\": \"ls\",\n                \"lazy\": True,\n                \"class\": \"kitty-dropterm\",\n            },\n            \"browser\": {\n                \"command\": \"ls\",\n                \"lazy\": True,\n                \"class\": \"firefox\",\n                \"excludes\": [\"*\"],\n                \"restore_excluded\": True,\n            },\n        },\n    }\n    monkeypatch.setattr(\"tomllib.load\", lambda x: d)\n\n\n@pytest.mark.asyncio\nasync def test_excluded_wildcard_list(exclude_wildcard_scratchpads, subprocess_shell_mock, server_fixture):\n    \"\"\"Verify excludes = [\"*\"] expands to all other scratchpads, same as excludes = \"*\".\"\"\"\n    mocks.json_commands_result[\"clients\"] = CLIENT_CONFIG + [BROWSER_CLIENT]\n\n    # 1. Show term scratchpad first\n    await mocks.pypr(\"toggle term\")\n    await _send_window_events()\n    await asyncio.sleep(0.1)\n\n    # 2. Show browser scratchpad (excludes=[\"*\"] should hide term)\n    await mocks.pypr(\"toggle browser\")\n    await _send_window_events(address=\"BROWSER123\", klass=\"firefox\", title=\"Firefox\")\n    await asyncio.sleep(0.1)\n\n    # 3. Verify internal state\n    plugin = mocks.pyprland_instance.plugins[\"scratchpads\"]\n    term_scratch = plugin.scratches.get(\"term\")\n    browser_scratch = plugin.scratches.get(\"browser\")\n\n    # browser should have excluded term (wildcard expanded to all others)\n    assert \"term\" in browser_scratch.excluded_scratches, 'browser with excludes=[\"*\"] should track that it excluded term'\n\n    # term should have empty excluded list\n    assert term_scratch.excluded_scratches == [], \"term should have its own empty excluded_scratches list\"\n\n    # 4. Hide browser - should restore term\n    mocks.hyprctl.reset_mock()\n    await mocks.pypr(\"toggle browser\")\n    await asyncio.sleep(0.2)\n\n    assert browser_scratch.excluded_scratches == [], \"browser.excluded_scratches should be cleared after hide\"\n\n\n@pytest.mark.asyncio\nasync def test_command_serialization(scratchpads, subprocess_shell_mock, server_fixture):\n    \"\"\"Verify rapid commands are serialized through the queue (not interleaved).\"\"\"\n    mocks.json_commands_result[\"clients\"] = CLIENT_CONFIG\n\n    # Track command execution order\n    execution_order = []\n    plugin = None\n\n    # Wait for plugin to be available\n    for _ in range(10):\n        if mocks.pyprland_instance and \"scratchpads\" in mocks.pyprland_instance.plugins:\n            plugin = mocks.pyprland_instance.plugins[\"scratchpads\"]\n            break\n        await asyncio.sleep(0.1)\n\n    assert plugin is not None, \"Scratchpads plugin not loaded\"\n\n    # Send window events to initialize the scratchpad\n    await mocks.pypr(\"toggle term\")\n    await _send_window_events()\n    await asyncio.sleep(0.1)\n\n    # Patch run_hide to track execution order\n    original_run_hide = plugin.run_hide\n\n    async def tracked_run_hide(uid: str, flavor=None):\n        execution_order.append(f\"start:{uid}\")\n        await asyncio.sleep(0.05)  # Simulate some async work\n        if flavor is not None:\n            await original_run_hide(uid, flavor)\n        else:\n            await original_run_hide(uid)\n        execution_order.append(f\"end:{uid}\")\n\n    plugin.run_hide = tracked_run_hide\n\n    # Reset tracking\n    execution_order.clear()\n\n    # Fire two hide commands concurrently - they should serialize\n    await asyncio.gather(\n        mocks.pypr(\"hide term\"),\n        mocks.pypr(\"hide term\"),\n    )\n    await asyncio.sleep(0.2)\n\n    # Restore original method\n    plugin.run_hide = original_run_hide\n\n    # Verify serialization: operations should not interleave\n    # Valid serialized: [start, end, start, end] - each start followed by its end\n    # Invalid interleaved: [start, start, end, end]\n    if len(execution_order) >= 4:\n        starts = [i for i, x in enumerate(execution_order) if x.startswith(\"start\")]\n        ends = [i for i, x in enumerate(execution_order) if x.startswith(\"end\")]\n        # First end should come before second start\n        assert ends[0] < starts[1], f\"Commands interleaved! Order: {execution_order}\"\n    # If less than 4 entries, second command may have been a no-op (already hidden) - that's fine\n"
  },
  {
    "path": "tests/test_plugin_shift_monitors.py",
    "content": "import pytest\nfrom unittest.mock import Mock\n\nfrom pyprland.plugins.shift_monitors import Extension\nfrom tests.conftest import make_extension\nfrom tests.testtools import get_executed_commands\n\n\n@pytest.fixture\ndef extension():\n    ext = make_extension(Extension)\n    ext.monitors = [\"M1\", \"M2\", \"M3\"]\n    ext.state.environment = \"hyprland\"  # Default to hyprland for existing tests\n    return ext\n\n\n@pytest.mark.asyncio\nasync def test_init(extension):\n    extension.monitors = []\n    extension.backend.get_monitors.return_value = [{\"name\": \"A\"}, {\"name\": \"B\"}]\n\n    await extension.init()\n\n    assert extension.monitors == [\"A\", \"B\"]\n\n\n@pytest.mark.asyncio\nasync def test_shift_positive(extension):\n    # +1 shift: W1->M2, W2->M3, W3->M1\n    # Logic derived: swap(M3, M2) then swap(M2, M1)\n\n    await extension.run_shift_monitors(\"1\")\n\n    commands = get_executed_commands(extension.backend.execute)\n    cmd_strings = [c for c, _ in commands]\n    # Verify order matters\n    assert cmd_strings == [\"swapactiveworkspaces M3 M2\", \"swapactiveworkspaces M2 M1\"]\n\n\n@pytest.mark.asyncio\nasync def test_shift_negative(extension):\n    # -1 shift: W1->M3, W2->M1, W3->M2\n    # Logic derived: swap(M1, M2) then swap(M2, M3)\n\n    await extension.run_shift_monitors(\"-1\")\n\n    commands = get_executed_commands(extension.backend.execute)\n    cmd_strings = [c for c, _ in commands]\n    assert cmd_strings == [\"swapactiveworkspaces M1 M2\", \"swapactiveworkspaces M2 M3\"]\n\n\n@pytest.mark.asyncio\nasync def test_monitor_events(extension):\n    await extension.event_monitoradded(\"M4\")\n    assert extension.monitors == [\"M1\", \"M2\", \"M3\", \"M4\"]\n\n    await extension.event_monitorremoved(\"M1\")\n    assert extension.monitors == [\"M2\", \"M3\", \"M4\"]\n\n    # Removing non-existent shouldn't crash\n    extension.log = Mock()\n    await extension.event_monitorremoved(\"ghost\")\n    extension.log.warning.assert_called()\n"
  },
  {
    "path": "tests/test_plugin_shortcuts_menu.py",
    "content": "import pytest\nfrom unittest.mock import AsyncMock\nfrom pyprland.plugins.shortcuts_menu import Extension\nfrom pyprland.config import Configuration\nfrom tests.conftest import make_extension\n\n\n@pytest.fixture\ndef extension(test_logger):\n    return make_extension(\n        Extension,\n        logger=test_logger,\n        config={\n            \"entries\": {\n                \"Network\": {\"WiFi\": \"nm-connection-editor\", \"Bluetooth\": \"blueman-manager\"},\n                \"System\": {\"Power\": {\"Shutdown\": \"poweroff\", \"Reboot\": \"reboot\"}},\n                \"Simple\": \"echo hello\",\n            },\n            \"skip_single\": True,\n        },\n        menu=AsyncMock(),\n        _menu_configured=True,\n    )\n\n\n@pytest.mark.asyncio\nasync def test_run_menu_simple_command(extension):\n    # Test running a simple command directly\n    await extension.run_menu(\"Simple\")\n    # Verify asyncio.create_subprocess_shell was called (implied by _run_command)\n    # We can mock asyncio.create_subprocess_shell if we want to be more specific,\n    # but checking that we didn't crash is a start.\n    # To check properly, we need to mock asyncio.create_subprocess_shell globally or refactor _run_command\n    # but here let's assume if it reaches _run_command it works.\n    # Actually, let's better mock _run_command on the extension since it's an internal method we want to verify called\n    extension._run_command = AsyncMock()\n\n    await extension.run_menu(\"Simple\")\n    extension._run_command.assert_called_with(\"echo hello\", {})\n\n\n@pytest.mark.asyncio\nasync def test_run_menu_nested(extension):\n    # The keys in the menu are formatted with default suffixes.\n    # \"Network\" -> \"Network ➜\"\n    # \"WiFi\" -> \"WiFi\"\n    extension.menu.run.side_effect = [\"Network ➜\", \"WiFi\"]\n    extension._run_command = AsyncMock()\n\n    await extension.run_menu()\n\n    # It should first show the top level menu\n    # Then show the Network submenu\n    # Then execute the WiFi command\n    assert extension.menu.run.call_count == 2\n    extension._run_command.assert_called_with(\"nm-connection-editor\", {})\n\n\n@pytest.mark.asyncio\nasync def test_run_menu_cancellation(extension):\n    extension.menu.run.side_effect = KeyError(\"Cancelled\")\n\n    await extension.run_menu()\n\n    # Should stop after first menu cancellation\n    assert extension.menu.run.call_count == 1\n\n\n@pytest.mark.asyncio\nasync def test_run_menu_with_skip_single(extension):\n    # Setup config with a single entry to test skip_single\n    extension.config = Configuration(\n        {\"entries\": {\"SingleGroup\": {\"OnlyOption\": \"do_something\"}}},\n        logger=extension.config.log,\n        schema=extension.config_schema,  # Apply schema for default value lookups\n    )\n    extension._run_command = AsyncMock()\n\n    await extension.run_menu()\n\n    # Should skip the SingleGroup menu because there's only one option (if we were selecting it)\n    # But wait, the logic is:\n    # 1. Shows top level: \"SingleGroup\" -> 1 option.\n    # If skip_single=True (default), it should auto-select \"SingleGroup\"\n    # Then inside \"SingleGroup\", there is \"OnlyOption\" -> 1 option. Auto-select.\n    # Then execute \"do_something\"\n\n    extension._run_command.assert_called_with(\"do_something\", {})\n    # menu.run should not be called if everything is skipped\n    assert extension.menu.run.call_count == 0\n\n\n@pytest.mark.asyncio\nasync def test_run_menu_formatting(extension):\n    # Test custom formatting\n    extension.config[\"submenu_start\"] = \"[\"\n    extension.config[\"submenu_end\"] = \"]\"\n    extension.config[\"command_start\"] = \"(\"\n    extension.config[\"command_end\"] = \")\"\n\n    extension.menu.run.return_value = \"Network\"\n    # We want to check what arguments were passed to menu.run\n\n    # First call should have formatted keys\n    try:\n        await extension.run_menu()\n    except:\n        pass  # Ignore potential errors in subsequent steps\n\n    call_args = extension.menu.run.call_args\n    if call_args:\n        options = call_args[0][0]\n        # Keys should be formatted\n        assert \"[ Network ]\" in options\n        assert \"( Simple )\" in options\n"
  },
  {
    "path": "tests/test_plugin_stash.py",
    "content": "import pytest\n\nfrom pyprland.plugins.stash import Extension\nfrom tests.conftest import make_extension\nfrom tests.testtools import get_executed_commands\n\n\n@pytest.fixture\ndef extension():\n    return make_extension(Extension)\n\n\n@pytest.fixture\ndef styled_extension():\n    return make_extension(Extension, config={\"style\": [\"border_color rgb(ec8800)\", \"border_size 3\"]})\n\n\n# -- run_stash: stashing --\n\n\n@pytest.mark.asyncio\nasync def test_stash_moves_window_to_special_workspace(extension):\n    \"\"\"Stashing a tiled window moves it to special:st-default and makes it floating.\"\"\"\n    extension.backend.execute_json.return_value = {\n        \"address\": \"0xabc\",\n        \"floating\": False,\n        \"workspace\": {\"id\": 1, \"name\": \"1\"},\n    }\n\n    await extension.run_stash()\n\n    extension.backend.move_window_to_workspace.assert_called_with(\"0xabc\", \"special:st-default\", silent=True)\n    extension.backend.toggle_floating.assert_called_once_with(\"0xabc\")\n    assert extension._was_floating[\"0xabc\"] is False\n\n\n@pytest.mark.asyncio\nasync def test_stash_custom_name(extension):\n    \"\"\"Stashing with a custom name uses that name.\"\"\"\n    extension.backend.execute_json.return_value = {\n        \"address\": \"0xabc\",\n        \"floating\": False,\n        \"workspace\": {\"id\": 2, \"name\": \"2\"},\n    }\n\n    await extension.run_stash(\"work\")\n\n    extension.backend.move_window_to_workspace.assert_called_with(\"0xabc\", \"special:st-work\", silent=True)\n\n\n@pytest.mark.asyncio\nasync def test_stash_already_floating_no_toggle(extension):\n    \"\"\"Stashing an already-floating window does not toggle floating.\"\"\"\n    extension.backend.execute_json.return_value = {\n        \"address\": \"0xabc\",\n        \"floating\": True,\n        \"workspace\": {\"id\": 1, \"name\": \"1\"},\n    }\n\n    await extension.run_stash()\n\n    extension.backend.move_window_to_workspace.assert_called_with(\"0xabc\", \"special:st-default\", silent=True)\n    extension.backend.toggle_floating.assert_not_called()\n    assert extension._was_floating[\"0xabc\"] is True\n\n\n# -- run_stash: unstashing --\n\n\n@pytest.mark.asyncio\nasync def test_unstash_moves_window_back(extension):\n    \"\"\"Unstashing a stashed tiled window restores it to the active workspace and restores tiled state.\"\"\"\n    extension._was_floating[\"0xabc\"] = False\n    extension.backend.execute_json.return_value = {\n        \"address\": \"0xabc\",\n        \"workspace\": {\"id\": -99, \"name\": \"special:st-default\"},\n    }\n\n    await extension.run_stash()\n\n    extension.backend.move_window_to_workspace.assert_called_with(\"0xabc\", extension.state.active_workspace, silent=True)\n    extension.backend.focus_window.assert_called_with(\"0xabc\")\n    extension.backend.toggle_floating.assert_called_once_with(\"0xabc\")\n    assert \"0xabc\" not in extension._was_floating\n\n\n@pytest.mark.asyncio\nasync def test_unstash_from_different_stash(extension):\n    \"\"\"A window in stash-work is unstashed even when called with default name.\"\"\"\n    extension._was_floating[\"0xdef\"] = False\n    extension.backend.execute_json.return_value = {\n        \"address\": \"0xdef\",\n        \"workspace\": {\"id\": -42, \"name\": \"special:st-work\"},\n    }\n\n    # Called without arguments (name=\"default\"), but window is in stash-work\n    await extension.run_stash()\n\n    extension.backend.move_window_to_workspace.assert_called_with(\"0xdef\", extension.state.active_workspace, silent=True)\n    extension.backend.focus_window.assert_called_with(\"0xdef\")\n\n\n@pytest.mark.asyncio\nasync def test_unstash_originally_floating_stays_floating(extension):\n    \"\"\"Unstashing a window that was originally floating does not toggle floating.\"\"\"\n    extension._was_floating[\"0xabc\"] = True\n    extension.backend.execute_json.return_value = {\n        \"address\": \"0xabc\",\n        \"workspace\": {\"id\": -99, \"name\": \"special:st-default\"},\n    }\n\n    await extension.run_stash()\n\n    extension.backend.toggle_floating.assert_not_called()\n    assert \"0xabc\" not in extension._was_floating\n\n\n# -- run_stash: edge cases --\n\n\n@pytest.mark.asyncio\nasync def test_stash_no_active_window(extension):\n    \"\"\"No-op when there is no active window.\"\"\"\n    extension.backend.execute_json.return_value = {\n        \"address\": \"\",\n        \"workspace\": {\"id\": 0, \"name\": \"\"},\n    }\n\n    await extension.run_stash()\n\n    extension.backend.move_window_to_workspace.assert_not_called()\n    assert get_executed_commands(extension.backend.execute) == []\n\n\n@pytest.mark.asyncio\nasync def test_stash_on_shown_window_removes_from_stash(extension):\n    \"\"\"Calling stash on a shown window removes it from the stash and restores tiled state.\"\"\"\n    extension._was_floating[\"0xaaa\"] = False\n    extension._was_floating[\"0xbbb\"] = False\n    extension.get_clients.return_value = [\n        {\"address\": \"0xaaa\"},\n        {\"address\": \"0xbbb\"},\n    ]\n    await extension.run_stash_toggle()  # show both\n    extension.backend.reset_mock()\n\n    # User focuses 0xaaa and calls stash\n    extension.backend.execute_json.return_value = {\n        \"address\": \"0xaaa\",\n        \"workspace\": {\"id\": 1, \"name\": \"1\"},\n    }\n\n    await extension.run_stash()\n\n    # Window stays on the active workspace — no move commands\n    extension.backend.move_window_to_workspace.assert_not_called()\n    # Floating was restored (was originally tiled)\n    extension.backend.toggle_floating.assert_called_once_with(\"0xaaa\")\n    # Removed from tracking, but stash is still visible (0xbbb remains)\n    assert \"0xaaa\" not in extension._shown_addresses[\"default\"]\n    assert \"0xbbb\" in extension._shown_addresses[\"default\"]\n    assert extension._visible[\"default\"] is True\n    assert \"0xaaa\" not in extension._was_floating\n\n\n@pytest.mark.asyncio\nasync def test_stash_on_shown_window_originally_floating_no_toggle(extension):\n    \"\"\"Removing a shown window that was originally floating does not toggle floating.\"\"\"\n    extension._was_floating[\"0xaaa\"] = True\n    extension.get_clients.return_value = [\n        {\"address\": \"0xaaa\"},\n    ]\n    await extension.run_stash_toggle()  # show\n    extension.backend.reset_mock()\n\n    extension.backend.execute_json.return_value = {\n        \"address\": \"0xaaa\",\n        \"workspace\": {\"id\": 1, \"name\": \"1\"},\n    }\n\n    await extension.run_stash()\n\n    extension.backend.toggle_floating.assert_not_called()\n    assert \"0xaaa\" not in extension._was_floating\n\n\n@pytest.mark.asyncio\nasync def test_stash_on_last_shown_window_clears_visibility(extension):\n    \"\"\"Removing the last shown window also clears the stash visibility state.\"\"\"\n    extension._was_floating[\"0xaaa\"] = False\n    extension.get_clients.return_value = [\n        {\"address\": \"0xaaa\"},\n    ]\n    await extension.run_stash_toggle()  # show\n    extension.backend.reset_mock()\n\n    extension.backend.execute_json.return_value = {\n        \"address\": \"0xaaa\",\n        \"workspace\": {\"id\": 1, \"name\": \"1\"},\n    }\n\n    await extension.run_stash()\n\n    extension.backend.move_window_to_workspace.assert_not_called()\n    assert extension._visible.get(\"default\", False) is False\n    assert \"default\" not in extension._shown_addresses\n\n\n# -- run_stash_toggle --\n\n\n@pytest.mark.asyncio\nasync def test_stash_toggle_show_moves_windows_to_active_workspace(extension):\n    \"\"\"Showing a stash moves windows from the special workspace to the active workspace.\"\"\"\n    extension.get_clients.return_value = [\n        {\"address\": \"0xaaa\"},\n        {\"address\": \"0xbbb\"},\n    ]\n\n    await extension.run_stash_toggle()\n\n    extension.get_clients.assert_called_with(workspace=\"special:st-default\")\n    extension.backend.move_window_to_workspace.assert_any_call(\"0xaaa\", \"1\", silent=True)\n    extension.backend.move_window_to_workspace.assert_any_call(\"0xbbb\", \"1\", silent=True)\n\n\n@pytest.mark.asyncio\nasync def test_stash_toggle_show_all_moves_are_silent(extension):\n    \"\"\"All window moves use movetoworkspacesilent.\"\"\"\n    extension.get_clients.return_value = [\n        {\"address\": \"0xaaa\"},\n        {\"address\": \"0xbbb\"},\n    ]\n\n    await extension.run_stash_toggle()\n\n    calls = extension.backend.move_window_to_workspace.call_args_list\n    assert calls[0].args == (\"0xaaa\", \"1\")\n    assert calls[0].kwargs == {\"silent\": True}\n    assert calls[1].args == (\"0xbbb\", \"1\")\n    assert calls[1].kwargs == {\"silent\": True}\n    extension.backend.focus_window.assert_not_called()\n\n\n@pytest.mark.asyncio\nasync def test_stash_toggle_show_does_not_toggle_floating(extension):\n    \"\"\"Showing a stash does not toggle floating — floating is set at stash time.\"\"\"\n    extension.get_clients.return_value = [\n        {\"address\": \"0xaaa\"},\n    ]\n\n    await extension.run_stash_toggle()\n\n    extension.backend.toggle_floating.assert_not_called()\n\n\n@pytest.mark.asyncio\nasync def test_stash_toggle_show_no_windows_is_noop(extension):\n    \"\"\"Showing an empty stash does nothing.\"\"\"\n    extension.get_clients.return_value = []\n\n    await extension.run_stash_toggle()\n\n    extension.backend.move_window_to_workspace.assert_not_called()\n\n\n@pytest.mark.asyncio\nasync def test_stash_toggle_show_custom_name(extension):\n    \"\"\"Showing a custom-named stash queries the right workspace.\"\"\"\n    extension.get_clients.return_value = [\n        {\"address\": \"0xaaa\"},\n    ]\n\n    await extension.run_stash_toggle(\"music\")\n\n    extension.get_clients.assert_called_with(workspace=\"special:st-music\")\n    extension.backend.move_window_to_workspace.assert_called_with(\"0xaaa\", \"1\", silent=True)\n\n\n@pytest.mark.asyncio\nasync def test_stash_toggle_hide_moves_windows_back(extension):\n    \"\"\"Hiding a shown stash moves tracked windows back to the special workspace.\"\"\"\n    extension.get_clients.return_value = [\n        {\"address\": \"0xaaa\"},\n        {\"address\": \"0xbbb\"},\n    ]\n\n    # Show first\n    await extension.run_stash_toggle()\n    extension.backend.reset_mock()\n\n    # Then hide\n    await extension.run_stash_toggle()\n\n    extension.backend.move_window_to_workspace.assert_any_call(\"0xaaa\", \"special:st-default\")\n    extension.backend.move_window_to_workspace.assert_any_call(\"0xbbb\", \"special:st-default\")\n\n\n@pytest.mark.asyncio\nasync def test_stash_toggle_hide_clears_tracking(extension):\n    \"\"\"After hiding, the stash is no longer considered visible.\"\"\"\n    extension.get_clients.return_value = [\n        {\"address\": \"0xaaa\"},\n    ]\n\n    await extension.run_stash_toggle()  # show\n    assert extension._visible[\"default\"] is True\n\n    await extension.run_stash_toggle()  # hide\n    assert extension._visible[\"default\"] is False\n    assert \"default\" not in extension._shown_addresses\n\n\n# -- style tagging --\n\n\n@pytest.mark.asyncio\nasync def test_on_reload_clears_old_rules_and_registers_new(styled_extension):\n    \"\"\"on_reload clears old rules then registers tag-matched window rules.\"\"\"\n    await styled_extension.on_reload()\n\n    commands = get_executed_commands(styled_extension.backend.execute)\n    assert (\"windowrule tag -stashed\", {\"base_command\": \"keyword\"}) in commands\n    assert (\"windowrule border_color rgb(ec8800), match:tag stashed\", {\"base_command\": \"keyword\"}) in commands\n    assert (\"windowrule border_size 3, match:tag stashed\", {\"base_command\": \"keyword\"}) in commands\n\n\n@pytest.mark.asyncio\nasync def test_on_reload_clears_rules_even_when_style_empty(extension):\n    \"\"\"on_reload clears old rules even when style config is empty.\"\"\"\n    await extension.on_reload()\n\n    commands = get_executed_commands(extension.backend.execute)\n    assert commands == [(\"windowrule tag -stashed\", {\"base_command\": \"keyword\"})]\n\n\n@pytest.mark.asyncio\nasync def test_stash_tags_window_when_style_configured(styled_extension):\n    \"\"\"Stashing a window tags it when style is configured.\"\"\"\n    styled_extension.backend.execute_json.return_value = {\n        \"address\": \"0xabc\",\n        \"floating\": True,\n        \"workspace\": {\"id\": 1, \"name\": \"1\"},\n    }\n\n    await styled_extension.run_stash()\n\n    commands = get_executed_commands(styled_extension.backend.execute)\n    assert commands == [(\"tagwindow +stashed address:0xabc\", {})]\n\n\n@pytest.mark.asyncio\nasync def test_stash_does_not_tag_without_style(extension):\n    \"\"\"Stashing a window does not tag it when style is empty.\"\"\"\n    extension.backend.execute_json.return_value = {\n        \"address\": \"0xabc\",\n        \"floating\": True,\n        \"workspace\": {\"id\": 1, \"name\": \"1\"},\n    }\n\n    await extension.run_stash()\n\n    commands = get_executed_commands(extension.backend.execute)\n    assert all(\"tagwindow\" not in cmd for cmd, _ in commands)\n\n\n@pytest.mark.asyncio\nasync def test_unstash_untags_window_when_style_configured(styled_extension):\n    \"\"\"Unstashing a window from special workspace untags it.\"\"\"\n    styled_extension.backend.execute_json.return_value = {\n        \"address\": \"0xabc\",\n        \"workspace\": {\"id\": -99, \"name\": \"special:st-default\"},\n    }\n\n    await styled_extension.run_stash()\n\n    commands = get_executed_commands(styled_extension.backend.execute)\n    assert commands == [(\"tagwindow -stashed address:0xabc\", {})]\n\n\n@pytest.mark.asyncio\nasync def test_stash_on_shown_window_untags_when_style_configured(styled_extension):\n    \"\"\"Calling stash on a shown window untags it when style is configured.\"\"\"\n    styled_extension._was_floating[\"0xaaa\"] = True\n    styled_extension.get_clients.return_value = [\n        {\"address\": \"0xaaa\"},\n    ]\n    await styled_extension.run_stash_toggle()  # show\n    styled_extension.backend.reset_mock()\n\n    styled_extension.backend.execute_json.return_value = {\n        \"address\": \"0xaaa\",\n        \"workspace\": {\"id\": 1, \"name\": \"1\"},\n    }\n\n    await styled_extension.run_stash()\n\n    commands = get_executed_commands(styled_extension.backend.execute)\n    assert commands == [(\"tagwindow -stashed address:0xaaa\", {})]\n\n\n# -- event_closewindow --\n\n\n@pytest.mark.asyncio\nasync def test_closewindow_removes_from_was_floating(extension):\n    \"\"\"Closing a stashed window removes it from floating state tracking.\"\"\"\n    extension._was_floating[\"0xabc\"] = False\n\n    await extension.event_closewindow(\"abc\")\n\n    assert \"0xabc\" not in extension._was_floating\n\n\n@pytest.mark.asyncio\nasync def test_closewindow_removes_from_shown_addresses(extension):\n    \"\"\"Closing a shown window removes it from the group but keeps the group alive.\"\"\"\n    extension._shown_addresses[\"default\"] = [\"0xaaa\", \"0xbbb\"]\n    extension._visible[\"default\"] = True\n\n    await extension.event_closewindow(\"aaa\")\n\n    assert \"0xaaa\" not in extension._shown_addresses[\"default\"]\n    assert \"0xbbb\" in extension._shown_addresses[\"default\"]\n    assert extension._visible[\"default\"] is True\n\n\n@pytest.mark.asyncio\nasync def test_closewindow_clears_group_when_last_shown_window_closed(extension):\n    \"\"\"Closing the last shown window in a group clears the group and visibility.\"\"\"\n    extension._shown_addresses[\"default\"] = [\"0xaaa\"]\n    extension._visible[\"default\"] = True\n\n    await extension.event_closewindow(\"aaa\")\n\n    assert \"default\" not in extension._shown_addresses\n    assert extension._visible[\"default\"] is False\n\n\n@pytest.mark.asyncio\nasync def test_closewindow_noop_for_unknown_window(extension):\n    \"\"\"Closing an untracked window does not error or change state.\"\"\"\n    extension._was_floating[\"0xother\"] = True\n    extension._shown_addresses[\"default\"] = [\"0xother\"]\n    extension._visible[\"default\"] = True\n\n    await extension.event_closewindow(\"unknown\")\n\n    assert extension._was_floating == {\"0xother\": True}\n    assert extension._shown_addresses == {\"default\": [\"0xother\"]}\n    assert extension._visible[\"default\"] is True\n\n\n@pytest.mark.asyncio\nasync def test_closewindow_cleans_both_was_floating_and_shown(extension):\n    \"\"\"Closing a shown window cleans up both _was_floating and _shown_addresses.\"\"\"\n    extension._was_floating[\"0xaaa\"] = False\n    extension._shown_addresses[\"default\"] = [\"0xaaa\"]\n    extension._visible[\"default\"] = True\n\n    await extension.event_closewindow(\"aaa\")\n\n    assert \"0xaaa\" not in extension._was_floating\n    assert \"default\" not in extension._shown_addresses\n    assert extension._visible[\"default\"] is False\n"
  },
  {
    "path": "tests/test_plugin_system_notifier.py",
    "content": "import pytest\nimport pytest_asyncio\nimport asyncio\nfrom unittest.mock import Mock, AsyncMock, patch\nfrom pyprland.plugins.system_notifier import Extension, builtin_parsers\nfrom tests.conftest import make_extension\n\n\n@pytest_asyncio.fixture\nasync def extension():\n    ext = make_extension(\n        Extension,\n        config={\"parsers\": {}, \"sources\": [], \"default_color\": \"#000000\"},\n    )\n    yield ext\n    await ext.exit()\n\n\n@pytest.mark.asyncio\nasync def test_initialization(extension):\n    assert extension._tasks.running is False  # TaskManager starts stopped\n    assert extension.sources == {}\n    assert extension.parsers == {}\n\n\n@pytest.mark.asyncio\nasync def test_on_reload_builtin_parser(extension):\n    # Should load builtin parsers\n    await extension.on_reload()\n    assert \"journal\" in extension.parsers\n    assert extension._tasks.running  # TaskManager should be running after reload\n\n\n@pytest.mark.asyncio\nasync def test_on_reload_custom_parser(extension):\n    extension.config[\"parsers\"] = {\"custom\": [{\"pattern\": \"test\", \"color\": \"#123456\"}]}\n    await extension.on_reload()\n    assert \"custom\" in extension.parsers\n    assert \"journal\" in extension.parsers  # built-in should still be there\n\n\n@pytest.mark.asyncio\nasync def test_parser_matching(extension):\n    # Setup a queue for a custom parser\n    q = asyncio.Queue()\n    extension.parsers[\"test_parser\"] = q\n    extension.config[\"default_color\"] = \"#FFFFFF\"\n\n    # Start TaskManager so the parser loop runs\n    extension._tasks.start()\n\n    # Define parser properties\n    props = [{\"pattern\": r\"Error: (.*)\", \"filter\": r\"s/Error: (.*)/Something failed: \\1/\", \"color\": \"#FF0000\", \"duration\": 5}]\n\n    with patch(\"pyprland.plugins.system_notifier.convert_color\", side_effect=lambda x: int(x[1:], 16) if x.startswith(\"#\") else x):\n        task = asyncio.create_task(extension.start_parser(\"test_parser\", props))\n        await asyncio.sleep(0.01)\n        await q.put(\"Error: Database connection lost\")\n        await asyncio.sleep(0.01)\n\n        extension.backend.notify.assert_called_with(\"Something failed: Database connection lost\", color=0xFF0000, duration=5)\n\n    # Feed non-matching content\n    extension.backend.notify.reset_mock()\n    await q.put(\"Info: All systems go\")\n    await asyncio.sleep(0.01)\n    extension.backend.notify.assert_not_called()\n\n    # Clean up\n    await extension._tasks.stop()\n    task.cancel()\n    try:\n        await task\n    except asyncio.CancelledError:\n        pass\n\n\n@pytest.mark.asyncio\nasync def test_notify_send_option(extension):\n    extension.config[\"use_notify_send\"] = True\n    q = asyncio.Queue()\n    extension.parsers[\"test_parser\"] = q\n\n    # Start TaskManager so the parser loop runs\n    extension._tasks.start()\n\n    props = [{\"pattern\": r\"Match me\", \"duration\": 2}]\n\n    with patch(\"pyprland.plugins.system_notifier.notify_send\", new_callable=AsyncMock) as mock_notify_send:\n        task = asyncio.create_task(extension.start_parser(\"test_parser\", props))\n        await asyncio.sleep(0.01)\n\n        await q.put(\"Match me\")\n        await asyncio.sleep(0.01)\n\n        # Should use notify_send instead of self.backend.notify\n        extension.backend.notify.assert_not_called()\n\n        mock_notify_send.assert_called_once()\n        args, kwargs = mock_notify_send.call_args\n        assert args[0] == \"Match me\"\n        assert kwargs[\"duration\"] == 2000\n\n        await extension._tasks.stop()\n        task.cancel()\n        try:\n            await task\n        except asyncio.CancelledError:\n            pass\n\n\n@pytest.mark.asyncio\nasync def test_exit_cleanup(extension):\n    # Start the TaskManager and add some sources\n    extension._tasks.start()\n\n    mock_proc = Mock()\n    mock_proc.stop = AsyncMock()\n    extension.sources[\"cmd\"] = mock_proc\n\n    await extension.exit()\n\n    assert extension._tasks.running is False\n    mock_proc.stop.assert_called_once()\n    assert extension.sources == {}\n"
  },
  {
    "path": "tests/test_plugin_toggle_dpms.py",
    "content": "import pytest\nfrom unittest.mock import AsyncMock\n\nfrom pyprland.plugins.toggle_dpms import Extension\nfrom tests.conftest import make_extension\nfrom tests.testtools import get_executed_commands\n\n\n@pytest.fixture\ndef extension():\n    ext = make_extension(Extension)\n    # Mocking monitor list\n    ext.backend.get_monitors = AsyncMock(return_value=[{\"name\": \"DP-1\", \"dpmsStatus\": True}, {\"name\": \"DP-2\", \"dpmsStatus\": True}])\n    return ext\n\n\n@pytest.mark.asyncio\nasync def test_run_toggle_dpms_off(extension):\n    # Initial state: monitors are on (dpmsStatus: True)\n    await extension.run_toggle_dpms()\n    commands = get_executed_commands(extension.backend.execute)\n    assert (\"dpms off\", {}) in commands\n\n\n@pytest.mark.asyncio\nasync def test_run_toggle_dpms_on(extension):\n    # First call: monitors are ON, should turn OFF\n    await extension.run_toggle_dpms()\n    commands = get_executed_commands(extension.backend.execute)\n    assert (\"dpms off\", {}) in commands\n\n    extension.backend.execute.reset_mock()\n\n    # Change state to OFF for the second call\n    extension.backend.get_monitors.return_value = [{\"name\": \"DP-1\", \"dpmsStatus\": False}, {\"name\": \"DP-2\", \"dpmsStatus\": False}]\n\n    # Second toggle should turn it on\n    await extension.run_toggle_dpms()\n    commands = get_executed_commands(extension.backend.execute)\n    assert (\"dpms on\", {}) in commands\n"
  },
  {
    "path": "tests/test_plugin_toggle_special.py",
    "content": "import pytest\n\nfrom pyprland.plugins.toggle_special import Extension\nfrom tests.conftest import make_extension\nfrom tests.testtools import get_executed_commands\n\n\n@pytest.fixture\ndef extension():\n    return make_extension(Extension)\n\n\n@pytest.mark.asyncio\nasync def test_run_toggle_special_minimize(extension):\n    # Current window is in a normal workspace (id >= 1)\n    # Should move to special workspace\n    extension.backend.execute_json.return_value = {\"address\": \"0x123\", \"workspace\": {\"id\": 1}}\n\n    await extension.run_toggle_special(\"minimized\")\n\n    extension.backend.move_window_to_workspace.assert_called_with(\"0x123\", \"special:minimized\")\n\n\n@pytest.mark.asyncio\nasync def test_run_toggle_special_restore(extension):\n    # Current window is in a special workspace (id < 1)\n    # Should toggle special workspace, move back to active, and focus\n    extension.backend.execute_json.return_value = {\"address\": \"0x123\", \"workspace\": {\"id\": -99}}\n\n    await extension.run_toggle_special(\"minimized\")\n\n    commands = get_executed_commands(extension.backend.execute)\n    cmd_strings = [c for c, _ in commands]\n    assert cmd_strings == [\n        f\"movetoworkspacesilent {extension.state.active_workspace},address:0x123\",\n        \"togglespecialworkspace minimized\",\n        \"focuswindow address:0x123\",\n    ]\n"
  },
  {
    "path": "tests/test_plugin_wallpapers.py",
    "content": "import asyncio\nfrom pathlib import Path\nfrom unittest.mock import AsyncMock, Mock, patch\n\nimport pytest\n\nfrom pyprland.plugins.wallpapers import Extension, OnlineState\nfrom pyprland.plugins.wallpapers.models import Theme\nfrom pyprland.plugins.wallpapers.online import OnlineFetcher\nfrom tests.conftest import make_extension\n\n\n@pytest.fixture\ndef extension(mocker, test_logger):\n    ext = make_extension(\n        Extension,\n        logger=test_logger,\n        config={\"path\": \"/tmp/wallpapers\", \"extensions\": [\"png\", \"jpg\"], \"recurse\": False},\n    )\n    # Configure backend methods with specific return values\n    ext.backend.execute_json.return_value = [{\"name\": \"DP-1\", \"width\": 1920, \"height\": 1080, \"transform\": 0, \"scale\": 1.0}]\n    return ext\n\n\n@pytest.mark.asyncio\nasync def test_on_reload(extension, mocker):\n    # Mock expand_path\n    mocker.patch(\"pyprland.plugins.wallpapers.expand_path\", side_effect=lambda x: x)\n\n    # Mock get_files_with_ext to return an async iterator (yields full paths like the real function)\n    async def mock_get_files(*args, **kwargs):\n        yield \"/tmp/wallpapers/wp1.png\"\n        yield \"/tmp/wallpapers/wp2.jpg\"\n\n    mocker.patch(\"pyprland.plugins.wallpapers.get_files_with_ext\", side_effect=mock_get_files)\n\n    # Mock TaskManager.create to prevent main loop from starting\n    # Use a simple lambda to avoid MagicMock introspection issues with coroutines\n    extension._tasks.create = Mock(return_value=Mock())\n    extension._loop_started = True  # Prevent the create call entirely\n\n    await extension.on_reload()\n\n    assert len(extension.image_list) == 2\n    assert \"/tmp/wallpapers/wp1.png\" in extension.image_list\n    assert \"/tmp/wallpapers/wp2.jpg\" in extension.image_list\n\n\n@pytest.mark.asyncio\nasync def test_select_next_image(extension):\n    extension.image_list = [\"/tmp/wallpapers/wp1.png\", \"/tmp/wallpapers/wp2.jpg\"]\n    extension.cur_image = \"/tmp/wallpapers/wp1.png\"\n\n    # Force random.random() to return 1.0 (>= online_ratio of 0.0, so picks local)\n    # Force random.choice to pick wp2\n    with (\n        patch(\"random.random\", return_value=1.0),\n        patch(\"random.choice\", return_value=\"/tmp/wallpapers/wp2.jpg\"),\n    ):\n        next_img = await extension.select_next_image()\n        assert next_img == \"/tmp/wallpapers/wp2.jpg\"\n        assert extension.cur_image == \"/tmp/wallpapers/wp2.jpg\"\n\n\n@pytest.mark.asyncio\nasync def test_run_wall_next(extension):\n    extension.next_background_event = asyncio.Event()\n    extension._paused = True\n\n    await extension.run_wall_next()\n\n    assert extension._paused is False\n    assert extension.next_background_event.is_set()\n\n\n@pytest.mark.asyncio\nasync def test_detect_theme(mocker, test_logger):\n    # Mock subprocess for gsettings\n    proc_mock = AsyncMock()\n    proc_mock.communicate.return_value = (b\"'prefer-dark'\\n\", b\"\")\n    proc_mock.returncode = 0\n\n    mocker.patch(\"asyncio.create_subprocess_shell\", return_value=proc_mock)\n\n    from pyprland.plugins.wallpapers.theme import detect_theme\n\n    theme = await detect_theme(test_logger)\n    assert theme == Theme.DARK\n\n\n@pytest.mark.asyncio\nasync def test_material_palette_generation():\n    # Just verify that it generates keys correctly based on the constant dictionary\n    rgb_list = [(255, 0, 0), (0, 255, 0), (0, 0, 255)]  # Red, Green, Blue\n\n    # Mock nicify_oklab and get_variant_color to avoid complex color math dependencies in test if not needed\n    # But since we imported them in the module, let's assume they work or mock them if they are external\n    # For now, let's test if the structure is correct\n\n    with (\n        patch(\"pyprland.plugins.wallpapers.colorutils.nicify_oklab\", side_effect=lambda rgb, **kwargs: rgb),\n        patch(\"pyprland.plugins.wallpapers.theme.get_variant_color\", return_value=(100, 100, 100)),\n    ):\n        # Simple process_color mock\n        def process_color(rgb):\n            return (0.0, 0.5, 0.5)  # hue, light, sat\n\n        from pyprland.plugins.wallpapers.theme import generate_palette\n\n        palette = generate_palette(rgb_list, process_color)\n\n        assert \"colors.primary.dark\" in palette\n        assert \"colors.secondary.light\" in palette\n        assert \"colors.surface.default.hex\" in palette\n\n\n@pytest.mark.asyncio\nasync def test_run_palette_terminal(extension, mocker):\n    \"\"\"Test palette command with terminal output.\"\"\"\n\n    # Mock detect_theme\n    async def mock_detect_theme(_):\n        return Theme.DARK\n\n    mocker.patch(\n        \"pyprland.plugins.wallpapers.detect_theme\",\n        side_effect=mock_detect_theme,\n    )\n\n    result = await extension.run_palette(\"#4285F4\")\n\n    assert result is not None\n    assert isinstance(result, str)\n    # Should contain terminal formatting\n    assert \"Primary:\" in result\n    assert \"colors.primary\" in result\n    assert \"\\033[\" in result  # ANSI escape codes\n\n\n@pytest.mark.asyncio\nasync def test_run_palette_json(extension, mocker):\n    \"\"\"Test palette command with JSON output.\"\"\"\n    import json\n\n    # Mock detect_theme\n    async def mock_detect_theme(_):\n        return Theme.DARK\n\n    mocker.patch(\n        \"pyprland.plugins.wallpapers.detect_theme\",\n        side_effect=mock_detect_theme,\n    )\n\n    result = await extension.run_palette(\"#FF5500 json\")\n\n    assert result is not None\n    # Should be valid JSON\n    parsed = json.loads(result)\n    assert \"variables\" in parsed\n    assert \"categories\" in parsed\n    assert \"filters\" in parsed\n\n\n@pytest.mark.asyncio\nasync def test_run_palette_default_color(extension, mocker):\n    \"\"\"Test palette command with default color when no image is set.\"\"\"\n\n    # Mock detect_theme\n    async def mock_detect_theme(_):\n        return Theme.DARK\n\n    mocker.patch(\n        \"pyprland.plugins.wallpapers.detect_theme\",\n        side_effect=mock_detect_theme,\n    )\n\n    # No current image set\n    extension.cur_image = \"\"\n\n    result = await extension.run_palette(\"json\")\n\n    assert result is not None\n    # Should use default Google blue\n    import json\n\n    parsed = json.loads(result)\n    assert \"variables\" in parsed\n\n\n@pytest.mark.asyncio\nasync def test_run_color(extension, mocker):\n    \"\"\"Test color command generates templates.\"\"\"\n    # Mock _generate_templates\n    extension._generate_templates = AsyncMock()\n\n    await extension.run_color(\"#FF5500\")\n\n    extension._generate_templates.assert_called_once_with(\"color-#FF5500\", \"#FF5500\")\n\n\n@pytest.mark.asyncio\nasync def test_run_color_with_scheme(extension, mocker):\n    \"\"\"Test color command with color scheme.\"\"\"\n    # Mock _generate_templates\n    extension._generate_templates = AsyncMock()\n\n    await extension.run_color(\"#FF5500 pastel\")\n\n    extension._generate_templates.assert_called_once_with(\"color-#FF5500\", \"#FF5500\")\n    assert extension.config[\"color_scheme\"] == \"pastel\"\n\n\n# --- Prefetch Tests ---\n\n\n@pytest.fixture\ndef online_extension(mocker, test_logger):\n    \"\"\"Extension with online fetching enabled.\"\"\"\n    ext = make_extension(\n        Extension,\n        logger=test_logger,\n        config={\"path\": \"/tmp/wallpapers\", \"extensions\": [\"png\", \"jpg\"], \"online_ratio\": 0.5},\n    )\n    # Configure backend methods with specific return values\n    ext.backend.execute_json.return_value = [{\"name\": \"DP-1\", \"width\": 1920, \"height\": 1080, \"transform\": 0, \"scale\": 1.0}]\n    # Initialize image_list\n    ext.image_list = []\n    # Set up mock online state\n    mock_fetcher = AsyncMock(spec=OnlineFetcher)\n    mock_fetcher.get_image = AsyncMock(return_value=Path(\"/tmp/wallpapers/online/test.jpg\"))\n    ext._online = OnlineState(\n        fetcher=mock_fetcher,\n        folder_path=Path(\"/tmp/wallpapers/online\"),\n        cache=Mock(),\n        rounded_cache=Mock(),\n        prefetched_path=None,\n    )\n    return ext\n\n\n@pytest.mark.asyncio\nasync def test_fetch_online_image_uses_prefetched(online_extension, mocker):\n    \"\"\"_fetch_online_image() uses prefetched path if available.\"\"\"\n    # Set prefetched path\n    online_extension._online.prefetched_path = \"/tmp/wallpapers/online/prefetched.jpg\"\n\n    # Mock aiexists to return True (file exists)\n    mocker.patch(\"pyprland.plugins.wallpapers.aiexists\", return_value=True)\n\n    result = await online_extension._fetch_online_image()\n\n    assert result == \"/tmp/wallpapers/online/prefetched.jpg\"\n    # Prefetched path should be cleared after use\n    assert online_extension._online.prefetched_path is None\n    # Fetcher should NOT be called since we used prefetched\n    online_extension._online.fetcher.get_image.assert_not_called()\n\n\n@pytest.mark.asyncio\nasync def test_fetch_online_image_prefetched_missing(online_extension, mocker):\n    \"\"\"_fetch_online_image() falls back to fetcher if prefetched file is gone.\"\"\"\n    # Set prefetched path\n    online_extension._online.prefetched_path = \"/tmp/wallpapers/online/deleted.jpg\"\n\n    # Mock aiexists to return False (file was deleted)\n    mocker.patch(\"pyprland.plugins.wallpapers.aiexists\", return_value=False)\n\n    # Mock fetch_monitors\n    mocker.patch(\n        \"pyprland.plugins.wallpapers.fetch_monitors\",\n        return_value=[Mock(width=1920, height=1080, transform=0)],\n    )\n\n    result = await online_extension._fetch_online_image()\n\n    # Should have called fetcher since prefetched file was missing\n    online_extension._online.fetcher.get_image.assert_called_once()\n    assert result == str(Path(\"/tmp/wallpapers/online/test.jpg\"))\n\n\n@pytest.mark.asyncio\nasync def test_prefetch_online_image_success(online_extension, mocker):\n    \"\"\"_prefetch_online_image() downloads and stores path in OnlineState.\"\"\"\n    # Mock fetch_monitors\n    mocker.patch(\n        \"pyprland.plugins.wallpapers.fetch_monitors\",\n        return_value=[Mock(width=1920, height=1080, transform=0)],\n    )\n\n    # Ensure no prefetched path initially\n    assert online_extension._online.prefetched_path is None\n\n    await online_extension._prefetch_online_image()\n\n    # Path should be stored\n    assert online_extension._online.prefetched_path == \"/tmp/wallpapers/online/test.jpg\"\n    # Should be added to image_list\n    assert \"/tmp/wallpapers/online/test.jpg\" in online_extension.image_list\n\n\n@pytest.mark.asyncio\nasync def test_prefetch_online_image_retry(online_extension, mocker):\n    \"\"\"_prefetch_online_image() retries on failure with exponential backoff.\"\"\"\n    # Mock fetch_monitors\n    mocker.patch(\n        \"pyprland.plugins.wallpapers.fetch_monitors\",\n        return_value=[Mock(width=1920, height=1080, transform=0)],\n    )\n\n    # Mock fetcher to fail twice then succeed\n    online_extension._online.fetcher.get_image = AsyncMock(\n        side_effect=[\n            Exception(\"Network error\"),\n            Exception(\"Timeout\"),\n            Path(\"/tmp/wallpapers/online/retry_success.jpg\"),\n        ]\n    )\n\n    # Mock asyncio.sleep to track delays\n    sleep_calls = []\n\n    async def mock_sleep(delay):\n        sleep_calls.append(delay)\n        # Don't actually sleep in tests\n\n    mocker.patch(\"asyncio.sleep\", side_effect=mock_sleep)\n\n    await online_extension._prefetch_online_image()\n\n    # Should have retried with exponential backoff (2s, 4s)\n    assert len(sleep_calls) == 2\n    assert sleep_calls[0] == 2  # First retry: 2 seconds\n    assert sleep_calls[1] == 4  # Second retry: 4 seconds\n\n    # Should have succeeded on third attempt\n    assert online_extension._online.prefetched_path == \"/tmp/wallpapers/online/retry_success.jpg\"\n"
  },
  {
    "path": "tests/test_plugin_workspaces_follow_focus.py",
    "content": "import pytest\nfrom pytest_asyncio import fixture\n\nfrom .conftest import mocks\nfrom .testtools import wait_called\n\nworkspaces = [\n    {\n        \"id\": 1,\n        \"name\": \"1\",\n        \"monitor\": \"DP-1\",\n        \"monitorID\": 1,\n        \"windows\": 1,\n        \"hasfullscreen\": False,\n        \"lastwindow\": \"0x626abe441980\",\n        \"lastwindowtitle\": \"\",\n    },\n    {\n        \"id\": 9,\n        \"name\": \"9\",\n        \"monitor\": \"DP-1\",\n        \"monitorID\": 1,\n        \"windows\": 2,\n        \"hasfullscreen\": False,\n        \"lastwindow\": \"0x626abd058570\",\n        \"lastwindowtitle\": \"top\",\n    },\n    {\n        \"id\": -97,\n        \"name\": \"special:special:scratch_term\",\n        \"monitor\": \"DP-1\",\n        \"monitorID\": 1,\n        \"windows\": 1,\n        \"hasfullscreen\": False,\n        \"lastwindow\": \"0x626abd12c8e0\",\n        \"lastwindowtitle\": \"WLR Layout\",\n    },\n    {\n        \"id\": 2,\n        \"name\": \"2\",\n        \"monitor\": \"HDMI-A-1\",\n        \"monitorID\": 0,\n        \"windows\": 1,\n        \"hasfullscreen\": True,\n        \"lastwindow\": \"0x626abe440390\",\n        \"lastwindowtitle\": \"\",\n    },\n    {\n        \"id\": 3,\n        \"name\": \"3\",\n        \"monitor\": \"DP-1\",\n        \"monitorID\": 1,\n        \"windows\": 4,\n        \"hasfullscreen\": False,\n        \"lastwindow\": \"0x626abd0f8170\",\n        \"lastwindowtitle\": \"~\",\n    },\n    {\n        \"id\": 4,\n        \"name\": \"4\",\n        \"monitor\": \"DP-1\",\n        \"monitorID\": 1,\n        \"windows\": 1,\n        \"hasfullscreen\": False,\n        \"lastwindow\": \"0x626abe552190\",\n        \"lastwindowtitle\": \"\",\n    },\n]\n\n\n@fixture\nasync def layout_config(monkeypatch):\n    \"\"\"Enable the plugin.\"\"\"\n    config = {\"pyprland\": {\"plugins\": [\"workspaces_follow_focus\"]}}\n    monkeypatch.setattr(\"tomllib.load\", lambda x: config)\n    yield\n\n\n@pytest.mark.asyncio\n@pytest.mark.usefixtures(\"layout_config\", \"server_fixture\")\nasync def test_layout_center():\n    mocks.json_commands_result[\"workspaces\"] = workspaces\n    await mocks.send_event(\"focusedmon>>HDMI-A-1,1\")\n\n    await wait_called(mocks.hyprctl)  # Toggle + resize + move\n    assert mocks.hyprctl.call_args[0][0][0].startswith(\"moveworkspacetomonitor\")\n    mocks.hyprctl.reset_mock()\n\n    await mocks.pypr(\"change_workspace +1\")\n    await wait_called(mocks.hyprctl)\n    mocks.hyprctl.reset_mock()\n\n    await mocks.pypr(\"change_workspace -1\")\n    await wait_called(mocks.hyprctl)\n"
  },
  {
    "path": "tests/test_process.py",
    "content": "\"\"\"Tests for process lifecycle management utilities.\"\"\"\n\nimport asyncio\n\nimport pytest\n\nfrom pyprland.process import ManagedProcess, SupervisedProcess\n\n\nclass TestManagedProcess:\n    \"\"\"Tests for ManagedProcess.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_start_and_stop(self):\n        \"\"\"Test basic start and stop lifecycle.\"\"\"\n        proc = ManagedProcess()\n        assert not proc.is_alive\n        assert proc.pid is None\n\n        await proc.start(\"sleep 10\")\n        assert proc.is_alive\n        assert proc.pid is not None\n\n        returncode = await proc.stop()\n        assert not proc.is_alive\n        # SIGTERM returns negative signal number or None depending on platform\n        assert returncode is not None\n\n    @pytest.mark.asyncio\n    async def test_stop_not_started(self):\n        \"\"\"Test stop when never started returns None.\"\"\"\n        proc = ManagedProcess()\n        result = await proc.stop()\n        assert result is None\n\n    @pytest.mark.asyncio\n    async def test_stop_already_exited(self):\n        \"\"\"Test stop on already exited process.\"\"\"\n        proc = ManagedProcess()\n        await proc.start(\"true\")  # Exits immediately\n        await asyncio.sleep(0.1)  # Let it exit\n\n        returncode = await proc.stop()\n        assert returncode == 0\n\n    @pytest.mark.asyncio\n    async def test_start_stops_existing(self):\n        \"\"\"Test that start() stops existing process first.\"\"\"\n        proc = ManagedProcess()\n        await proc.start(\"sleep 10\")\n        first_pid = proc.pid\n\n        await proc.start(\"sleep 10\")\n        second_pid = proc.pid\n\n        assert first_pid != second_pid\n        await proc.stop()\n\n    @pytest.mark.asyncio\n    async def test_restart(self):\n        \"\"\"Test restart with same command.\"\"\"\n        proc = ManagedProcess()\n        await proc.start(\"sleep 10\")\n        first_pid = proc.pid\n\n        await proc.restart()\n        second_pid = proc.pid\n\n        assert first_pid != second_pid\n        assert proc.is_alive\n        await proc.stop()\n\n    @pytest.mark.asyncio\n    async def test_restart_without_start_raises(self):\n        \"\"\"Test restart without prior start raises RuntimeError.\"\"\"\n        proc = ManagedProcess()\n        with pytest.raises(RuntimeError, match=\"no command\"):\n            await proc.restart()\n\n    @pytest.mark.asyncio\n    async def test_wait(self):\n        \"\"\"Test wait for process completion.\"\"\"\n        proc = ManagedProcess()\n        await proc.start(\"sleep 0.1\")\n\n        returncode = await proc.wait()\n        assert returncode == 0\n        assert not proc.is_alive\n\n    @pytest.mark.asyncio\n    async def test_wait_without_start_raises(self):\n        \"\"\"Test wait without process raises RuntimeError.\"\"\"\n        proc = ManagedProcess()\n        with pytest.raises(RuntimeError, match=\"No process\"):\n            await proc.wait()\n\n    @pytest.mark.asyncio\n    async def test_returncode(self):\n        \"\"\"Test returncode property.\"\"\"\n        proc = ManagedProcess()\n        assert proc.returncode is None\n\n        await proc.start(\"exit 42\")\n        await proc.wait()\n        assert proc.returncode == 42\n\n    @pytest.mark.asyncio\n    async def test_iter_lines(self):\n        \"\"\"Test iterating over stdout lines.\"\"\"\n        proc = ManagedProcess()\n        await proc.start(\"printf 'line1\\nline2\\nline3\\n'\", stdout=asyncio.subprocess.PIPE)\n\n        lines = [line async for line in proc.iter_lines()]\n        assert lines == [\"line1\", \"line2\", \"line3\"]\n\n    @pytest.mark.asyncio\n    async def test_iter_lines_no_stdout_raises(self):\n        \"\"\"Test iter_lines without stdout pipe raises.\"\"\"\n        proc = ManagedProcess()\n        await proc.start(\"echo hello\")\n\n        with pytest.raises(RuntimeError, match=\"stdout not piped\"):\n            async for _ in proc.iter_lines():\n                pass\n\n        await proc.stop()\n\n    @pytest.mark.asyncio\n    async def test_iter_lines_not_started_raises(self):\n        \"\"\"Test iter_lines without process raises.\"\"\"\n        proc = ManagedProcess()\n        with pytest.raises(RuntimeError, match=\"No process\"):\n            async for _ in proc.iter_lines():\n                pass\n\n    @pytest.mark.asyncio\n    async def test_graceful_timeout(self):\n        \"\"\"Test that process is killed after graceful timeout.\"\"\"\n        # Use a process that ignores SIGTERM\n        proc = ManagedProcess(graceful_timeout=0.2)\n        await proc.start(\"trap '' TERM; sleep 10\")\n\n        # stop() should kill after timeout\n        returncode = await proc.stop()\n        assert not proc.is_alive\n        assert returncode is not None\n\n    @pytest.mark.asyncio\n    async def test_process_property(self):\n        \"\"\"Test accessing underlying process.\"\"\"\n        proc = ManagedProcess()\n        assert proc.process is None\n\n        await proc.start(\"sleep 10\")\n        assert proc.process is not None\n        assert proc.process.pid == proc.pid\n\n        await proc.stop()\n\n\nclass TestSupervisedProcess:\n    \"\"\"Tests for SupervisedProcess.\"\"\"\n\n    @pytest.mark.asyncio\n    async def test_start_and_stop(self):\n        \"\"\"Test basic supervised start and stop.\"\"\"\n        proc = SupervisedProcess()\n        await proc.start(\"sleep 10\")\n\n        # Wait for process to actually start (it's in a background task)\n        await asyncio.sleep(0.1)\n\n        assert proc.is_alive\n        assert proc.is_supervised\n\n        await proc.stop()\n        assert not proc.is_alive\n        assert not proc.is_supervised\n\n    @pytest.mark.asyncio\n    async def test_auto_restart(self):\n        \"\"\"Test that process auto-restarts on crash.\"\"\"\n        restart_count = 0\n\n        async def on_crash(proc: SupervisedProcess, code: int) -> None:\n            nonlocal restart_count\n            restart_count += 1\n\n        proc = SupervisedProcess(\n            cooldown=0.1,\n            min_runtime=0.0,\n            on_crash=on_crash,\n        )\n\n        # Start a process that exits immediately\n        await proc.start(\"exit 1\")\n\n        # Wait for a couple restarts\n        await asyncio.sleep(0.5)\n\n        await proc.stop()\n\n        # Should have restarted multiple times\n        assert restart_count >= 2\n\n    @pytest.mark.asyncio\n    async def test_cooldown(self):\n        \"\"\"Test that cooldown delays restarts for short-lived processes.\"\"\"\n        crash_times: list[float] = []\n\n        async def on_crash(proc: SupervisedProcess, code: int) -> None:\n            crash_times.append(asyncio.get_event_loop().time())\n\n        proc = SupervisedProcess(\n            cooldown=1.0,\n            min_runtime=0.5,\n            on_crash=on_crash,\n        )\n\n        await proc.start(\"exit 1\")\n\n        # Wait for 2 crashes\n        while len(crash_times) < 2:\n            await asyncio.sleep(0.1)\n\n        await proc.stop()\n\n        # Second crash should be delayed due to cooldown\n        time_between = crash_times[1] - crash_times[0]\n        assert time_between >= 0.2  # At least some delay\n\n    @pytest.mark.asyncio\n    async def test_on_crash_receives_returncode(self):\n        \"\"\"Test that on_crash callback receives correct return code.\"\"\"\n        received_codes: list[int] = []\n\n        async def on_crash(proc: SupervisedProcess, code: int) -> None:\n            received_codes.append(code)\n\n        proc = SupervisedProcess(\n            cooldown=0.1,\n            min_runtime=0.0,\n            on_crash=on_crash,\n        )\n\n        await proc.start(\"exit 42\")\n\n        # Wait for at least one crash\n        while not received_codes:\n            await asyncio.sleep(0.1)\n\n        await proc.stop()\n\n        assert 42 in received_codes\n\n    @pytest.mark.asyncio\n    async def test_stop_cancels_supervision(self):\n        \"\"\"Test that stop() properly cancels the supervision task.\"\"\"\n        proc = SupervisedProcess()\n        await proc.start(\"sleep 10\")\n\n        assert proc._supervisor_task is not None\n        assert not proc._supervisor_task.done()\n\n        await proc.stop()\n\n        assert proc._supervisor_task is None\n        assert not proc.is_supervised\n\n    @pytest.mark.asyncio\n    async def test_start_stops_previous(self):\n        \"\"\"Test that start() stops previous supervision.\"\"\"\n        proc = SupervisedProcess()\n        await proc.start(\"sleep 10\")\n        first_task = proc._supervisor_task\n\n        await proc.start(\"sleep 10\")\n        second_task = proc._supervisor_task\n\n        assert first_task != second_task\n        assert first_task is None or first_task.done()\n\n        await proc.stop()\n\n    @pytest.mark.asyncio\n    async def test_no_on_crash_callback(self):\n        \"\"\"Test supervision works without on_crash callback.\"\"\"\n        proc = SupervisedProcess(\n            cooldown=0.1,\n            min_runtime=0.0,\n        )\n\n        await proc.start(\"exit 1\")\n        await asyncio.sleep(0.3)\n\n        # Should still be supervised even without callback\n        assert proc.is_supervised\n\n        await proc.stop()\n"
  },
  {
    "path": "tests/test_pyprland.py",
    "content": "from typing import cast\nfrom unittest.mock import AsyncMock, Mock\n\nimport pytest\nimport tomllib\n\nfrom .conftest import mocks as tst\nfrom .testtools import wait_called\n\nfrom pyprland.manager import Pyprland\n\n\n@pytest.mark.usefixtures(\"sample1_config\", \"server_fixture\")\n@pytest.mark.asyncio\nasync def test_reload(monkeypatch):\n    config = \"\"\"\n[pyprland]\nplugins = [\"monitors\"]\n\n[monitors]\nstartup_relayout = true\nplacement = {}\n\"\"\"\n    master = cast(Pyprland, tst.pyprland_instance)\n    cfg = master.plugins[\"monitors\"].config\n\n    placement = cfg[\"placement\"]\n    bool_opt = cfg[\"startup_relayout\"]\n\n    load_proxy = Mock(wraps=lambda x: tomllib.loads(config))\n\n    # Wrap the plugin's load_config to detect when config is updated\n    original_load_config = master.plugins[\"monitors\"].load_config\n    load_config_proxy = AsyncMock(wraps=original_load_config)\n    master.plugins[\"monitors\"].load_config = load_config_proxy\n\n    monkeypatch.setattr(\"tomllib.load\", load_proxy)\n    await tst.pypr(\"reload\")\n\n    await wait_called(load_config_proxy)\n\n    assert placement is cfg[\"placement\"]\n    assert bool_opt != cfg[\"startup_relayout\"]\n    assert cfg[\"startup_relayout\"] is True\n    assert cfg[\"placement\"] == {}\n"
  },
  {
    "path": "tests/test_scratchpad_vulnerabilities.py",
    "content": "import asyncio\nimport pytest\nfrom pytest_asyncio import fixture\nfrom .conftest import mocks\nfrom .testtools import wait_called\nfrom unittest.mock import AsyncMock\n\n\n# Setup two scratchpads for multi-scratchpad tests\n@fixture\ndef multi_scratchpads(monkeypatch, mocker):\n    d = {\n        \"pyprland\": {\"plugins\": [\"scratchpads\"]},\n        \"scratchpads\": {\n            \"term\": {\n                \"command\": \"ls\",\n                \"lazy\": True,\n                \"class\": \"scratch-term\",\n                \"process_tracking\": False,\n            },\n            \"volume\": {\n                \"command\": \"pavucontrol\",\n                \"lazy\": True,\n                \"class\": \"scratch-volume\",\n                \"process_tracking\": False,\n            },\n        },\n    }\n    monkeypatch.setattr(\"tomllib.load\", lambda x: d)\n\n\n@fixture\ndef subprocess_shell_mock(mocker):\n    # Mocking the asyncio.create_subprocess_shell function with incrementing PIDs\n    mocked_subprocess_shell = mocker.patch(\"asyncio.create_subprocess_shell\", name=\"mocked_shell_command\")\n\n    class MockProcess:\n        _pid_counter = 3001\n\n        def __init__(self):\n            self.pid = MockProcess._pid_counter\n            MockProcess._pid_counter += 1\n            self.stderr = AsyncMock(return_code=\"\")\n            self.stdout = AsyncMock(return_code=\"\")\n            self.terminate = mocker.Mock()\n            self.wait = AsyncMock()\n            self.kill = mocker.Mock()\n            self.returncode = 0\n\n        async def communicate(self):\n            return b\"\", b\"\"\n\n    async def create_proc(*args, **kwargs):\n        return MockProcess()\n\n    mocked_subprocess_shell.side_effect = create_proc\n    return mocked_subprocess_shell\n\n\n@fixture\ndef mock_aioops(mocker):\n    # Mock aiexists to return True for /proc/PID checks, but allow selective failure\n    # We'll use a set to track \"dead\" PIDs\n    dead_pids = set()\n\n    async def mock_aiexists(path):\n        # path format is usually /proc/<pid>\n        if path.startswith(\"/proc/\"):\n            try:\n                pid = int(path.split(\"/\")[2])\n                if pid in dead_pids:\n                    return False\n            except (ValueError, IndexError):\n                pass\n        return True\n\n    # Patch aiexists in both locations\n    mocker.patch(\"pyprland.aioops.aiexists\", side_effect=mock_aiexists)\n    # mocker.patch(\"pyprland.plugins.scratchpads.helpers.aiexists\", side_effect=mock_aiexists)\n    mocker.patch(\"pyprland.plugins.scratchpads.objects.aiexists\", side_effect=mock_aiexists)\n\n    # Expose the dead_pids set to tests\n    mock_aiexists.dead_pids = dead_pids\n\n    # Mock aiopen for reading /proc/PID/status\n    mock_file = mocker.MagicMock()\n\n    # Make readlines return a list (it's awaited in the code: await f.readlines())\n    future = asyncio.Future()\n    future.set_result([\"State: S (sleeping)\\n\"])\n    mock_file.readlines.return_value = future\n\n    # Make the file object an async context manager\n    async def enter(*args, **kwargs):\n        return mock_file\n\n    async def exit(*args, **kwargs):\n        return None\n\n    mock_file.__aenter__ = enter\n    mock_file.__aexit__ = exit\n\n    # Patch aiopen to return this context manager\n    def mock_aiopen(*args, **kwargs):\n        return mock_file\n\n    mocker.patch(\"pyprland.aioops.aiopen\", side_effect=mock_aiopen)\n    # mocker.patch(\"pyprland.plugins.scratchpads.helpers.aiopen\", side_effect=mock_aiopen)\n    mocker.patch(\"pyprland.plugins.scratchpads.objects.aiopen\", side_effect=mock_aiopen)\n    return mock_aiexists\n\n\ndef gen_call_set(call_list: list) -> set[str]:\n    \"\"\"Generate a set of calls from a list of calls.\"\"\"\n    call_set: set[str] = set()\n    for item in call_list:\n        if isinstance(item, str):\n            call_set.add(item)\n        else:\n            call_set.update(gen_call_set(item))\n    return call_set\n\n\nasync def _send_window_events(address=\"12345677890\", klass=\"scratch-term\", title=\"my fake terminal\"):\n    await mocks.send_event(f\"openwindow>>address:0x{address},1,{klass},{title}\")\n    await mocks.send_event(\"activewindowv2>>44444677890\")\n    await mocks.send_event(f\"activewindowv2>>{address}\")\n\n\nCLIENT_CONFIG = [\n    {\n        \"address\": \"0x12345677890\",  # term\n        \"mapped\": True,\n        \"hidden\": False,\n        \"at\": [100, 100],\n        \"size\": [500, 500],\n        \"workspace\": {\"id\": 1, \"name\": \"1\"},\n        \"floating\": False,\n        \"monitor\": 0,\n        \"class\": \"scratch-term\",\n        \"title\": \"my fake terminal\",\n        \"pid\": 1001,  # Matches MockProcess first PID\n        \"pinned\": False,\n    },\n    {\n        \"address\": \"0xabcdef12345\",  # volume\n        \"mapped\": True,\n        \"hidden\": False,\n        \"at\": [200, 200],\n        \"size\": [400, 400],\n        \"workspace\": {\"id\": 1, \"name\": \"1\"},\n        \"floating\": False,\n        \"monitor\": 0,\n        \"class\": \"scratch-volume\",\n        \"title\": \"volume control\",\n        \"pid\": 1002,  # Matches MockProcess second PID\n        \"pinned\": False,\n    },\n    {\n        \"address\": \"0x99999999999\",  # independent client\n        \"mapped\": True,\n        \"hidden\": False,\n        \"at\": [300, 300],\n        \"size\": [600, 600],\n        \"workspace\": {\"id\": 1, \"name\": \"1\"},\n        \"floating\": False,\n        \"monitor\": 0,\n        \"class\": \"firefox\",\n        \"title\": \"browser\",\n        \"pid\": 2001,\n        \"pinned\": False,\n    },\n]\n\n\n@pytest.mark.asyncio\nasync def test_shared_custody_conflict(multi_scratchpads, subprocess_shell_mock, server_fixture, mock_aioops):\n    \"\"\"\n    Test 1: The 'Shared Custody' Conflict\n    Verify behavior when a single window is attached to two different scratchpads simultaneously.\n    \"\"\"\n    mocks.json_commands_result[\"clients\"] = CLIENT_CONFIG\n\n    # 1. Initialize scratchpads\n    # We must ensure they are \"alive\" so they can be shown/hidden\n\n    # Start the toggle task for term\n    t1 = asyncio.create_task(mocks.pypr(\"toggle term\"))\n    await asyncio.sleep(0.5)\n    # Simulate window appearance\n    await _send_window_events(address=\"12345677890\", klass=\"scratch-term\")\n    await t1  # Wait for toggle to complete successfully\n\n    # Start the toggle task for volume\n    t2 = asyncio.create_task(mocks.pypr(\"toggle volume\"))\n    await asyncio.sleep(0.5)\n    await _send_window_events(address=\"abcdef12345\", klass=\"scratch-volume\")\n    await t2\n\n    mocks.hyprctl.reset_mock()\n\n    # 2. Focus independent client\n    client_addr = \"99999999999\"\n    await mocks.send_event(f\"activewindowv2>>{client_addr}\")\n    await asyncio.sleep(0.05)\n\n    # 3. Attach client to 'term'\n\n    # Focus term window (this sets self.last_focused to 'term')\n    await mocks.send_event(\"activewindowv2>>12345677890\")\n    await asyncio.sleep(0.05)\n\n    # Focus client (this is the window to be attached)\n    await mocks.send_event(f\"activewindowv2>>{client_addr}\")\n    await asyncio.sleep(0.05)\n\n    # Attach to term\n    mocks.hyprctl.reset_mock()\n    await mocks.pypr(\"attach\")\n    # Note: attach might not call hyprctl if \"pinned\" is False or if it fails silently,\n    # but with default config \"pinned\" is True, so it should call \"pin address:...\"\n    await wait_called(mocks.hyprctl)\n\n    # 4. Attach client to 'volume'\n    # Focus volume window (sets self.last_focused to 'volume')\n    await mocks.send_event(\"activewindowv2>>abcdef12345\")\n    await asyncio.sleep(0.05)\n\n    # Focus client\n    await mocks.send_event(f\"activewindowv2>>{client_addr}\")\n    await asyncio.sleep(0.05)\n\n    # Attach to volume\n    mocks.hyprctl.reset_mock()\n    await mocks.pypr(\"attach\")\n    await wait_called(mocks.hyprctl)\n\n    # 5. Hide 'term'\n    # Should hide 'term' BUT NOT the client (because volume stole it)\n    mocks.hyprctl.reset_mock()\n    await mocks.pypr(\"toggle term\")\n    await wait_called(mocks.hyprctl)\n\n    call_set = gen_call_set(mocks.hyprctl.call_args_list)\n\n    # Verify client is NOT moved to special workspace (hidden)\n    moved_client = any(f\"movetoworkspacesilent special:S-term,address:0x{client_addr}\" in str(c) for c in call_set)\n    assert not moved_client, \"Client should NOT be hidden when 'term' is toggled off (volume has custody)\"\n\n    # 6. Hide 'volume'\n    # 'volume' thinks it also owns the client. It should hide it.\n    mocks.hyprctl.reset_mock()\n    await mocks.pypr(\"toggle volume\")\n    await wait_called(mocks.hyprctl)\n\n    call_set = gen_call_set(mocks.hyprctl.call_args_list)\n    print(f\"Call set for volume hide: {call_set}\")\n\n    # The client should be moved to special:S-volume\n    moved_client_vol = any(f\"movetoworkspacesilent special:S-volume,address:0x{client_addr}\" in str(c) for c in call_set)\n    assert moved_client_vol, \"Client should be moved to volume scratchpad when volume is hidden\"\n\n    # 7. Show 'term'\n    # It will try to bring back the client from special:S-term, but it is in special:S-volume (or wherever volume put it)\n    mocks.hyprctl.reset_mock()\n    await mocks.pypr(\"toggle term\")\n    await wait_called(mocks.hyprctl)\n\n    call_set = gen_call_set(mocks.hyprctl.call_args_list)\n\n    # Check if it brings the client back\n    brought_back = False\n    for call in call_set:\n        if f\"address:0x{client_addr}\" in call and \"movetoworkspacesilent 1\" in call:\n            brought_back = True\n\n    assert not brought_back, \"Client should NOT be brought back when 'term' is shown, because 'volume' stole it\"\n\n\n@pytest.mark.asyncio\nasync def test_zombie_process_recovery(multi_scratchpads, subprocess_shell_mock, server_fixture, mock_aioops):\n    \"\"\"\n    Test 2: The 'Zombie' State (Process Desync)\n    Verify that if a scratchpad process dies (e.g. kill -9), the plugin detects it and respawns.\n    \"\"\"\n    # Start with NO clients\n    mocks.json_commands_result[\"clients\"] = []\n\n    # 1. Start 'term' normally\n    mocks.hyprctl.reset_mock()\n    t1 = asyncio.create_task(mocks.pypr(\"toggle term\"))\n    await asyncio.sleep(0.5)\n\n    # Now update clients to show it started with PID 3001\n    CLIENT_CONFIG[0][\"pid\"] = 3001\n    mocks.json_commands_result[\"clients\"] = [CLIENT_CONFIG[0]]\n\n    await _send_window_events(address=\"12345677890\", klass=\"scratch-term\")\n    await t1\n\n    # Verify it started\n    manager = mocks.pyprland_instance\n    plugin = manager.plugins[\"scratchpads\"]\n    term_scratch = plugin.scratches.get(\"term\")\n    assert term_scratch.pid == 3001\n    assert await term_scratch.is_alive()\n\n    # 2. Simulate \"kill -9\" (Process disappears from /proc)\n    mock_aioops.dead_pids.add(3001)\n\n    # To test zombie recovery, we MUST enable process_tracking.\n    term_scratch.conf.ref[\"process_tracking\"] = True\n\n    assert not await term_scratch.is_alive()\n\n    # 3. Toggle 'term' again\n    # Expected: Plugin detects death -> Respawns (PID 3002) -> Shows window\n    mocks.hyprctl.reset_mock()\n\n    t2 = asyncio.create_task(mocks.pypr(\"toggle term\"))\n    await asyncio.sleep(0.5)  # Wait for it to try spawning\n\n    # Update clients to show the NEW process (PID 3002)\n    # We simulate that the old window is gone/dead, and a new one appeared with new PID\n    CLIENT_CONFIG[0][\"pid\"] = 3002\n    mocks.json_commands_result[\"clients\"] = [CLIENT_CONFIG[0]]\n\n    # Send events for the NEW window\n    # Update the address to match what _send_window_events uses, or update the client config to match\n    # CLIENT_CONFIG[0] already has address=\"0x12345677890\" which matches \"12345677890\" used in _send_window_events\n    # The issue might be timing or how `wait_for_client` checks visibility.\n    # But wait, we are reusing the SAME address \"12345677890\" for the new window.\n    # The plugin might still have the old window info with that address.\n\n    # In a real scenario, a new window might have a different address.\n    # Let's try changing the address for the \"respawned\" window to be safe.\n    new_addr = \"12345677999\"\n    CLIENT_CONFIG[0][\"address\"] = f\"0x{new_addr}\"\n    mocks.json_commands_result[\"clients\"] = [CLIENT_CONFIG[0]]\n\n    await _send_window_events(address=new_addr, klass=\"scratch-term\")\n\n    await t2\n\n    # 4. Verify Recovery\n    assert term_scratch.pid != 3001\n    assert term_scratch.pid == 3002  # Should be the next available PID\n    assert term_scratch.visible\n\n    # Check that we actually ran the spawn command again\n    # subprocess_shell_mock should have been called twice (once init, once respawn)\n    assert subprocess_shell_mock.call_count == 2\n"
  },
  {
    "path": "tests/test_string_template.py",
    "content": "def test_templates():\n    \"\"\"Test the template function.\"\"\"\n    from pyprland.common import apply_variables\n\n    assert apply_variables(\"[one] $var [two] ${var2} [three]\", {\"one\": \"X\", \"three\": \"Y\"}) == \"X $var [two] ${var2} Y\"\n    assert (\n        apply_variables(\n            \"[one thing] $one one [one] ${var2} [one other thing] [one] [one thing]\",\n            {\"one\": \"X\", \"one thing\": \"Y\"},\n        )\n        == \"Y $one one X ${var2} [one other thing] X Y\"\n    )\n"
  },
  {
    "path": "tests/test_wallpapers_cache.py",
    "content": "\"\"\"Tests for ImageCache class.\"\"\"\n\nimport os\nimport time\n\nimport pytest\n\nfrom pyprland.plugins.wallpapers.cache import ImageCache\n\n\ndef test_cache_get_path(tmp_path):\n    \"\"\"get_path() returns deterministic path for key.\"\"\"\n    cache = ImageCache(cache_dir=tmp_path)\n    path1 = cache.get_path(\"test-key\", \"jpg\")\n    path2 = cache.get_path(\"test-key\", \"jpg\")\n    assert path1 == path2\n    assert str(path1).endswith(\".jpg\")\n\n\ndef test_cache_get_path_different_keys(tmp_path):\n    \"\"\"get_path() returns different paths for different keys.\"\"\"\n    cache = ImageCache(cache_dir=tmp_path)\n    path1 = cache.get_path(\"key1\", \"jpg\")\n    path2 = cache.get_path(\"key2\", \"jpg\")\n    assert path1 != path2\n\n\ndef test_cache_is_valid_no_ttl(tmp_path):\n    \"\"\"is_valid() returns True for existing files when no TTL.\"\"\"\n    cache = ImageCache(cache_dir=tmp_path)\n    path = tmp_path / \"test.jpg\"\n    path.write_bytes(b\"data\")\n    assert cache.is_valid(path) is True\n\n\ndef test_cache_is_valid_nonexistent(tmp_path):\n    \"\"\"is_valid() returns False for non-existent files.\"\"\"\n    cache = ImageCache(cache_dir=tmp_path)\n    path = tmp_path / \"nonexistent.jpg\"\n    assert cache.is_valid(path) is False\n\n\ndef test_cache_is_valid_with_ttl_fresh(tmp_path):\n    \"\"\"is_valid() returns True for fresh files within TTL.\"\"\"\n    cache = ImageCache(cache_dir=tmp_path, ttl=60)\n    path = tmp_path / \"test.jpg\"\n    path.write_bytes(b\"data\")\n    assert cache.is_valid(path) is True\n\n\ndef test_cache_is_valid_with_ttl_expired(tmp_path):\n    \"\"\"is_valid() returns False for expired files.\"\"\"\n    cache = ImageCache(cache_dir=tmp_path, ttl=1)\n    path = tmp_path / \"test.jpg\"\n    path.write_bytes(b\"data\")\n    # Set mtime to 2 seconds ago\n    os.utime(path, (time.time() - 2, time.time() - 2))\n    assert cache.is_valid(path) is False\n\n\ndef test_cache_get_hit(tmp_path):\n    \"\"\"get() returns path for valid cached file.\"\"\"\n    cache = ImageCache(cache_dir=tmp_path)\n    path = cache.get_path(\"key\", \"jpg\")\n    path.write_bytes(b\"data\")\n    assert cache.get(\"key\", \"jpg\") == path\n\n\ndef test_cache_get_miss(tmp_path):\n    \"\"\"get() returns None for missing file.\"\"\"\n    cache = ImageCache(cache_dir=tmp_path)\n    assert cache.get(\"nonexistent\", \"jpg\") is None\n\n\ndef test_cache_get_expired(tmp_path):\n    \"\"\"get() returns None for expired file.\"\"\"\n    cache = ImageCache(cache_dir=tmp_path, ttl=1)\n    path = cache.get_path(\"key\", \"jpg\")\n    path.write_bytes(b\"data\")\n    # Set mtime to 2 seconds ago\n    os.utime(path, (time.time() - 2, time.time() - 2))\n    assert cache.get(\"key\", \"jpg\") is None\n\n\n@pytest.mark.asyncio\nasync def test_cache_store(tmp_path):\n    \"\"\"store() writes data and returns path.\"\"\"\n    cache = ImageCache(cache_dir=tmp_path)\n    path = await cache.store(\"key\", b\"image data\", \"jpg\")\n    assert path.exists()\n    assert path.read_bytes() == b\"image data\"\n\n\n@pytest.mark.asyncio\nasync def test_cache_store_overwrite(tmp_path):\n    \"\"\"store() overwrites existing file.\"\"\"\n    cache = ImageCache(cache_dir=tmp_path)\n    path1 = await cache.store(\"key\", b\"old data\", \"jpg\")\n    path2 = await cache.store(\"key\", b\"new data\", \"jpg\")\n    assert path1 == path2\n    assert path2.read_bytes() == b\"new data\"\n\n\ndef test_cache_cleanup_with_ttl(tmp_path):\n    \"\"\"cleanup() removes files older than TTL.\"\"\"\n    cache = ImageCache(cache_dir=tmp_path, ttl=1)\n    old_file = tmp_path / \"old.jpg\"\n    new_file = tmp_path / \"new.jpg\"\n\n    old_file.write_bytes(b\"old data\")\n    os.utime(old_file, (time.time() - 2, time.time() - 2))\n\n    new_file.write_bytes(b\"new data\")\n\n    removed = cache.cleanup()\n\n    assert removed == 1\n    assert not old_file.exists()\n    assert new_file.exists()\n\n\ndef test_cache_cleanup_no_ttl(tmp_path):\n    \"\"\"cleanup() removes nothing when no TTL is set.\"\"\"\n    cache = ImageCache(cache_dir=tmp_path)\n    old_file = tmp_path / \"old.jpg\"\n    old_file.write_bytes(b\"data\")\n    os.utime(old_file, (time.time() - 1000, time.time() - 1000))\n\n    removed = cache.cleanup()\n\n    assert removed == 0\n    assert old_file.exists()\n\n\ndef test_cache_cleanup_with_custom_max_age(tmp_path):\n    \"\"\"cleanup() uses provided max_age over TTL.\"\"\"\n    cache = ImageCache(cache_dir=tmp_path, ttl=1000)  # High TTL\n    old_file = tmp_path / \"old.jpg\"\n    old_file.write_bytes(b\"data\")\n    os.utime(old_file, (time.time() - 5, time.time() - 5))\n\n    # Use custom max_age of 1 second\n    removed = cache.cleanup(max_age=1)\n\n    assert removed == 1\n    assert not old_file.exists()\n\n\ndef test_cache_auto_cleanup_max_size(tmp_path):\n    \"\"\"Auto-cleanup removes oldest files when max_size exceeded.\"\"\"\n    cache = ImageCache(cache_dir=tmp_path, max_size=100)\n\n    # Create files totaling > 100 bytes\n    old_file = tmp_path / \"old.jpg\"\n    new_file = tmp_path / \"new.jpg\"\n\n    old_file.write_bytes(b\"x\" * 60)\n    os.utime(old_file, (time.time() - 10, time.time() - 10))\n\n    new_file.write_bytes(b\"y\" * 60)\n\n    cache._auto_cleanup()\n\n    # Old file should be removed, new file kept\n    assert not old_file.exists()\n    assert new_file.exists()\n\n\ndef test_cache_auto_cleanup_max_count(tmp_path):\n    \"\"\"Auto-cleanup removes oldest files when max_count exceeded.\"\"\"\n    cache = ImageCache(cache_dir=tmp_path, max_count=1)\n\n    old_file = tmp_path / \"old.jpg\"\n    new_file = tmp_path / \"new.jpg\"\n\n    old_file.write_bytes(b\"old\")\n    os.utime(old_file, (time.time() - 10, time.time() - 10))\n\n    new_file.write_bytes(b\"new\")\n\n    cache._auto_cleanup()\n\n    assert not old_file.exists()\n    assert new_file.exists()\n\n\ndef test_cache_auto_cleanup_under_limits(tmp_path):\n    \"\"\"Auto-cleanup does nothing when under all limits.\"\"\"\n    cache = ImageCache(cache_dir=tmp_path, max_size=1000, max_count=10)\n\n    file1 = tmp_path / \"a.jpg\"\n    file2 = tmp_path / \"b.jpg\"\n\n    file1.write_bytes(b\"data1\")\n    file2.write_bytes(b\"data2\")\n\n    cache._auto_cleanup()\n\n    # Both files should still exist\n    assert file1.exists()\n    assert file2.exists()\n\n\ndef test_cache_auto_cleanup_no_limits(tmp_path):\n    \"\"\"Auto-cleanup does nothing when no limits are set.\"\"\"\n    cache = ImageCache(cache_dir=tmp_path)\n\n    file1 = tmp_path / \"a.jpg\"\n    file1.write_bytes(b\"data\")\n\n    cache._auto_cleanup()\n\n    assert file1.exists()\n\n\ndef test_cache_clear(tmp_path):\n    \"\"\"clear() removes all cached files.\"\"\"\n    cache = ImageCache(cache_dir=tmp_path)\n\n    (tmp_path / \"a.jpg\").write_bytes(b\"a\")\n    (tmp_path / \"b.jpg\").write_bytes(b\"b\")\n    (tmp_path / \"c.png\").write_bytes(b\"c\")\n\n    removed = cache.clear()\n\n    assert removed == 3\n    assert not (tmp_path / \"a.jpg\").exists()\n    assert not (tmp_path / \"b.jpg\").exists()\n    assert not (tmp_path / \"c.png\").exists()\n\n\ndef test_cache_clear_empty(tmp_path):\n    \"\"\"clear() returns 0 for empty cache.\"\"\"\n    cache = ImageCache(cache_dir=tmp_path)\n    removed = cache.clear()\n    assert removed == 0\n\n\ndef test_cache_hash_key(tmp_path):\n    \"\"\"_hash_key() generates consistent short hashes.\"\"\"\n    cache = ImageCache(cache_dir=tmp_path)\n\n    # Same key should produce same hash\n    hash1 = cache._hash_key(\"test-key\")\n    hash2 = cache._hash_key(\"test-key\")\n    assert hash1 == hash2\n\n    # Different keys should produce different hashes\n    hash3 = cache._hash_key(\"other-key\")\n    assert hash1 != hash3\n\n    # Hash should be 32 characters (truncated SHA256)\n    assert len(hash1) == 32\n\n\ndef test_cache_get_cache_size(tmp_path):\n    \"\"\"_get_cache_size() returns total size of cached files.\"\"\"\n    cache = ImageCache(cache_dir=tmp_path)\n\n    (tmp_path / \"a.jpg\").write_bytes(b\"a\" * 100)\n    (tmp_path / \"b.jpg\").write_bytes(b\"b\" * 50)\n\n    size = cache._get_cache_size()\n    assert size == 150\n\n\ndef test_cache_get_cache_count(tmp_path):\n    \"\"\"_get_cache_count() returns number of cached files.\"\"\"\n    cache = ImageCache(cache_dir=tmp_path)\n\n    (tmp_path / \"a.jpg\").write_bytes(b\"a\")\n    (tmp_path / \"b.jpg\").write_bytes(b\"b\")\n    (tmp_path / \"c.png\").write_bytes(b\"c\")\n\n    count = cache._get_cache_count()\n    assert count == 3\n"
  },
  {
    "path": "tests/test_wallpapers_colors.py",
    "content": "import pytest\nimport math\nfrom unittest.mock import Mock, patch, MagicMock\nfrom pyprland.plugins.wallpapers.colorutils import (\n    _build_hue_histogram,\n    _smooth_histogram,\n    _find_peaks,\n    _get_best_pixel_for_hue,\n    _calculate_hue_diff,\n    _select_colors_from_peaks,\n    get_dominant_colors,\n    nicify_oklab,\n    HUE_MAX,\n    MIN_SATURATION,\n    MIN_BRIGHTNESS,\n)\nimport colorsys\nfrom pyprland.plugins.wallpapers import Extension\nfrom pyprland.plugins.wallpapers.models import Theme\nfrom pyprland.plugins.wallpapers.theme import (\n    get_color_scheme_props,\n    generate_palette,\n)\nfrom pyprland.plugins.wallpapers.templates import (\n    _set_alpha,\n    _set_lightness,\n    _apply_filters,\n)\n\n# --- Histogram Tests ---\n\n\ndef test_build_hue_histogram():\n    # Setup simple pixels:\n    # 1. Valid: H=10, S=100, V=100\n    # 2. Invalid: Low saturation\n    # 3. Invalid: Low brightness\n    pixels = [(10, 100, 100), (20, MIN_SATURATION - 1, 100), (30, 100, MIN_BRIGHTNESS - 1)]\n\n    weights, indices = _build_hue_histogram(pixels)\n\n    assert len(weights) == HUE_MAX\n    assert len(indices) == HUE_MAX\n\n    # Check valid pixel\n    expected_weight = (100 * 100) / (255.0 * 255.0)\n    assert math.isclose(weights[10], expected_weight, rel_tol=1e-5)\n    assert indices[10] == [0]\n\n    # Check invalid pixels\n    assert weights[20] == 0.0\n    assert indices[20] == []\n    assert weights[30] == 0.0\n    assert indices[30] == []\n\n\ndef test_smooth_histogram():\n    weights = [0.0] * HUE_MAX\n    # Single spike\n    weights[10] = 16.0\n\n    smoothed = _smooth_histogram(weights)\n\n    # Kernel: [1, 4, 6, 4, 1] / 16\n    # So index 10 (center) should receive 16 * 6/16 = 6\n    assert smoothed[10] == 6.0\n    # Index 9: 16 * 4/16 = 4\n    assert smoothed[9] == 4.0\n    # Index 11: 16 * 4/16 = 4\n    assert smoothed[11] == 4.0\n\n    # Check wrap around\n    weights_wrap = [0.0] * HUE_MAX\n    weights_wrap[0] = 16.0\n    smoothed_wrap = _smooth_histogram(weights_wrap)\n    assert smoothed_wrap[0] == 6.0\n    assert smoothed_wrap[HUE_MAX - 1] == 4.0\n\n\ndef test_find_peaks():\n    weights = [0.0] * HUE_MAX\n    weights[10] = 5.0  # Peak\n    weights[9] = 2.0\n    weights[11] = 2.0\n\n    weights[50] = 10.0  # Higher Peak\n    weights[49] = 8.0\n    weights[51] = 8.0\n\n    peaks = _find_peaks(weights)\n\n    # Should be sorted by value descending\n    assert len(peaks) == 2\n    assert peaks[0] == (10.0, 50)\n    assert peaks[1] == (5.0, 10)\n\n\ndef test_calculate_hue_diff():\n    # Direct distance\n    assert _calculate_hue_diff(10, 20) == 10\n    # Wrap around distance\n    assert _calculate_hue_diff(10, 250) == 16  # 256 - 250 + 10 = 6 + 10 = 16 (assuming HUE_MAX 256)\n\n    # Test specific threshold logic\n    # HUE_DIFF_THRESHOLD is 128\n    assert _calculate_hue_diff(0, 128) == 128\n    assert _calculate_hue_diff(0, 129) == 127  # 256 - 129 = 127\n\n\n# --- Color Selection Tests ---\n\n\ndef test_get_best_pixel_for_hue():\n    target_hue = 10\n    indices = [[] for _ in range(HUE_MAX)]\n    indices[10] = [0, 1]\n\n    # px0: S=50, V=50 -> w=2500\n    # px1: S=100, V=100 -> w=10000 (Best)\n    hsv_pixels = [(10, 50, 50), (10, 100, 100)]\n    rgb_pixels = [(50, 50, 50), (100, 100, 100)]\n\n    result = _get_best_pixel_for_hue(target_hue, indices, hsv_pixels, rgb_pixels)\n    assert result == (100, 100, 100)\n\n    # Test neighbor lookup\n    indices[10] = []\n    indices[11] = [0]  # Neighbor\n    hsv_pixels = [(11, 50, 50)]\n    rgb_pixels = [(50, 50, 50)]\n\n    result = _get_best_pixel_for_hue(target_hue, indices, hsv_pixels, rgb_pixels)\n    assert result == (50, 50, 50)\n\n\ndef test_select_colors_from_peaks():\n    # Prepare dummy data\n    peaks = [(1.0, 10), (0.8, 40), (0.5, 70)]  # All far enough apart (>21)\n    indices = [[] for _ in range(HUE_MAX)]\n    # Populate indices for the peak hues\n    indices[10] = [0]\n    indices[40] = [1]\n    indices[70] = [2]\n\n    hsv_pixels = [(10, 100, 100), (40, 100, 100), (70, 100, 100)]\n    rgb_pixels = [(10, 10, 10), (40, 40, 40), (70, 70, 70)]\n\n    colors = _select_colors_from_peaks(peaks, indices, hsv_pixels, rgb_pixels)\n\n    assert len(colors) == 3\n    assert colors[0] == (10, 10, 10)\n    assert colors[1] == (40, 40, 40)\n    assert colors[2] == (70, 70, 70)\n\n\ndef test_get_dominant_colors_integration():\n    with patch(\"pyprland.plugins.wallpapers.colorutils.Image\") as MockImage:\n        # Mock Image.open context manager\n        mock_img = Mock()\n        MockImage.open.return_value.__enter__.return_value = mock_img\n\n        # Mock conversions\n        mock_rgb = Mock()\n        mock_rgb.getdata.return_value = [(255, 0, 0)] * 10\n\n        mock_hsv = Mock()\n        mock_hsv.getdata.return_value = [(0, 100, 100)] * 10  # Red pixels\n\n        # Need to handle chaining: img.convert(\"RGB\") -> mock_rgb, mock_rgb.convert(\"HSV\") -> mock_hsv\n        # And img.thumbnail\n\n        def convert_side_effect(mode):\n            if mode == \"RGB\":\n                return mock_rgb\n            if mode == \"HSV\":\n                return mock_hsv\n            return Mock()\n\n        mock_img.convert.side_effect = convert_side_effect\n        mock_rgb.convert.side_effect = convert_side_effect\n\n        colors = get_dominant_colors(\"dummy.jpg\")\n\n        assert len(colors) == 3\n        # Should be mostly red\n        assert colors[0] == (255, 0, 0)\n        # Should be padded\n        assert colors[1] == (255, 0, 0)\n\n\n# --- OkLab Tests ---\n\n\ndef test_nicify_oklab():\n    # Black\n    res = nicify_oklab((0, 0, 0))\n    # It will brighten it due to min_light constraint\n    assert res != (0, 0, 0)\n    assert res[0] > 0\n\n    # White\n    res = nicify_oklab((255, 255, 255))\n    # It might darken it due to max_light\n    assert res != (255, 255, 255)\n\n    # Simple roundtrip stability check (values shouldn't explode)\n    test_color = (100, 150, 200)\n    res = nicify_oklab(test_color)\n    assert 0 <= res[0] <= 255\n    assert 0 <= res[1] <= 255\n    assert 0 <= res[2] <= 255\n\n\n# --- OkLab Logical / Property Tests ---\n\n\ndef _oklab_hue(rgb: tuple[int, int, int]) -> float:\n    \"\"\"Helper: compute the OkLab hue angle (degrees) for an sRGB color.\"\"\"\n    SRGB_LINEAR_CUTOFF = 0.04045\n\n    def to_linear(val: float) -> float:\n        val = val / 255.0\n        return val / 12.92 if val <= SRGB_LINEAR_CUTOFF else pow((val + 0.055) / 1.055, 2.4)\n\n    r_lin = to_linear(rgb[0])\n    g_lin = to_linear(rgb[1])\n    b_lin = to_linear(rgb[2])\n\n    l_val = 0.4122214708 * r_lin + 0.5363325363 * g_lin + 0.0514459929 * b_lin\n    m_val = 0.2119034982 * r_lin + 0.6806995451 * g_lin + 0.1073969566 * b_lin\n    s_val = 0.0883024619 * r_lin + 0.0853627803 * g_lin + 0.8301696993 * b_lin\n\n    l_ = pow(l_val, 1 / 3)\n    m_ = pow(m_val, 1 / 3)\n    s_ = pow(s_val, 1 / 3)\n\n    a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_\n    b_v = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_\n\n    return math.degrees(math.atan2(b_v, a))\n\n\ndef _hls_saturation(rgb: tuple[int, int, int]) -> float:\n    \"\"\"Helper: return HLS saturation (0.0-1.0) for an sRGB color.\"\"\"\n    _, _, s = colorsys.rgb_to_hls(rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0)\n    return s\n\n\ndef _hue_distance(h1: float, h2: float) -> float:\n    \"\"\"Shortest angular distance between two hue angles in degrees.\"\"\"\n    diff = abs(h1 - h2) % 360\n    return min(diff, 360 - diff)\n\n\ndef test_nicify_oklab_hue_preservation():\n    \"\"\"nicify_oklab should preserve the perceptual hue of the input color.\n\n    #556677 is blue-ish and should produce a blue-ish output.\n    #557766 is green-ish and should produce a green-ish output.\n    The output hues should not converge or collapse.\n    \"\"\"\n    blue_ish = (0x55, 0x66, 0x77)  # #556677\n    green_ish = (0x55, 0x77, 0x66)  # #557766\n\n    result_blue = nicify_oklab(blue_ish)\n    result_green = nicify_oklab(green_ish)\n\n    input_hue_blue = _oklab_hue(blue_ish)\n    input_hue_green = _oklab_hue(green_ish)\n    output_hue_blue = _oklab_hue(result_blue)\n    output_hue_green = _oklab_hue(result_green)\n\n    # Hue should be approximately preserved (within 30 degrees)\n    assert _hue_distance(input_hue_blue, output_hue_blue) < 30, (\n        f\"Blue-ish hue shifted too much: input={input_hue_blue:.1f}° -> output={output_hue_blue:.1f}°\"\n    )\n    assert _hue_distance(input_hue_green, output_hue_green) < 30, (\n        f\"Green-ish hue shifted too much: input={input_hue_green:.1f}° -> output={output_hue_green:.1f}°\"\n    )\n\n\ndef test_nicify_oklab_distinct_outputs_for_distinct_inputs():\n    \"\"\"Two colors with clearly different dominant channels should produce\n    clearly different outputs, not collapse to similar-looking colors.\"\"\"\n    blue_ish = (0x55, 0x66, 0x77)  # #556677 - dominant blue channel\n    green_ish = (0x55, 0x77, 0x66)  # #557766 - dominant green channel\n\n    result_blue = nicify_oklab(blue_ish)\n    result_green = nicify_oklab(green_ish)\n\n    # Outputs must be different\n    assert result_blue != result_green, \"Distinct inputs should produce distinct outputs\"\n\n    # The output hues should be far apart (these colors differ by ~105° in OkLab hue)\n    output_hue_blue = _oklab_hue(result_blue)\n    output_hue_green = _oklab_hue(result_green)\n    hue_diff = _hue_distance(output_hue_blue, output_hue_green)\n    assert hue_diff > 60, f\"Output hues should remain well-separated, got only {hue_diff:.1f}° apart\"\n\n\ndef test_nicify_oklab_muted_input_not_oversaturated():\n    \"\"\"A muted/desaturated input should not become maximally saturated.\n\n    #556677 has very low saturation (~0.17 in HLS). After nicification with\n    default params, it should remain moderate — not become fully saturated.\n    \"\"\"\n    muted_color = (0x55, 0x66, 0x77)  # Low saturation input\n    result = nicify_oklab(muted_color)\n\n    sat = _hls_saturation(result)\n    # A muted input should not become fully saturated\n    assert sat < 0.95, f\"Muted input ({muted_color}) became oversaturated: HLS S={sat:.3f}\"\n\n\ndef test_nicify_oklab_saturation_params_are_effective():\n    \"\"\"The min_sat/max_sat parameters should meaningfully affect the output.\n\n    With low max_sat (neutral scheme), output should be less saturated than\n    with high min_sat (fluorescent scheme).\n    \"\"\"\n    color = (200, 50, 50)  # A vivid red\n\n    result_neutral = nicify_oklab(color, min_sat=0.05, max_sat=0.1)\n    result_fluorescent = nicify_oklab(color, min_sat=0.7, max_sat=1.0)\n\n    sat_neutral = _hls_saturation(result_neutral)\n    sat_fluorescent = _hls_saturation(result_fluorescent)\n\n    # Neutral should be significantly less saturated\n    assert sat_neutral < sat_fluorescent, f\"Neutral ({sat_neutral:.3f}) should be less saturated than fluorescent ({sat_fluorescent:.3f})\"\n    # And the difference should be meaningful (not just rounding)\n    assert sat_fluorescent - sat_neutral > 0.15, (\n        f\"Saturation difference too small: neutral={sat_neutral:.3f}, fluorescent={sat_fluorescent:.3f}\"\n    )\n\n\ndef test_nicify_oklab_chroma_not_always_capped():\n    \"\"\"The output chroma should vary with input — not always be the same fixed value.\n\n    A nearly-grey input and a vivid input should produce different chroma levels.\n    \"\"\"\n    grey_ish = (128, 128, 130)  # Almost grey\n    vivid = (255, 0, 0)  # Pure red\n\n    result_grey = nicify_oklab(grey_ish)\n    result_vivid = nicify_oklab(vivid)\n\n    sat_grey = _hls_saturation(result_grey)\n    sat_vivid = _hls_saturation(result_vivid)\n\n    # Both should be valid\n    assert 0 <= sat_grey <= 1.0\n    assert 0 <= sat_vivid <= 1.0\n\n    # The vivid input should produce higher saturation than the grey one\n    assert sat_vivid > sat_grey, f\"Vivid input should be more saturated than grey: vivid={sat_vivid:.3f}, grey={sat_grey:.3f}\"\n\n\ndef test_nicify_oklab_user_reported_bug():\n    \"\"\"Regression test for user-reported issue: pypr color #556677 and\n    pypr color #557766 should produce visually distinct palettes.\n\n    #556677 is blue-dominant and should yield a blue-ish primary.\n    #557766 is green-dominant and should yield a green-ish primary.\n    \"\"\"\n    blue_ish = (0x55, 0x66, 0x77)\n    green_ish = (0x55, 0x77, 0x66)\n\n    result_blue = nicify_oklab(blue_ish)\n    result_green = nicify_oklab(green_ish)\n\n    # Blue-ish: the blue channel should be dominant in output\n    assert result_blue[2] > result_blue[1], (\n        f\"#556677 output should have B > G: got RGB({result_blue[0]}, {result_blue[1]}, {result_blue[2]})\"\n    )\n\n    # Green-ish: the green channel should be dominant in output\n    assert result_green[1] > result_green[2], (\n        f\"#557766 output should have G > B: got RGB({result_green[0]}, {result_green[1]}, {result_green[2]})\"\n    )\n\n\n# --- Theme / Existing Tests Preserved Below ---\n\n\n@pytest.fixture\ndef wallpaper_plugin():\n    return Extension(\"wallpapers\")\n\n\ndef test_color_scheme_props(wallpaper_plugin):\n    schemes = {\n        \"pastel\": {\n            \"min_sat\": 0.2,\n            \"max_sat\": 0.5,\n            \"min_light\": 0.6,\n            \"max_light\": 0.9,\n        },\n        \"fluo\": {\n            \"min_sat\": 0.7,\n            \"max_sat\": 1.0,\n            \"min_light\": 0.4,\n            \"max_light\": 0.85,\n        },\n        \"fluo_variant\": {  # Test startswith(\"fluo\")\n            \"min_sat\": 0.7,\n            \"max_sat\": 1.0,\n            \"min_light\": 0.4,\n            \"max_light\": 0.85,\n        },\n        \"vibrant\": {\n            \"min_sat\": 0.5,\n            \"max_sat\": 0.8,\n            \"min_light\": 0.4,\n            \"max_light\": 0.85,\n        },\n        \"mellow\": {\n            \"min_sat\": 0.3,\n            \"max_sat\": 0.5,\n            \"min_light\": 0.4,\n            \"max_light\": 0.85,\n        },\n        \"neutral\": {\n            \"min_sat\": 0.05,\n            \"max_sat\": 0.1,\n            \"min_light\": 0.4,\n            \"max_light\": 0.65,\n        },\n        \"earth\": {\n            \"min_sat\": 0.2,\n            \"max_sat\": 0.6,\n            \"min_light\": 0.2,\n            \"max_light\": 0.6,\n        },\n    }\n\n    for scheme, expected in schemes.items():\n        props = get_color_scheme_props(scheme)\n        assert props == expected, f\"Failed for scheme: {scheme}\"\n\n\ndef test_color_scheme_props_default(wallpaper_plugin):\n    props = get_color_scheme_props(\"default\")\n    assert props == {}\n\n\ndef test_generate_palette_basic(wallpaper_plugin):\n    def mock_process_color(rgb):\n        return (0.0, 0.5, 1.0)  # H=0, L=0.5, S=1.0\n\n    rgb_list = [(255, 0, 0), (0, 255, 0), (0, 0, 255)]\n    wallpaper_plugin.config = {}\n\n    palette = generate_palette(rgb_list, mock_process_color, theme=Theme.DARK)\n\n    assert palette[\"scheme\"] == \"dark\"\n    # Basic check for existence\n    assert \"colors.primary\" in palette\n    assert palette[\"colors.primary\"].startswith(\"#\")\n\n\ndef test_generate_palette_islands(wallpaper_plugin):\n    # Mock return values for different inputs\n    def mock_process_color(rgb):\n        if rgb == (255, 0, 0):\n            return (0.0, 0.5, 1.0)  # Red\n        if rgb == (0, 255, 0):\n            return (0.33, 0.5, 1.0)  # Green\n        if rgb == (0, 0, 255):\n            return (0.66, 0.5, 1.0)  # Blue\n        return (0.0, 0.0, 0.0)\n\n    rgb_list = [(255, 0, 0), (0, 255, 0), (0, 0, 255)]\n    # variant=\"islands\" passed as argument now\n\n    palette = generate_palette(rgb_list, mock_process_color, variant_type=\"islands\")\n\n    # In islands mode:\n    # Primary uses 1st color (Red hue 0.0)\n    # Secondary uses 2nd color (Green hue 0.33)\n    # Tertiary uses 3rd color (Blue hue 0.66)\n\n    p_hex = palette[\"colors.primary\"]\n    s_hex = palette[\"colors.secondary\"]\n    t_hex = palette[\"colors.tertiary\"]\n\n    # They should be different in islands mode given different inputs\n    assert p_hex != s_hex\n    assert s_hex != t_hex\n    assert p_hex != t_hex\n\n\ndef test_set_alpha(wallpaper_plugin):\n    # Hex 6\n    assert _set_alpha(\"FFFFFF\", \"0.5\") == \"rgba(255, 255, 255, 0.5)\"\n    # Hex 7 (#)\n    assert _set_alpha(\"#FFFFFF\", \"0.5\") == \"rgba(255, 255, 255, 0.5)\"\n    # RGBA already\n    assert _set_alpha(\"rgba(0, 0, 0, 1.0)\", \"0.5\") == \"rgba(0, 0, 0, 0.5)\"\n\n\ndef test_set_lightness(wallpaper_plugin):\n    # Black -> lighter\n    # #000000 is H=0, L=0, S=0. +20% lightness = L=0.2\n    # Expect non-black\n    res = _set_lightness(\"#000000\", \"20\")\n    assert res != \"#000000\"\n\n    # White -> darker\n    # #FFFFFF is L=1.0. -20% = L=0.8\n    res = _set_lightness(\"#FFFFFF\", \"-20\")\n    assert res != \"#ffffff\"\n    assert res != \"#FFFFFF\"\n\n\n@pytest.mark.asyncio\nasync def test_apply_filters(wallpaper_plugin):\n    replacements = {\"color\": \"#FF0000\"}\n\n    # Simple replacement\n    content = \"Color is {{ color }}\"\n    res = await _apply_filters(content, replacements)\n    assert res == \"Color is #FF0000\"\n\n    # Filter set_alpha\n    content = \"Alpha: {{ color | set_alpha: 0.5 }}\"\n    res = await _apply_filters(content, replacements)\n    assert \"rgba(255, 0, 0, 0.5)\" in res\n\n    # Filter set_lightness\n    content = \"Light: {{ color | set_lightness: -50 }}\"\n    res = await _apply_filters(content, replacements)\n    assert res != \"Light: #FF0000\"\n\n    # Unknown filter (should return value as is)\n    content = \"Unknown: {{ color | invalid_filter: 123 }}\"\n    res = await _apply_filters(content, replacements)\n    assert res == \"Unknown: #FF0000\"\n\n    # Missing variable\n    content = \"Missing: {{ missing_var }}\"\n    res = await _apply_filters(content, replacements)\n    assert res == \"Missing: {{ missing_var }}\"\n\n\ndef test_color_scheme_effect_on_saturation(wallpaper_plugin):\n    \"\"\"Verify that color schemes actually impact the visual properties of the generated colors.\"\"\"\n    # Base color: Bright Red (High Saturation)\n    base_rgb = (255, 0, 0)\n\n    # 1. Neutral (Low Saturation)\n    props_neutral = get_color_scheme_props(\"neutral\")\n    res_neutral = nicify_oklab(base_rgb, **props_neutral)\n\n    # Convert back to HLS to check saturation (0.0 - 1.0)\n    # rgb_to_hls expects 0.0-1.0 inputs\n    _, l_neutral, s_neutral = colorsys.rgb_to_hls(res_neutral[0] / 255.0, res_neutral[1] / 255.0, res_neutral[2] / 255.0)\n\n    # 2. Fluo (High Saturation)\n    props_fluo = get_color_scheme_props(\"fluo\")\n    res_fluo = nicify_oklab(base_rgb, **props_fluo)\n\n    _, l_fluo, s_fluo = colorsys.rgb_to_hls(res_fluo[0] / 255.0, res_fluo[1] / 255.0, res_fluo[2] / 255.0)\n\n    # Assertions\n    # Neutral saturation should be low\n    # Note: Oklab chroma conversion to HLS saturation is not 1:1, so we use a lenient threshold\n    # 0.33 was observed for pure red input with neutral settings\n    assert s_neutral <= 0.4, f\"Neutral saturation {s_neutral} is too high (expected <= 0.4)\"\n\n    # Fluo saturation should be high (min_sat is 0.7)\n    assert s_fluo >= 0.6, f\"Fluo saturation {s_fluo} is too low (expected >= 0.6)\"\n\n    # Confirm relative difference - this is the most important check for \"greyer vs more saturated\"\n    assert s_neutral < s_fluo - 0.2, \"Neutral scheme should be significantly less saturated than Fluo scheme\"\n\n\n# --- Palette Display Tests ---\n\n\nfrom pyprland.plugins.wallpapers.palette import (\n    hex_to_rgb,\n    generate_sample_palette,\n    palette_to_json,\n    palette_to_terminal,\n    _categorize_palette,\n)\nimport json\n\n\ndef test_hex_to_rgb():\n    \"\"\"Test hex color to RGB conversion.\"\"\"\n    # With hash\n    assert hex_to_rgb(\"#FF0000\") == (255, 0, 0)\n    assert hex_to_rgb(\"#00FF00\") == (0, 255, 0)\n    assert hex_to_rgb(\"#0000FF\") == (0, 0, 255)\n    assert hex_to_rgb(\"#4285F4\") == (66, 133, 244)\n\n    # Without hash\n    assert hex_to_rgb(\"FF0000\") == (255, 0, 0)\n    assert hex_to_rgb(\"4285F4\") == (66, 133, 244)\n\n    # Lowercase\n    assert hex_to_rgb(\"#ff5500\") == (255, 85, 0)\n\n\ndef test_generate_sample_palette():\n    \"\"\"Test sample palette generation.\"\"\"\n    base_rgb = (66, 133, 244)  # Google blue\n    palette = generate_sample_palette(base_rgb, theme=Theme.DARK)\n\n    # Check that palette contains expected keys\n    assert \"scheme\" in palette\n    assert palette[\"scheme\"] == \"dark\"\n\n    # Check color categories exist\n    assert \"colors.primary.dark.hex\" in palette\n    assert \"colors.primary.light.hex\" in palette\n    assert \"colors.secondary.dark.hex\" in palette\n    assert \"colors.surface.dark.hex\" in palette\n    assert \"colors.error.dark.hex\" in palette\n\n    # Check hex format\n    assert palette[\"colors.primary.dark.hex\"].startswith(\"#\")\n    assert len(palette[\"colors.primary.dark.hex\"]) == 7\n\n    # Check other formats exist\n    assert \"colors.primary.dark.rgb\" in palette\n    assert \"colors.primary.dark.rgba\" in palette\n    assert \"colors.primary.dark.hex_stripped\" in palette\n\n    # hex_stripped should not have #\n    assert not palette[\"colors.primary.dark.hex_stripped\"].startswith(\"#\")\n\n\ndef test_generate_sample_palette_light_theme():\n    \"\"\"Test sample palette generation with light theme.\"\"\"\n    base_rgb = (66, 133, 244)\n    palette = generate_sample_palette(base_rgb, theme=Theme.LIGHT)\n\n    assert palette[\"scheme\"] == \"light\"\n    # Default should match light variant\n    assert palette[\"colors.primary\"] == palette[\"colors.primary.light.hex\"]\n\n\ndef test_categorize_palette():\n    \"\"\"Test palette categorization.\"\"\"\n    # Create a minimal palette for testing\n    palette = {\n        \"scheme\": \"dark\",\n        \"colors.primary.dark.hex\": \"#AABBCC\",\n        \"colors.secondary.dark.hex\": \"#DDEEFF\",\n        \"colors.surface.dark.hex\": \"#112233\",\n        \"colors.error.dark.hex\": \"#FF0000\",\n        \"colors.red.dark.hex\": \"#FF6666\",\n        \"colors.background.dark.hex\": \"#000000\",\n    }\n\n    categories = _categorize_palette(palette)\n\n    assert \"colors.primary.dark.hex\" in categories[\"primary\"]\n    assert \"colors.secondary.dark.hex\" in categories[\"secondary\"]\n    assert \"colors.surface.dark.hex\" in categories[\"surface\"]\n    assert \"colors.error.dark.hex\" in categories[\"error\"]\n    assert \"colors.red.dark.hex\" in categories[\"ansi\"]\n    assert \"colors.background.dark.hex\" in categories[\"utility\"]\n\n\ndef test_palette_to_json():\n    \"\"\"Test JSON palette output.\"\"\"\n    base_rgb = (255, 85, 0)  # Orange\n    palette = generate_sample_palette(base_rgb, theme=Theme.DARK)\n\n    json_output = palette_to_json(palette)\n\n    # Should be valid JSON\n    parsed = json.loads(json_output)\n\n    # Check structure\n    assert \"variables\" in parsed\n    assert \"categories\" in parsed\n    assert \"filters\" in parsed\n    assert \"theme\" in parsed\n\n    # Check variables\n    assert \"colors.primary.dark.hex\" in parsed[\"variables\"]\n\n    # Check categories\n    assert \"primary\" in parsed[\"categories\"]\n    assert \"secondary\" in parsed[\"categories\"]\n    assert \"ansi\" in parsed[\"categories\"]\n\n    # Check filters documentation\n    assert \"set_alpha\" in parsed[\"filters\"]\n    assert \"set_lightness\" in parsed[\"filters\"]\n    assert \"example\" in parsed[\"filters\"][\"set_alpha\"]\n    assert \"description\" in parsed[\"filters\"][\"set_lightness\"]\n\n    # Check theme\n    assert parsed[\"theme\"] == \"dark\"\n\n\ndef test_palette_to_terminal():\n    \"\"\"Test terminal palette output.\"\"\"\n    base_rgb = (66, 133, 244)\n    palette = generate_sample_palette(base_rgb, theme=Theme.DARK)\n\n    terminal_output = palette_to_terminal(palette)\n\n    # Should contain category headers\n    assert \"Primary:\" in terminal_output\n    assert \"Secondary:\" in terminal_output\n    assert \"Surface:\" in terminal_output\n    assert \"Error:\" in terminal_output\n    assert \"ANSI Colors:\" in terminal_output\n\n    # Should contain ANSI escape codes (24-bit color)\n    assert \"\\033[48;2;\" in terminal_output\n    assert \"\\033[0m\" in terminal_output\n\n    # Should contain variable names\n    assert \"colors.primary.dark.hex\" in terminal_output\n\n    # Should contain hex values\n    assert \"#\" in terminal_output\n\n    # Should contain filter examples\n    assert \"Filters:\" in terminal_output\n    assert \"set_alpha\" in terminal_output\n    assert \"set_lightness\" in terminal_output\n"
  },
  {
    "path": "tests/test_wallpapers_imageutils.py",
    "content": "import os\nfrom pathlib import Path\nfrom unittest.mock import AsyncMock, Mock, patch\n\nimport pytest\nfrom pyprland.plugins.wallpapers.cache import ImageCache\nfrom pyprland.plugins.wallpapers.imageutils import (\n    IMAGE_FORMAT,\n    MonitorInfo,\n    RoundedImageManager,\n    expand_path,\n    get_effective_dimensions,\n    get_files_with_ext,\n    get_variant_color,\n    to_hex,\n    to_rgb,\n    to_rgba,\n)\n\n\ndef test_expand_path():\n    with patch.dict(os.environ, {\"MY_VAR\": \"expanded\"}):\n        path = \"$MY_VAR/path\"\n        expanded = expand_path(path)\n        assert \"expanded/path\" in expanded\n\n    # ~ expansion - expand_path calls Path.expanduser()\n    with patch.object(Path, \"expanduser\") as mock_expanduser:\n        mock_expanduser.return_value = Path(\"/home/user/path\")\n        assert expand_path(\"~/path\") == \"/home/user/path\"\n\n\n@pytest.mark.asyncio\nasync def test_get_files_with_ext():\n    # We need to mock ailistdir (from pyprland.aioops) and Path.is_dir\n    with patch(\"pyprland.plugins.wallpapers.imageutils.ailistdir\") as mock_ailistdir:\n        # Structure:\n        # /root\n        #   - a.jpg\n        #   - b.png (skipped)\n        #   - sub/\n        #     - c.jpg\n\n        async def ailistdir_side_effect(path):\n            if path == \"/root\":\n                return [\"a.jpg\", \"b.png\", \"sub\"]\n            if path == \"/root/sub\":\n                return [\"c.jpg\"]\n            return []\n\n        mock_ailistdir.side_effect = ailistdir_side_effect\n\n        # Mock aiisdir to return True for paths ending with \"sub\"\n        async def mock_aiisdir(path):\n            return str(path).endswith(\"sub\")\n\n        with patch(\"pyprland.plugins.wallpapers.imageutils.aiisdir\", side_effect=mock_aiisdir):\n            # Test non-recursive\n            files = []\n            async for f in get_files_with_ext(\"/root\", [\"jpg\"], recurse=False):\n                files.append(f)\n            assert len(files) == 1\n            assert files[0].endswith(\"a.jpg\")\n\n            # Test recursive\n            files = []\n            async for f in get_files_with_ext(\"/root\", [\"jpg\"], recurse=True):\n                files.append(f)\n            assert len(files) == 2\n            assert any(f.endswith(\"a.jpg\") for f in files)\n            assert any(f.endswith(\"c.jpg\") for f in files)\n\n\ndef test_color_conversions():\n    assert to_hex(255, 0, 0) == \"#ff0000\"\n    assert to_hex(0, 255, 0) == \"#00ff00\"\n    assert to_hex(0, 0, 255) == \"#0000ff\"\n\n    assert to_rgb(255, 0, 0) == \"rgb(255, 0, 0)\"\n    assert to_rgba(255, 0, 0) == \"rgba(255, 0, 0, 1.0)\"\n\n\ndef test_get_variant_color():\n    # HLS: Hue, Lightness, Saturation\n    # Hue=0 (Red), L=0.5, S=1.0 -> RGB(255, 0, 0)\n    r, g, b = get_variant_color(0.0, 1.0, 0.5)\n    assert r == 255\n    assert g == 0\n    assert b == 0\n\n    # Check clamping logic (max(0, min(1.0, lightness)))\n    # If we pass lightness > 1.0, it should clamp to 1.0 (White)\n    r, g, b = get_variant_color(0.0, 1.0, 1.5)\n    assert r == 255\n    assert g == 255\n    assert b == 255\n\n\ndef test_rounded_image_manager_paths(tmp_path):\n    cache = ImageCache(cache_dir=tmp_path)\n    manager = RoundedImageManager(radius=10, cache=cache)\n    monitor = MonitorInfo(name=\"DP-1\", width=1920, height=1080, transform=0, scale=1.0)\n\n    key = manager.build_key(monitor, \"/path/to/img.jpg\")\n    # Key uses dual-hash format: {source_hash}_{settings_hash}\n    # Each hash is 16 chars, separated by underscore\n    assert \"_\" in key\n    parts = key.split(\"_\")\n    assert len(parts) == 2\n    assert len(parts[0]) == 16  # source hash\n    assert len(parts[1]) == 16  # settings hash\n\n    # Verify hash_source is consistent\n    source_hash = manager.hash_source(\"/path/to/img.jpg\")\n    assert key.startswith(source_hash)\n\n    # Path is obtained through cache.get_path()\n    path = cache.get_path(key, IMAGE_FORMAT)\n    assert str(tmp_path) in str(path)\n    assert str(path).endswith(f\".{IMAGE_FORMAT}\")\n\n\ndef test_get_effective_dimensions_no_rotation():\n    \"\"\"Transforms 0, 2, 4, 6 should NOT swap dimensions.\"\"\"\n    for transform in [0, 2, 4, 6]:\n        monitor = MonitorInfo(name=\"DP-1\", width=1920, height=1080, transform=transform, scale=1.0)\n        w, h = get_effective_dimensions(monitor)\n        assert (w, h) == (1920, 1080), f\"Transform {transform} should not swap dimensions\"\n\n\ndef test_get_effective_dimensions_rotated():\n    \"\"\"Transforms 1, 3, 5, 7 (90/270 degree rotations) should swap width and height.\"\"\"\n    for transform in [1, 3, 5, 7]:\n        monitor = MonitorInfo(name=\"DP-1\", width=1920, height=1080, transform=transform, scale=1.0)\n        w, h = get_effective_dimensions(monitor)\n        assert (w, h) == (1080, 1920), f\"Transform {transform} should swap dimensions\"\n\n\ndef test_rounded_image_manager_processing(tmp_path):\n    with (\n        patch(\"pyprland.plugins.wallpapers.imageutils.Image\") as MockImage,\n        patch(\"pyprland.plugins.wallpapers.imageutils.ImageOps\") as MockImageOps,\n        patch(\"pyprland.plugins.wallpapers.imageutils.ImageDraw\") as MockImageDraw,\n        patch.object(Path, \"exists\", return_value=False),\n        patch(\"builtins.open\", Mock()),\n    ):  # Prevent accidental file access? No, Image.open handles files.\n        cache = ImageCache(cache_dir=tmp_path)\n        manager = RoundedImageManager(radius=10, cache=cache)\n        monitor = MonitorInfo(name=\"DP-1\", width=100, height=100, transform=0, scale=1.0)\n\n        # Mock cache.get() to return None (cache miss)\n        cache.get = Mock(return_value=None)\n\n        mock_img = Mock()\n        mock_img.width = 200\n        mock_img.height = 200\n        MockImage.open.return_value.__enter__.return_value = mock_img\n\n        # Mock resize/fit result\n        mock_resized = Mock()\n        mock_resized.size = (100, 100)\n        mock_resized.width = 100\n        mock_resized.height = 100\n        MockImageOps.fit.return_value = mock_resized\n\n        # Mock new image creation\n        mock_new_img = Mock()\n        MockImage.new.return_value = mock_new_img\n\n        dest = manager.scale_and_round(\"/path/to/img.jpg\", monitor)\n\n        # Verify workflow\n        MockImage.open.assert_called_with(\"/path/to/img.jpg\")\n        # Check fit called with correct dimensions (width/scale, height/scale)\n        MockImageOps.fit.assert_called()\n\n        # Check mask creation called\n        MockImageDraw.Draw.assert_called()\n\n        # Check saving\n        mock_new_img.convert.return_value.save.assert_called_with(dest)\n"
  },
  {
    "path": "tests/testtools.py",
    "content": "import asyncio\nfrom unittest.mock import AsyncMock, Mock\n\n\nasync def wait_called(fn, timeout=1.0, count=1):\n    delay = 0.0\n    ival = 0.05\n    while True:\n        if fn.call_count >= count:\n            break\n        await asyncio.sleep(ival)\n        delay += ival\n\n        if delay > timeout:\n            raise TimeoutError()\n\n\ndef get_executed_commands(mock):\n    \"\"\"Flatten all execute() calls into an ordered list of (command, kwargs) tuples.\n\n    Handles both single-string and list-of-strings calls transparently,\n    so tests don't break when batching strategy changes.\n\n    Args:\n        mock: The AsyncMock used for backend.execute\n\n    Returns:\n        A list of ``(command_string, kwargs_dict)`` in call order.\n    \"\"\"\n    result = []\n    for c in mock.call_args_list:\n        args, kwargs = c\n        cmd = args[0] if args else None\n        if isinstance(cmd, list):\n            result.extend((item, kwargs) for item in cmd)\n        elif cmd is not None:\n            result.append((cmd, kwargs))\n    return result\n\n\nclass MockReader:\n    \"\"\"A StreamReader mock.\"\"\"\n\n    def __init__(self):\n        self.q = asyncio.Queue()\n\n    async def readline(self, *a):\n        return await self.q.get()\n\n    read = readline\n\n\nclass MockWriter:\n    \"\"\"A StreamWriter mock.\"\"\"\n\n    def __init__(self):\n        self.write = Mock()\n        self.drain = AsyncMock()\n        self.close = Mock()\n        self.wait_closed = AsyncMock()\n"
  },
  {
    "path": "tests/vreg/01_client_id_change.py",
    "content": "#!/bin/env python\n\nimport itertools\n\nimport pyglet\n\ncounter = itertools.count()\n\n\ndef show_window():\n    window = pyglet.window.Window()\n\n    current = next(counter)\n\n    @window.event\n    def on_key_press(symbol, modifiers):\n        next(counter)\n        window.close()\n\n    @window.event\n    def on_draw():\n        window.clear()\n        label = pyglet.text.Label(\n            f\"Hello {current}\",\n            font_name=\"Times New Roman\",\n            font_size=36,\n            x=window.width // 2,\n            y=window.height // 2,\n            anchor_x=\"center\",\n            anchor_y=\"center\",\n        )\n        window.set_wm_class(\"test\")\n        label.draw()\n\n    pyglet.app.run()\n\n\nfor _n in range(5):\n    show_window()\n"
  },
  {
    "path": "tests/vreg/run_tests.sh",
    "content": "#!/bin/sh\ncd tests/vreg\nexport WAYLAND_DISPLAY=wayland-1\nexport DISPLAY=:0\nfor n in *.py; do\n    python $n\ndone\n"
  },
  {
    "path": "tickets.rst",
    "content": "Tickets\n=======\n\n:total-count: 58\n\n--------------------------------------------------------------------------------\n\n\"theme\" command\n===============\n\n:bugid: 28\n:created: 2024-03-03T01:54:45\n:priority: 0\n\n- save\n- list\n- use\n- forget\n- fetch\n\nCan parse hyprland config in such way:\n\n.. code:: python\n\n    import re\n\n    def parse_config(file_path, sections_of_interest):\n        config = {}\n        current_section = []\n        current_key = config\n\n        with open(file_path, 'r') as file:\n            for line in file:\n                # Remove comments\n                line = re.sub(r'#.*', '', line)\n                # Match section headers\n                section_match = re.match(r'\\s*([a-zA-Z_]+)\\s*{', line)\n                if section_match:\n                    section_name = section_match.group(1)\n                    if section_name in sections_of_interest:\n                        if len(current_section) > 0:\n                            # Append the current section name to the hierarchy\n                            current_key[section_name] = {}\n                            # Update the current section to the new nested section\n                            current_key = current_key[section_name]\n                        else:\n                            # Top-level section\n                            config[section_name] = {}\n                            current_key = config[section_name]\n                        current_section.append(section_name)\n                # Match key-value pairs\n                key_value_match = re.match(r'\\s*([a-zA-Z_]+)\\s*=\\s*(.+)', line)\n                if key_value_match and len(current_section) > 0:\n                    key, value = key_value_match.groups()\n                    current_key[key.strip()] = value.strip()\n                # Match closing braces for sections\n                if '}' in line:\n                    current_section.pop()\n                    if len(current_section) > 0:\n                        current_key = config\n                        for section in current_section:\n                            current_key = current_key[section]\n\n        return config\n\n    file_path = \"your_file.txt\"\n    sections_of_interest = [\"general\", \"decoration\", \"animations\"]\n    parsed_config = parse_config(file_path, sections_of_interest)\n    print(parsed_config)\n\n--------------------------------------------------------------------------------\n\npreserve_aspect could recall aspect per screen resolution/size\n==============================================================\n\n:bugid: 39\n:created: 2024-04-17T23:55:01\n:priority: 0\n\n--------------------------------------------------------------------------------\n\nFix multi-monitor layout changes (including attached clients)\n=============================================================\n\n:bugid: 40\n:created: 2024-04-23T22:01:39\n:priority: 0\n\nStatus\n------\n\nbroken in corner case scenarios (eg: monitor layout change)\n\nIdentified problem\n------------------\n\nPosition is relative to the last one, without any state in hyprland, as in `preserve` option.\n\nProposed solution\n-----------------\n\n- on hide\n    Compute relative distance from the main scratchpad window\n- on show\n    Compute the absolute position from the saved distance and perform an absolute positioning\n\nBlocker\n-------\n\nHyprland doesn't notify in case of layout change. Querying monitors each time seems overkill...\n\n--------------------------------------------------------------------------------\n\nTest a configuration with zero initial command/window\n=====================================================\n\n:bugid: 46\n:created: 2024-05-01T23:37:31\n:priority: 0\n\n--------------------------------------------------------------------------------\n\nGeneralize a \"monitors\" call filtering out the invalid ones (cf gBar)\n=====================================================================\n\n:bugid: 50\n:created: 2024-06-04T22:53:36\n:priority: 0\n\n--------------------------------------------------------------------------------\n\nExperiment with minisearch on the website\n=========================================\n\n:bugid: 51\n:created: 2024-06-05T22:21:07\n:priority: 0\n\n--------------------------------------------------------------------------------\n\nAI voice assistant / task manager\n=================================\n\n:bugid: 53\n:created: 2024-11-30T23:17:37\n:priority: 0\n\nAllow setting tasks with different properties\nurgent: bool\ndue date: date\ndescription: text\npriority: int\n\nWill sort them according to priorities, making urgent or soon due tasks first (so priority applies last - have less importance than those)\n\nWill speak when a user event is received every X minutes depending on the urgency of the task\n\n--------------------------------------------------------------------------------\n\nconfigreloaded event should trigger a reload of pyprload\n========================================================\n\n:bugid: 54\n:created: 2025-08-07T21:27:25\n:priority: 0\n\n--------------------------------------------------------------------------------\n\nSkip \"configreloaded\" in monitors plugin if it's due to the post_command of wallpaper for instance\n==================================================================================================\n\n:bugid: 55\n:created: 2025-12-02T22:10:17\n:priority: 0\n\n--------------------------------------------------------------------------------\n\nWhen \"reload\"ing, improve the merge algorithm of the config\n===========================================================\n\n:bugid: 58\n:created: 2026-01-15T21:30:00\n:priority: 0\n\n- delete missing keys in src\n- replace content of lists and dicts recursively (keeping references intact)\nuse clear + update / extend etc...\n"
  },
  {
    "path": "tox.ini",
    "content": "[tox]\nenvlist = py{311, 312, 313, 314}-unit, linting, coverage, wiki, deadcode\n\n# other: doc\n\n[testenv]\n\nenvdir = {toxworkdir}/{envname}\n\ndeps =\n    uv\n    doc: pdoc\n    vreg: pyglet\n\nallowlist_externals =\n    # ./scripts/title\n    vreg: ./tests/vreg/**\n    wiki: ./scripts/*.py\n    unit: ./scripts/*.py\n\ncommands =\n\n    uv sync --active --all-groups\n\n    unit: ./scripts/generate_plugin_docs.py\n    unit: uv run --active pytest -q tests\n\n    doc: pdoc --docformat google ./pyprland\n\n    vreg: ./tests/vreg/run_tests.sh\n\n    # linting: ./scripts/title MYPY\n    linting: uv run --active mypy --install-types --non-interactive --check-untyped-defs pyprland\n    # linting: ./scripts/title RUFF FORMAT\n    linting: uv run --active ruff format pyprland\n    # linting: ./scripts/title RUFF CHECK\n    linting: uv run --active ruff check --fix pyprland\n    # linting: ./scripts/title PYLINT\n    linting: uv run --active pylint -E pyprland\n    # linting: ./scripts/title FLAKE8\n    linting: uv run --active flake8 pyprland\n    # coverage: ./scripts/title COVERAGE\n    coverage: uv run --active coverage run --source=pyprland -m pytest tests -q\n    coverage: uv run --active coverage html\n    coverage: uv run --active coverage report\n    wiki: ./scripts/generate_plugin_docs.py\n    wiki: ./scripts/check_plugin_docs.py\n    deadcode: uv run --active vulture --ignore-names 'event_*,run_*,fromtop,frombottom,fromleft,fromright,instance' pyprland scripts/v_whitelist.py\n\n# Tools\n\n[flake8]\nmax-line-length = 140\nignore = E203,W503,E704\n\n[pycodestyle]\nignore = E203,W503\nmax-line-length = 140\nstatistics = True\n\n[pydocstyle]\nadd-ignore = D105,D107,D203\n"
  }
]