Repository: hyprland-community/pyprland Branch: main Commit: c5e15d52405e Files: 794 Total size: 2.6 MB Directory structure: gitextract_3e73ij71/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── feature_request.md │ │ └── wiki_improvement.md │ ├── PULL_REQUEST_TEMPLATE/ │ │ └── pull_request_template.md │ ├── dependabot.yml │ └── workflows/ │ ├── aur.yml │ ├── ci.yml │ ├── nix-setup.yml │ ├── nix.yml │ ├── site.yml │ └── uv-install.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .pylintrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── client/ │ ├── pypr-client.c │ ├── pypr-client.rs │ └── pypr-rs/ │ └── Cargo.toml ├── default.nix ├── done.rst ├── examples/ │ ├── README.md │ └── copy_conf.sh ├── flake.nix ├── hatch_build.py ├── justfile ├── package.json ├── pyprland/ │ ├── __init__.py │ ├── adapters/ │ │ ├── __init__.py │ │ ├── backend.py │ │ ├── colors.py │ │ ├── fallback.py │ │ ├── hyprland.py │ │ ├── menus.py │ │ ├── niri.py │ │ ├── proxy.py │ │ ├── units.py │ │ ├── wayland.py │ │ └── xorg.py │ ├── aioops.py │ ├── ansi.py │ ├── client.py │ ├── command.py │ ├── commands/ │ │ ├── __init__.py │ │ ├── discovery.py │ │ ├── models.py │ │ ├── parsing.py │ │ └── tree.py │ ├── common.py │ ├── completions/ │ │ ├── __init__.py │ │ ├── discovery.py │ │ ├── generators/ │ │ │ ├── __init__.py │ │ │ ├── bash.py │ │ │ ├── fish.py │ │ │ └── zsh.py │ │ ├── handlers.py │ │ └── models.py │ ├── config.py │ ├── config_loader.py │ ├── constants.py │ ├── debug.py │ ├── doc.py │ ├── gui/ │ │ ├── __init__.py │ │ ├── __main__.py │ │ ├── api.py │ │ ├── frontend/ │ │ │ ├── .gitignore │ │ │ ├── .vscode/ │ │ │ │ └── extensions.json │ │ │ ├── README.md │ │ │ ├── index.html │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── App.vue │ │ │ │ ├── components/ │ │ │ │ │ ├── DictEditor.vue │ │ │ │ │ ├── FieldInput.vue │ │ │ │ │ └── PluginEditor.vue │ │ │ │ ├── composables/ │ │ │ │ │ ├── useLocalCopy.js │ │ │ │ │ └── useToggleMap.js │ │ │ │ ├── main.js │ │ │ │ ├── style.css │ │ │ │ └── utils.js │ │ │ └── vite.config.js │ │ ├── server.py │ │ └── static/ │ │ ├── assets/ │ │ │ ├── index-CX03GsX-.js │ │ │ └── index-Dpu0NgRN.css │ │ └── index.html │ ├── help.py │ ├── httpclient.py │ ├── ipc.py │ ├── ipc_paths.py │ ├── logging_setup.py │ ├── manager.py │ ├── models.py │ ├── plugins/ │ │ ├── __init__.py │ │ ├── experimental.py │ │ ├── expose.py │ │ ├── fcitx5_switcher.py │ │ ├── fetch_client_menu.py │ │ ├── gamemode.py │ │ ├── interface.py │ │ ├── layout_center.py │ │ ├── lost_windows.py │ │ ├── magnify.py │ │ ├── menubar.py │ │ ├── mixins.py │ │ ├── monitors/ │ │ │ ├── __init__.py │ │ │ ├── commands.py │ │ │ ├── layout.py │ │ │ ├── resolution.py │ │ │ └── schema.py │ │ ├── protocols.py │ │ ├── pyprland/ │ │ │ ├── __init__.py │ │ │ ├── hyprland_core.py │ │ │ ├── niri_core.py │ │ │ └── schema.py │ │ ├── scratchpads/ │ │ │ ├── __init__.py │ │ │ ├── animations.py │ │ │ ├── common.py │ │ │ ├── events.py │ │ │ ├── helpers.py │ │ │ ├── lifecycle.py │ │ │ ├── lookup.py │ │ │ ├── objects.py │ │ │ ├── schema.py │ │ │ ├── transitions.py │ │ │ └── windowruleset.py │ │ ├── shift_monitors.py │ │ ├── shortcuts_menu.py │ │ ├── stash.py │ │ ├── system_notifier.py │ │ ├── toggle_dpms.py │ │ ├── toggle_special.py │ │ ├── wallpapers/ │ │ │ ├── __init__.py │ │ │ ├── cache.py │ │ │ ├── colorutils.py │ │ │ ├── hyprpaper.py │ │ │ ├── imageutils.py │ │ │ ├── models.py │ │ │ ├── online/ │ │ │ │ ├── __init__.py │ │ │ │ └── backends/ │ │ │ │ ├── __init__.py │ │ │ │ ├── base.py │ │ │ │ ├── bing.py │ │ │ │ ├── picsum.py │ │ │ │ ├── reddit.py │ │ │ │ ├── unsplash.py │ │ │ │ └── wallhaven.py │ │ │ ├── palette.py │ │ │ ├── templates.py │ │ │ └── theme.py │ │ └── workspaces_follow_focus.py │ ├── process.py │ ├── pypr_daemon.py │ ├── quickstart/ │ │ ├── __init__.py │ │ ├── __main__.py │ │ ├── discovery.py │ │ ├── generator.py │ │ ├── helpers/ │ │ │ ├── __init__.py │ │ │ ├── monitors.py │ │ │ └── scratchpads.py │ │ ├── questions.py │ │ └── wizard.py │ ├── state.py │ ├── terminal.py │ ├── utils.py │ ├── validate_cli.py │ ├── validation.py │ └── version.py ├── pyproject.toml ├── sample_extension/ │ ├── README.md │ ├── pypr_examples/ │ │ ├── __init__.py │ │ └── focus_counter.py │ └── pyproject.toml ├── scripts/ │ ├── backquote_as_links.py │ ├── check_plugin_docs.py │ ├── completions/ │ │ ├── README.md │ │ ├── pypr.bash │ │ └── pypr.zsh │ ├── generate_codebase_overview.py │ ├── generate_monitor_diagrams.py │ ├── generate_plugin_docs.py │ ├── get-pypr │ ├── make_release │ ├── plugin_metadata.toml │ ├── pypr.py │ ├── pypr.sh │ ├── title │ ├── update_get-pypr.sh │ ├── update_version │ └── v_whitelist.py ├── site/ │ ├── .vitepress/ │ │ ├── config.mjs │ │ └── theme/ │ │ ├── custom.css │ │ └── index.js │ ├── Architecture.md │ ├── Architecture_core.md │ ├── Architecture_overview.md │ ├── Commands.md │ ├── Configuration.md │ ├── Development.md │ ├── Examples.md │ ├── Getting-started.md │ ├── InstallVirtualEnvironment.md │ ├── Menu.md │ ├── MultipleConfigurationFiles.md │ ├── Nix.md │ ├── Optimizations.md │ ├── Plugins.md │ ├── Troubleshooting.md │ ├── Variables.md │ ├── components/ │ │ ├── CommandList.vue │ │ ├── ConfigBadges.vue │ │ ├── ConfigTable.vue │ │ ├── EngineDefaults.vue │ │ ├── EngineList.vue │ │ ├── PluginCommands.vue │ │ ├── PluginConfig.vue │ │ ├── PluginList.vue │ │ ├── configHelpers.js │ │ ├── jsonLoader.js │ │ └── usePluginData.js │ ├── expose.md │ ├── fcitx5_switcher.md │ ├── fetch_client_menu.md │ ├── filters.md │ ├── gamemode.md │ ├── generated/ │ │ └── generted_files.keep_me │ ├── index.md │ ├── layout_center.md │ ├── lost_windows.md │ ├── magnify.md │ ├── make_version.sh │ ├── menubar.md │ ├── monitors.md │ ├── scratchpads.md │ ├── scratchpads_advanced.md │ ├── scratchpads_nonstandard.md │ ├── shift_monitors.md │ ├── shortcuts_menu.md │ ├── sidebar.json │ ├── stash.md │ ├── system_notifier.md │ ├── toggle_dpms.md │ ├── toggle_special.md │ ├── versions/ │ │ ├── 2.3.5/ │ │ │ ├── Development.md │ │ │ ├── Getting-started.md │ │ │ ├── Menu.md │ │ │ ├── MultipleConfigurationFiles.md │ │ │ ├── Nix.md │ │ │ ├── Optimizations.md │ │ │ ├── Plugins.md │ │ │ ├── Troubleshooting.md │ │ │ ├── Variables.md │ │ │ ├── components/ │ │ │ │ └── PluginList.vue │ │ │ ├── expose.md │ │ │ ├── fetch_client_menu.md │ │ │ ├── filters.md │ │ │ ├── gbar.md │ │ │ ├── index.md │ │ │ ├── layout_center.md │ │ │ ├── lost_windows.md │ │ │ ├── magnify.md │ │ │ ├── monitors.md │ │ │ ├── scratchpads.md │ │ │ ├── scratchpads_advanced.md │ │ │ ├── scratchpads_nonstandard.md │ │ │ ├── shift_monitors.md │ │ │ ├── shortcuts_menu.md │ │ │ ├── sidebar.json │ │ │ ├── system_notifier.md │ │ │ ├── toggle_dpms.md │ │ │ ├── toggle_special.md │ │ │ ├── wallpapers.md │ │ │ └── workspaces_follow_focus.md │ │ ├── 2.3.6,7/ │ │ │ ├── Development.md │ │ │ ├── Getting-started.md │ │ │ ├── Menu.md │ │ │ ├── MultipleConfigurationFiles.md │ │ │ ├── Nix.md │ │ │ ├── Optimizations.md │ │ │ ├── Plugins.md │ │ │ ├── Troubleshooting.md │ │ │ ├── Variables.md │ │ │ ├── components/ │ │ │ │ ├── CommandList.vue │ │ │ │ └── PluginList.vue │ │ │ ├── expose.md │ │ │ ├── fetch_client_menu.md │ │ │ ├── filters.md │ │ │ ├── gbar.md │ │ │ ├── index.md │ │ │ ├── layout_center.md │ │ │ ├── lost_windows.md │ │ │ ├── magnify.md │ │ │ ├── monitors.md │ │ │ ├── scratchpads.md │ │ │ ├── scratchpads_advanced.md │ │ │ ├── scratchpads_nonstandard.md │ │ │ ├── shift_monitors.md │ │ │ ├── shortcuts_menu.md │ │ │ ├── sidebar.json │ │ │ ├── system_notifier.md │ │ │ ├── toggle_dpms.md │ │ │ ├── toggle_special.md │ │ │ ├── wallpapers.md │ │ │ └── workspaces_follow_focus.md │ │ ├── 2.3.8/ │ │ │ ├── Development.md │ │ │ ├── Getting-started.md │ │ │ ├── Menu.md │ │ │ ├── MultipleConfigurationFiles.md │ │ │ ├── Nix.md │ │ │ ├── Optimizations.md │ │ │ ├── Plugins.md │ │ │ ├── Troubleshooting.md │ │ │ ├── Variables.md │ │ │ ├── components/ │ │ │ │ ├── CommandList.vue │ │ │ │ └── PluginList.vue │ │ │ ├── expose.md │ │ │ ├── fetch_client_menu.md │ │ │ ├── filters.md │ │ │ ├── gbar.md │ │ │ ├── index.md │ │ │ ├── layout_center.md │ │ │ ├── lost_windows.md │ │ │ ├── magnify.md │ │ │ ├── monitors.md │ │ │ ├── scratchpads.md │ │ │ ├── scratchpads_advanced.md │ │ │ ├── scratchpads_nonstandard.md │ │ │ ├── shift_monitors.md │ │ │ ├── shortcuts_menu.md │ │ │ ├── sidebar.json │ │ │ ├── system_notifier.md │ │ │ ├── toggle_dpms.md │ │ │ ├── toggle_special.md │ │ │ ├── wallpapers.md │ │ │ └── workspaces_follow_focus.md │ │ ├── 2.4.0/ │ │ │ ├── Development.md │ │ │ ├── Getting-started.md │ │ │ ├── InstallVirtualEnvironment.md │ │ │ ├── Menu.md │ │ │ ├── MultipleConfigurationFiles.md │ │ │ ├── Nix.md │ │ │ ├── Optimizations.md │ │ │ ├── Plugins.md │ │ │ ├── Troubleshooting.md │ │ │ ├── Variables.md │ │ │ ├── components/ │ │ │ │ ├── CommandList.vue │ │ │ │ └── PluginList.vue │ │ │ ├── expose.md │ │ │ ├── fetch_client_menu.md │ │ │ ├── filters.md │ │ │ ├── gbar.md │ │ │ ├── index.md │ │ │ ├── layout_center.md │ │ │ ├── lost_windows.md │ │ │ ├── magnify.md │ │ │ ├── monitors.md │ │ │ ├── scratchpads.md │ │ │ ├── scratchpads_advanced.md │ │ │ ├── scratchpads_nonstandard.md │ │ │ ├── shift_monitors.md │ │ │ ├── shortcuts_menu.md │ │ │ ├── sidebar.json │ │ │ ├── system_notifier.md │ │ │ ├── toggle_dpms.md │ │ │ ├── toggle_special.md │ │ │ ├── wallpapers.md │ │ │ └── workspaces_follow_focus.md │ │ ├── 2.4.1+/ │ │ │ ├── Development.md │ │ │ ├── Getting-started.md │ │ │ ├── InstallVirtualEnvironment.md │ │ │ ├── Menu.md │ │ │ ├── MultipleConfigurationFiles.md │ │ │ ├── Nix.md │ │ │ ├── Optimizations.md │ │ │ ├── Plugins.md │ │ │ ├── Troubleshooting.md │ │ │ ├── Variables.md │ │ │ ├── components/ │ │ │ │ ├── CommandList.vue │ │ │ │ └── PluginList.vue │ │ │ ├── expose.md │ │ │ ├── fetch_client_menu.md │ │ │ ├── filters.md │ │ │ ├── gbar.md │ │ │ ├── index.md │ │ │ ├── layout_center.md │ │ │ ├── lost_windows.md │ │ │ ├── magnify.md │ │ │ ├── monitors.md │ │ │ ├── scratchpads.md │ │ │ ├── scratchpads_advanced.md │ │ │ ├── scratchpads_nonstandard.md │ │ │ ├── shift_monitors.md │ │ │ ├── shortcuts_menu.md │ │ │ ├── sidebar.json │ │ │ ├── system_notifier.md │ │ │ ├── toggle_dpms.md │ │ │ ├── toggle_special.md │ │ │ ├── wallpapers.md │ │ │ └── workspaces_follow_focus.md │ │ ├── 2.4.6/ │ │ │ ├── Development.md │ │ │ ├── Getting-started.md │ │ │ ├── InstallVirtualEnvironment.md │ │ │ ├── Menu.md │ │ │ ├── MultipleConfigurationFiles.md │ │ │ ├── Nix.md │ │ │ ├── Optimizations.md │ │ │ ├── Plugins.md │ │ │ ├── Troubleshooting.md │ │ │ ├── Variables.md │ │ │ ├── bar.md │ │ │ ├── components/ │ │ │ │ ├── CommandList.vue │ │ │ │ └── PluginList.vue │ │ │ ├── expose.md │ │ │ ├── fcitx5_switcher.md │ │ │ ├── fetch_client_menu.md │ │ │ ├── filters.md │ │ │ ├── index.md │ │ │ ├── layout_center.md │ │ │ ├── lost_windows.md │ │ │ ├── magnify.md │ │ │ ├── monitors.md │ │ │ ├── scratchpads.md │ │ │ ├── scratchpads_advanced.md │ │ │ ├── scratchpads_nonstandard.md │ │ │ ├── shift_monitors.md │ │ │ ├── shortcuts_menu.md │ │ │ ├── sidebar.json │ │ │ ├── system_notifier.md │ │ │ ├── toggle_dpms.md │ │ │ ├── toggle_special.md │ │ │ ├── wallpapers.md │ │ │ └── workspaces_follow_focus.md │ │ ├── 2.4.7/ │ │ │ ├── Development.md │ │ │ ├── Getting-started.md │ │ │ ├── InstallVirtualEnvironment.md │ │ │ ├── Menu.md │ │ │ ├── MultipleConfigurationFiles.md │ │ │ ├── Nix.md │ │ │ ├── Optimizations.md │ │ │ ├── Plugins.md │ │ │ ├── Troubleshooting.md │ │ │ ├── Variables.md │ │ │ ├── bar.md │ │ │ ├── components/ │ │ │ │ ├── CommandList.vue │ │ │ │ └── PluginList.vue │ │ │ ├── expose.md │ │ │ ├── fcitx5_switcher.md │ │ │ ├── fetch_client_menu.md │ │ │ ├── filters.md │ │ │ ├── index.md │ │ │ ├── layout_center.md │ │ │ ├── lost_windows.md │ │ │ ├── magnify.md │ │ │ ├── monitors.md │ │ │ ├── scratchpads.md │ │ │ ├── scratchpads_advanced.md │ │ │ ├── scratchpads_nonstandard.md │ │ │ ├── shift_monitors.md │ │ │ ├── shortcuts_menu.md │ │ │ ├── sidebar.json │ │ │ ├── system_notifier.md │ │ │ ├── toggle_dpms.md │ │ │ ├── toggle_special.md │ │ │ ├── wallpapers.md │ │ │ └── workspaces_follow_focus.md │ │ ├── 2.5.x/ │ │ │ ├── Development.md │ │ │ ├── Getting-started.md │ │ │ ├── InstallVirtualEnvironment.md │ │ │ ├── Menu.md │ │ │ ├── MultipleConfigurationFiles.md │ │ │ ├── Nix.md │ │ │ ├── Optimizations.md │ │ │ ├── Plugins.md │ │ │ ├── Troubleshooting.md │ │ │ ├── Variables.md │ │ │ ├── bar.md │ │ │ ├── components/ │ │ │ │ ├── CommandList.vue │ │ │ │ └── PluginList.vue │ │ │ ├── expose.md │ │ │ ├── fcitx5_switcher.md │ │ │ ├── fetch_client_menu.md │ │ │ ├── filters.md │ │ │ ├── index.md │ │ │ ├── layout_center.md │ │ │ ├── lost_windows.md │ │ │ ├── magnify.md │ │ │ ├── monitors.md │ │ │ ├── scratchpads.md │ │ │ ├── scratchpads_advanced.md │ │ │ ├── scratchpads_nonstandard.md │ │ │ ├── shift_monitors.md │ │ │ ├── shortcuts_menu.md │ │ │ ├── sidebar.json │ │ │ ├── system_notifier.md │ │ │ ├── toggle_dpms.md │ │ │ ├── toggle_special.md │ │ │ ├── wallpapers.md │ │ │ └── workspaces_follow_focus.md │ │ ├── 2.6.2/ │ │ │ ├── Development.md │ │ │ ├── Getting-started.md │ │ │ ├── InstallVirtualEnvironment.md │ │ │ ├── Menu.md │ │ │ ├── MultipleConfigurationFiles.md │ │ │ ├── Nix.md │ │ │ ├── Optimizations.md │ │ │ ├── Plugins.md │ │ │ ├── Troubleshooting.md │ │ │ ├── Variables.md │ │ │ ├── components/ │ │ │ │ ├── CommandList.vue │ │ │ │ └── PluginList.vue │ │ │ ├── expose.md │ │ │ ├── fcitx5_switcher.md │ │ │ ├── fetch_client_menu.md │ │ │ ├── filters.md │ │ │ ├── index.md │ │ │ ├── layout_center.md │ │ │ ├── lost_windows.md │ │ │ ├── magnify.md │ │ │ ├── menubar.md │ │ │ ├── monitors.md │ │ │ ├── scratchpads.md │ │ │ ├── scratchpads_advanced.md │ │ │ ├── scratchpads_nonstandard.md │ │ │ ├── shift_monitors.md │ │ │ ├── shortcuts_menu.md │ │ │ ├── sidebar.json │ │ │ ├── system_notifier.md │ │ │ ├── toggle_dpms.md │ │ │ ├── toggle_special.md │ │ │ ├── wallpapers.md │ │ │ └── workspaces_follow_focus.md │ │ ├── 3.0.0/ │ │ │ ├── Architecture.md │ │ │ ├── Architecture_core.md │ │ │ ├── Architecture_overview.md │ │ │ ├── Commands.md │ │ │ ├── Configuration.md │ │ │ ├── Development.md │ │ │ ├── Examples.md │ │ │ ├── Getting-started.md │ │ │ ├── InstallVirtualEnvironment.md │ │ │ ├── Menu.md │ │ │ ├── MultipleConfigurationFiles.md │ │ │ ├── Nix.md │ │ │ ├── Optimizations.md │ │ │ ├── Plugins.md │ │ │ ├── Troubleshooting.md │ │ │ ├── Variables.md │ │ │ ├── components/ │ │ │ │ ├── CommandList.vue │ │ │ │ ├── ConfigBadges.vue │ │ │ │ ├── ConfigTable.vue │ │ │ │ ├── EngineDefaults.vue │ │ │ │ ├── PluginCommands.vue │ │ │ │ ├── PluginConfig.vue │ │ │ │ ├── PluginList.vue │ │ │ │ ├── configHelpers.js │ │ │ │ └── usePluginData.js │ │ │ ├── expose.md │ │ │ ├── fcitx5_switcher.md │ │ │ ├── fetch_client_menu.md │ │ │ ├── filters.md │ │ │ ├── generated/ │ │ │ │ ├── expose.json │ │ │ │ ├── fcitx5_switcher.json │ │ │ │ ├── fetch_client_menu.json │ │ │ │ ├── index.json │ │ │ │ ├── layout_center.json │ │ │ │ ├── lost_windows.json │ │ │ │ ├── magnify.json │ │ │ │ ├── menu.json │ │ │ │ ├── menubar.json │ │ │ │ ├── monitors.json │ │ │ │ ├── pyprland.json │ │ │ │ ├── scratchpads.json │ │ │ │ ├── shift_monitors.json │ │ │ │ ├── shortcuts_menu.json │ │ │ │ ├── system_notifier.json │ │ │ │ ├── toggle_dpms.json │ │ │ │ ├── toggle_special.json │ │ │ │ ├── wallpapers.json │ │ │ │ └── workspaces_follow_focus.json │ │ │ ├── index.md │ │ │ ├── layout_center.md │ │ │ ├── lost_windows.md │ │ │ ├── magnify.md │ │ │ ├── menubar.md │ │ │ ├── monitors.md │ │ │ ├── scratchpads.md │ │ │ ├── scratchpads_advanced.md │ │ │ ├── scratchpads_nonstandard.md │ │ │ ├── shift_monitors.md │ │ │ ├── shortcuts_menu.md │ │ │ ├── sidebar.json │ │ │ ├── system_notifier.md │ │ │ ├── toggle_dpms.md │ │ │ ├── toggle_special.md │ │ │ ├── wallpapers.md │ │ │ ├── wallpapers_online.md │ │ │ ├── wallpapers_templates.md │ │ │ └── workspaces_follow_focus.md │ │ ├── 3.1.1/ │ │ │ ├── Architecture.md │ │ │ ├── Architecture_core.md │ │ │ ├── Architecture_overview.md │ │ │ ├── Commands.md │ │ │ ├── Configuration.md │ │ │ ├── Development.md │ │ │ ├── Examples.md │ │ │ ├── Getting-started.md │ │ │ ├── InstallVirtualEnvironment.md │ │ │ ├── Menu.md │ │ │ ├── MultipleConfigurationFiles.md │ │ │ ├── Nix.md │ │ │ ├── Optimizations.md │ │ │ ├── Plugins.md │ │ │ ├── Troubleshooting.md │ │ │ ├── Variables.md │ │ │ ├── components/ │ │ │ │ ├── CommandList.vue │ │ │ │ ├── ConfigBadges.vue │ │ │ │ ├── ConfigTable.vue │ │ │ │ ├── EngineDefaults.vue │ │ │ │ ├── EngineList.vue │ │ │ │ ├── PluginCommands.vue │ │ │ │ ├── PluginConfig.vue │ │ │ │ ├── PluginList.vue │ │ │ │ ├── configHelpers.js │ │ │ │ ├── jsonLoader.js │ │ │ │ └── usePluginData.js │ │ │ ├── expose.md │ │ │ ├── fcitx5_switcher.md │ │ │ ├── fetch_client_menu.md │ │ │ ├── filters.md │ │ │ ├── gamemode.md │ │ │ ├── generated/ │ │ │ │ ├── expose.json │ │ │ │ ├── fcitx5_switcher.json │ │ │ │ ├── fetch_client_menu.json │ │ │ │ ├── gamemode.json │ │ │ │ ├── index.json │ │ │ │ ├── layout_center.json │ │ │ │ ├── lost_windows.json │ │ │ │ ├── magnify.json │ │ │ │ ├── menu.json │ │ │ │ ├── menubar.json │ │ │ │ ├── monitors.json │ │ │ │ ├── pyprland.json │ │ │ │ ├── scratchpads.json │ │ │ │ ├── shift_monitors.json │ │ │ │ ├── shortcuts_menu.json │ │ │ │ ├── system_notifier.json │ │ │ │ ├── toggle_dpms.json │ │ │ │ ├── toggle_special.json │ │ │ │ ├── wallpapers.json │ │ │ │ └── workspaces_follow_focus.json │ │ │ ├── index.md │ │ │ ├── layout_center.md │ │ │ ├── lost_windows.md │ │ │ ├── magnify.md │ │ │ ├── menubar.md │ │ │ ├── monitors.md │ │ │ ├── scratchpads.md │ │ │ ├── scratchpads_advanced.md │ │ │ ├── scratchpads_nonstandard.md │ │ │ ├── shift_monitors.md │ │ │ ├── shortcuts_menu.md │ │ │ ├── sidebar.json │ │ │ ├── system_notifier.md │ │ │ ├── toggle_dpms.md │ │ │ ├── toggle_special.md │ │ │ ├── wallpapers.md │ │ │ ├── wallpapers_online.md │ │ │ ├── wallpapers_templates.md │ │ │ └── workspaces_follow_focus.md │ │ └── 3.2.1/ │ │ ├── Architecture.md │ │ ├── Architecture_core.md │ │ ├── Architecture_overview.md │ │ ├── Commands.md │ │ ├── Configuration.md │ │ ├── Development.md │ │ ├── Examples.md │ │ ├── Getting-started.md │ │ ├── InstallVirtualEnvironment.md │ │ ├── Menu.md │ │ ├── MultipleConfigurationFiles.md │ │ ├── Nix.md │ │ ├── Optimizations.md │ │ ├── Plugins.md │ │ ├── Troubleshooting.md │ │ ├── Variables.md │ │ ├── components/ │ │ │ ├── CommandList.vue │ │ │ ├── ConfigBadges.vue │ │ │ ├── ConfigTable.vue │ │ │ ├── EngineDefaults.vue │ │ │ ├── EngineList.vue │ │ │ ├── PluginCommands.vue │ │ │ ├── PluginConfig.vue │ │ │ ├── PluginList.vue │ │ │ ├── configHelpers.js │ │ │ ├── jsonLoader.js │ │ │ └── usePluginData.js │ │ ├── expose.md │ │ ├── fcitx5_switcher.md │ │ ├── fetch_client_menu.md │ │ ├── filters.md │ │ ├── gamemode.md │ │ ├── generated/ │ │ │ ├── expose.json │ │ │ ├── fcitx5_switcher.json │ │ │ ├── fetch_client_menu.json │ │ │ ├── gamemode.json │ │ │ ├── index.json │ │ │ ├── layout_center.json │ │ │ ├── lost_windows.json │ │ │ ├── magnify.json │ │ │ ├── menu.json │ │ │ ├── menubar.json │ │ │ ├── monitors.json │ │ │ ├── pyprland.json │ │ │ ├── scratchpads.json │ │ │ ├── shift_monitors.json │ │ │ ├── shortcuts_menu.json │ │ │ ├── stash.json │ │ │ ├── system_notifier.json │ │ │ ├── toggle_dpms.json │ │ │ ├── toggle_special.json │ │ │ ├── wallpapers.json │ │ │ └── workspaces_follow_focus.json │ │ ├── index.md │ │ ├── layout_center.md │ │ ├── lost_windows.md │ │ ├── magnify.md │ │ ├── menubar.md │ │ ├── monitors.md │ │ ├── scratchpads.md │ │ ├── scratchpads_advanced.md │ │ ├── scratchpads_nonstandard.md │ │ ├── shift_monitors.md │ │ ├── shortcuts_menu.md │ │ ├── sidebar.json │ │ ├── stash.md │ │ ├── system_notifier.md │ │ ├── toggle_dpms.md │ │ ├── toggle_special.md │ │ ├── wallpapers.md │ │ ├── wallpapers_online.md │ │ ├── wallpapers_templates.md │ │ └── workspaces_follow_focus.md │ ├── wallpapers.md │ ├── wallpapers_online.md │ ├── wallpapers_templates.md │ └── workspaces_follow_focus.md ├── systemd-unit/ │ └── pyprland.service ├── tests/ │ ├── __init__.py │ ├── conftest.py │ ├── sample_config.toml │ ├── test_adapters_fallback.py │ ├── test_ansi.py │ ├── test_command.py │ ├── test_command_registry.py │ ├── test_common_types.py │ ├── test_common_utils.py │ ├── test_completions.py │ ├── test_config.py │ ├── test_event_signatures.py │ ├── test_external_plugins.py │ ├── test_http.py │ ├── test_interface.py │ ├── test_ipc.py │ ├── test_load_all.py │ ├── test_monitors_commands.py │ ├── test_monitors_layout.py │ ├── test_monitors_resolution.py │ ├── test_plugin_expose.py │ ├── test_plugin_fetch_client_menu.py │ ├── test_plugin_layout_center.py │ ├── test_plugin_lost_windows.py │ ├── test_plugin_magnify.py │ ├── test_plugin_menubar.py │ ├── test_plugin_monitor.py │ ├── test_plugin_scratchpads.py │ ├── test_plugin_shift_monitors.py │ ├── test_plugin_shortcuts_menu.py │ ├── test_plugin_stash.py │ ├── test_plugin_system_notifier.py │ ├── test_plugin_toggle_dpms.py │ ├── test_plugin_toggle_special.py │ ├── test_plugin_wallpapers.py │ ├── test_plugin_workspaces_follow_focus.py │ ├── test_process.py │ ├── test_pyprland.py │ ├── test_scratchpad_vulnerabilities.py │ ├── test_string_template.py │ ├── test_wallpapers_cache.py │ ├── test_wallpapers_colors.py │ ├── test_wallpapers_imageutils.py │ ├── testtools.py │ └── vreg/ │ ├── 01_client_id_change.py │ └── run_tests.sh ├── tickets.rst └── tox.ini ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: "[BUG] Enter a short bug description here" labels: bug assignees: fdev31 --- **Pyprland version** Which version did you use? (copy & paste the string returned by `pypr version`) **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Configuration (provide following files/samples when relevant):** - pyprland.toml - hyprland.conf **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: "[FEAT] Description of the feature" labels: enhancement assignees: fdev31 --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/ISSUE_TEMPLATE/wiki_improvement.md ================================================ --- name: Wiki improvement about: Suggest a fix or improvement in the documentation title: "[WIKI] Description of the problem" labels: documentation assignees: fdev31 --- **Is your feature request related to a problem or an improvement? Please describe.** A clear and concise description of what the problem is. Ex. There is a link broken in... **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/PULL_REQUEST_TEMPLATE/pull_request_template.md ================================================ --- name: Default PR template about: Improve the code title: "Change description here" --- # Description of the pull request content & goal In order to ... I have implemented ... # Relevant wiki content to be added/updated ## In "Wiki page XXX" Add configuration details for ...eg: ... ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: github-actions directory: "/" schedule: interval: weekly time: "01:00" open-pull-requests-limit: 10 reviewers: - NotAShelf - fdev31 assignees: - NotAShelf - fdev31 ================================================ FILE: .github/workflows/aur.yml ================================================ name: AUR Package Validation on: workflow_dispatch: pull_request: paths: - "pyprland/**" - "pyproject.toml" - "uv.lock" - "client/**" - ".github/workflows/aur.yml" push: paths: - "pyprland/**" - "pyproject.toml" - "uv.lock" - "client/**" - ".github/workflows/aur.yml" jobs: makepkg: runs-on: ubuntu-latest container: image: archlinux:latest steps: - name: Install base dependencies run: pacman -Syu --noconfirm base-devel git python python-build python-installer python-hatchling python-aiofiles python-aiohttp python-pillow gcc - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - name: Fetch PKGBUILD from AUR run: | mkdir -p /tmp/build curl -sL "https://aur.archlinux.org/cgit/aur.git/plain/PKGBUILD?h=pyprland-git" -o /tmp/build/PKGBUILD - name: Patch PKGBUILD to use local source run: | # Point the PKGBUILD source at the checked-out repo instead of remote sed -i "s|source=(git+https://github.com/fdev31/pyprland#branch=main)|source=(\"git+file://${GITHUB_WORKSPACE}\")|" /tmp/build/PKGBUILD # Show the patched PKGBUILD for debugging cat /tmp/build/PKGBUILD - name: Build and install with makepkg run: | # makepkg refuses to run as root, create a builder user useradd -m builder chown -R builder:builder /tmp/build # Allow builder to install packages via pacman echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers # git needs to trust the workspace git config --global --add safe.directory "${GITHUB_WORKSPACE}" su builder -c "git config --global --add safe.directory '${GITHUB_WORKSPACE}'" cd /tmp/build su builder -c "makepkg -si --noconfirm" - name: Smoke test run: | which pypr which pypr-client mkdir -p ~/.config/hypr echo -e '[pyprland]\nplugins = []' > ~/.config/hypr/pyprland.toml pypr validate ================================================ FILE: .github/workflows/ci.yml ================================================ --- name: CI on: [push] jobs: test: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v6 - name: Set up uv uses: astral-sh/setup-uv@v7 with: python-version: ${{ matrix.python-version }} - name: Finish environment setup run: | mkdir ~/.config/hypr/ touch ~/.config/hypr/pyprland.toml - name: Install dependencies run: uv sync --all-groups - name: Run tests run: | uv run ./scripts/generate_plugin_docs.py uv run pytest -q tests - name: Check wiki docs if: matrix.python-version == '3.14' run: | uv run ./scripts/generate_plugin_docs.py uv run ./scripts/check_plugin_docs.py ================================================ FILE: .github/workflows/nix-setup.yml ================================================ # This is a re-usable workflow that is used by .github/workflows/check.yml to handle necessary setup # before running Nix commands. E.g. this will install Nix and set up Magic Nix Cache name: "Nix Setup" on: workflow_call: inputs: command: required: false type: string platform: default: "ubuntu" required: false type: string secrets: GH_TOKEN: required: true jobs: nix: runs-on: "${{ inputs.platform }}-latest" steps: - name: "Set default git branch (to reduce log spam)" run: git config --global init.defaultBranch main - name: "Checkout" uses: actions/checkout@v6 with: token: "${{ secrets.GH_TOKEN }}" - name: "Set up QEMU support" uses: docker/setup-qemu-action@v4 with: platforms: arm64 - name: "Install nix" uses: cachix/install-nix-action@master with: install_url: https://nixos.org/nix/install extra_nix_config: | experimental-features = nix-command flakes fetch-tree allow-import-from-derivation = false extra-platforms = aarch64-linux - name: "Cachix Setup" uses: cachix/cachix-action@v17 with: authToken: ${{ secrets.CACHIX_TOKEN }} name: hyprland-community - name: "Nix Magic Cache" uses: DeterminateSystems/magic-nix-cache-action@main - name: "Run Input: ${{ inputs.command }}" run: "${{ inputs.command }}" ================================================ FILE: .github/workflows/nix.yml ================================================ name: Nix Flake Validation on: workflow_dispatch: pull_request: paths: - "pyprland/**" - "**.nix" - "**.lock" - ".github/workflows/check.yml" push: paths: - "pyprland/**" - "**.nix" - "**.lock" - ".github/workflows/check.yml" jobs: check: uses: ./.github/workflows/nix-setup.yml secrets: GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" with: command: nix flake check --accept-flake-config --print-build-logs build: needs: [check] uses: ./.github/workflows/nix-setup.yml strategy: matrix: package: - pyprland secrets: GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" with: command: nix build .#${{ matrix.package }} --print-build-logs ================================================ FILE: .github/workflows/site.yml ================================================ # Sample workflow for building and deploying a VitePress site to GitHub Pages # name: Website deployment on: # Runs on pushes targeting the `main` branch. Change this to `master` if you're # using the `master` branch as the default branch. push: branches: [main] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: contents: read pages: write id-token: write # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. concurrency: group: pages cancel-in-progress: false jobs: # Build job build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 # Not needed if lastUpdated is not enabled # - uses: pnpm/action-setup@v3 # Uncomment this if you're using pnpm # - uses: oven-sh/setup-bun@v1 # Uncomment this if you're using Bun - name: Setup Node uses: actions/setup-node@v6 with: node-version: 20 cache: npm # or pnpm / yarn - name: Setup Python uses: actions/setup-python@v6 with: python-version: '3.14' - name: Install Python dependencies run: pip install . - name: Setup Pages uses: actions/configure-pages@v6 - name: Install dependencies run: npm ci # or pnpm install / yarn install / bun install - name: Generate plugin documentation JSON run: python scripts/generate_plugin_docs.py - name: Build with VitePress run: npm run docs:build # or pnpm docs:build / yarn docs:build / bun run docs:build - name: Upload artifact uses: actions/upload-pages-artifact@v4 with: path: site/.vitepress/dist # Deployment job deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} needs: build runs-on: ubuntu-latest name: Deploy steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v5 ================================================ FILE: .github/workflows/uv-install.yml ================================================ name: uv tool install Validation on: workflow_dispatch: pull_request: paths: - "pyprland/**" - "pyproject.toml" - "uv.lock" - ".github/workflows/uv-install.yml" push: paths: - "pyprland/**" - "pyproject.toml" - "uv.lock" - ".github/workflows/uv-install.yml" jobs: install: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - name: Checkout uses: actions/checkout@v6 - name: Set up uv uses: astral-sh/setup-uv@v7 with: python-version: ${{ matrix.python-version }} - name: Install with uv tool install run: uv tool install . - name: Smoke test run: | which pypr mkdir -p ~/.config/hypr echo -e '[pyprland]\nplugins = []' > ~/.config/hypr/pyprland.toml pypr validate ================================================ FILE: .gitignore ================================================ RELEASE_NOTES.md # Byte-compiled / optimized / DLL files site/.vitepress/cache/ site/.vitepress/dist/ node_modules/ __pycache__/ *.py[cod] *$py.class # Generated documentation JSON (regenerated at build time) site/generated/*.json # stale merges *.orig # random crap *.out # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/#use-with-ide .pdm.toml # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # Nix result # Local development files OLD/ # AI assistant / editor tool files .opencode/ CODEBASE_OVERVIEW.md DEVELOPMENT_GUIDELINES.md # One-time migration scripts site/migration/ ================================================ FILE: .pre-commit-config.yaml ================================================ --- repos: - repo: local hooks: - id: versionMgmt name: Increment the version number entry: ./scripts/update_version language: system pass_filenames: false - id: wikiDocGen name: Generate wiki docs entry: ./scripts/generate_plugin_docs.py language: system pass_filenames: false - id: wikiDocCheck name: Check wiki docs coverage entry: python scripts/check_plugin_docs.py language: system pass_filenames: false - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. rev: "v0.14.14" hooks: # Run the linter. - id: ruff-check - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks rev: "v6.0.0" hooks: - id: check-yaml - id: check-json - id: pretty-format-json args: ['--autofix', '--no-sort-keys'] - repo: https://github.com/PyCQA/flake8 rev: 7.3.0 hooks: - id: flake8 files: ^pyprland/ args: [--max-line-length=140] - repo: https://github.com/lovesegfault/beautysh rev: "v6.4.2" hooks: - id: beautysh - repo: https://github.com/adrienverge/yamllint rev: "v1.38.0" hooks: - id: yamllint - repo: local hooks: - id: runtests name: Run pytest entry: uv run pytest tests pass_filenames: false language: system stages: [pre-push] # types: [python] # pass_filenames: false ================================================ FILE: .pylintrc ================================================ [MASTER] # Specify a configuration file. #rcfile= # Python code to execute, usually for sys.path manipulation such as # pygtk.require(). #init-hook= # Add to the black list. It should be a base name, not a # path. You may set this option multiple times. ignore=.hg # Pickle collected data for later comparisons. persistent=yes # List of plugins (as comma separated values of python modules names) to load, # usually to register additional checkers. load-plugins= [MESSAGES CONTROL] # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option # multiple time. #enable= # Disable the message, report, category or checker with the given id(s). You # can either give multiple identifier separated by comma (,) or put this option # multiple time (only on the command line, not in the configuration file where # it should appear only once). disable=R0903,W0603,yield-inside-async-function,unnecessary-ellipsis [REPORTS] # Set the output format. Available formats are text, parseable, colorized, msvs # (visual studio) and html output-format=text # Tells whether to display a full report or only the messages reports=yes # Python expression which should return a note less than 10 (10 is the highest # note). You have access to the variables errors warning, statement which # respectively contain the number of errors / warnings messages and the total # number of statements analyzed. This is used by the global evaluation report # (R0004). evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) [BASIC] # Regular expression which should only match correct module names module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ # Regular expression which should only match correct module level names const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__)|log(_.*)?)$ # Regular expression which should only match correct class names class-rgx=[A-Z_][a-zA-Z0-9]+$ # Regular expression which should only match correct function names function-rgx=[a-z_][a-zA-Z0-9_]{2,30}$ # Regular expression which should only match correct method names method-rgx=[a-z_][a-zA-Z0-9_]{2,30}$ # Regular expression which should only match correct instance attribute names attr-rgx=[a-z_][a-z0-9_]{2,30}$ # Regular expression which should only match correct argument names argument-rgx=[a-z_][a-z0-9_]{2,30}$ # Regular expression which should only match correct variable names variable-rgx=[a-z_][a-z0-9_]{,30}$ # Regular expression which should only match correct list comprehension / # generator expression variable names inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ # Good variable names which should always be accepted, separated by a comma good-names=i,j,k,ex,Run,_ # Bad variable names which should always be refused, separated by a comma bad-names=foo,bar,baz,toto,tutu,tata,pdb # Regular expression which should only match functions or classes name which do # not require a docstring no-docstring-rgx=__.*__ [FORMAT] # Maximum number of characters on a single line. max-line-length=140 # Maximum number of lines in a module max-module-lines=1000 # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 # tab). indent-string=' ' [MISCELLANEOUS] # List of note tags to take in consideration, separated by a comma. notes=FIXME,XXX,TODO [SIMILARITIES] # Minimum lines number of a similarity. min-similarity-lines=4 # Ignore comments when computing similarities. ignore-comments=yes # Ignore docstrings when computing similarities. ignore-docstrings=yes [TYPECHECK] # Tells whether missing members accessed in mixin class should be ignored. A # mixin class is detected if its name ends with "mixin" (case insensitive). ignore-mixin-members=yes # List of classes names for which member attributes should not be checked # (useful for classes with attributes dynamically set). ignored-classes=SQLObject # List of members which are set dynamically and missed by pylint inference # system, and so shouldn't trigger E0201 when accessed. generated-members=REQUEST,acl_users,aq_parent [VARIABLES] # Tells whether we should check for unused import in __init__ files. init-import=no # A regular expression matching names used for dummy variables (i.e. not used). dummy-variables-rgx=_|dummy # List of additional names supposed to be defined in builtins. Remember that # you should avoid to define new builtins when possible. #additional-builtins= additional-builtins = _,DBG [CLASSES] # List of method names used to declare (i.e. assign) instance attributes. defining-attr-methods=__init__,__new__,setUp [DESIGN] # Maximum number of arguments for function / method max-args=7 # Argument names that match this expression will be ignored. Default to name # with leading underscore ignored-argument-names=_.* # Maximum number of locals for function / method body max-locals=15 # Maximum number of return / yield for function / method body max-returns=6 # Maximum number of statements in function / method body max-statements=50 # Maximum number of parents for a class (see R0901). max-parents=7 # Maximum number of attributes for a class (see R0902). max-attributes=10 # Minimum number of public methods for a class (see R0903). min-public-methods=2 # Maximum number of public methods for a class (see R0904). max-public-methods=24 [IMPORTS] # Deprecated modules which should not be used, separated by a comma deprecated-modules=regsub,string,TERMIOS,Bastion,rexec # Create a graph of every (i.e. internal and external) dependencies in the # given file (report RP0402 must not be disabled) import-graph= # Create a graph of external dependencies in the given file (report RP0402 must # not be disabled) ext-import-graph= # Create a graph of internal dependencies in the given file (report RP0402 must # not be disabled) int-import-graph= ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at fdev31@gmail.com. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ================================================ FILE: CONTRIBUTING.md ================================================ - ensure everything works when you run `tox` - use `ruff format` to format the code - provide documentation to be added to the wiki when relevant - provide some *tests* when possible - ensure you read https://github.com/hyprland-community/pyprland/wiki/Development ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023 Fabien Devaux Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ ![rect](https://github.com/hyprland-community/pyprland/assets/238622/3fab93b6-6445-4e7b-b757-035095b5c8e8) [![Hyprland](https://img.shields.io/badge/Made%20for-Hyprland-blue)](https://github.com/hyprwm/Hyprland) [![Discord](https://img.shields.io/discord/1055990214411169892?label=discord)](https://discord.com/channels/1458202721294356522/1458202722892386519) [![Documentation](https://img.shields.io/badge/Documentation-Read%20Now-brightgreen?style=for-the-badge)](https://hyprland-community.github.io/pyprland) [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) ## Power up your desktop A plugin system that extends your graphical environment with features like scratchpads, dynamic popup nested menus, custom notifications, easy monitor settings and more. Think 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. Contributions, suggestions, bug reports and comments are welcome.
About Pyprland (latest stable is: 3.3.1) [![Packaging Status](https://repology.org/badge/vertical-allrepos/pyprland.svg)](https://repology.org/project/pyprland/versions) 🎉 Hear what others are saying: - [Elsa in Mac](https://elsainmac.tistory.com/915) some tutorial article for fedora in Korean with a nice short demo video - [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) - ["It just works very very well" - The Linux Cast (video)](https://youtu.be/Cjn0SFyyucY?si=hGb0TM9IDvlbcD6A&t=131) - February 2024 - [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))
Contributing Check 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. - Report bugs or propose features [here](https://github.com/hyprland-community/pyprland/issues) - Improve our [wiki](https://hyprland-community.github.io/pyprland/) - Read the [internal ticket list](https://github.com/hyprland-community/pyprland/blob/main/tickets.rst) for some PR ideas and if you have coding skills you can also - Enhance the coverage of our [tests](https://github.com/hyprland-community/pyprland/tree/main/tests) - Propose & write new plugins or enhancements
Dependencies - **Python** >= 3.11 - **aiofiles** (optional but recommended) - **pillow** (optional, required for rounded borders in `wallpapers`)
Latest major changes Check the [Releases change log](https://github.com/hyprland-community/pyprland/releases) for more information ### 3.0.0 - Dynamic shell completions - Better error handling and configuration validation - Removed hard dependency on Hyprland - General polish including a couple ofbreaking changes - remove old or broken options - fixes ### 2.5 - wallpapers plugin refactored, supports rounded corners and pause - fcitx5 switcher plugin (appeared in late 2.4) ### 2.4 - Scratchpads are now pinned by default (set `pinned = false` for the old behavior) - Version >=2.4.4 is required for Hyprland 0.48.0 - A snappier `pypr-client` command is available, meant to be used in the keyboard bindings (NOT to start pypr on startup!), eg: ```sh $pypr = uwsm-app -- pypr-client bind = $mainMod SHIFT, Z, exec, $pypr zoom ++0.5 ``` ### 2.3 - Supports *Hyprland > 0.40.0* - Improved code kwaleetee - [monitors](https://hyprland-community.github.io/pyprland/monitors) allows general monitor settings - [scratchpads](https://hyprland-community.github.io/pyprland/scratchpads) - better multi-window support - better `preserve_aspect` implementation (i3 "compatibility") ### 2.2 - Added [wallpapers](https://hyprland-community.github.io/pyprland/wallpapers) and [system_notifier](https://hyprland-community.github.io/pyprland/system_notifier) plugins. - Deprecated [class_match](https://hyprland-community.github.io/pyprland/scratchpads_nonstandard) in [scratchpads](https://hyprland-community.github.io/pyprland/scratchpads) - Added [gbar](https://hyprland-community.github.io/pyprland/gbar) in 2.2.6 - [scratchpads](https://hyprland-community.github.io/pyprland/scratchpads) supports multiple client windows (using 2.2.19 is recommended) - [monitors](https://hyprland-community.github.io/pyprland/monitors) and [scratchpads](https://hyprland-community.github.io/pyprland/scratchpads) supports rotation in 2.2.13 - Improve [Nix support](https://hyprland-community.github.io/pyprland/Nix) ### 2.1 - Requires Hyprland >= 0.37 - [Monitors](https://hyprland-community.github.io/pyprland/monitors) plugin improvements. ### 2.0 - New dependency: [aiofiles](https://pypi.org/project/aiofiles/) - Added [hysteresis](https://hyprland-community.github.io/pyprland/scratchpads#hysteresis-optional) support for [scratchpads](https://hyprland-community.github.io/pyprland/scratchpads). ### 1.10 - 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. ### 1.9 - Introduced [shortcuts_menu](https://hyprland-community.github.io/pyprland/shortcuts_menu) plugin. ### 1.8 - Requires Hyprland >= 0.30 - Added [layout_center](https://hyprland-community.github.io/pyprland/layout_center) plugin.
Star History Chart ================================================ FILE: client/pypr-client.c ================================================ #include #include #include #include #include #include #include // Exit codes matching pyprland/models.py ExitCode #define EXIT_SUCCESS_CODE 0 #define EXIT_USAGE_ERROR 1 #define EXIT_ENV_ERROR 2 #define EXIT_CONNECTION_ERROR 3 #define EXIT_COMMAND_ERROR 4 // Response prefixes #define RESPONSE_OK "OK" #define RESPONSE_ERROR "ERROR" int main(int argc, char *argv[]) { // If no argument passed, show usage if (argc < 2) { fprintf(stderr, "No command provided.\n"); fprintf(stderr, "Usage: pypr [args...]\n"); fprintf(stderr, "Try 'pypr help' for available commands.\n"); exit(EXIT_USAGE_ERROR); } // Get environment variables for socket path detection const char *runtimeDir = getenv("XDG_RUNTIME_DIR"); const char *signature = getenv("HYPRLAND_INSTANCE_SIGNATURE"); const char *niriSocket = getenv("NIRI_SOCKET"); const char *dataHome = getenv("XDG_DATA_HOME"); const char *home = getenv("HOME"); // Construct the socket path based on environment priority: Hyprland > Niri > Standalone char socketPath[256]; int pathLen; if (signature != NULL && runtimeDir != NULL) { // Hyprland environment pathLen = snprintf(socketPath, sizeof(socketPath), "%s/hypr/%s/.pyprland.sock", runtimeDir, signature); } else if (niriSocket != NULL) { // Niri environment - use dirname of NIRI_SOCKET char *niriSocketCopy = strdup(niriSocket); if (niriSocketCopy == NULL) { fprintf(stderr, "Error: Memory allocation failed.\n"); exit(EXIT_ENV_ERROR); } char *niriDir = dirname(niriSocketCopy); pathLen = snprintf(socketPath, sizeof(socketPath), "%s/.pyprland.sock", niriDir); free(niriSocketCopy); } else { // Standalone fallback - use XDG_DATA_HOME or ~/.local/share if (dataHome != NULL) { pathLen = snprintf(socketPath, sizeof(socketPath), "%s/.pyprland.sock", dataHome); } else if (home != NULL) { pathLen = snprintf(socketPath, sizeof(socketPath), "%s/.local/share/.pyprland.sock", home); } else { fprintf(stderr, "Error: Cannot determine socket path. HOME not set.\n"); exit(EXIT_ENV_ERROR); } } if (pathLen >= (int)sizeof(socketPath)) { fprintf(stderr, "Error: Socket path too long (max %zu characters).\n", sizeof(socketPath) - 1); exit(EXIT_ENV_ERROR); } // Connect to the Unix socket int sockfd = socket(AF_UNIX, SOCK_STREAM, 0); if (sockfd < 0) { fprintf(stderr, "Error: Failed to create socket.\n"); exit(EXIT_CONNECTION_ERROR); } struct sockaddr_un addr; memset(&addr, 0, sizeof(addr)); addr.sun_family = AF_UNIX; strncpy(addr.sun_path, socketPath, sizeof(addr.sun_path) - 1); if (connect(sockfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { fprintf(stderr, "Cannot connect to pyprland daemon at %s.\n", socketPath); fprintf(stderr, "Is the daemon running? Start it with: pypr (no arguments)\n"); close(sockfd); exit(EXIT_CONNECTION_ERROR); } // Concatenate all command-line arguments with spaces, plus newline char message[1024] = {0}; int offset = 0; for (int i = 1; i < argc; i++) { int remaining = sizeof(message) - offset - 2; // Reserve space for \n and \0 if (remaining <= 0) { fprintf(stderr, "Error: Command too long (max %zu characters).\n", sizeof(message) - 2); close(sockfd); exit(EXIT_USAGE_ERROR); } int written = snprintf(message + offset, remaining + 1, "%s", argv[i]); if (written > remaining) { fprintf(stderr, "Error: Command too long (max %zu characters).\n", sizeof(message) - 2); close(sockfd); exit(EXIT_USAGE_ERROR); } offset += written; if (i < argc - 1) { if (offset < (int)sizeof(message) - 2) { message[offset++] = ' '; } } } // Add newline for protocol message[offset++] = '\n'; message[offset] = '\0'; // Send the message to the socket if (write(sockfd, message, strlen(message)) < 0) { fprintf(stderr, "Error: Failed to send command to daemon.\n"); close(sockfd); exit(EXIT_CONNECTION_ERROR); } // send EOF to indicate end of message if (shutdown(sockfd, SHUT_WR) < 0) { fprintf(stderr, "Error: Failed to complete command transmission.\n"); close(sockfd); exit(EXIT_CONNECTION_ERROR); } // Read the response from the socket until EOF char buffer[4096]; char response[65536] = {0}; size_t totalRead = 0; ssize_t bytesRead; while ((bytesRead = read(sockfd, buffer, sizeof(buffer) - 1)) > 0) { if (totalRead + bytesRead >= sizeof(response) - 1) { // Response too large, just print what we have buffer[bytesRead] = '\0'; printf("%s", buffer); } else { memcpy(response + totalRead, buffer, bytesRead); totalRead += bytesRead; } } response[totalRead] = '\0'; close(sockfd); // Parse response and determine exit code int exitCode = EXIT_SUCCESS_CODE; if (strncmp(response, RESPONSE_ERROR ":", strlen(RESPONSE_ERROR ":")) == 0) { // Error response - extract message after "ERROR: " const char *errorMsg = response + strlen(RESPONSE_ERROR ": "); // Trim trailing whitespace size_t len = strlen(errorMsg); while (len > 0 && (errorMsg[len-1] == '\n' || errorMsg[len-1] == ' ')) { len--; } fprintf(stderr, "Error: %.*s\n", (int)len, errorMsg); exitCode = EXIT_COMMAND_ERROR; } else if (strncmp(response, RESPONSE_OK, strlen(RESPONSE_OK)) == 0) { // OK response - check for additional output after "OK" const char *remaining = response + strlen(RESPONSE_OK); // Skip whitespace/newlines while (*remaining == ' ' || *remaining == '\n') { remaining++; } if (*remaining != '\0') { printf("%s", remaining); } exitCode = EXIT_SUCCESS_CODE; } else if (totalRead > 0) { // Legacy response (version, help, dumpjson) - print as-is // Trim trailing newlines for cleaner output while (totalRead > 0 && response[totalRead-1] == '\n') { totalRead--; } response[totalRead] = '\0'; if (totalRead > 0) { printf("%s\n", response); } exitCode = EXIT_SUCCESS_CODE; } return exitCode; } ================================================ FILE: client/pypr-client.rs ================================================ use std::env; use std::io::{Read, Write}; use std::os::unix::net::UnixStream; use std::process::exit; // Exit codes matching pyprland/models.py ExitCode const EXIT_SUCCESS: i32 = 0; const EXIT_USAGE_ERROR: i32 = 1; const EXIT_ENV_ERROR: i32 = 2; const EXIT_CONNECTION_ERROR: i32 = 3; const EXIT_COMMAND_ERROR: i32 = 4; fn run() -> Result<(), i32> { // Collect arguments (skip program name) let args: Vec = env::args().skip(1).collect(); if args.is_empty() { eprintln!("No command provided."); eprintln!("Usage: pypr [args...]"); eprintln!("Try 'pypr help' for available commands."); return Err(EXIT_USAGE_ERROR); } // Build command message let message = format!("{}\n", args.join(" ")); if message.len() > 1024 { eprintln!("Error: Command too long (max 1022 characters)."); return Err(EXIT_USAGE_ERROR); } // Get socket path from environment let runtime_dir = env::var("XDG_RUNTIME_DIR").map_err(|_| { eprintln!("Environment error: XDG_RUNTIME_DIR or HYPRLAND_INSTANCE_SIGNATURE not set."); eprintln!("Are you running under Hyprland?"); EXIT_ENV_ERROR })?; let signature = env::var("HYPRLAND_INSTANCE_SIGNATURE").map_err(|_| { eprintln!("Environment error: XDG_RUNTIME_DIR or HYPRLAND_INSTANCE_SIGNATURE not set."); eprintln!("Are you running under Hyprland?"); EXIT_ENV_ERROR })?; let socket_path = format!("{}/hypr/{}/.pyprland.sock", runtime_dir, signature); if socket_path.len() >= 256 { eprintln!("Error: Socket path too long (max 255 characters)."); return Err(EXIT_ENV_ERROR); } // Connect to Unix socket let mut stream = UnixStream::connect(&socket_path).map_err(|_| { eprintln!("Cannot connect to pyprland daemon at {}.", socket_path); eprintln!("Is the daemon running? Start it with: pypr (no arguments)"); EXIT_CONNECTION_ERROR })?; // Send command stream.write_all(message.as_bytes()).map_err(|_| { eprintln!("Error: Failed to send command to daemon."); EXIT_CONNECTION_ERROR })?; // Signal end of message stream.shutdown(std::net::Shutdown::Write).map_err(|_| { eprintln!("Error: Failed to complete command transmission."); EXIT_CONNECTION_ERROR })?; // Read response let mut response = String::new(); stream.read_to_string(&mut response).map_err(|_| { eprintln!("Error: Failed to read response from daemon."); EXIT_CONNECTION_ERROR })?; // Parse response and determine exit code if let Some(error_msg) = response.strip_prefix("ERROR: ") { eprintln!("Error: {}", error_msg.trim_end()); Err(EXIT_COMMAND_ERROR) } else if let Some(rest) = response.strip_prefix("OK") { // Print any content after "OK" (skip leading whitespace/newlines) let output = rest.trim_start(); if !output.is_empty() { print!("{}", output); } Ok(()) } else if !response.is_empty() { // Legacy response (version, help, dumpjson) - print as-is println!("{}", response.trim_end_matches('\n')); Ok(()) } else { Ok(()) } } fn main() { exit(run().err().unwrap_or(EXIT_SUCCESS)); } ================================================ FILE: client/pypr-rs/Cargo.toml ================================================ [package] name = "pypr-client" version = "0.1.0" edition = "2021" [profile.release] opt-level = "z" lto = true codegen-units = 1 panic = "abort" strip = true ================================================ FILE: default.nix ================================================ ( import ( let lock = builtins.fromJSON (builtins.readFile ./flake.lock); nodeName = lock.nodes.root.inputs.flake-compat; in fetchTarball { url = lock.nodes.${nodeName}.locked.url or "https://github.com/hyprland-community/pyprland/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz"; sha256 = lock.nodes.${nodeName}.locked.narHash; } ) {src = ./.;} ) ================================================ FILE: done.rst ================================================ scratchpads: attach / detach only attaches ! ============================================ :bugid: 56 :created: 2026-1-11T22:10:17 :fixed: 2026-01-19T22:03:48 :priority: 0 -------------------------------------------------------------------------------- Hyprpaper integration ===================== :bugid: 56 :created: 2025-12-16T21:08:03 :fixed: 2025-12-16T21:44:08 :priority: 0 Use the socket $XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.hyprpaper.sock directly if no command is provided in "wallpapers" -------------------------------------------------------------------------------- Wiki: remove optional and add mandatory (to the titles for the configuration options) ===================================================================================== :bugid: 52 :created: 2024-07-08T21:51:28 :fixed: 2025-08-07T21:27:38 :priority: 0 -------------------------------------------------------------------------------- improve groups support ====================== :bugid: 37 :created: 2024-04-15T00:27:52 :fixed: 2024-07-08T21:54:09 :priority: 0 Instead of making it in "layout_center" by lack of choice, refactor: - make run_command return a code compatible with shell (0 = success, < 0 = error) - by default it returns 0 else: Add it to "layout_center" overriding prev & next if grouped, toggle over groups, when at the limit, really changes the focus Option: think about a "chaining" in handlers, (eg: "pypr groups prev OR layout_center prev") in case of a separate plugin called "groups" -------------------------------------------------------------------------------- Add a fallback to aiofiles (removes one dependency) =================================================== :bugid: 49 :created: 2024-06-02T01:41:33 :fixed: 2024-07-08T21:51:40 :priority: 0 -------------------------------------------------------------------------------- monitors: allow "screen descr".transform = 3 ============================================ :bugid: 48 :created: 2024-05-07T00:50:34 :fixed: 2024-05-23T20:56:53 :priority: 0 also allow `.scale = ` -------------------------------------------------------------------------------- review CanceledError handling ============================= :bugid: 38 :created: 2024-04-17T23:24:13 :fixed: 2024-05-16T20:38:37 :priority: 0 :timer: 20 -------------------------------------------------------------------------------- Add "satellite" scratchpads =========================== :bugid: 36 :created: 2024-04-08T23:42:26 :fixed: 2024-05-16T20:38:18 :priority: 0 - add a "scratch" command that sets the focused window into the currently focused scratchpad window Eg: open a terminal, hover it + "scratch" it while a scratchpad is open. Behind the hood, it creates attached "ghost scratchpads" for each attached window. They use "perserve_aspect" by default. **Alternative** Move focused client into the named scratchpad's special workspace. Rework 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. If called on a scratchpad window, will "de-attach" this window. Every attached window should be synchronized with the main one. **Option** Prepare / Simplify this dev by adding support for "ScratchGroups" (contains multiple Scratches which are synchronized). Would generalize the current feature: passing multiple scratches to the toggle command. -------------------------------------------------------------------------------- offset & margin: support % and px units ======================================= :bugid: 33 :created: 2024-03-08T00:07:02 :fixed: 2024-05-16T20:38:09 :priority: 0 -------------------------------------------------------------------------------- scratchpads: experiment handling manual scratchpad workspace change =================================================================== :bugid: 47 :created: 2024-05-01T23:38:51 :fixed: 2024-05-16T20:37:54 :priority: 0 -------------------------------------------------------------------------------- Check behavior of monitors when no match is found ================================================= :bugid: 42 :created: 2024-04-26T00:26:22 :fixed: 2024-05-16T20:37:32 :priority: 0 Should ignore applying any rule -------------------------------------------------------------------------------- CHECK / fix multi-monitor & attach command ========================================== :bugid: 40 :created: 2024-04-23T22:01:39 :fixed: 2024-05-16T20:36:40 :priority: 0 -------------------------------------------------------------------------------- Review smart_focus when toggling on a special workspace ======================================================= :bugid: 43 :created: 2024-04-27T18:25:47 :fixed: 2024-05-16T20:36:26 :priority: 0 :timer: 20 -------------------------------------------------------------------------------- Re-introduce focus tracking with a twist ======================================== :bugid: 41 :created: 2024-04-25T23:54:53 :fixed: 2024-05-01T23:42:00 :priority: 0 Only enable it if the focuse changed the active workspace -------------------------------------------------------------------------------- TESTS: ensure commands are completed (push the proper events in the queue) ========================================================================== :bugid: 27 :created: 2024-02-29T23:30:02 :fixed: 2024-05-01T23:40:05 :priority: 0 -------------------------------------------------------------------------------- Add a command to update config ============================== :bugid: 22 :created: 2024-02-18T17:53:17 :fixed: 2024-05-01T23:39:56 :priority: 0 cfg_set and cfg_toggle commands eg:: pypr cfg_toggle scratchpads.term.unfocus (toggles will toggle strings to "" and back - keeping a memory) -------------------------------------------------------------------------------- Rework focus ============ :bugid: 45 :created: 2024-04-29T00:01:27 :fixed: 2024-05-01T23:39:44 :priority: 0 Save workspace before hide, when hide is done, after processing some events (use a task), focus the workspace again -------------------------------------------------------------------------------- AUR: add zsh completion file ============================ :bugid: 44 :created: 2024-04-27T23:54:28 :fixed: 2024-05-01T23:38:57 :priority: 0 -------------------------------------------------------------------------------- 2.1 ? ===== :bugid: 35 :created: 2024-03-08T00:22:35 :fixed: 2024-04-09T21:28:26 :priority: 0 - lazy = true - positions in % and px (defaults to px if no unit is provided) - #34 done - #33 done - VISUAL REGRESSION TESTS -------------------------------------------------------------------------------- Make an "system_notifier" plugin ================================ :bugid: 21 :created: 2024-02-16T00:16:11 :fixed: 2024-04-08T19:58:46 :priority: 0 Reads journalctl -fxn and notifies some errors, user can use some patterns to match additional errors and create their own notifications > Started, works but better examples are needed .. code:: toml [system_notifier] builtin_rules = true [[system_notifier.source]] name = "kernel" source = "sudo journalctl -fkn" duration = 10 rules = [ {match="xxx", filter=["s/foobar//", "s/meow/plop/g"], message="bad"}, {contains="xxx", filter=["s/foobar//", "s/meow/plop/g"], message="xxx happened [orig] [filtered]"}, ] [[system_notifier.source]] name = "user journal" source = "journalctl -fxn --user" rules = [ {match="Consumed \d+.?\d*s CPU time", filter="s/.*: //", message="[filtered]"}, {match="xxx", filter=["s/foobar//", "s/meow/plop/g"], message="bad"}, {contains="xxx", filter=["s/foobar//", "s/meow/plop/g"], message="xxx happened [orig] [filtered]"}, ] [[system_notifier.source]] name = "user journal" source = "sudo journalctl -fxn" rules = [ {match="systemd-networkd\[\d+\]: ([a-z0-9]+): Link (DOWN|UP)", filter="s/.*: ([a-z0-9]+): Link (DOWN|UP)/\1 \2/", message="[filtered]"} {match="wireplumber[1831]: Failure in Bluetooth audio transport "} {match="usb 7-1: Product: USB2.0 Hub", message="detected"} {match="févr. 02 17:30:24 gamix systemd-coredump[11872]: [🡕] Process 11801 (tracker-extract) of user 1000 dumped core."} ] [[system_notifier.source]] name = "Hyprland" source = "/tmp/pypr.log" duration = 10 rules = [ {message="[orig]"}, ] [[system_notifier.source]] name = "networkd" -------------------------------------------------------------------------------- preserve_aspect to manage multi-screen setups ============================================= :bugid: 30 :created: 2024-03-04T22:21:41 :fixed: 2024-04-08T19:58:23 :priority: 0 -------------------------------------------------------------------------------- offset computation (hide anim) rework ===================================== :bugid: 34 :created: 2024-03-08T00:11:31 :fixed: 2024-03-08T21:35:57 :priority: 0 use animation type + margin to do a reverse computation of the placement (out of screen) -------------------------------------------------------------------------------- set preserve_aspect=true by default =================================== :bugid: 32 :created: 2024-03-06T23:28:41 :fixed: 2024-03-06T23:29:33 :priority: 0 also add a command "scratch reset " to set active scratch position and size according to the rules. Can support ommitting the , requires tracking of the currently active scratch (or just check focused window) -------------------------------------------------------------------------------- preserve_aspect should adapt to screen changes ============================================== :bugid: 29 :created: 2024-03-03T01:56:28 :fixed: 2024-03-06T23:29:32 :priority: 0 -------------------------------------------------------------------------------- BUG: preserve_aspect + offset = KO ================================== :bugid: 31 :created: 2024-03-05T00:22:34 :fixed: 2024-03-06T23:29:21 :priority: 0 :tags: #bug tested on "term" -------------------------------------------------------------------------------- scratchpad: per monitor overrides ================================= :bugid: 9 :created: 2023-12-02T21:53:48 :fixed: 2024-03-02T15:30:25 :priority: 10 -------------------------------------------------------------------------------- Check for types (schema?) in the config ======================================= :bugid: 24 :created: 2024-02-21T00:50:34 :fixed: 2024-03-02T15:30:15 :priority: 0 notify an error in case type isn't matching -------------------------------------------------------------------------------- Make "replace links" script =========================== :bugid: 23 :created: 2024-02-20T00:15:31 :fixed: 2024-03-02T15:29:57 :priority: 0 Reads a file (like RELEASE NOTES) and replace `links` with something in the wiki uses difflib to make the job -------------------------------------------------------------------------------- Hide / Show ALL command ======================= :bugid: 10 :created: 2023-12-26T21:48:36 :fixed: 2024-02-29T23:30:14 :priority: 10 hide all command for scratchpad -------------------------------------------------------------------------------- Make a get_bool() util function =============================== :bugid: 25 :created: 2024-02-21T23:34:39 :fixed: 2024-02-29T23:30:12 :priority: 10 Should detect "no", "False", etc.. (strings) as being false makes a notification warning, that it has been automatically fixed to `False` -------------------------------------------------------------------------------- Add "exit" command that exits cleanly (& removing the socket) ============================================================= :bugid: 20 :created: 2024-02-15T19:29:48 :fixed: 2024-02-29T22:38:15 :priority: 0 -------------------------------------------------------------------------------- scratchpads: autofloat=True =========================== :bugid: 26 :created: 2024-02-28T19:40:02 :fixed: 2024-02-29T22:37:52 :priority: 0 Allows to disable the automatic float toggle when the scratch is opened ================================================ FILE: examples/README.md ================================================ # Contribute your configuration [Dotfiles](https://github.com/fdev31/dotfiles) [![Discord](https://img.shields.io/discord/1055990214411169892?label=discord)](https://discord.com/channels/1458202721294356522/1458202722892386519) Make 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. ================================================ FILE: examples/copy_conf.sh ================================================ #!/bin/sh if [ -z "$1" ]; then echo -n "config name: " read name else name=$1 fi [ -d $name/hypr/ ] || mkdir $name/hypr/ FILENAMES=("hyprland.conf" "pyprland.toml") for fname in ${FILENAMES[@]}; do install -T ~/.config/hypr/$fname $name/hypr/$fname done # recursively install the ~/config/hypr/pyprland.d folder into $name/hypr/pyprland.d # cp -r ~/.config/hypr/pyprland.d $name/hypr/ # for fname in "config" "style.scss" ; do # install -T ~/.config/gBar/$fname $name/hypr/gBar/$fname # done ================================================ FILE: flake.nix ================================================ { inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable-small"; # pyproject-nix = { url = "github:nix-community/pyproject.nix"; inputs.nixpkgs.follows = "nixpkgs"; }; # systems.url = "github:nix-systems/default-linux"; # flake-compat = { url = "github:edolstra/flake-compat"; flake = false; }; }; outputs = { self, nixpkgs, pyproject-nix, systems, ... }: let eachSystem = nixpkgs.lib.genAttrs (import systems); pkgsFor = eachSystem (system: nixpkgs.legacyPackages.${system}); project = pyproject-nix.lib.project.loadPyproject { projectRoot = ./.; }; in { packages = eachSystem ( system: let pkgs = pkgsFor.${system}; python = pkgs.python3; attrs = project.renderers.buildPythonPackage { inherit python; }; in { default = self.packages.${system}.pyprland; pyprland = python.pkgs.buildPythonPackage ( attrs // { nativeBuildInputs = (attrs.nativeBuildInputs or [ ]) ++ [ python.pkgs.hatchling pkgs.stdenv.cc ]; postInstall = '' $CC -O2 -o $out/bin/pypr-client $src/client/pypr-client.c ''; } ); } ); devShells = eachSystem ( system: let pkgs = pkgsFor.${system}; python = pkgs.python3; getDependencies = project.renderers.withPackages { inherit python; }; pythonWithPackages = python.withPackages getDependencies; in { default = self.devShells.${system}.pyprland; pyprland = pkgs.mkShell { packages = [ pythonWithPackages pkgs.uv ]; }; } ); }; nixConfig = { extra-substituters = [ "https://hyprland-community.cachix.org" ]; extra-trusted-public-keys = [ "hyprland-community.cachix.org-1:5dTHY+TjAJjnQs23X+vwMQG4va7j+zmvkTKoYuSXnmE=" ]; }; } ================================================ FILE: hatch_build.py ================================================ """Custom hatch build hook to compile the optional C client. When the PYPRLAND_BUILD_NATIVE environment variable is set to "1", compiles client/pypr-client.c into a statically-linked native binary and includes it in a platform-specific wheel tagged for manylinux_2_17_x86_64. Without the env var the hook does nothing and the wheel stays pure-python. """ import os import shutil import subprocess import tempfile from pathlib import Path from hatchling.builders.hooks.plugin.interface import BuildHookInterface # The manylinux tag to use for the platform-specific wheel. # 2.17 corresponds to glibc 2.17 (CentOS 7) — effectively all modern Linux. # With static linking the binary has *no* glibc dependency, so this is safe. MANYLINUX_TAG = "cp3-none-manylinux_2_17_x86_64" def _find_compiler() -> str: """Find a C compiler from CC env var or common names. Returns: The compiler command string, or empty string if none found. """ cc = os.environ.get("CC", "") if cc: return cc for candidate in ("cc", "gcc", "clang"): if shutil.which(candidate): return candidate return "" def _try_compile(cc: str, source: Path, *, static: bool = False) -> tuple[Path | None, str]: """Attempt to compile the C client. Args: cc: C compiler command. source: Path to the C source file. static: Whether to produce a statically-linked binary. Returns: Tuple of (output_path or None, warning message if failed). """ tmpdir = Path(tempfile.mkdtemp(prefix="pypr-build-")) output = tmpdir / "pypr-client" cmd = [cc, "-O2"] if static: cmd.append("-static") cmd.extend(["-o", str(output), str(source)]) try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=60, check=False) # noqa: S603 except FileNotFoundError: return None, f"C compiler '{cc}' not found. Skipping native client build." except subprocess.TimeoutExpired: return None, "C client compilation timed out. Skipping native client build." if result.returncode != 0: return None, ( f"C client compilation failed (exit {result.returncode}). Skipping native client build.\nstderr: {result.stderr.strip()}" ) if not output.exists(): return None, "Compiled binary not found after build. Skipping native client." output.chmod(0o755) # noqa: S103 return output, "" class NativeClientBuildHook(BuildHookInterface): """Build hook that compiles the native C client.""" PLUGIN_NAME = "native-client" def initialize(self, version: str, build_data: dict) -> None: # noqa: ARG002 """Compile the C client and include it in the wheel if successful. Only runs when PYPRLAND_BUILD_NATIVE=1 is set and the build target is a wheel. The resulting wheel is tagged as manylinux so it can be uploaded to PyPI. """ if self.target_name != "wheel": return if os.environ.get("PYPRLAND_BUILD_NATIVE") != "1": return source = Path(self.root) / "client" / "pypr-client.c" if not source.exists(): self.app.display_warning("C client source not found, skipping native client build") return cc = _find_compiler() if not cc: self.app.display_warning("No C compiler found (set CC env var or install gcc/clang). Skipping native pypr-client build.") return self.app.display_info(f"Compiling native client with {cc} (static)") output, warning = _try_compile(cc, source, static=True) if output is None: self.app.display_warning(warning) return self.app.display_success("Native pypr-client compiled successfully") # Use shared_scripts so hatchling generates the correct # {name}-{version}.data/scripts/ path in the wheel (PEP 427). build_data["shared_scripts"][str(output)] = "pypr-client" # Mark the wheel as platform-specific with an explicit manylinux tag. build_data["pure_python"] = False build_data["tag"] = MANYLINUX_TAG ================================================ FILE: justfile ================================================ # Run tests quickly quicktest: uv run pytest -q tests # Run pytest with optional parameters debug *params='tests': uv run pytest --pdb -s {{params}} # Start the documentation website in dev mode website: gendoc npm i npm run docs:dev # Run linting and dead code detection lint: uv run mypy --install-types --non-interactive --check-untyped-defs pyprland uv run ruff format pyprland uv run ruff check --fix pyprland uv run pylint -E pyprland uv run flake8 pyprland uv run vulture --ignore-names 'event_*,run_*,fromtop,frombottom,fromleft,fromright,instance' pyprland scripts/v_whitelist.py # Run version registry checks vreg: uv run --group vreg ./tests/vreg/run_tests.sh # Build documentation doc: uv run pdoc --docformat google ./pyprland # Generate wiki pages wiki: ./scripts/generate_plugin_docs.py ./scripts/check_plugin_docs.py # Generate plugin documentation from source gendoc: python scripts/generate_plugin_docs.py # Generate codebase overview from module docstrings overview: python scripts/generate_codebase_overview.py # Archive documentation for a specific version (creates static snapshot) archive-docs version: cd site && ./make_version.sh {{version}} # Create a new release release: uv lock --upgrade git add uv.lock ./scripts/make_release # Generate and open HTML coverage report htmlcov: uv run coverage run --source=pyprland -m pytest tests -q uv run coverage html uv run coverage report xdg-open ./htmlcov/index.html # Run mypy type checks on pyprland types: uv run mypy --check-untyped-defs pyprland # Build C client - release (~17K) compile-c-client: gcc -O2 -o client/pypr-client client/pypr-client.c # Build C client - debug with symbols compile-c-client-debug: gcc -g -O0 -o client/pypr-client client/pypr-client.c # Build Rust client via Cargo - release with LTO (~312K) compile-rust-client: cargo build --release --manifest-path client/pypr-rs/Cargo.toml cp client/pypr-rs/target/release/pypr-client client/pypr-client # Build Rust client via Cargo - debug compile-rust-client-debug: cargo build --manifest-path client/pypr-rs/Cargo.toml cp client/pypr-rs/target/debug/pypr-client client/pypr-client # Build Rust client via rustc - release (~375K) compile-rust-client-simple: rustc -C opt-level=3 -C strip=symbols client/pypr-client.rs -o client/pypr-client # Build Rust client via rustc - debug compile-rust-client-simple-debug: rustc client/pypr-client.rs -o client/pypr-client # Build GUI frontend static assets gui-build: cd pyprland/gui/frontend && npm install && npm run build # Start GUI frontend dev server (hot-reload) + backend API server gui-dev: cd pyprland/gui/frontend && npm install && npm run dev & uv run pypr-gui --port 8099 --no-browser # Launch the GUI (production mode — serves pre-built frontend) gui *args='': uv run pypr-gui {{args}} ================================================ FILE: package.json ================================================ { "scripts": { "docs:dev": "vitepress dev site", "docs:build": "vitepress build site", "docs:preview": "vitepress preview site" }, "devDependencies": { "mermaid": "^11.12.2", "vitepress": "^1.6.4", "vitepress-plugin-mermaid": "^2.0.17" }, "dependencies": { "markdown-it": "^14.1.0" } } ================================================ FILE: pyprland/__init__.py ================================================ """Pyprland - a companion application for Hyprland and other Wayland compositors. Provides a plugin-based architecture for extending window manager functionality with features like scratchpads, workspace management, monitor control, and more. The daemon runs as an asyncio service, communicating via Unix sockets. """ ================================================ FILE: pyprland/adapters/__init__.py ================================================ """Backend adapters for compositor abstraction. This package provides the EnvironmentBackend abstraction layer that allows Pyprland to work with multiple compositors (Hyprland, Niri) and fallback environments (generic Wayland via wlr-randr, X11 via xrandr). """ from .proxy import BackendProxy __all__ = ["BackendProxy"] ================================================ FILE: pyprland/adapters/backend.py ================================================ """Abstract base class defining the compositor backend interface. EnvironmentBackend defines the contract for all compositor backends: - Window operations (get_clients, focus, move, resize, close) - Monitor queries (get_monitors, get_monitor_props) - Command execution (execute, execute_batch, execute_json) - Notifications (notify, notify_info, notify_error) - Event parsing (parse_event) All methods accept a 'log' parameter for traceability via BackendProxy. """ from abc import ABC, abstractmethod from collections.abc import Callable from logging import Logger from typing import Any from ..common import MINIMUM_ADDR_LEN, SharedState from ..models import ClientInfo, MonitorInfo class EnvironmentBackend(ABC): """Abstract base class for environment backends (Hyprland, Niri, etc). All methods that perform logging require a `log` parameter to be passed. This allows the calling code (via BackendProxy) to inject the appropriate logger for traceability. """ def __init__(self, state: SharedState) -> None: """Initialize the backend. Args: state: Shared state object """ self.state = state @abstractmethod async def get_clients( self, mapped: bool = True, workspace: str | None = None, workspace_bl: str | None = None, *, log: Logger, ) -> list[ClientInfo]: """Return the list of clients, optionally filtered. Args: mapped: If True, only return mapped clients workspace: Filter to this workspace name workspace_bl: Blacklist this workspace name log: Logger to use for this operation """ @abstractmethod async def get_monitors(self, *, log: Logger, include_disabled: bool = False) -> list[MonitorInfo]: """Return the list of monitors. Args: log: Logger to use for this operation include_disabled: If True, include disabled monitors (Hyprland only) """ @abstractmethod def parse_event(self, raw_data: str, *, log: Logger) -> tuple[str, Any] | None: """Parse a raw event string into (event_name, event_data). Args: raw_data: Raw event string from the compositor log: Logger to use for this operation """ async def get_monitor_props( self, name: str | None = None, include_disabled: bool = False, *, log: Logger, ) -> MonitorInfo: """Return focused monitor data if `name` is not defined, else use monitor's name. Args: name: Monitor name to look for, or None for focused monitor include_disabled: If True, include disabled monitors in search log: Logger to use for this operation """ monitors = await self.get_monitors(log=log, include_disabled=include_disabled) if name: for mon in monitors: if mon["name"] == name: return mon else: for mon in monitors: if mon.get("focused"): return mon msg = "no focused monitor" raise RuntimeError(msg) @abstractmethod async def execute(self, command: str | list | dict, *, log: Logger, **kwargs: Any) -> bool: """Execute a command (or list of commands). Args: command: The command to execute log: Logger to use for this operation **kwargs: Additional arguments (base_command, weak, etc.) """ @abstractmethod async def execute_json(self, command: str, *, log: Logger, **kwargs: Any) -> Any: """Execute a command and return the JSON result. Args: command: The command to execute log: Logger to use for this operation **kwargs: Additional arguments """ @abstractmethod async def execute_batch(self, commands: list[str], *, log: Logger) -> None: """Execute a batch of commands. Args: commands: List of commands to execute log: Logger to use for this operation """ @abstractmethod async def notify(self, message: str, duration: int, color: str, *, log: Logger) -> None: """Send a notification. Args: message: The notification message duration: Duration in milliseconds color: Hex color code log: Logger to use for this operation """ async def notify_info(self, message: str, duration: int = 5000, *, log: Logger) -> None: """Send an info notification (default: blue color). Args: message: The notification message duration: Duration in milliseconds log: Logger to use for this operation """ await self.notify(message, duration, "0000ff", log=log) async def notify_error(self, message: str, duration: int = 5000, *, log: Logger) -> None: """Send an error notification (default: red color). Args: message: The notification message duration: Duration in milliseconds log: Logger to use for this operation """ await self.notify(message, duration, "ff0000", log=log) async def get_client_props( self, match_fn: Callable[[Any, Any], bool] | None = None, clients: list[ClientInfo] | None = None, *, log: Logger, **kw: Any, ) -> ClientInfo | None: """Return the properties of a client matching the given criteria. Args: match_fn: Custom match function (defaults to equality) clients: Optional pre-fetched client list log: Logger to use for this operation **kw: Property to match (addr, cls, etc.) """ if match_fn is None: def default_match_fn(value1: Any, value2: Any) -> bool: return bool(value1 == value2) match_fn = default_match_fn assert kw addr = kw.get("addr") klass = kw.get("cls") if addr: assert len(addr) > MINIMUM_ADDR_LEN, "Client address is invalid" prop_name = "address" prop_value = addr elif klass: prop_name = "class" prop_value = klass else: prop_name, prop_value = next(iter(kw.items())) clients_list = clients or await self.get_clients(mapped=False, log=log) for client in clients_list: assert isinstance(client, dict) val = client.get(prop_name) if match_fn(val, prop_value): return client return None # ─── Window Operation Helpers ───────────────────────────────────────────── async def focus_window(self, address: str, *, log: Logger) -> bool: """Focus a window by address. Args: address: Window address (without 'address:' prefix) log: Logger to use for this operation Returns: True if command succeeded """ return await self.execute(f"focuswindow address:{address}", log=log) async def move_window_to_workspace( self, address: str, workspace: str, *, silent: bool = True, log: Logger, ) -> bool: """Move a window to a workspace. Args: address: Window address (without 'address:' prefix) workspace: Target workspace name or ID silent: If True, don't follow the window (default: True) log: Logger to use for this operation Returns: True if command succeeded """ cmd = "movetoworkspacesilent" if silent else "movetoworkspace" return await self.execute(f"{cmd} {workspace},address:{address}", log=log) async def pin_window(self, address: str, *, log: Logger) -> bool: """Toggle pin state of a window. Args: address: Window address (without 'address:' prefix) log: Logger to use for this operation Returns: True if command succeeded """ return await self.execute(f"pin address:{address}", log=log) async def close_window(self, address: str, *, silent: bool = True, log: Logger) -> bool: """Close a window. Args: address: Window address (without 'address:' prefix) silent: Accepted for API consistency (currently unused for close) log: Logger to use for this operation Returns: True if command succeeded """ return await self.execute(f"closewindow address:{address}", log=log) async def resize_window(self, address: str, width: int, height: int, *, log: Logger) -> bool: """Resize a window to exact pixel dimensions. Args: address: Window address (without 'address:' prefix) width: Target width in pixels height: Target height in pixels log: Logger to use for this operation Returns: True if command succeeded """ return await self.execute(f"resizewindowpixel exact {width} {height},address:{address}", log=log) async def move_window(self, address: str, x: int, y: int, *, log: Logger) -> bool: # pylint: disable=invalid-name """Move a window to exact pixel position. Args: address: Window address (without 'address:' prefix) x: Target x position in pixels y: Target y position in pixels log: Logger to use for this operation Returns: True if command succeeded """ return await self.execute(f"movewindowpixel exact {x} {y},address:{address}", log=log) async def toggle_floating(self, address: str, *, log: Logger) -> bool: """Toggle floating state of a window. Args: address: Window address (without 'address:' prefix) log: Logger to use for this operation Returns: True if command succeeded """ return await self.execute(f"togglefloating address:{address}", log=log) async def set_keyword(self, keyword_command: str, *, log: Logger) -> bool: """Execute a keyword/config command. Args: keyword_command: The keyword command string (e.g., "general:gaps_out 10") log: Logger to use for this operation Returns: True if command succeeded """ return await self.execute(keyword_command, log=log, base_command="keyword") ================================================ FILE: pyprland/adapters/colors.py ================================================ """Color conversion & misc color related helpers.""" def convert_color(description: str) -> str: """Get a color description and returns the 6 HEX digits as string. Args: description: Color description (e.g. "#FF0000" or "rgb(255, 0, 0)") """ if description[0] == "#": return description[1:] if description.startswith("rgb("): return "".join([f"{int(i):02x}" for i in description[4:-1].split(", ")]) return description ================================================ FILE: pyprland/adapters/fallback.py ================================================ """Fallback backend base class for limited functionality environments.""" import asyncio from abc import abstractmethod from collections.abc import Callable from logging import Logger from typing import Any from ..constants import DEFAULT_NOTIFICATION_DURATION_MS, DEFAULT_REFRESH_RATE_HZ from ..models import ClientInfo, MonitorInfo from .backend import EnvironmentBackend def make_monitor_info( # noqa: PLR0913 # pylint: disable=too-many-arguments,too-many-positional-arguments index: int, name: str, width: int, height: int, pos_x: int = 0, pos_y: int = 0, scale: float = 1.0, transform: int = 0, refresh_rate: float = DEFAULT_REFRESH_RATE_HZ, enabled: bool = True, description: str = "", ) -> MonitorInfo: """Create a MonitorInfo dict with default values for fallback backends. Args: index: Monitor index name: Monitor name (e.g., "DP-1") width: Monitor width in pixels height: Monitor height in pixels pos_x: X position pos_y: Y position scale: Scale factor transform: Transform value (0-7) refresh_rate: Refresh rate in Hz enabled: Whether the monitor is enabled description: Monitor description Returns: MonitorInfo dict with all required fields """ return MonitorInfo( id=index, name=name, description=description or name, make="", model="", serial="", width=width, height=height, refreshRate=refresh_rate, x=pos_x, y=pos_y, activeWorkspace={"id": 0, "name": ""}, specialWorkspace={"id": 0, "name": ""}, reserved=[0, 0, 0, 0], scale=scale, transform=transform, focused=index == 0, dpmsStatus=enabled, vrr=False, activelyTearing=False, disabled=not enabled, currentFormat="", availableModes=[], to_disable=False, ) class FallbackBackend(EnvironmentBackend): """Base class for fallback backends (X11, generic Wayland). Provides minimal functionality - only get_monitors() is implemented by subclasses. Other methods are stubs that log warnings or no-op. These backends provide monitor information for plugins like wallpapers but do not support compositor-specific features like window management or event handling. """ async def get_clients( self, mapped: bool = True, workspace: str | None = None, workspace_bl: str | None = None, *, log: Logger, ) -> list[ClientInfo]: """Not supported in fallback mode. Args: mapped: Ignored workspace: Ignored workspace_bl: Ignored log: Logger to use for this operation Returns: Empty list """ log.debug("get_clients() not supported in fallback backend") return [] def parse_event(self, raw_data: str, *, log: Logger) -> tuple[str, Any] | None: """No event support in fallback mode. Args: raw_data: Ignored log: Logger to use for this operation Returns: None (no events) """ return None async def execute(self, command: str | list | dict, *, log: Logger, **kwargs: Any) -> bool: """Not supported in fallback mode. Args: command: Ignored log: Logger to use for this operation **kwargs: Ignored Returns: False (command not executed) """ log.debug("execute() not supported in fallback backend") return False async def execute_batch(self, commands: list[str], *, log: Logger) -> None: """Not supported in fallback mode. Args: commands: Ignored log: Logger to use for this operation """ log.debug("execute_batch() not supported in fallback backend") async def execute_json(self, command: str, *, log: Logger, **kwargs: Any) -> Any: """Not supported in fallback mode. Args: command: Ignored log: Logger to use for this operation **kwargs: Ignored Returns: Empty dict """ log.debug("execute_json() not supported in fallback backend") return {} async def notify( self, message: str, duration: int = DEFAULT_NOTIFICATION_DURATION_MS, color: str = "ff0000", *, log: Logger, ) -> None: """Send notification via notify-send. Args: message: The notification message duration: Duration in milliseconds color: Ignored (notify-send doesn't support colors) log: Logger to use for this operation """ log.info("Notification: %s", message) try: # Convert duration from ms to ms (notify-send uses ms) proc = await asyncio.create_subprocess_shell( f'notify-send -t {duration} "Pyprland" "{message}"', stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) await proc.wait() except OSError as e: log.debug("notify-send failed: %s", e) @classmethod @abstractmethod async def is_available(cls) -> bool: """Check if this backend's required tool is available. Subclasses must implement this to check for their required tool (e.g., xrandr, wlr-randr). Returns: True if the backend can be used """ @classmethod async def _check_command(cls, command: str) -> bool: """Check if a command is available and works. Args: command: The command to test Returns: True if command executed successfully """ try: proc = await asyncio.create_subprocess_shell( command, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) return await proc.wait() == 0 except OSError: return False async def _run_monitor_command( self, command: str, tool_name: str, parser: Callable[[str, bool, Logger], list[MonitorInfo]], *, include_disabled: bool, log: Logger, ) -> list[MonitorInfo]: """Run a command and parse its output for monitor information. This is a shared helper for wayland/xorg backends to reduce duplication. Args: command: Shell command to execute tool_name: Name of the tool for error messages parser: Function to parse the command output include_disabled: Whether to include disabled monitors log: Logger instance Returns: List of MonitorInfo dicts, empty on failure """ try: proc = await asyncio.create_subprocess_shell( command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await proc.communicate() if proc.returncode != 0: log.error("%s failed: %s", tool_name, stderr.decode()) return [] return parser(stdout.decode(), include_disabled, log) except OSError as e: log.warning("Failed to get monitors from %s: %s", tool_name, e) return [] ================================================ FILE: pyprland/adapters/hyprland.py ================================================ """Hyprland compositor backend implementation. Primary backend for Hyprland, using its Unix socket IPC protocol. Provides full functionality including batched commands, JSON queries, native notifications, and Hyprland-specific event parsing. """ from logging import Logger from typing import Any, cast from ..constants import DEFAULT_NOTIFICATION_DURATION_MS from ..ipc import get_response, hyprctl_connection, retry_on_reset from ..models import ClientInfo, MonitorInfo from .backend import EnvironmentBackend class HyprlandBackend(EnvironmentBackend): """Hyprland backend implementation.""" def _format_command(self, command_list: list[str] | list[list[str]], default_base_command: str) -> list[str]: """Format a list of commands to be sent to Hyprland.""" result = [] for command in command_list: if isinstance(command, str): result.append(f"{default_base_command} {command}") else: result.append(f"{command[1]} {command[0]}") return result @retry_on_reset async def execute(self, command: str | list | dict, *, log: Logger, **kwargs: Any) -> bool: """Execute a command (or list of commands). Args: command: The command to execute log: Logger to use for this operation **kwargs: Additional arguments (base_command, weak, etc.) """ base_command = kwargs.get("base_command", "dispatch") weak = kwargs.get("weak", False) if not command: log.warning("%s triggered without a command!", base_command) return False log.debug("%s %s", base_command, command) async with hyprctl_connection(log) as (ctl_reader, ctl_writer): if isinstance(command, list): nb_cmds = len(command) ctl_writer.write(f"[[BATCH]] {' ; '.join(self._format_command(command, base_command))}".encode()) else: nb_cmds = 1 ctl_writer.write(f"/{base_command} {command}".encode()) await ctl_writer.drain() resp = await ctl_reader.read(100) # remove "\n" from the response resp = b"".join(resp.split(b"\n")) r: bool = resp == b"ok" * nb_cmds if not r: if weak: log.warning("FAILED %s", resp) else: log.error("FAILED %s", resp) return r @retry_on_reset async def execute_json(self, command: str, *, log: Logger, **kwargs: Any) -> Any: """Execute a command and return the JSON result. Args: command: The command to execute log: Logger to use for this operation **kwargs: Additional arguments """ ret = await get_response(f"-j/{command}".encode(), log) assert isinstance(ret, list | dict) return ret async def get_clients( self, mapped: bool = True, workspace: str | None = None, workspace_bl: str | None = None, *, log: Logger, ) -> list[ClientInfo]: """Return the list of clients, optionally filtered. Args: mapped: If True, only return mapped clients workspace: Filter to this workspace name workspace_bl: Blacklist this workspace name log: Logger to use for this operation """ return [ client for client in cast("list[ClientInfo]", await self.execute_json("clients", log=log)) if (not mapped or client["mapped"]) and (workspace is None or client["workspace"]["name"] == workspace) and (workspace_bl is None or client["workspace"]["name"] != workspace_bl) ] async def get_monitors(self, *, log: Logger, include_disabled: bool = False) -> list[MonitorInfo]: """Return the list of monitors. Args: log: Logger to use for this operation include_disabled: If True, include disabled monitors """ cmd = "monitors all" if include_disabled else "monitors" return cast("list[MonitorInfo]", await self.execute_json(cmd, log=log)) async def execute_batch(self, commands: list[str], *, log: Logger) -> None: """Execute a batch of commands. Args: commands: List of commands to execute log: Logger to use for this operation """ if not commands: return log.debug("Batch %s", commands) # Format commands for batch execution # Based on ipc.py _format_command implementation formatted_cmds = [f"dispatch {command}" for command in commands] async with hyprctl_connection(log) as (_, ctl_writer): ctl_writer.write(f"[[BATCH]] {' ; '.join(formatted_cmds)}".encode()) await ctl_writer.drain() # We assume it worked, similar to current implementation # detailed error checking for batch is limited in current ipc.py implementation def parse_event(self, raw_data: str, *, log: Logger) -> tuple[str, Any] | None: """Parse a raw event string into (event_name, event_data). Args: raw_data: Raw event string from the compositor log: Logger to use for this operation (unused in Hyprland - simple parsing) """ if ">>" not in raw_data: return None cmd, params = raw_data.split(">>", 1) return f"event_{cmd}", params.rstrip("\n") async def notify(self, message: str, duration: int = DEFAULT_NOTIFICATION_DURATION_MS, color: str = "ff1010", *, log: Logger) -> None: """Send a notification. Args: message: The notification message duration: Duration in milliseconds color: Hex color code log: Logger to use for this operation """ # Using icon -1 for default/generic await self._notify_impl(message, duration, color, -1, log=log) async def notify_info(self, message: str, duration: int = DEFAULT_NOTIFICATION_DURATION_MS, *, log: Logger) -> None: """Send an info notification. Args: message: The notification message duration: Duration in milliseconds log: Logger to use for this operation """ # Using icon 1 for info await self._notify_impl(message, duration, "1010ff", 1, log=log) async def notify_error(self, message: str, duration: int = DEFAULT_NOTIFICATION_DURATION_MS, *, log: Logger) -> None: """Send an error notification. Args: message: The notification message duration: Duration in milliseconds log: Logger to use for this operation """ # Using icon 0 for error await self._notify_impl(message, duration, "ff1010", 0, log=log) async def _notify_impl(self, text: str, duration: int, color: str, icon: int, *, log: Logger) -> None: """Internal notify implementation. Args: text: The notification text duration: Duration in milliseconds color: Hex color code icon: Icon code (-1 default, 0 error, 1 info) log: Logger to use for this operation """ # This mirrors ipc.notify logic for Hyprland await self.execute(f"{icon} {duration} rgb({color}) {text}", log=log, base_command="notify") ================================================ FILE: pyprland/adapters/menus.py ================================================ """Menu engine adapter.""" import asyncio import subprocess from collections.abc import Iterable from logging import Logger from typing import TYPE_CHECKING, ClassVar from ..common import apply_variables, get_logger from ..models import PyprError, ReloadReason from ..validation import ConfigField, ConfigItems if TYPE_CHECKING: from ..config import Configuration __all__ = ["MenuEngine", "MenuMixin"] menu_logger = get_logger("menus adapter") class MenuEngine: """Menu backend interface.""" proc_name: str " process name for this engine " proc_extra_parameters: str = "" " process parameters to use for this engine " proc_detect_parameters: ClassVar[list[str]] = ["--help"] " process parameters used to check if the engine can run " def __init__(self, extra_parameters: str) -> None: """Initialize the engine with extra parameters. Args: extra_parameters: extra parameters to pass to the program """ if extra_parameters: self.proc_extra_parameters = extra_parameters @classmethod def is_available(cls) -> bool: """Check engine availability.""" try: subprocess.call([cls.proc_name, *cls.proc_detect_parameters], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except FileNotFoundError: return False return True async def run(self, choices: Iterable[str], prompt: str = "") -> str: """Run the engine and get the response for the proposed `choices`. Args: choices: options to chose from prompt: prompt replacement variable (passed in `apply_variables`) Returns: The choice which have been selected by the user, or an empty string """ menu_text = "\n".join(choices) if not menu_text.strip(): return "" command = apply_variables( f"{self.proc_name} {self.proc_extra_parameters}", {"prompt": f"{prompt}: "} if prompt else {"prompt": ""}, ) menu_logger.debug(command) proc = await asyncio.create_subprocess_shell( command, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, ) assert proc.stdin assert proc.stdout proc.stdin.write(menu_text.encode()) # flush program execution await proc.stdin.drain() proc.stdin.close() await proc.wait() return (await proc.stdout.read()).decode().strip() def _menu(proc: str, params: str) -> type[MenuEngine]: """Create a menu engine class. Args: proc: process name for this engine params: default parameters to pass to the process Returns: A MenuEngine subclass configured for the specified menu program """ return type( f"{proc.title()}Menu", (MenuEngine,), {"proc_name": proc, "proc_extra_parameters": params, "__doc__": f"A {proc} based menu."}, ) TofiMenu = _menu("tofi", "--prompt-text '[prompt]'") RofiMenu = _menu("rofi", "-dmenu -i -p '[prompt]'") WofiMenu = _menu("wofi", "-dmenu -i -p '[prompt]'") DmenuMenu = _menu("dmenu", "-i") BemenuMenu = _menu("bemenu", "-c") FuzzelMenu = _menu("fuzzel", "--match-mode=fuzzy -d -p '[prompt]'") WalkerMenu = _menu("walker", "-d -k -p '[prompt]'") AnyrunMenu = _menu("anyrun", "--plugins libstdin.so --show-results-immediately true") VicinaeMenu = _menu("vicinae", "dmenu --no-quick-look") every_menu_engine = [FuzzelMenu, TofiMenu, RofiMenu, WofiMenu, BemenuMenu, DmenuMenu, AnyrunMenu, WalkerMenu, VicinaeMenu] MENU_ENGINE_CHOICES: list[str] = [engine.proc_name for engine in every_menu_engine] """List of available menu engine names, derived from every_menu_engine.""" async def init(force_engine: str | None = None, extra_parameters: str = "") -> MenuEngine: """Initialize the module. Args: force_engine: Name of the engine to force use of extra_parameters: Extra parameters to pass to the engine """ try: engines = [next(e for e in every_menu_engine if e.proc_name == force_engine)] if force_engine else every_menu_engine except StopIteration: engines = [] if force_engine and engines: return engines[0](extra_parameters) # detect engine for engine in engines: if engine.is_available(): return engine(extra_parameters) # fallback if not found but forced if force_engine: # Attempt to use the user-supplied command me = MenuEngine(extra_parameters) me.proc_name = force_engine return me msg = "No engine found" raise PyprError(msg) class MenuMixin: """An extension mixin supporting 'engine' and 'parameters' config options to show a menu.""" menu_config_schema = ConfigItems( ConfigField( "engine", str, description="Menu engine to use", choices=MENU_ENGINE_CHOICES, category="menu", ), ConfigField( "parameters", str, description="Extra parameters for the menu engine command", category="menu", ), ) """Schema for menu configuration fields. Plugins using MenuMixin should include this in their config_schema.""" _menu_configured = False menu: MenuEngine """ provided `MenuEngine` """ config: "Configuration" " used by the mixin but provided by `pyprland.plugins.interface.Plugin` " log: Logger " used by the mixin but provided by `pyprland.plugins.interface.Plugin` " async def ensure_menu_configured(self) -> None: """If not configured, init the menu system.""" if not self._menu_configured: self.menu = await init(self.config.get_str("engine") or None, self.config.get_str("parameters")) self.log.info("Using %s engine", self.menu.proc_name) self._menu_configured = True async def on_reload(self, reason: ReloadReason = ReloadReason.RELOAD) -> None: """Reset the configuration status.""" _ = reason # unused self._menu_configured = False ================================================ FILE: pyprland/adapters/niri.py ================================================ """Niri compositor backend implementation. Backend for Niri compositor using its JSON-based IPC protocol. Maps Niri's window/output data structures to Hyprland-compatible formats. Some operations (pin, resize, move) are unavailable due to Niri's tiling nature. """ import json from logging import Logger from typing import Any, cast from ..common import notify_send from ..constants import DEFAULT_NOTIFICATION_DURATION_MS, DEFAULT_REFRESH_RATE_HZ from ..ipc import niri_request from ..models import ClientInfo, MonitorInfo from .backend import EnvironmentBackend # Niri transform string to Hyprland-compatible integer mapping # Keys are lowercase for case-insensitive lookup NIRI_TRANSFORM_MAP: dict[str, int] = { "normal": 0, "90": 1, "180": 2, "270": 3, "flipped": 4, "flipped90": 5, "flipped-90": 5, "flipped180": 6, "flipped-180": 6, "flipped270": 7, "flipped-270": 7, } def get_niri_transform(value: str, default: int = 0) -> int: """Get transform integer from Niri transform string (case-insensitive). Args: value: Transform string like "Normal", "90", "Flipped-90", etc. default: Value to return if not found Returns: Integer transform value (0-7) """ return NIRI_TRANSFORM_MAP.get(value.lower(), default) def niri_output_to_monitor_info(name: str, data: dict[str, Any]) -> MonitorInfo: """Convert Niri output data to MonitorInfo. Handles both Niri output formats: - Format A: Uses "logical" object with nested x, y, scale, transform - Format B: Uses "logical_position", "logical_size", "scale" at root level Args: name: Output name (e.g., "DP-1") data: Niri output data dictionary Returns: MonitorInfo TypedDict with normalized fields """ # Try format A first (more detailed - has modes, logical object) logical = data.get("logical") or {} mode: dict[str, Any] = next((m for m in data.get("modes", []) if m.get("is_active")), {}) # Fall back to format B for position/size if format A fields missing x = logical.get("x") if logical else data.get("logical_position", {}).get("x", 0) y = logical.get("y") if logical else data.get("logical_position", {}).get("y", 0) scale = logical.get("scale") if logical else data.get("scale", 1.0) # Width/height: prefer active mode, fall back to logical_size width = mode.get("width") if mode else data.get("logical_size", {}).get("width", 0) height = mode.get("height") if mode else data.get("logical_size", {}).get("height", 0) # Refresh rate from mode (in mHz), default to 60Hz refresh_rate = mode.get("refresh_rate", DEFAULT_REFRESH_RATE_HZ * 1000) / 1000.0 if mode else DEFAULT_REFRESH_RATE_HZ # Transform from logical object transform_str = logical.get("transform", "Normal") if logical else "Normal" # Build description from make/model if available make = data.get("make", "") model = data.get("model", "") description = f"{make} {model}".strip() if make or model else "" return cast( "MonitorInfo", { "name": name, "description": description, "make": make, "model": model, "serial": data.get("serial", ""), "width": width, "height": height, "refreshRate": refresh_rate, "x": x if x is not None else 0, "y": y if y is not None else 0, "scale": scale if scale is not None else 1.0, "transform": get_niri_transform(transform_str), "focused": data.get("is_focused", False), # Fields not available in Niri - provide sensible defaults "id": -1, "activeWorkspace": {"id": -1, "name": ""}, "specialWorkspace": {"id": -1, "name": ""}, "reserved": [], "dpmsStatus": True, "vrr": False, "activelyTearing": False, "disabled": False, "currentFormat": "", "availableModes": [], "to_disable": False, }, ) class NiriBackend(EnvironmentBackend): """Niri backend implementation.""" def parse_event(self, raw_data: str, *, log: Logger) -> tuple[str, Any] | None: """Parse a raw event string into (event_name, event_data). Args: raw_data: Raw event string from the compositor log: Logger to use for this operation """ if not raw_data.strip().startswith("{"): return None try: event = json.loads(raw_data) except json.JSONDecodeError: log.exception("Invalid JSON event: %s", raw_data) return None if "Variant" in event: type_name = event["Variant"]["type"] data = event["Variant"] return f"niri_{type_name.lower()}", data return None async def execute(self, command: str | list | dict, *, log: Logger, **kwargs: Any) -> bool: """Execute a command (or list of commands). Args: command: The command to execute log: Logger to use for this operation **kwargs: Additional arguments (weak, etc.) """ weak = kwargs.get("weak", False) # Niri commands are typically lists of strings or objects, not a single string command line # If we receive a string, we might need to wrap it. # But looking at existing usage, nirictl expects list or dict. # If we receive a list of strings from execute(), it might be multiple commands? # Niri socket protocol is request-response JSON. try: ret = await niri_request(command, log) if isinstance(ret, dict) and "Ok" in ret: return True except (OSError, ConnectionError, json.JSONDecodeError) as e: log.warning("Niri command failed: %s", e) return False if weak: log.warning("Niri command failed: %s", ret) else: log.error("Niri command failed: %s", ret) return False async def execute_json(self, command: str, *, log: Logger, **kwargs: Any) -> Any: """Execute a command and return the JSON result. Args: command: The command to execute log: Logger to use for this operation **kwargs: Additional arguments """ ret = await niri_request(command, log) if isinstance(ret, dict) and "Ok" in ret: return ret["Ok"] msg = f"Niri command failed: {ret}" raise RuntimeError(msg) async def get_clients( self, mapped: bool = True, workspace: str | None = None, workspace_bl: str | None = None, *, log: Logger, ) -> list[ClientInfo]: """Return the list of clients, optionally filtered. Args: mapped: If True, only return mapped clients workspace: Filter to this workspace name workspace_bl: Blacklist this workspace name log: Logger to use for this operation """ return [ self._map_niri_client(client) for client in cast("list[dict]", await self.execute_json("windows", log=log)) if (not mapped or client.get("is_mapped", True)) and (workspace is None or str(client.get("workspace_id")) == workspace) and (workspace_bl is None or str(client.get("workspace_id")) != workspace_bl) ] def _map_niri_client(self, niri_client: dict[str, Any]) -> ClientInfo: """Helper to map Niri window dict to ClientInfo TypedDict.""" return cast( "ClientInfo", { "address": str(niri_client.get("id")), "class": niri_client.get("app_id"), "title": niri_client.get("title"), "workspace": {"name": str(niri_client.get("workspace_id"))}, "pid": -1, "mapped": niri_client.get("is_mapped", True), "hidden": False, "at": (0, 0), "size": (0, 0), "floating": False, "monitor": -1, "initialClass": niri_client.get("app_id"), "initialTitle": niri_client.get("title"), "xwayland": False, "pinned": False, "fullscreen": False, "fullscreenMode": 0, "fakeFullscreen": False, "grouped": [], "swallowing": "", "focusHistoryID": 0, }, ) async def get_monitors(self, *, log: Logger, include_disabled: bool = False) -> list[MonitorInfo]: """Return the list of monitors. Args: log: Logger to use for this operation include_disabled: Ignored for Niri (no concept of disabled monitors) """ outputs = await self.execute_json("outputs", log=log) return [niri_output_to_monitor_info(name, output) for name, output in outputs.items()] async def execute_batch(self, commands: list[str], *, log: Logger) -> None: """Execute a batch of commands. Args: commands: List of commands to execute log: Logger to use for this operation """ # Niri doesn't support batching in the same way, so we iterate for cmd in commands: # We need to parse the command string into an action # This is a bit tricky as niri commands are structured objects/lists # For now, let's assume 'action' is a command to be sent via nirictl # But wait, execute_batch typically receives "dispatch " type strings for Hyprland. # We need to adapt this. # Simple adaptation: if it's a known string command, we try to map it or just send it if niri accepts string commands # (it mostly uses 'action' msg) # This part requires more knowledge of how commands are passed. # In current Pyprland, nirictl takes a list or dict. # Placeholder implementation: await self.execute(["action", cmd], log=log) async def notify( self, message: str, duration: int = DEFAULT_NOTIFICATION_DURATION_MS, color: str = "ff0000", *, log: Logger, ) -> None: """Send a notification. Args: message: The notification message duration: Duration in milliseconds color: Hex color code log: Logger to use for this operation (unused - notify_send doesn't log) """ # Niri doesn't have a built-in notification system exposed via IPC like Hyprland's `notify` # We rely on `notify-send` via the common utility await notify_send(message, duration, color) # ─── Window Operation Helpers (Niri overrides) ──────────────────────────── async def focus_window(self, address: str, *, log: Logger) -> bool: """Focus a window by ID. Args: address: Window ID log: Logger to use for this operation """ return await self.execute({"Action": {"FocusWindow": {"id": int(address)}}}, log=log) async def move_window_to_workspace( self, address: str, workspace: str, *, silent: bool = True, log: Logger, ) -> bool: """Move a window to a workspace (silent parameter ignored in Niri). Args: address: Window ID workspace: Target workspace ID silent: Ignored in Niri log: Logger to use for this operation """ return await self.execute( {"Action": {"MoveWindowToWorkspace": {"window_id": int(address), "reference": {"Id": int(workspace)}}}}, log=log, ) async def pin_window(self, address: str, *, log: Logger) -> bool: """Toggle pin state - not available in Niri. Args: address: Window ID (unused) log: Logger to use for this operation """ log.debug("pin_window: not available in Niri") return False async def close_window(self, address: str, *, silent: bool = True, log: Logger) -> bool: """Close a window by ID. Args: address: Window ID silent: Accepted for API consistency (currently unused for close) log: Logger to use for this operation """ return await self.execute({"Action": {"CloseWindow": {"id": int(address)}}}, log=log) async def resize_window(self, address: str, width: int, height: int, *, log: Logger) -> bool: """Resize a window - not available in Niri (tiling WM). Args: address: Window ID (unused) width: Target width (unused) height: Target height (unused) log: Logger to use for this operation """ log.debug("resize_window: not available in Niri") return False async def move_window(self, address: str, x: int, y: int, *, log: Logger) -> bool: """Move a window to exact position - not available in Niri (tiling WM). Args: address: Window ID (unused) x: Target x position (unused) y: Target y position (unused) log: Logger to use for this operation """ log.debug("move_window: not available in Niri") return False async def toggle_floating(self, address: str, *, log: Logger) -> bool: """Toggle floating state - not available in Niri. Args: address: Window ID (unused) log: Logger to use for this operation """ log.debug("toggle_floating: not available in Niri") return False async def set_keyword(self, keyword_command: str, *, log: Logger) -> bool: """Execute a keyword command - not available in Niri. Args: keyword_command: The keyword command (unused) log: Logger to use for this operation """ log.debug("set_keyword: not available in Niri") return False ================================================ FILE: pyprland/adapters/proxy.py ================================================ """Backend proxy that injects plugin logger into all calls. This module provides a BackendProxy class that wraps an EnvironmentBackend and automatically passes the plugin's logger to all backend method calls. This allows backend operations to be logged under the calling plugin's logger for better traceability. """ from collections.abc import Callable from logging import Logger from typing import TYPE_CHECKING, Any from ..models import ClientInfo, MonitorInfo if TYPE_CHECKING: from .backend import EnvironmentBackend class BackendProxy: """Proxy that injects the plugin logger into all backend calls. This allows backend operations to be logged under the calling plugin's logger for better traceability. Each plugin gets its own BackendProxy instance with its own logger, while sharing the underlying backend. Attributes: log: The logger to use for all backend operations state: Reference to the shared state (from the underlying backend) """ def __init__(self, backend: "EnvironmentBackend", log: Logger) -> None: """Initialize the proxy. Args: backend: The underlying backend to delegate calls to log: The logger to inject into all backend calls """ self._backend = backend self.log = log self.state = backend.state # === Core execution methods === async def execute(self, command: str | list | dict, **kwargs: Any) -> bool: """Execute a command (or list of commands). Args: command: The command to execute **kwargs: Additional arguments (base_command, weak, etc.) Returns: True if command succeeded """ return await self._backend.execute(command, log=self.log, **kwargs) async def execute_json(self, command: str, **kwargs: Any) -> Any: """Execute a command and return the JSON result. Args: command: The command to execute **kwargs: Additional arguments Returns: The JSON response """ return await self._backend.execute_json(command, log=self.log, **kwargs) async def execute_batch(self, commands: list[str]) -> None: """Execute a batch of commands. Args: commands: List of commands to execute """ return await self._backend.execute_batch(commands, log=self.log) # === Query methods === async def get_clients( self, mapped: bool = True, workspace: str | None = None, workspace_bl: str | None = None, ) -> list[ClientInfo]: """Return the list of clients, optionally filtered. Args: mapped: If True, only return mapped clients workspace: Filter to this workspace name workspace_bl: Blacklist this workspace name Returns: List of matching clients """ return await self._backend.get_clients(mapped, workspace, workspace_bl, log=self.log) async def get_monitors(self, include_disabled: bool = False) -> list[MonitorInfo]: """Return the list of monitors. Args: include_disabled: If True, include disabled monitors Returns: List of monitors """ return await self._backend.get_monitors(log=self.log, include_disabled=include_disabled) async def get_monitor_props( self, name: str | None = None, include_disabled: bool = False, ) -> MonitorInfo: """Return focused monitor data if name is not defined, else use monitor's name. Args: name: Monitor name to look for, or None for focused monitor include_disabled: If True, include disabled monitors in search Returns: Monitor info dict """ return await self._backend.get_monitor_props(name, include_disabled, log=self.log) async def get_client_props( self, match_fn: Callable[[Any, Any], bool] | None = None, clients: list[ClientInfo] | None = None, **kw: Any, ) -> ClientInfo | None: """Return the properties of a client matching the given criteria. Args: match_fn: Custom match function (defaults to equality) clients: Optional pre-fetched client list **kw: Property to match (addr, cls, etc.) Returns: Matching client info or None """ return await self._backend.get_client_props(match_fn, clients, log=self.log, **kw) # === Notification methods === async def notify(self, message: str, duration: int = 5000, color: str = "ff0000") -> None: """Send a notification. Args: message: The notification message duration: Duration in milliseconds color: Hex color code """ return await self._backend.notify(message, duration, color, log=self.log) async def notify_info(self, message: str, duration: int = 5000) -> None: """Send an info notification (blue color). Args: message: The notification message duration: Duration in milliseconds """ return await self._backend.notify_info(message, duration, log=self.log) async def notify_error(self, message: str, duration: int = 5000) -> None: """Send an error notification (red color). Args: message: The notification message duration: Duration in milliseconds """ return await self._backend.notify_error(message, duration, log=self.log) # === Window operation helpers === async def focus_window(self, address: str) -> bool: """Focus a window by address. Args: address: Window address (without 'address:' prefix) Returns: True if command succeeded """ return await self._backend.focus_window(address, log=self.log) async def move_window_to_workspace( self, address: str, workspace: str, *, silent: bool = True, ) -> bool: """Move a window to a workspace. Args: address: Window address (without 'address:' prefix) workspace: Target workspace name or ID silent: If True, don't follow the window Returns: True if command succeeded """ return await self._backend.move_window_to_workspace(address, workspace, silent=silent, log=self.log) async def pin_window(self, address: str) -> bool: """Toggle pin state of a window. Args: address: Window address (without 'address:' prefix) Returns: True if command succeeded """ return await self._backend.pin_window(address, log=self.log) async def close_window(self, address: str, silent: bool = True) -> bool: """Close a window. Args: address: Window address (without 'address:' prefix) silent: If True, don't shift focus (default: True) Returns: True if command succeeded """ return await self._backend.close_window(address, silent=silent, log=self.log) async def resize_window(self, address: str, width: int, height: int) -> bool: """Resize a window to exact pixel dimensions. Args: address: Window address (without 'address:' prefix) width: Target width in pixels height: Target height in pixels Returns: True if command succeeded """ return await self._backend.resize_window(address, width, height, log=self.log) async def move_window(self, address: str, x: int, y: int) -> bool: # pylint: disable=invalid-name """Move a window to exact pixel position. Args: address: Window address (without 'address:' prefix) x: Target x position in pixels y: Target y position in pixels Returns: True if command succeeded """ return await self._backend.move_window(address, x, y, log=self.log) async def toggle_floating(self, address: str) -> bool: """Toggle floating state of a window. Args: address: Window address (without 'address:' prefix) Returns: True if command succeeded """ return await self._backend.toggle_floating(address, log=self.log) async def set_keyword(self, keyword_command: str) -> bool: """Execute a keyword/config command. Args: keyword_command: The keyword command string Returns: True if command succeeded """ return await self._backend.set_keyword(keyword_command, log=self.log) # === Event parsing === def parse_event(self, raw_data: str) -> tuple[str, Any] | None: """Parse a raw event string into (event_name, event_data). Args: raw_data: Raw event string from the compositor Returns: Tuple of (event_name, event_data) or None if parsing failed """ return self._backend.parse_event(raw_data, log=self.log) ================================================ FILE: pyprland/adapters/units.py ================================================ """Conversion functions for units used in Pyprland & plugins.""" from typing import Literal from ..common import is_rotated from ..models import MonitorInfo MonitorDimension = Literal["width", "height"] def convert_monitor_dimension(size: int | str, ref_value: int, monitor: MonitorInfo) -> int: """Convert `size` into pixels (given a reference value applied to a `monitor`). if size is an integer, assumed pixels & return it if size is a string, expects a "%" or "px" suffix else throws an error Args: size: The size to convert (int or string with unit) ref_value: Reference value for percentage calculations monitor: Monitor information """ if isinstance(size, int): return size if isinstance(size, str): if size.endswith("%"): p = int(size[:-1]) return int(ref_value / monitor["scale"] * p / 100) if size.endswith("px"): return int(size[:-2]) msg = f"Unsupported format: {size} (applied to {ref_value})" raise ValueError(msg) def convert_coords(coords: str, monitor: MonitorInfo) -> list[int]: """Convert a string like "X Y" to coordinates relative to monitor. Supported formats for X, Y: - Percentage: "V%". V in [0; 100] - Pixels: "Vpx". V should fit in your screen and not be zero Example: "10% 20%", monitor 800x600 => 80, 120 Args: coords: Coordinates string "X Y" monitor: Monitor information """ coord_list = [coord.strip() for coord in coords.split()] refs: tuple[MonitorDimension, MonitorDimension] = ("height", "width") if is_rotated(monitor) else ("width", "height") return [convert_monitor_dimension(name, monitor[ref], monitor) for (name, ref) in zip(coord_list, refs, strict=False)] ================================================ FILE: pyprland/adapters/wayland.py ================================================ """Generic Wayland backend using wlr-randr for monitor detection.""" import re from logging import Logger from ..models import MonitorInfo from .fallback import FallbackBackend, make_monitor_info from .niri import NIRI_TRANSFORM_MAP class WaylandBackend(FallbackBackend): """Generic Wayland backend using wlr-randr for monitor information. Provides monitor detection for the wallpapers plugin on wlroots-based compositors (Sway, etc.). Does not support window management, events, or other compositor features. """ @classmethod async def is_available(cls) -> bool: """Check if wlr-randr is available. Returns: True if wlr-randr command works """ return await cls._check_command("wlr-randr") async def get_monitors(self, *, log: Logger, include_disabled: bool = False) -> list[MonitorInfo]: """Get monitor information from wlr-randr. Args: log: Logger to use for this operation include_disabled: If True, include disabled monitors Returns: List of MonitorInfo dicts """ return await self._run_monitor_command( "wlr-randr", "wlr-randr", self._parse_wlr_randr_output, include_disabled=include_disabled, log=log, ) def _parse_wlr_randr_output(self, output: str, include_disabled: bool, log: Logger) -> list[MonitorInfo]: """Parse wlr-randr output to extract monitor information. Example wlr-randr output: DP-1 "Dell Inc. DELL U2415 ABC123" Enabled: yes Modes: 1920x1200 px, 59.950 Hz (preferred, current) 1920x1080 px, 60.000 Hz Position: 0,0 Transform: normal Scale: 1.000000 HDMI-A-1 "Some Monitor" Enabled: no ... Args: output: Raw wlr-randr output include_disabled: Whether to include disabled outputs log: Logger for debug output Returns: List of MonitorInfo dicts """ monitors: list[MonitorInfo] = [] # Split into sections per output (each starts with output name at column 0) sections = re.split(r"^(?=\S)", output, flags=re.MULTILINE) for raw_section in sections: section = raw_section.strip() if not section: continue monitor = self._parse_output_section(section, len(monitors), log) if monitor is None: continue # Skip disabled unless requested if monitor.get("disabled") and not include_disabled: continue monitors.append(monitor) return monitors def _parse_output_section( # noqa: C901 # pylint: disable=too-many-locals self, section: str, index: int, log: Logger ) -> MonitorInfo | None: """Parse a single output section from wlr-randr. Args: section: Section text for one output index: Index for this monitor log: Logger for debug output Returns: MonitorInfo dict or None if parsing failed """ lines = section.splitlines() if not lines: return None # First line: output name and description # Format: "DP-1 "Dell Inc. DELL U2415 ABC123"" header_match = re.match(r'^(\S+)\s*(?:"(.+)")?', lines[0]) if not header_match: return None name = header_match.group(1) description = header_match.group(2) or name # Parse properties enabled = True width, height = 0, 0 x, y = 0, 0 scale = 1.0 transform = 0 refresh_rate = 60.0 for raw_line in lines[1:]: line = raw_line.strip() # Enabled: yes/no if line.startswith("Enabled:"): enabled = "yes" in line.lower() # Position: x,y elif line.startswith("Position:"): pos_match = re.search(r"(\d+),\s*(\d+)", line) if pos_match: x, y = int(pos_match.group(1)), int(pos_match.group(2)) # Transform: normal/90/180/270/flipped/etc elif line.startswith("Transform:"): transform_str = line.split(":", 1)[1].strip() transform = NIRI_TRANSFORM_MAP.get(transform_str, 0) # Scale: 1.000000 elif line.startswith("Scale:"): try: scale = float(line.split(":", 1)[1].strip()) except ValueError: scale = 1.0 # Mode line with "current": 1920x1200 px, 59.950 Hz (preferred, current) elif "current" in line.lower() and "x" in line: mode_match = re.match(r"(\d+)x(\d+)\s*px,\s*([\d.]+)\s*Hz", line) if mode_match: width = int(mode_match.group(1)) height = int(mode_match.group(2)) refresh_rate = float(mode_match.group(3)) # Skip outputs without resolution if width == 0 or height == 0: log.debug("wlr-randr: skipping %s (no active mode)", name) return None log.debug("wlr-randr monitor: %s %dx%d+%d+%d scale=%.2f transform=%d", name, width, height, x, y, scale, transform) return make_monitor_info( index=index, name=name, width=width, height=height, pos_x=x, pos_y=y, scale=scale, transform=transform, refresh_rate=refresh_rate, enabled=enabled, description=description, ) ================================================ FILE: pyprland/adapters/xorg.py ================================================ """X11/Xorg backend using xrandr for monitor detection.""" # pylint: disable=duplicate-code # make_monitor_info calls share parameter patterns import re from logging import Logger from ..models import MonitorInfo from .fallback import FallbackBackend, make_monitor_info # Map xrandr rotation names to transform integers # 0=normal, 1=90° (left), 2=180° (inverted), 3=270° (right) TRANSFORM_MAP = { "normal": 0, "left": 1, "inverted": 2, "right": 3, } class XorgBackend(FallbackBackend): """X11/Xorg backend using xrandr for monitor information. Provides monitor detection for the wallpapers plugin on X11 systems. Does not support window management, events, or other compositor features. """ @classmethod async def is_available(cls) -> bool: """Check if xrandr is available. Returns: True if xrandr command works """ return await cls._check_command("xrandr --version") async def get_monitors(self, *, log: Logger, include_disabled: bool = False) -> list[MonitorInfo]: """Get monitor information from xrandr. Args: log: Logger to use for this operation include_disabled: If True, include disconnected monitors Returns: List of MonitorInfo dicts """ return await self._run_monitor_command( "xrandr --query", "xrandr", self._parse_xrandr_output, include_disabled=include_disabled, log=log, ) def _parse_xrandr_output( # pylint: disable=too-many-locals self, output: str, include_disabled: bool, log: Logger ) -> list[MonitorInfo]: """Parse xrandr --query output to extract monitor information. Example xrandr output: DP-1 connected primary 1920x1080+0+0 left (normal left inverted right x axis y axis) 527mm x 296mm 1920x1080 60.00*+ HDMI-1 connected 2560x1440+1920+0 (normal left inverted right x axis y axis) 597mm x 336mm 2560x1440 59.95*+ VGA-1 disconnected (normal left inverted right x axis y axis) Args: output: Raw xrandr output include_disabled: Whether to include disconnected outputs log: Logger for debug output Returns: List of MonitorInfo dicts """ monitors: list[MonitorInfo] = [] # Pattern to match connected outputs with resolution # Groups: name, primary?, resolution+position, transform? # Example: "DP-1 connected primary 1920x1080+0+0 left" pattern = re.compile( r"^(\S+)\s+(connected|disconnected)" # name, status r"(?:\s+primary)?" # optional primary r"(?:\s+(\d+)x(\d+)\+(\d+)\+(\d+))?" # optional WxH+X+Y r"(?:\s+(normal|left|inverted|right))?" # optional transform ) for line in output.splitlines(): match = pattern.match(line) if not match: continue name = match.group(1) connected = match.group(2) == "connected" width = int(match.group(3)) if match.group(3) else 0 height = int(match.group(4)) if match.group(4) else 0 x = int(match.group(5)) if match.group(5) else 0 y = int(match.group(6)) if match.group(6) else 0 transform_str = match.group(7) or "normal" # Skip disconnected unless requested if not connected and not include_disabled: continue # Skip outputs without resolution (not active) if (width == 0 or height == 0) and not include_disabled: continue transform = TRANSFORM_MAP.get(transform_str, 0) log.debug("xrandr monitor: %s %dx%d+%d+%d transform=%d", name, width, height, x, y, transform) # Build MonitorInfo - X11 doesn't have fractional scaling via xrandr monitor = make_monitor_info( index=len(monitors), name=name, width=width, height=height, pos_x=x, pos_y=y, transform=transform, enabled=connected, ) monitors.append(monitor) return monitors ================================================ FILE: pyprland/aioops.py ================================================ """Async operation utilities. Provides fallback sync methods if aiofiles is not installed, plus async task management utilities. """ __all__ = [ "DebouncedTask", "TaskManager", "aiexists", "aiisdir", "aiisfile", "ailistdir", "aiopen", "airmdir", "airmtree", "aiunlink", "graceful_cancel_tasks", "is_process_running", ] import asyncio import contextlib import io import os import shutil from collections.abc import AsyncIterator, Callable, Coroutine from types import TracebackType from typing import Any, Self try: import aiofiles.os from aiofiles import open as aiopen from aiofiles.os import listdir as ailistdir from aiofiles.os import unlink as aiunlink aiexists = aiofiles.os.path.exists aiisdir = aiofiles.os.path.isdir aiisfile = aiofiles.os.path.isfile except ImportError: class AsyncFile: """Async file wrapper. Args: file: The file object to wrap """ def __init__(self, file: io.TextIOWrapper) -> None: self.file = file async def readlines(self) -> list[str]: """Read lines.""" return self.file.readlines() async def read(self) -> str: """Read lines.""" return self.file.read() async def __aenter__(self) -> Self: return self async def __aexit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None, ) -> None: self.file.close() @contextlib.asynccontextmanager # type: ignore[no-redef, unused-ignore] async def aiopen(*args, **kwargs) -> AsyncIterator[AsyncFile]: """Async > sync wrapper.""" with open(*args, **kwargs) as f: # noqa: ASYNC230, PTH123 # pylint: disable=unspecified-encoding yield AsyncFile(f) async def aiexists(*args, **kwargs) -> bool: """Async > sync wrapper.""" return os.path.exists(*args, **kwargs) # noqa: ASYNC240 async def aiisdir(*args, **kwargs) -> bool: """Async > sync wrapper.""" return os.path.isdir(*args, **kwargs) # noqa: ASYNC240 async def aiisfile(*args, **kwargs) -> bool: """Async > sync wrapper.""" return await asyncio.to_thread(os.path.isfile, *args, **kwargs) async def ailistdir(*args, **kwargs) -> list[str]: # type: ignore[no-redef, unused-ignore] """Async > sync wrapper.""" return await asyncio.to_thread(os.listdir, *args, **kwargs) async def aiunlink(*args, **kwargs) -> None: # type: ignore[no-redef, misc, unused-ignore] """Async > sync wrapper.""" await asyncio.to_thread(os.unlink, *args, **kwargs) async def airmtree(path: str) -> None: """Async wrapper for shutil.rmtree. Removes a directory tree recursively. Args: path: Directory to remove recursively. """ await asyncio.to_thread(shutil.rmtree, path) async def airmdir(path: str) -> None: """Async wrapper for os.rmdir. Removes an empty directory. Args: path: Empty directory to remove. """ await asyncio.to_thread(os.rmdir, path) async def is_process_running(name: str) -> bool: """Check if a process with the given name is running. Uses /proc filesystem to check process names (Linux only). Args: name: The process name to search for (matches /proc//comm) Returns: True if a process with that name is running, False otherwise. """ for pid in await ailistdir("/proc"): if pid.isdigit(): try: async with aiopen(f"/proc/{pid}/comm") as f: if (await f.read()).strip() == name: return True except OSError: pass # Process may have exited return False async def graceful_cancel_tasks( tasks: list[asyncio.Task], timeout: float = 1.0, # noqa: ASYNC109 ) -> None: """Cancel tasks with graceful timeout, then force cancel remaining. This is the standard shutdown pattern for async tasks: 1. Wait up to `timeout` seconds for tasks to complete gracefully 2. Force cancel any tasks still running 3. Await all cancelled tasks to ensure cleanup Args: tasks: List of tasks to cancel (filters out already-done tasks) timeout: Seconds to wait for graceful completion (default: 1.0) """ pending = [t for t in tasks if not t.done()] if not pending: return # Wait for graceful completion _, still_pending = await asyncio.wait( pending, timeout=timeout, return_when=asyncio.ALL_COMPLETED, ) # Force cancel remaining for task in still_pending: task.cancel() # Await all cancelled tasks for task in still_pending: with contextlib.suppress(asyncio.CancelledError): await task class DebouncedTask: """A debounced async task with ignore window support. Useful for plugins that react to events they can also trigger themselves. The ignore window prevents reacting to self-triggered events. Usage: # Create instance (typically in on_reload) self._relayout_debouncer = DebouncedTask(ignore_window=3.0) # In event handler - schedule with delay self._relayout_debouncer.schedule(self._delayed_relayout, delay=1.0) # Before self-triggering actions - set ignore window self._relayout_debouncer.set_ignore_window() await self.backend.execute(cmd, base_command="keyword") """ def __init__(self, ignore_window: float = 3.0) -> None: """Initialize the debounced task. Args: ignore_window: Duration in seconds to ignore schedule() calls after set_ignore_window() is called. """ self._task: asyncio.Task[None] | None = None self._ignore_window = ignore_window self._ignore_until: float = 0 def schedule(self, coro_func: Callable[[], Coroutine[Any, Any, Any]], delay: float = 0) -> bool: """Schedule or reschedule the task. Cancels any pending task before scheduling. If within the ignore window, the task is not scheduled. Args: coro_func: Async function to call (no arguments) delay: Delay in seconds before executing Returns: True if scheduled, False if in ignore window """ if asyncio.get_event_loop().time() < self._ignore_until: return False self.cancel() async def _run() -> None: try: if delay > 0: await asyncio.sleep(delay) await coro_func() except asyncio.CancelledError: pass self._task = asyncio.create_task(_run()) return True def set_ignore_window(self) -> None: """Start the ignore window and cancel any pending task. Calls to schedule() will be ignored until the window expires. """ self._ignore_until = asyncio.get_event_loop().time() + self._ignore_window self.cancel() def cancel(self) -> None: """Cancel any pending task.""" if self._task and not self._task.done(): self._task.cancel() self._task = None class TaskManager: """Manages async tasks with proper lifecycle handling. Provides consistent start/stop behavior with graceful shutdown: 1. Set running=False and signal stop event (graceful) 2. Wait with timeout for tasks to complete 3. Cancel remaining tasks if still alive 4. Always await to completion Similar to ManagedProcess but for asyncio Tasks instead of subprocesses. Usage: # Single background loop self._tasks = TaskManager() async def on_reload(self): self._tasks.start() self._tasks.create(self._main_loop()) async def _main_loop(self): while self._tasks.running: await self.do_work() if await self._tasks.sleep(60): break # Stop requested async def exit(self): await self._tasks.stop() Keyed tasks (for per-item tracking like scratchpad hysteresis): self._tasks.create(self._delayed_hide(uid), key=uid) self._tasks.cancel_keyed(uid) # Cancel specific task """ def __init__( self, graceful_timeout: float = 1.0, on_error: Callable[[asyncio.Task, BaseException], Coroutine[Any, Any, None]] | None = None, ) -> None: """Initialize. Args: graceful_timeout: Seconds to wait for graceful stop before force cancel on_error: Async callback when a task fails (receives task and exception) """ self._tasks: list[asyncio.Task] = [] self._keyed_tasks: dict[str, asyncio.Task] = {} self._running: bool = False self._stop_event: asyncio.Event | None = None self._graceful_timeout = graceful_timeout self._on_error = on_error @property def running(self) -> bool: """Check if manager is running (tasks should continue).""" return self._running def start(self) -> None: """Mark manager as running. Call before creating tasks.""" self._running = True self._stop_event = asyncio.Event() def create(self, coro: Coroutine[Any, Any, Any], *, key: str | None = None) -> asyncio.Task: """Create and track a task. Args: coro: Coroutine to run key: Optional key for keyed task (replaces existing task with same key) Returns: The created task """ if key is not None: self.cancel_keyed(key) task = asyncio.create_task(self._wrap_task(coro)) self._keyed_tasks[key] = task else: task = asyncio.create_task(self._wrap_task(coro)) self._tasks.append(task) return task async def _wrap_task(self, coro: Coroutine[Any, Any, Any]) -> None: """Wrap a coroutine to handle errors via callback.""" try: await coro except asyncio.CancelledError: raise except BaseException as e: # pylint: disable=broad-exception-caught if self._on_error: task = asyncio.current_task() assert task is not None await self._on_error(task, e) else: raise def cancel_keyed(self, key: str) -> bool: """Cancel a keyed task immediately. Args: key: The task key Returns: True if task existed and was cancelled """ task = self._keyed_tasks.pop(key, None) if task and not task.done(): task.cancel() return True return False async def sleep(self, duration: float) -> bool: """Interruptible sleep that respects stop signal. Use this instead of asyncio.sleep() in loops. Args: duration: Sleep duration in seconds Returns: True if interrupted (should exit loop), False if completed normally """ if self._stop_event is None: await asyncio.sleep(duration) return not self._running try: await asyncio.wait_for(self._stop_event.wait(), timeout=duration) except TimeoutError: return False # Sleep completed normally return True # Stop event was set async def stop(self) -> None: """Stop all tasks with graceful timeout. Shutdown sequence (mirrors ManagedProcess): 1. Set running=False and signal stop event (graceful) 2. Wait up to graceful_timeout for tasks to complete 3. Cancel remaining tasks if still alive 4. Await all tasks to completion """ self._running = False if self._stop_event: self._stop_event.set() all_tasks = self._tasks + list(self._keyed_tasks.values()) await graceful_cancel_tasks(all_tasks, timeout=self._graceful_timeout) self._tasks.clear() self._keyed_tasks.clear() self._stop_event = None ================================================ FILE: pyprland/ansi.py ================================================ """ANSI terminal color utilities. Provides constants and helpers for terminal coloring with proper NO_COLOR environment variable support and TTY detection. """ import os import sys from typing import TextIO __all__ = [ "BLACK", "BLUE", "BOLD", "CYAN", "DIM", "GREEN", "RED", "RESET", "YELLOW", "HandlerStyles", "LogStyles", "colorize", "make_style", "should_colorize", ] # ANSI escape sequence prefix _ESC = "\x1b[" # Reset all attributes RESET = f"{_ESC}0m" # Style codes BOLD = "1" DIM = "2" # Foreground color codes BLACK = "30" RED = "31" GREEN = "32" YELLOW = "33" BLUE = "34" CYAN = "36" def should_colorize(stream: TextIO | None = None) -> bool: """Determine if ANSI colors should be used for the given stream. Respects: - NO_COLOR environment variable (disables colors) - FORCE_COLOR environment variable (forces colors) - TTY detection (disables colors when piping) Args: stream: The output stream to check. Defaults to sys.stderr. Returns: True if colors should be used, False otherwise. """ if os.environ.get("NO_COLOR"): return False if os.environ.get("FORCE_COLOR"): return True if stream is None: stream = sys.stderr return hasattr(stream, "isatty") and stream.isatty() def colorize(text: str, *codes: str) -> str: """Wrap text in ANSI color codes. Args: text: The text to colorize. *codes: ANSI codes to apply (e.g., RED, BOLD). Returns: The text wrapped in ANSI escape sequences. """ if not codes: return text return f"{_ESC}{';'.join(codes)}m{text}{RESET}" def make_style(*codes: str) -> tuple[str, str]: """Create a style prefix and suffix pair. Args: *codes: ANSI codes to apply. Returns: Tuple of (prefix, suffix) strings for use in formatters. """ if not codes: return ("", RESET) return (f"{_ESC}{';'.join(codes)}m", RESET) class LogStyles: """Pre-built styles for log levels.""" WARNING = (YELLOW, DIM) ERROR = (RED, DIM) CRITICAL = (RED, BOLD) class HandlerStyles: """Pre-built styles for handler logging.""" COMMAND = (YELLOW, BOLD) # run_* methods EVENT = (BLACK, BOLD) # event_* methods ================================================ FILE: pyprland/client.py ================================================ """Client-side functions for pyprland CLI.""" import asyncio import contextlib import os import sys from . import constants as pyprland_constants from .commands.parsing import normalize_command_name from .common import get_logger, notify_send, run_interactive_program from .models import ExitCode, ResponsePrefix __all__ = ["run_client"] def _get_config_file_path() -> str: """Get the config file path, checking new location then legacy fallback. Returns: Path to the config file as a string. """ config_path = pyprland_constants.CONFIG_FILE legacy_path = pyprland_constants.LEGACY_CONFIG_FILE if config_path.exists(): return str(config_path) if legacy_path.exists(): return str(legacy_path) # Default to new path (will be created by user) return str(config_path) async def run_client() -> None: """Run the client (CLI).""" log = get_logger("client") if sys.argv[1] == "edit": editor = os.environ.get("EDITOR", os.environ.get("VISUAL", "vi")) filename = _get_config_file_path() run_interactive_program(f'{editor} "{filename}"') sys.argv[1] = "reload" elif sys.argv[1] == "validate": # Validate doesn't require daemon - run locally and exit from .validate_cli import run_validate # noqa: PLC0415 run_validate() return elif sys.argv[1] in {"--help", "-h"}: sys.argv[1] = "help" try: reader, writer = await asyncio.open_unix_connection(pyprland_constants.CONTROL) except (ConnectionRefusedError, FileNotFoundError): log.critical( "Cannot connect to pyprland daemon at %s.\nIs the daemon running? Start it with: pypr (no arguments)", pyprland_constants.CONTROL, ) with contextlib.suppress(Exception): await notify_send("Pypr can't connect. Is daemon running?", icon="dialog-error") sys.exit(ExitCode.CONNECTION_ERROR) args = sys.argv[1:] args[0] = normalize_command_name(args[0]) writer.write((" ".join(args) + "\n").encode()) writer.write_eof() await writer.drain() return_value = (await reader.read()).decode("utf-8") writer.close() await writer.wait_closed() # Parse response and set exit code if return_value.startswith(f"{ResponsePrefix.ERROR}:"): # Extract error message (skip "ERROR: " prefix) error_msg = return_value[len(ResponsePrefix.ERROR) + 2 :].strip() print(f"Error: {error_msg}", file=sys.stderr) sys.exit(ExitCode.COMMAND_ERROR) elif return_value.startswith(f"{ResponsePrefix.OK}"): # Command succeeded, check for additional output after OK remaining = return_value[len(ResponsePrefix.OK) :] if remaining.startswith(": "): # Success message format: "OK: message\n" print(remaining[2:].strip()) elif remaining.startswith("\n"): # Content format: "OK\n" content = remaining[1:].rstrip("\n") if content: print(content) # "OK\n" with no content: print nothing sys.exit(ExitCode.SUCCESS) else: # Legacy response (version, help, dumpjson) - print as-is print(return_value.rstrip()) sys.exit(ExitCode.SUCCESS) ================================================ FILE: pyprland/command.py ================================================ """Pyprland - an Hyprland companion app (cli client & daemon).""" import asyncio import json import sys from pathlib import Path from typing import Literal, overload from . import constants as pyprland_constants from .common import get_logger, init_logger from .constants import CONTROL from .ipc import init as ipc_init from .models import PyprError __all__: list[str] = ["main"] @overload def use_param(txt: str, optional_value: Literal[False] = ...) -> str: ... @overload def use_param(txt: str, optional_value: Literal[True]) -> str | bool: ... def use_param(txt: str, optional_value: bool = False) -> str | bool: """Check if parameter `txt` is in sys.argv. If found, removes it from sys.argv & returns the argument value. If optional_value is True, the parameter value is optional. Args: txt: Parameter name to look for optional_value: If True, value after parameter is optional Returns: - "" if parameter not present - True if parameter present but no value (only when optional_value=True) - The value string if parameter present with value """ if txt not in sys.argv: return "" i = sys.argv.index(txt) # Check if there's a next arg and it's not a flag if optional_value and (i + 1 >= len(sys.argv) or sys.argv[i + 1].startswith("-")): del sys.argv[i] return True v = sys.argv[i + 1] del sys.argv[i : i + 2] return v def _run(invoke_daemon: bool) -> None: """Run the daemon or client, handling errors.""" log = get_logger("startup") try: if invoke_daemon: from .pypr_daemon import run_daemon # noqa: PLC0415 asyncio.run(run_daemon()) else: from .client import run_client # noqa: PLC0415 asyncio.run(run_client()) except KeyboardInterrupt: pass except PyprError: log.critical("Command failed.") except json.decoder.JSONDecodeError as e: log.critical("Invalid JSON syntax in the config file: %s", e.args[0]) except Exception: # pylint: disable=W0718 log.critical("Unhandled exception:", exc_info=True) finally: if invoke_daemon and Path(CONTROL).exists(): Path(CONTROL).unlink() def main() -> None: """Run the command.""" debug_flag = use_param("--debug", optional_value=True) if debug_flag: filename = debug_flag if isinstance(debug_flag, str) else None init_logger(filename=filename, force_debug=True) else: init_logger() ipc_init() config_override = use_param("--config") if config_override: pyprland_constants.CONFIG_FILE = Path(config_override) invoke_daemon = len(sys.argv) <= 1 if invoke_daemon and Path(CONTROL).exists(): get_logger("startup").critical( """%s exists, is pypr already running ? If that's not the case, delete this file and run again.""", CONTROL, ) else: _run(invoke_daemon) if __name__ == "__main__": main() ================================================ FILE: pyprland/commands/__init__.py ================================================ """Command handling utilities for pyprland. This package provides: - models: Data structures (CommandArg, CommandInfo, CommandNode, CLIENT_COMMANDS) - parsing: Docstring and command name parsing - discovery: Command extraction from plugins - tree: Hierarchical command tree building """ ================================================ FILE: pyprland/commands/discovery.py ================================================ """Command extraction and discovery from plugins.""" from __future__ import annotations import inspect from typing import TYPE_CHECKING from .models import CLIENT_COMMANDS, CommandInfo from .parsing import parse_docstring if TYPE_CHECKING: from ..manager import Pyprland __all__ = ["extract_commands_from_object", "get_all_commands", "get_client_commands"] def extract_commands_from_object(obj: object, source: str) -> list[CommandInfo]: """Extract commands from a plugin class or instance. Works with both classes (for docs generation) and instances (runtime). Looks for methods starting with "run_" and extracts their docstrings. Args: obj: A plugin class or instance source: The source identifier (plugin name, "built-in", or "client") Returns: List of CommandInfo objects """ commands: list[CommandInfo] = [] for name in dir(obj): if not name.startswith("run_"): continue method = getattr(obj, name) if not callable(method): continue command_name = name[4:] # Remove 'run_' prefix docstring = inspect.getdoc(method) or "" args, short_desc, full_desc = parse_docstring(docstring) commands.append( CommandInfo( name=command_name, args=args, short_description=short_desc, full_description=full_desc, source=source, ) ) return commands def get_client_commands() -> list[CommandInfo]: """Get client-only commands (edit, validate). These commands run on the client side and don't go through the daemon. Returns: List of CommandInfo for client-only commands """ commands: list[CommandInfo] = [] for name, doc in CLIENT_COMMANDS.items(): args, short_desc, full_desc = parse_docstring(doc) commands.append( CommandInfo( name=name, args=args, short_description=short_desc, full_description=full_desc, source="client", ) ) return commands def get_all_commands(manager: Pyprland) -> dict[str, CommandInfo]: """Get all commands from plugins and client. Args: manager: The Pyprland manager instance with loaded plugins Returns: Dict mapping command name to CommandInfo """ commands: dict[str, CommandInfo] = {} # Extract from all plugins for plugin in manager.plugins.values(): source = "built-in" if plugin.name == "pyprland" else plugin.name for cmd in extract_commands_from_object(plugin, source): commands[cmd.name] = cmd # Add client-only commands for cmd in get_client_commands(): commands[cmd.name] = cmd return commands ================================================ FILE: pyprland/commands/models.py ================================================ """Data models for command handling.""" from __future__ import annotations from dataclasses import dataclass, field __all__ = ["CLIENT_COMMANDS", "CommandArg", "CommandInfo", "CommandNode"] # Client-only commands with their docstrings (not sent to daemon) CLIENT_COMMANDS: dict[str, str] = { "edit": """Open the configuration file in $EDITOR, then reload. Opens pyprland.toml in your preferred editor (EDITOR or VISUAL env var, defaults to vi). After the editor closes, the configuration is reloaded.""", "validate": """Validate the configuration file. Checks the configuration file for syntax errors and validates plugin configurations against their schemas. Does not require the daemon.""", } @dataclass class CommandArg: """An argument parsed from a command's docstring.""" value: str # e.g., "next|pause|clear" or "name" required: bool # True for , False for [arg] @dataclass class CommandInfo: """Complete information about a command.""" name: str args: list[CommandArg] short_description: str full_description: str source: str # "built-in", plugin name, or "client" @dataclass class CommandNode: """A node in the command hierarchy. Used to represent commands with subcommands (e.g., "wall next", "wall pause"). A node can have both its own handler (info) and children subcommands. """ name: str # The segment name (e.g., "wall" or "next") full_name: str # Full command name (e.g., "wall" or "wall_next") info: CommandInfo | None = None # Command info if this node is callable children: dict[str, CommandNode] = field(default_factory=dict) ================================================ FILE: pyprland/commands/parsing.py ================================================ """Docstring and command name parsing utilities.""" from __future__ import annotations import re from .models import CommandArg __all__ = ["normalize_command_name", "parse_docstring"] # Regex pattern to match args: or [optional] _ARG_PATTERN = re.compile(r"([<\[])([^>\]]+)([>\]])") def normalize_command_name(cmd: str) -> str: """Normalize a user-typed command to internal format. Converts spaces and hyphens to underscores. E.g., "wall rm" -> "wall_rm", "toggle-special" -> "toggle_special" Args: cmd: User-typed command string Returns: Normalized command name with underscores """ return cmd.replace("-", "_").replace(" ", "_") def parse_docstring(docstring: str) -> tuple[list[CommandArg], str, str]: """Parse a docstring to extract arguments and descriptions. The first line may contain arguments like: " Short description" or "[optional_arg] Short description" Args: docstring: The raw docstring to parse Returns: Tuple of (args, short_description, full_description) - args: List of CommandArg objects - short_description: Text after arguments on first line - full_description: Complete docstring """ if not docstring: return [], "No description available.", "" full_description = docstring.strip() lines = full_description.split("\n") first_line = lines[0].strip() args: list[CommandArg] = [] last_end = 0 # Find all args at the start of the line for match in _ARG_PATTERN.finditer(first_line): # Check if this match is at the expected position (start or after whitespace) if match.start() != last_end and first_line[last_end : match.start()].strip(): # There's non-whitespace before this match, stop parsing args break bracket_open = match.group(1) content = match.group(2) required = bracket_open == "<" args.append(CommandArg(value=content, required=required)) last_end = match.end() # Skip any whitespace after the arg while last_end < len(first_line) and first_line[last_end] == " ": last_end += 1 # The short description is what comes after the args if args: short_description = first_line[last_end:].strip() if not short_description: short_description = first_line else: short_description = first_line return args, short_description, full_description ================================================ FILE: pyprland/commands/tree.py ================================================ """Hierarchical command tree building and display name utilities.""" from __future__ import annotations from typing import TYPE_CHECKING from .models import CommandInfo, CommandNode from .parsing import normalize_command_name if TYPE_CHECKING: from collections.abc import Iterable __all__ = ["build_command_tree", "get_display_name", "get_parent_prefixes"] def get_parent_prefixes(commands: dict[str, str] | Iterable[str]) -> set[str]: """Identify prefixes that have multiple child commands from the same source. A prefix becomes a parent node when more than one command from the SAME source/plugin shares it. This prevents unrelated commands like toggle_special and toggle_dpms (from different plugins) from being grouped. Args: commands: Either a dict mapping command name -> source/plugin name, or an iterable of command names (legacy, no source filtering) Returns: Set of prefixes that should become parent nodes """ # Handle legacy call with just command names (no source info) if not isinstance(commands, dict): commands = dict.fromkeys(commands, "") # Group commands by (prefix, source) to find true hierarchies prefix_source_counts: dict[tuple[str, str], int] = {} for name, source in commands.items(): parts = name.split("_") for i in range(1, len(parts)): prefix = "_".join(parts[:i]) key = (prefix, source) prefix_source_counts[key] = prefix_source_counts.get(key, 0) + 1 # A prefix is a parent only if multiple commands from same source share it return {prefix for (prefix, _source), count in prefix_source_counts.items() if count > 1} def get_display_name(cmd_name: str, parent_prefixes: set[str]) -> str: """Get the user-facing display name for a command. Converts underscore-separated hierarchical commands to space-separated. E.g., "wall_rm" -> "wall rm" if "wall" is a parent prefix. Non-hierarchical commands stay unchanged: "shift_monitors" -> "shift_monitors" Args: cmd_name: The internal command name (underscore-separated) parent_prefixes: Set of prefixes that have multiple children Returns: The display name (space-separated for hierarchical commands) """ parts = cmd_name.split("_") for i in range(1, len(parts)): prefix = "_".join(parts[:i]) if prefix in parent_prefixes: subcommand = "_".join(parts[i:]) return f"{prefix} {subcommand}" return cmd_name def build_command_tree(commands: dict[str, CommandInfo]) -> dict[str, CommandNode]: """Build hierarchical command tree from flat command names. Groups commands with shared prefixes into a tree structure. For example, wall_next, wall_pause, wall_clear become children of "wall". Only creates hierarchy when multiple commands share a prefix: - wall_next + wall_pause -> wall: {next, pause} - layout_center (alone) -> layout_center (no split) Accepts command names in both formats: - Internal format: wall_next (underscore-separated) - Display format: wall next (space-separated) Args: commands: Dict mapping command name to CommandInfo Returns: Dict mapping root command names to CommandNode trees """ # Normalize names to internal format (underscore) for tree building normalized_commands = {normalize_command_name(name): info for name, info in commands.items()} parent_prefixes = get_parent_prefixes({name: info.source for name, info in normalized_commands.items()}) # Build the tree roots: dict[str, CommandNode] = {} for name, info in sorted(normalized_commands.items()): parts = name.split("_") # Find the longest parent prefix for this command parent_depth = 0 for i in range(1, len(parts)): prefix = "_".join(parts[:i]) if prefix in parent_prefixes: parent_depth = i if parent_depth == 0: # No parent prefix - this is a root command if name not in roots: roots[name] = CommandNode(name=name, full_name=name, info=info) else: roots[name].info = info else: # Has a parent prefix - add to tree root_name = "_".join(parts[:parent_depth]) # Ensure root node exists if root_name not in roots: # Check if root itself is a command root_info = normalized_commands.get(root_name) roots[root_name] = CommandNode(name=root_name, full_name=root_name, info=root_info) # Add this command as a child if name != root_name: child_name = "_".join(parts[parent_depth:]) roots[root_name].children[child_name] = CommandNode( name=child_name, full_name=name, info=info, ) return roots ================================================ FILE: pyprland/common.py ================================================ """Shared utilities - re-exports from focused modules for backward compatibility. This module aggregates exports from specialized modules (debug, ipc_paths, logging_setup, state, terminal, utils) providing a single import point for commonly used functions and classes. Note: For new code, prefer importing directly from the specific modules. """ # Re-export from focused modules from .debug import DEBUG, is_debug, set_debug from .ipc_paths import ( HYPRLAND_INSTANCE_SIGNATURE, IPC_FOLDER, MINIMUM_ADDR_LEN, MINIMUM_FULL_ADDR_LEN, init_ipc_folder, ) from .logging_setup import LogObjects, get_logger, init_logger from .state import SharedState from .terminal import run_interactive_program, set_raw_mode, set_terminal_size from .utils import apply_filter, apply_variables, is_rotated, merge, notify_send __all__ = [ "DEBUG", "HYPRLAND_INSTANCE_SIGNATURE", "IPC_FOLDER", "MINIMUM_ADDR_LEN", "MINIMUM_FULL_ADDR_LEN", "LogObjects", "SharedState", "apply_filter", "apply_variables", "get_logger", "init_ipc_folder", "init_logger", "is_debug", "is_rotated", "merge", "notify_send", "run_interactive_program", "set_debug", "set_raw_mode", "set_terminal_size", ] ================================================ FILE: pyprland/completions/__init__.py ================================================ """Shell completion generators for pyprland. Generates dynamic shell completions based on loaded plugins and configuration. Supports positional argument awareness with type-specific completions. This package provides: - Command completion discovery from loaded plugins - Shell-specific completion script generators (bash, zsh, fish) - CLI handler for the `pypr compgen` command """ from __future__ import annotations from .discovery import get_command_completions from .generators import GENERATORS from .handlers import get_default_path, handle_compgen from .models import CommandCompletion, CompletionArg __all__ = [ "GENERATORS", "CommandCompletion", "CompletionArg", "get_command_completions", "get_default_path", "handle_compgen", ] ================================================ FILE: pyprland/completions/discovery.py ================================================ """Command completion discovery. Extracts structured completion data from loaded plugins and configuration. """ from __future__ import annotations from typing import TYPE_CHECKING from ..commands.discovery import get_all_commands from ..commands.tree import build_command_tree from ..constants import SUPPORTED_SHELLS from .models import ( HINT_ARGS, KNOWN_COMPLETIONS, SCRATCHPAD_COMMANDS, CommandCompletion, CompletionArg, ) if TYPE_CHECKING: from ..commands.models import CommandInfo, CommandNode from ..manager import Pyprland __all__ = ["get_command_completions"] def _classify_arg( arg_value: str, cmd_name: str, scratchpad_names: list[str], ) -> tuple[str, list[str]]: """Classify an argument and determine its completion type and values. Args: arg_value: The argument value from docstring (e.g., "next|pause|clear") cmd_name: The command name (for context-specific handling) scratchpad_names: Available scratchpad names from config Returns: Tuple of (completion_type, values) """ # Check for pipe-separated choices if "|" in arg_value: return ("choices", arg_value.split("|")) # Check for scratchpad commands with "name" arg if arg_value == "name" and cmd_name in SCRATCHPAD_COMMANDS: return ("dynamic", scratchpad_names) # Check for known completions if arg_value in KNOWN_COMPLETIONS: return ("choices", KNOWN_COMPLETIONS[arg_value]) # Check for literal values (like "json") if arg_value == "json": return ("literal", [arg_value]) # Check for hint args if arg_value in HINT_ARGS: return ("hint", [HINT_ARGS[arg_value]]) # Default: no completion, show arg name as hint return ("hint", [arg_value]) def _build_completion_args( cmd_name: str, cmd_info: CommandInfo | None, scratchpad_names: list[str], ) -> list[CompletionArg]: """Build completion args from a CommandInfo.""" if cmd_info is None: return [] completion_args: list[CompletionArg] = [] for pos, arg in enumerate(cmd_info.args, start=1): comp_type, values = _classify_arg(arg.value, cmd_name, scratchpad_names) completion_args.append( CompletionArg( position=pos, completion_type=comp_type, values=values, required=arg.required, description=arg.value, ) ) return completion_args def _build_command_from_node( root_name: str, node: CommandNode, scratchpad_names: list[str], ) -> CommandCompletion: """Build a CommandCompletion from a CommandNode.""" # Build subcommands dict subcommands: dict[str, CommandCompletion] = {} for child_name, child_node in node.children.items(): if child_node.info: subcommands[child_name] = CommandCompletion( name=child_name, args=_build_completion_args(child_node.full_name, child_node.info, scratchpad_names), description=child_node.info.short_description, ) # Build root command completion root_args: list[CompletionArg] = [] root_desc = "" if node.info: root_args = _build_completion_args(root_name, node.info, scratchpad_names) root_desc = node.info.short_description return CommandCompletion( name=root_name, args=root_args, description=root_desc, subcommands=subcommands, ) def _apply_command_overrides(commands: dict[str, CommandCompletion], manager: Pyprland) -> None: """Apply special overrides for built-in commands (help, compgen, doc).""" all_cmd_names = sorted(commands.keys()) # Build subcommand completions for help help_subcommands: dict[str, CommandCompletion] = {} for cmd_name, cmd in commands.items(): if cmd.subcommands: help_subcommands[cmd_name] = CommandCompletion( name=cmd_name, args=[ CompletionArg( position=1, completion_type="choices", values=sorted(cmd.subcommands.keys()), required=False, description="subcommand", ) ], description=f"Subcommands of {cmd_name}", ) if "help" in commands: commands["help"] = CommandCompletion( name="help", args=[ CompletionArg( position=1, completion_type="choices", values=all_cmd_names, required=False, description="command", ) ], description=commands["help"].description or "Show available commands or detailed help", subcommands=help_subcommands, ) if "compgen" in commands: commands["compgen"] = CommandCompletion( name="compgen", args=[ CompletionArg( position=1, completion_type="choices", values=list(SUPPORTED_SHELLS), required=True, description="shell", ), CompletionArg( position=2, completion_type="choices", values=["default"], required=False, description="path", ), ], description=commands["compgen"].description or "Generate shell completions", ) if "doc" in commands: plugin_names = [p for p in manager.plugins if p != "pyprland"] commands["doc"] = CommandCompletion( name="doc", args=[ CompletionArg( position=1, completion_type="choices", values=sorted(plugin_names), required=False, description="plugin", ) ], description=commands["doc"].description or "Show plugin documentation", ) def get_command_completions(manager: Pyprland) -> dict[str, CommandCompletion]: """Extract structured completion data from loaded plugins. Args: manager: The Pyprland manager instance with loaded plugins Returns: Dict mapping command name -> CommandCompletion (with subcommands for hierarchical commands) """ scratchpad_names: list[str] = list(manager.config.get("scratchpads", {}).keys()) command_tree = build_command_tree(get_all_commands(manager)) commands: dict[str, CommandCompletion] = {} for root_name, node in command_tree.items(): commands[root_name] = _build_command_from_node(root_name, node, scratchpad_names) _apply_command_overrides(commands, manager) return commands ================================================ FILE: pyprland/completions/generators/__init__.py ================================================ """Shell completion generators. Provides generator functions for each supported shell. """ from __future__ import annotations from typing import TYPE_CHECKING from .bash import generate_bash from .fish import generate_fish from .zsh import generate_zsh if TYPE_CHECKING: from collections.abc import Callable from ..models import CommandCompletion __all__ = ["GENERATORS", "generate_bash", "generate_fish", "generate_zsh"] GENERATORS: dict[str, Callable[[dict[str, CommandCompletion]], str]] = { "bash": generate_bash, "zsh": generate_zsh, "fish": generate_fish, } ================================================ FILE: pyprland/completions/generators/bash.py ================================================ """Bash shell completion generator.""" from __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: from ..models import CommandCompletion __all__ = ["generate_bash"] def _build_subcommand_case(cmd_name: str, cmd: CommandCompletion) -> str: """Build bash case statement for a command with subcommands.""" subcmd_list = " ".join(sorted(cmd.subcommands.keys())) # Position 1 is subcommand selection subcmd_cases: list[str] = [f' 1) COMPREPLY=($(compgen -W "{subcmd_list}" -- "$cur"));;'] # Build per-subcommand argument completions at position 2+ for subcmd_name, subcmd in sorted(cmd.subcommands.items()): for arg in subcmd.args: if arg.completion_type in ("choices", "dynamic", "literal"): values_str = " ".join(arg.values) # Subcommand args start at position 2 (pos 1 is the subcommand itself) subcmd_cases.append( f' *) [[ "${{COMP_WORDS[2]}}" == "{subcmd_name}" ]] && ' f"[[ $pos -eq {arg.position + 1} ]] && " f'COMPREPLY=($(compgen -W "{values_str}" -- "$cur"));;' ) pos_block = "\n".join(subcmd_cases) return f""" {cmd_name}) case $pos in {pos_block} esac ;;""" def _build_help_case(cmd: CommandCompletion, all_commands: str) -> str: """Build bash case for help command with hierarchical completion. Position 1: complete with all commands Position 2+: complete subcommands based on the command at position 1 """ # Build case statements for commands that have subcommands subcmd_cases: list[str] = [] for parent_name, parent_cmd in sorted(cmd.subcommands.items()): if parent_cmd.args: subcmds_str = " ".join(parent_cmd.args[0].values) subcmd_cases.append(f' {parent_name}) COMPREPLY=($(compgen -W "{subcmds_str}" -- "$cur"));;') subcmd_block = "\n".join(subcmd_cases) if subcmd_cases else " *) ;;" return f""" help) if [[ $pos -eq 1 ]]; then # Position 1: complete with all commands COMPREPLY=($(compgen -W "{all_commands}" -- "$cur")) else # Position 2+: complete subcommands based on COMP_WORDS[2] case "${{COMP_WORDS[2]}}" in {subcmd_block} esac fi ;;""" def _build_args_case(cmd_name: str, cmd: CommandCompletion) -> str | None: """Build bash case statement for a command with positional args.""" pos_cases: list[str] = [] for arg in cmd.args: if arg.completion_type in ("choices", "dynamic", "literal"): values_str = " ".join(arg.values) pos_cases.append(f' {arg.position}) COMPREPLY=($(compgen -W "{values_str}" -- "$cur"));;') elif arg.completion_type == "file": pos_cases.append(f' {arg.position}) COMPREPLY=($(compgen -f -- "$cur"));;') # hint and none types: no completion if not pos_cases: return None pos_block = "\n".join(pos_cases) return f""" {cmd_name}) case $pos in {pos_block} esac ;;""" def generate_bash(commands: dict[str, CommandCompletion]) -> str: """Generate bash completion script content. Args: commands: Dict mapping command name -> CommandCompletion Returns: The bash completion script content """ cmd_list = " ".join(sorted(commands.keys())) # Build case statements for each command case_statements: list[str] = [] for cmd_name, cmd in sorted(commands.items()): if cmd_name == "help": case_statements.append(_build_help_case(cmd, cmd_list)) elif cmd.subcommands: case_statements.append(_build_subcommand_case(cmd_name, cmd)) elif cmd.args: case_stmt = _build_args_case(cmd_name, cmd) if case_stmt: case_statements.append(case_stmt) case_block = "\n".join(case_statements) if case_statements else " *) ;;" return f"""# Bash completion for pypr # Generated by: pypr compgen bash _pypr() {{ local cur="${{COMP_WORDS[COMP_CWORD]}}" local cmd="${{COMP_WORDS[1]}}" local pos=$((COMP_CWORD - 1)) if [[ $COMP_CWORD -eq 1 ]]; then COMPREPLY=($(compgen -W "{cmd_list}" -- "$cur")) return fi case "$cmd" in {case_block} esac }} complete -F _pypr pypr """ ================================================ FILE: pyprland/completions/generators/fish.py ================================================ """Fish shell completion generator.""" from __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: from ..models import CommandCompletion __all__ = ["generate_fish"] _HEADER = """# Fish completion for pypr # Generated by: pypr compgen fish # Disable default file completions for pypr complete -c pypr -f # Helper function to count args after command function __pypr_arg_count set -l cmd (commandline -opc) math (count $cmd) - 1 end # Main commands""" def _build_main_commands(commands: dict[str, CommandCompletion]) -> list[str]: """Build main command completion lines.""" lines: list[str] = [] for cmd_name, cmd in sorted(commands.items()): desc = cmd.description.replace('"', '\\"') if cmd.description else "" # Add subcommand hint if applicable if cmd.subcommands and not cmd.args and not desc: subcmds = "|".join(sorted(cmd.subcommands.keys())) desc = f"<{subcmds}>" if desc: lines.append(f'complete -c pypr -n "__fish_use_subcommand" -a "{cmd_name}" -d "{desc}"') else: lines.append(f'complete -c pypr -n "__fish_use_subcommand" -a "{cmd_name}"') return lines def _build_subcommand_completions(cmd_name: str, cmd: CommandCompletion) -> list[str]: """Build fish completions for a command's subcommands.""" lines: list[str] = [] for subcmd_name, subcmd in sorted(cmd.subcommands.items()): subdesc = subcmd.description.replace('"', '\\"') if subcmd.description else "" if subdesc: lines.append( f'complete -c pypr -n "__fish_seen_subcommand_from {cmd_name}; ' f'and test (__pypr_arg_count) -eq 1" -a "{subcmd_name}" -d "{subdesc}"' ) else: lines.append( f'complete -c pypr -n "__fish_seen_subcommand_from {cmd_name}; and test (__pypr_arg_count) -eq 1" -a "{subcmd_name}"' ) return lines def _build_help_completions(cmd: CommandCompletion, all_commands: list[str]) -> list[str]: """Build fish completions for help command with hierarchical completion. Position 1: complete with all commands Position 2+: complete subcommands based on the command at position 1 """ lines: list[str] = [] # Position 1: complete with all command names all_cmds_str = " ".join(all_commands) lines.append(f'complete -c pypr -n "__fish_seen_subcommand_from help; and test (__pypr_arg_count) -eq 1" -a "{all_cmds_str}"') # Position 2+: complete subcommands for each parent command for parent_name, parent_cmd in sorted(cmd.subcommands.items()): if parent_cmd.args: subcmds_str = " ".join(parent_cmd.args[0].values) lines.append( f'complete -c pypr -n "__fish_seen_subcommand_from help; ' f"and contains {parent_name} (commandline -opc); " f'and test (__pypr_arg_count) -eq 2" -a "{subcmds_str}"' ) return lines def _build_args_completions(cmd_name: str, cmd: CommandCompletion) -> list[str]: """Build fish completions for a command's positional args.""" lines: list[str] = [] for arg in cmd.args: if arg.completion_type in ("choices", "dynamic", "literal"): values_str = " ".join(arg.values) lines.append( f'complete -c pypr -n "__fish_seen_subcommand_from {cmd_name}; ' f'and test (__pypr_arg_count) -eq {arg.position}" -a "{values_str}"' ) elif arg.completion_type == "file": lines.append(f'complete -c pypr -n "__fish_seen_subcommand_from {cmd_name}; and test (__pypr_arg_count) -eq {arg.position}" -F') # hint type: no completion added return lines def generate_fish(commands: dict[str, CommandCompletion]) -> str: """Generate fish completion script content. Args: commands: Dict mapping command name -> CommandCompletion Returns: The fish completion script content """ lines = [_HEADER] lines.extend(_build_main_commands(commands)) lines.append("") lines.append("# Subcommand and positional argument completions") all_cmd_names = sorted(commands.keys()) for cmd_name, cmd in sorted(commands.items()): if cmd_name == "help": lines.extend(_build_help_completions(cmd, all_cmd_names)) elif cmd.subcommands: lines.extend(_build_subcommand_completions(cmd_name, cmd)) elif cmd.args: lines.extend(_build_args_completions(cmd_name, cmd)) return "\n".join(lines) + "\n" ================================================ FILE: pyprland/completions/generators/zsh.py ================================================ """Zsh shell completion generator.""" from __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: from ..models import CommandCompletion __all__ = ["generate_zsh"] def _build_command_descriptions(commands: dict[str, CommandCompletion]) -> str: """Build the command descriptions block for zsh.""" cmd_descs: list[str] = [] for cmd_name, cmd in sorted(commands.items()): desc = cmd.description.replace("'", "'\\''") if cmd.description else cmd_name # Add subcommand hint if applicable if cmd.subcommands and not cmd.args: subcmds = "|".join(sorted(cmd.subcommands.keys())) desc = f"<{subcmds}> {desc}" if desc else f"<{subcmds}>" cmd_descs.append(f" '{cmd_name}:{desc}'") return "\n".join(cmd_descs) def _build_subcommand_case(cmd_name: str, cmd: CommandCompletion) -> str: """Build zsh case statement for a command with subcommands.""" subcmd_descs: list[str] = [] for subcmd_name, subcmd in sorted(cmd.subcommands.items()): subdesc = subcmd.description.replace("'", "'\\''") if subcmd.description else subcmd_name subcmd_descs.append(f"'{subcmd_name}:{subdesc}'") subcmd_desc_str = " ".join(subcmd_descs) return f""" {cmd_name}) local -a subcmds=({subcmd_desc_str}) if [[ $CURRENT -eq 2 ]]; then _describe 'subcommand' subcmds fi ;;""" def _build_help_case(cmd: CommandCompletion) -> str: """Build zsh case for help command with hierarchical completion. Position 1: complete with all commands (reuses the commands array) Position 2+: complete subcommands based on the command at position 1 """ # Build case statements for commands that have subcommands subcmd_cases: list[str] = [] for parent_name, parent_cmd in sorted(cmd.subcommands.items()): if parent_cmd.args: # Build subcommand descriptions for this parent subcmds_str = " ".join(f"'{val}'" for val in parent_cmd.args[0].values) subcmd_cases.append(f" {parent_name}) compadd {subcmds_str} ;;") subcmd_block = "\n".join(subcmd_cases) if subcmd_cases else " *) ;;" return f""" help) if [[ $CURRENT -eq 2 ]]; then # Position 1: complete with all commands _describe 'command' commands else # Position 2+: complete subcommands based on previous word case $words[2] in {subcmd_block} esac fi ;;""" def _build_args_case(cmd_name: str, cmd: CommandCompletion) -> str | None: """Build zsh case statement for a command with positional args.""" arg_specs: list[str] = [] for arg in cmd.args: pos = arg.position desc = arg.description.replace("'", "'\\''") if arg.completion_type in ("choices", "dynamic", "literal"): values_str = " ".join(arg.values) arg_specs.append(f"'{pos}:{desc}:({values_str})'") elif arg.completion_type == "file": arg_specs.append(f"'{pos}:{desc}:_files'") elif arg.completion_type == "hint": # Show description but no actual completions hint = arg.values[0] if arg.values else desc arg_specs.append(f"'{pos}:{hint}:'") if not arg_specs: return None args_line = " \\\n ".join(arg_specs) return f""" {cmd_name}) _arguments \\ {args_line} ;;""" def generate_zsh(commands: dict[str, CommandCompletion]) -> str: """Generate zsh completion script content. Args: commands: Dict mapping command name -> CommandCompletion Returns: The zsh completion script content """ cmd_desc_block = _build_command_descriptions(commands) # Build case statements for each command case_statements: list[str] = [] for cmd_name, cmd in sorted(commands.items()): if cmd_name == "help": case_statements.append(_build_help_case(cmd)) elif cmd.subcommands: case_statements.append(_build_subcommand_case(cmd_name, cmd)) elif cmd.args: case_stmt = _build_args_case(cmd_name, cmd) if case_stmt: case_statements.append(case_stmt) case_block = "\n".join(case_statements) if case_statements else " *) ;;" return f"""#compdef pypr # Zsh completion for pypr # Generated by: pypr compgen zsh _pypr() {{ local -a commands=( {cmd_desc_block} ) _arguments -C \\ '1:command:->command' \\ '*::arg:->args' case $state in command) _describe 'command' commands ;; args) case $words[1] in {case_block} esac ;; esac }} _pypr "$@" """ ================================================ FILE: pyprland/completions/handlers.py ================================================ """CLI handlers for shell completion commands. Provides the handle_compgen function used by the pyprland plugin to generate and install shell completions. """ from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING from ..constants import SUPPORTED_SHELLS from .discovery import get_command_completions from .generators import GENERATORS from .models import DEFAULT_PATHS if TYPE_CHECKING: from ..manager import Pyprland __all__ = ["get_default_path", "handle_compgen"] def get_default_path(shell: str) -> str: """Get the default user-level completion path for a shell. Args: shell: Shell type ("bash", "zsh", or "fish") Returns: Expanded absolute path to the default completion file """ return str(Path(DEFAULT_PATHS[shell]).expanduser()) def _get_success_message(shell: str, output_path: str, used_default: bool) -> str: """Generate a friendly success message after installing completions. Args: shell: Shell type output_path: Path where completions were written used_default: Whether the default path was used Returns: User-friendly success message """ # Use ~ in display path for readability display_path = output_path.replace(str(Path.home()), "~") if not used_default: return f"Completions written to {display_path}" if shell == "bash": return f"Completions installed to {display_path}\nReload your shell or run: source ~/.bashrc" if shell == "zsh": return ( f"Completions installed to {display_path}\n" "Ensure ~/.zsh/completions is in your fpath. Add to ~/.zshrc:\n" " fpath=(~/.zsh/completions $fpath)\n" " autoload -Uz compinit && compinit\n" "Then reload your shell." ) if shell == "fish": return f"Completions installed to {display_path}\nReload your shell or run: source ~/.config/fish/config.fish" return f"Completions written to {display_path}" def _parse_compgen_args(args: str) -> tuple[bool, str, str | None]: """Parse and validate compgen command arguments. Args: args: Arguments after "compgen" (e.g., "zsh" or "zsh default") Returns: Tuple of (success, shell_or_error, path_arg): - On success: (True, shell, path_arg or None) - On failure: (False, error_message, None) """ parts = args.split(None, 1) if not parts: shells = "|".join(SUPPORTED_SHELLS) return (False, f"Usage: compgen <{shells}> [default|path]", None) shell = parts[0] if shell not in SUPPORTED_SHELLS: return (False, f"Unsupported shell: {shell}. Supported: {', '.join(SUPPORTED_SHELLS)}", None) path_arg = parts[1] if len(parts) > 1 else None if path_arg is not None and path_arg != "default" and not path_arg.startswith(("/", "~")): return (False, "Relative paths not supported. Use absolute path, ~/path, or 'default'.", None) return (True, shell, path_arg) def handle_compgen(manager: Pyprland, args: str) -> tuple[bool, str]: """Handle compgen command with path semantics. Args: manager: The Pyprland manager instance args: Arguments after "compgen" (e.g., "zsh" or "zsh default") Returns: Tuple of (success, result): - No path arg: result is the script content - With path arg: result is success/error message """ success, shell_or_error, path_arg = _parse_compgen_args(args) if not success: return (False, shell_or_error) shell = shell_or_error try: commands = get_command_completions(manager) content = GENERATORS[shell](commands) except (KeyError, ValueError, TypeError) as e: return (False, f"Failed to generate completions: {e}") if path_arg is None: return (True, content) # Determine output path if path_arg == "default": output_path = get_default_path(shell) used_default = True else: output_path = str(Path(path_arg).expanduser()) used_default = False manager.log.debug("Writing completions to: %s", output_path) # Write to file try: parent_dir = Path(output_path).parent parent_dir.mkdir(parents=True, exist_ok=True) Path(output_path).write_text(content, encoding="utf-8") except OSError as e: return (False, f"Failed to write completion file: {e}") return (True, _get_success_message(shell, output_path, used_default)) ================================================ FILE: pyprland/completions/models.py ================================================ """Data models and constants for shell completions. Contains the data structures used to represent command completions and configuration constants for completion generation. """ from __future__ import annotations from dataclasses import dataclass, field from ..plugins.wallpapers.models import ColorScheme __all__ = [ "DEFAULT_PATHS", "HINT_ARGS", "KNOWN_COMPLETIONS", "SCRATCHPAD_COMMANDS", "CommandCompletion", "CompletionArg", ] # Default user-level completion paths DEFAULT_PATHS = { "bash": "~/.local/share/bash-completion/completions/pypr", "zsh": "~/.zsh/completions/_pypr", "fish": "~/.config/fish/completions/pypr.fish", } # Commands that use scratchpad names for completion SCRATCHPAD_COMMANDS = {"toggle", "show", "hide", "attach"} # Known static completions for specific arg names KNOWN_COMPLETIONS: dict[str, list[str]] = { "scheme": [c.value for c in ColorScheme if c.value] + ["fluorescent"], # Include alias "direction": ["1", "-1"], } # Args that should show as hints (no actual completion values) HINT_ARGS: dict[str, str] = { "#RRGGBB": "#RRGGBB (hex color)", "color": "#RRGGBB (hex color)", "factor": "number (zoom level)", } @dataclass class CompletionArg: """Argument completion specification.""" position: int # 1-based position after command completion_type: str # "choices", "literal", "hint", "file", "none" values: list[str] = field(default_factory=list) # Values to complete or hint text required: bool = True # Whether the arg is required description: str = "" # Description for zsh @dataclass class CommandCompletion: """Full completion spec for a command.""" name: str args: list[CompletionArg] = field(default_factory=list) description: str = "" subcommands: dict[str, CommandCompletion] = field(default_factory=dict) # For hierarchical commands ================================================ FILE: pyprland/config.py ================================================ """Configuration wrapper with typed accessors and schema-aware defaults. The Configuration class extends dict with: - Typed getters (get_bool, get_int, get_float, get_str) - Schema-based default values via set_schema() - Boolean coercion handling loose string values ("true", "yes", "1", etc.) Used by plugins to access their configuration sections with proper typing and automatic defaults from their config_schema definitions. """ from __future__ import annotations from typing import TYPE_CHECKING, Any, cast, overload if TYPE_CHECKING: import logging from collections.abc import Iterator from .validation import ConfigItems __all__ = ["BOOL_FALSE_STRINGS", "BOOL_STRINGS", "BOOL_TRUE_STRINGS", "Configuration", "SchemaAwareMixin", "coerce_to_bool"] # Type alias for config values ConfigValueType = float | bool | str | list | dict # Boolean string constants (shared with validation module) BOOL_TRUE_STRINGS = frozenset({"true", "yes", "on", "1", "enabled"}) BOOL_FALSE_STRINGS = frozenset({"false", "no", "off", "0", "disabled"}) BOOL_STRINGS = BOOL_TRUE_STRINGS | BOOL_FALSE_STRINGS def coerce_to_bool(value: ConfigValueType | None, default: bool = False) -> bool: """Coerce a value to boolean, handling loose typing. Args: value: The value to coerce default: Default value if value is None Returns: The boolean value Behavior: - None → default - Empty string → False - Explicit falsy strings ("false", "no", "off", "0", "disabled") → False - Any other non-empty string → True - Non-string values → bool(value) """ if value is None: return default if isinstance(value, str): if not value.strip(): return False return value.lower().strip() not in BOOL_FALSE_STRINGS return bool(value) class SchemaAwareMixin: """Mixin providing schema-aware defaults and typed config value accessors. Requires the implementing class to have: - self._get_raw(name) method that returns the raw value or raises KeyError - self.log (logging.Logger) attribute Provides: - Schema default value storage and lookup - Typed accessors: get_bool, get_int, get_float, get_str - Schema-aware get() method """ _schema_defaults: dict[str, ConfigValueType] log: logging.Logger # Required: implementing class must provide this def __init_schema__(self) -> None: """Initialize schema defaults storage. Call from subclass __init__.""" self._schema_defaults = {} def set_schema(self, schema: ConfigItems) -> None: """Set or update the schema for default value lookups. Args: schema: List of ConfigField definitions """ self._schema_defaults = {field.name: field.default for field in schema if field.default is not None} def _get_raw(self, name: str) -> ConfigValueType: """Get raw value without defaults. Raises KeyError if not found. Override in subclasses to provide the actual lookup mechanism. """ raise NotImplementedError @overload def get(self, name: str) -> ConfigValueType | None: ... @overload def get(self, name: str, default: None) -> ConfigValueType | None: ... @overload def get(self, name: str, default: ConfigValueType) -> ConfigValueType: ... def get(self, name: str, default: ConfigValueType | None = None) -> ConfigValueType | None: """Get a value with schema-aware defaults. Args: name: The configuration key default: Fallback if key is missing and not in schema defaults Returns: The value, schema default, or provided default """ try: return self._get_raw(name) except KeyError: if name in self._schema_defaults: return self._schema_defaults[name] return default def get_bool(self, name: str, default: bool = False) -> bool: """Get a boolean value, handling loose typing. Args: name: The key name default: Default value if key is missing Returns: The boolean value Behavior: - None (missing key) → default - Empty string → False - Explicit falsy strings ("false", "no", "off", "0", "disabled") → False - Any other non-empty string → True - Non-string values → bool(value) """ return coerce_to_bool(self.get(name), default) def get_int(self, name: str, default: int = 0) -> int: """Get an integer value. Args: name: The key name default: Default value if key is missing or invalid Returns: The integer value """ value = self.get(name) if value is None: return default try: return int(value) # type: ignore[arg-type] except (ValueError, TypeError): self.log.warning("Invalid integer value for %s: %s", name, value) return default def get_float(self, name: str, default: float = 0.0) -> float: """Get a float value. Args: name: The key name default: Default value if key is missing or invalid Returns: The float value """ value = self.get(name) if value is None: return default try: return float(value) # type: ignore[arg-type] except (ValueError, TypeError): self.log.warning("Invalid float value for %s: %s", name, value) return default def get_str(self, name: str, default: str = "") -> str: """Get a string value. Args: name: The key name default: Default value if key is missing Returns: The string value """ value = self.get(name) if value is None: return default return str(value) def has_explicit(self, name: str) -> bool: """Check if value was explicitly set (not from schema default). Args: name: The configuration key Returns: True if the value exists in the raw config (not from schema defaults) """ try: self._get_raw(name) except KeyError: return False return True class Configuration(SchemaAwareMixin, dict): """Configuration wrapper providing typed access and section filtering. Optionally accepts a schema to provide default values automatically. """ def __init__( self, *args: Any, logger: logging.Logger, schema: ConfigItems | None = None, **kwargs: Any, ): """Initialize the configuration object. Args: *args: Arguments for dict logger: Logger instance to use for warnings schema: Optional list of ConfigField definitions for automatic defaults **kwargs: Keyword arguments for dict """ super().__init__(*args, **kwargs) self.__init_schema__() self.log = logger if schema: self.set_schema(schema) def _get_raw(self, name: str) -> ConfigValueType: """Get raw value from dict. Raises KeyError if not found.""" if name in self: return cast("ConfigValueType", self[name]) raise KeyError(name) def get(self, name: str, default: ConfigValueType | None = None) -> ConfigValueType | None: # type: ignore[override] """Get a value with schema-aware defaults. Args: name: The configuration key default: Fallback if key is missing and not in schema defaults Returns: The value, schema default, or provided default """ return SchemaAwareMixin.get(self, name, default) def iter_subsections(self) -> Iterator[tuple[str, dict[str, Any]]]: """Yield only keys that have dictionary values (e.g., defined scratchpads). Returns: Iterator of (key, value) pairs where value is a dictionary """ for k, v in self.items(): if isinstance(v, dict): yield k, v ================================================ FILE: pyprland/config_loader.py ================================================ """Configuration file loading utilities. This module handles loading, parsing, and merging TOML/JSON configuration files. The module-level functions :func:`resolve_config_path`, :func:`load_toml`, :func:`load_toml_directory`, and :func:`load_config` are the shared primitives used by both the daemon (``ConfigLoader``) and the GUI (``pyprland.gui.api``). """ from __future__ import annotations import json import logging import os import tomllib from pathlib import Path from typing import Any, cast from .aioops import aiexists, aiisdir from .constants import CONFIG_FILE, LEGACY_CONFIG_FILE, MIGRATION_NOTIFICATION_DURATION_MS, OLD_CONFIG_FILE from .models import PyprError from .utils import merge __all__ = [ "ConfigLoader", "load_config", "load_toml", "load_toml_directory", "resolve_config_path", ] _log = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Shared primitives (used by both the daemon and the GUI) # --------------------------------------------------------------------------- def resolve_config_path(config_filename: str) -> Path: """Resolve a config filename with variable and user expansion. Args: config_filename: Raw config file path (may contain ``$VARS`` or ``~``) Returns: Resolved Path object """ return Path(os.path.expandvars(config_filename)).expanduser() def load_toml(path: Path) -> dict[str, Any]: """Load a single TOML file, returning ``{}`` on error. Unlike :meth:`ConfigLoader._load_config_file` this never raises and has no legacy JSON fallback — it is meant for best-effort loading. """ try: with path.open("rb") as fh: return tomllib.load(fh) except Exception: # noqa: BLE001 _log.warning("Failed to load %s", path, exc_info=True) return {} def load_toml_directory(directory: Path) -> dict[str, Any]: """Load and merge all ``.toml`` files in *directory* (sorted).""" config: dict[str, Any] = {} if not directory.is_dir(): return config for name in sorted(f.name for f in directory.iterdir() if f.suffix == ".toml"): merge(config, load_toml(directory / name)) return config def load_config(config_filename: str | None = None) -> dict[str, Any]: """Synchronously load config, recursively resolving includes. Mirrors the daemon's :meth:`ConfigLoader._open_config` logic so that any caller gets the same merged result as ``pypr dumpjson``. When *config_filename* is given it is treated as an include path (may be a file or a directory). When ``None`` the default config path is used and its ``pyprland.include`` entries are resolved recursively. """ if config_filename is not None: resolved = resolve_config_path(config_filename) if resolved.is_dir(): return load_toml_directory(resolved) return load_toml(resolved) # Default: load the main config and recursively resolve includes from .quickstart.generator import get_config_path # noqa: PLC0415 config_path = get_config_path() config = load_toml(config_path) if config_path.exists() else {} for extra in list(config.get("pyprland", {}).get("include", [])): merge(config, load_config(extra)) return config # --------------------------------------------------------------------------- # ConfigLoader — daemon-specific wrapper with async, logging, legacy fallback # --------------------------------------------------------------------------- class ConfigLoader: """Handles loading and merging configuration files. Supports: - TOML configuration files (preferred) - Legacy JSON configuration files - Directory-based config (multiple .toml files merged) - Include directives for modular configuration """ def __init__(self, log: logging.Logger) -> None: """Initialize the config loader. Args: log: Logger instance for status and error messages """ self.log = log self._config: dict[str, Any] = {} self.deferred_notifications: list[tuple[str, int]] = [] @property def config(self) -> dict[str, Any]: """Return the loaded configuration.""" return self._config async def load(self, config_filename: str = "") -> dict[str, Any]: """Load configuration from file or directory. Args: config_filename: Optional path to config file or directory. If empty, uses default CONFIG_FILE location. Returns: The loaded and merged configuration dictionary. Raises: PyprError: If config file not found or has syntax errors. """ config = await self._open_config(config_filename) merge(self._config, config, replace=True) return self._config async def _open_config(self, config_filename: str = "") -> dict[str, Any]: """Load config file(s) into a dictionary. Args: config_filename: Optional configuration file or directory path Returns: The loaded configuration dictionary """ if config_filename: fname = resolve_config_path(config_filename) if await aiisdir(str(fname)): return self._load_config_directory(fname) return self._load_config_file(fname) # No filename specified - use defaults with legacy fallback config_path = CONFIG_FILE legacy_path = LEGACY_CONFIG_FILE old_json_path = OLD_CONFIG_FILE if await aiexists(config_path): # New canonical location fname = config_path elif await aiexists(legacy_path): # Legacy TOML location - use it but warn user fname = legacy_path self.log.warning("Using legacy config path: %s", legacy_path) self.log.warning("Please move your config to: %s", config_path) self.deferred_notifications.append( ( f"Config at legacy location.\nMove to: {config_path}", MIGRATION_NOTIFICATION_DURATION_MS, ) ) elif await aiexists(old_json_path): # Very old JSON format - will be loaded via fallback in _load_config_file self.log.warning("Using deprecated JSON config: %s", old_json_path) self.log.warning("Please migrate to TOML format at: %s", config_path) self.deferred_notifications.append( ( f"JSON config is deprecated.\nMigrate to: {config_path}", MIGRATION_NOTIFICATION_DURATION_MS, ) ) fname = config_path # Will fall through to JSON loading in _load_config_file else: fname = config_path # Will error in _load_config_file config = self._load_config_file(fname) # Process includes for extra_config in list(config.get("pyprland", {}).get("include", [])): merge(config, await self._open_config(extra_config)) return config def _load_config_directory(self, directory: Path) -> dict[str, Any]: """Load and merge all .toml files from a directory. Delegates to :func:`load_toml_directory` but uses :meth:`_load_config_file` (which raises on errors) for each file. """ config: dict[str, Any] = {} for toml_file in sorted(f.name for f in directory.iterdir()): if not toml_file.endswith(".toml"): continue merge(config, self._load_config_file(directory / toml_file)) return config def _load_config_file(self, fname: Path) -> dict[str, Any]: """Load a single configuration file. Supports both TOML (preferred) and legacy JSON formats. Args: fname: Path to the configuration file Returns: Configuration dictionary Raises: PyprError: If file not found or has syntax errors """ if fname.exists(): self.log.info("Loading %s", fname) with fname.open("rb") as f: try: return tomllib.load(f) except tomllib.TOMLDecodeError as e: self.log.critical("Problem reading %s: %s", fname, e) raise PyprError from e # Fallback to very old JSON config if OLD_CONFIG_FILE.exists(): self.log.info("Loading %s", OLD_CONFIG_FILE) with OLD_CONFIG_FILE.open(encoding="utf-8") as f: return cast("dict[str, Any]", json.loads(f.read())) self.log.critical("Config file not found! Please create %s", fname) raise PyprError ================================================ FILE: pyprland/constants.py ================================================ """Shared constants and configuration defaults for Pyprland. Includes: - Config file paths (CONFIG_FILE, LEGACY_CONFIG_FILE) - Socket paths (CONTROL) - Timing constants (TASK_TIMEOUT, notification durations) - Display defaults (refresh rate, wallpaper dimensions) - IPC retry settings """ import os from pathlib import Path from .common import IPC_FOLDER __all__ = [ "CONFIG_FILE", "CONTROL", "DEFAULT_NOTIFICATION_DURATION_MS", "DEFAULT_PALETTE_COLOR_RGB", "DEFAULT_REFRESH_RATE_HZ", "DEFAULT_WALLPAPER_HEIGHT", "DEFAULT_WALLPAPER_WIDTH", "DEMO_NOTIFICATION_DURATION_MS", "ERROR_NOTIFICATION_DURATION_MS", "IPC_MAX_RETRIES", "IPC_RETRY_DELAY_MULTIPLIER", "LEGACY_CONFIG_FILE", "MIGRATION_NOTIFICATION_DURATION_MS", "MIN_CLIENTS_FOR_LAYOUT", "OLD_CONFIG_FILE", "PREFETCH_MAX_RETRIES", "PREFETCH_RETRY_BASE_SECONDS", "PREFETCH_RETRY_MAX_SECONDS", "PYPR_DEMO", "SECONDS_PER_DAY", "SUPPORTED_SHELLS", "TASK_TIMEOUT", ] CONTROL = f"{IPC_FOLDER}/.pyprland.sock" # Config file paths - use XDG_CONFIG_HOME with fallback to ~/.config _xdg_config_home = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") OLD_CONFIG_FILE = _xdg_config_home / "hypr" / "pyprland.json" # Very old JSON format LEGACY_CONFIG_FILE = _xdg_config_home / "hypr" / "pyprland.toml" # Old TOML location CONFIG_FILE = _xdg_config_home / "pypr" / "config.toml" # New canonical location TASK_TIMEOUT = 35.0 PYPR_DEMO = os.environ.get("PYPR_DEMO") # Supported shells for completion generation SUPPORTED_SHELLS = ("bash", "zsh", "fish") # Notification durations (milliseconds) DEFAULT_NOTIFICATION_DURATION_MS = 5000 ERROR_NOTIFICATION_DURATION_MS = 8000 DEMO_NOTIFICATION_DURATION_MS = 4000 MIGRATION_NOTIFICATION_DURATION_MS = 15000 # Display defaults DEFAULT_REFRESH_RATE_HZ = 60.0 # IPC retry settings IPC_MAX_RETRIES = 3 IPC_RETRY_DELAY_MULTIPLIER = 0.5 # Layout thresholds MIN_CLIENTS_FOR_LAYOUT = 2 # Time constants SECONDS_PER_DAY = 86400 # Wallpapers defaults DEFAULT_WALLPAPER_WIDTH = 1920 DEFAULT_WALLPAPER_HEIGHT = 1080 DEFAULT_PALETTE_COLOR_RGB = (66, 133, 244) # Google Blue #4285F4 # Wallpapers prefetch retry settings PREFETCH_RETRY_BASE_SECONDS = 2 PREFETCH_RETRY_MAX_SECONDS = 60 PREFETCH_MAX_RETRIES = 10 ================================================ FILE: pyprland/debug.py ================================================ """Debug mode state management.""" import os __all__ = [ "DEBUG", "is_debug", "set_debug", ] class _DebugState: """Container for mutable debug state to avoid global statement.""" value: bool = bool(os.environ.get("DEBUG")) _debug_state = _DebugState() def is_debug() -> bool: """Return the current debug state.""" return _debug_state.value def set_debug(value: bool) -> None: """Set the debug state.""" _debug_state.value = value # Backward compatible: DEBUG still works for reading initial state # New code should use is_debug() for dynamic checks DEBUG = bool(os.environ.get("DEBUG")) ================================================ FILE: pyprland/doc.py ================================================ """Documentation formatting for pypr doc command. Formats plugin and configuration documentation for terminal display with ANSI colors and structured output. Uses runtime schema data to ensure documentation is always accurate. """ from __future__ import annotations import sys from typing import TYPE_CHECKING, Any from .ansi import BOLD, CYAN, DIM, GREEN, colorize, should_colorize if TYPE_CHECKING: from .commands.models import CommandInfo from .plugins.interface import Plugin from .validation import ConfigField, ConfigItems __all__ = ["format_config_field_doc", "format_plugin_doc", "format_plugin_list"] def _c(text: str, *codes: str) -> str: """Colorize text if stdout is a TTY.""" if should_colorize(sys.stdout): return colorize(text, *codes) return text def _format_default(value: Any) -> str: """Format a default value for display.""" if isinstance(value, str): return f'"{value}"' if value else '""' if isinstance(value, bool): return "true" if value else "false" if isinstance(value, list): return "[]" if not value else str(value) if isinstance(value, dict): return "{}" if not value else str(value) return str(value) def _get_plugin_description(plugin: Plugin) -> str: """Get the first line of plugin's docstring as description.""" doc = getattr(plugin.__class__, "__doc__", "") or "" if doc: return doc.split("\n")[0].strip() return "" def format_plugin_list(plugins: dict[str, Plugin]) -> str: """Format list of all plugins with descriptions. Args: plugins: Dict mapping plugin name to Plugin instance Returns: Formatted string listing all plugins """ lines = [_c("AVAILABLE PLUGINS", BOLD), ""] # Sort by name, skip "pyprland" internal plugin for name in sorted(plugins.keys()): if name == "pyprland": continue plugin = plugins[name] desc = _get_plugin_description(plugin) # Get environments envs = getattr(plugin, "environments", []) env_str = f" [{', '.join(envs)}]" if envs else "" lines.append(f" {_c(name, CYAN)}{_c(env_str, DIM)}") if desc: lines.append(f" {desc}") lines.append("") lines.append("Use 'pypr doc ' for details.") return "\n".join(lines) def format_plugin_doc( plugin: Plugin, commands: list[CommandInfo], schema_override: ConfigItems | None = None, config_prefix: str = "", ) -> str: """Format full plugin documentation. Args: plugin: The plugin instance commands: List of CommandInfo for the plugin's commands schema_override: Optional schema to use instead of plugin's config_schema config_prefix: Prefix for config option names (e.g., "[name]." for scratchpads) Returns: Formatted string with full plugin documentation """ name = plugin.name.upper() lines = [_c(name, BOLD)] # Description from docstring desc = _get_plugin_description(plugin) if desc: lines.append(desc) # Environments envs = getattr(plugin, "environments", []) if envs: lines.append(f"\nEnvironments: {', '.join(envs)}") # Commands if commands: lines.append(f"\n{_c('COMMANDS', BOLD)}") for cmd in commands: args_str = " ".join(f"<{a.value}>" if a.required else f"[{a.value}]" for a in cmd.args) cmd_line = f" {_c(cmd.name, GREEN)}" if args_str: cmd_line += f" {args_str}" lines.append(cmd_line) if cmd.short_description: lines.append(f" {cmd.short_description}") # Configuration - use override if provided, otherwise get from plugin schema: ConfigItems | None = schema_override or getattr(plugin, "config_schema", None) if schema and len(schema) > 0: lines.append(f"\n{_c('CONFIGURATION', BOLD)}") if config_prefix: lines.append(f" (Options are per-item, prefix with {config_prefix})") lines.extend(_format_config_section(schema)) lines.append("") lines.append("Use 'pypr doc .