py-bg-changer 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- py_bg_changer-0.1.0/LICENSE +21 -0
- py_bg_changer-0.1.0/PKG-INFO +140 -0
- py_bg_changer-0.1.0/README.md +124 -0
- py_bg_changer-0.1.0/pyproject.toml +46 -0
- py_bg_changer-0.1.0/pyproject.toml.orig +39 -0
- py_bg_changer-0.1.0/src/py_bg_changer/__init__.py +6 -0
- py_bg_changer-0.1.0/src/py_bg_changer/__main__.py +3 -0
- py_bg_changer-0.1.0/src/py_bg_changer/cache.py +48 -0
- py_bg_changer-0.1.0/src/py_bg_changer/cli.py +400 -0
- py_bg_changer-0.1.0/src/py_bg_changer/config.py +117 -0
- py_bg_changer-0.1.0/src/py_bg_changer/gui.py +46 -0
- py_bg_changer-0.1.0/src/py_bg_changer/wallpaper.py +128 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mr MRF
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py-bg-changer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A simple cross-platform wallpaper changer made with Python
|
|
5
|
+
Keywords: wallpaper,background,desktop,cli,cross-platform
|
|
6
|
+
Author: Mr MRF Dev
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Project-URL: Homepage, https://github.com/Mr-MRF-Dev/Py-Bg-Changer
|
|
14
|
+
Project-URL: Issues, https://github.com/Mr-MRF-Dev/Py-Bg-Changer/issues
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# 🖼️ Py Bg Changer
|
|
18
|
+
|
|
19
|
+
Change your desktop wallpaper from the terminal, Win+R or any launcher.
|
|
20
|
+
Works on Windows, macOS and Linux. No dependencies.
|
|
21
|
+
|
|
22
|
+
The command is `py-bg-changer`, and `pbc` is the short alias for it.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
With [uv](https://docs.astral.sh/uv/) (recommended):
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uv tool install py-bg-changer
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Or try it without installing:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uvx --from py-bg-changer pbc --help
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Prefer pip? `pipx install py-bg-changer` or `pip install py-bg-changer` work too. Needs Python 3.9+.
|
|
39
|
+
|
|
40
|
+
## Use
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pbc add night ~/Pictures/night.jpg # save an image under a name
|
|
44
|
+
pbc add night img.jpg -a n -a dark # ...and give it extra names (aliases)
|
|
45
|
+
pbc alias night moon # add more aliases later
|
|
46
|
+
pbc rename night midnight # rename a wallpaper or an alias
|
|
47
|
+
pbc night # set it by name (or any alias: pbc n)
|
|
48
|
+
pbc ~/Pictures/any.png # or set any image directly
|
|
49
|
+
pbc list # see what you saved (* marks the default); `pbc ls` works too
|
|
50
|
+
pbc default night # choose what a plain "pbc" sets
|
|
51
|
+
pbc remove night # forget a name (or just an alias)
|
|
52
|
+
pbc # set the default wallpaper
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
If a saved name clashes with a command (say, one called `list`, `ls` or `default`), use `pbc set list`.
|
|
56
|
+
|
|
57
|
+
### Win+R and launchers (no console window)
|
|
58
|
+
|
|
59
|
+
Running `pbc` from Win+R opens a terminal that closes right away. Use `pbcw` (or
|
|
60
|
+
`py-bg-changer-w`) instead: it takes the same arguments, opens no window, and shows a
|
|
61
|
+
small message box only if something goes wrong.
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pbcw night # from Win+R: set it silently
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
It prints nothing on success, so use `pbc` in a terminal for `list`, `default` and
|
|
68
|
+
the like.
|
|
69
|
+
|
|
70
|
+
### Aliases
|
|
71
|
+
|
|
72
|
+
A wallpaper can have as many aliases as you like, and every alias works anywhere a name
|
|
73
|
+
does (`pbc n`, `pbc default n`, `pbc alias n moon`). Add them while saving with
|
|
74
|
+
`-a` (repeat it for more) or later with `pbc alias NAME ALIAS...`. `pbc list` shows
|
|
75
|
+
them in brackets after the name.
|
|
76
|
+
|
|
77
|
+
Removing an alias leaves the wallpaper alone; removing a wallpaper also removes its
|
|
78
|
+
aliases. If you replace the image saved under a name, its aliases follow the new image.
|
|
79
|
+
|
|
80
|
+
Rename either one with `pbc rename OLD NEW`. Renaming a wallpaper keeps its aliases, its
|
|
81
|
+
image and its place as the default.
|
|
82
|
+
|
|
83
|
+
### Default wallpaper
|
|
84
|
+
|
|
85
|
+
Run `pbc` with no arguments and it sets your default wallpaper. Pick the default with
|
|
86
|
+
`pbc default NAME`, or do it while saving an image with `pbc add NAME IMAGE --default`.
|
|
87
|
+
`pbc default` on its own shows the current choice.
|
|
88
|
+
|
|
89
|
+
### Your images are copied
|
|
90
|
+
|
|
91
|
+
When you save an image, `py-bg-changer` keeps its own copy next to the config file and
|
|
92
|
+
gives that copy to the system. If you later move or delete the original, the wallpaper
|
|
93
|
+
still works. The copy is removed when you remove the name (or replace its image), and
|
|
94
|
+
your original file is never touched.
|
|
95
|
+
|
|
96
|
+
## Supported desktops
|
|
97
|
+
|
|
98
|
+
| OS | Notes |
|
|
99
|
+
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
100
|
+
| Windows | Built in, nothing extra needed |
|
|
101
|
+
| macOS | Built in (uses `osascript`) |
|
|
102
|
+
| Linux | GNOME, Budgie, Cinnamon, MATE, KDE Plasma, XFCE and Sway work out of the box. Anything else (i3, bspwm, ...) falls back to `feh` or `nitrogen` if installed. |
|
|
103
|
+
|
|
104
|
+
## Config
|
|
105
|
+
|
|
106
|
+
Saved names live in a small JSON file, and the copied images in a `wallpapers` folder
|
|
107
|
+
next to it. Run `pbc path` to see where. Set `PBC_CONFIG` to use a different file.
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"wallpapers": {
|
|
112
|
+
"night": "/home/me/.config/py-bg-changer/wallpapers/3e23e8160039594a.png"
|
|
113
|
+
},
|
|
114
|
+
"aliases": { "n": "night", "dark": "night" },
|
|
115
|
+
"default": "night"
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Development
|
|
120
|
+
|
|
121
|
+
The project is managed with [uv](https://docs.astral.sh/uv/).
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
uv sync # create the virtualenv and install everything
|
|
125
|
+
uv run pytest # run the tests
|
|
126
|
+
uv run pbc --help # run the CLI from source
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Releasing
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
uv build # builds the sdist and wheel into dist/
|
|
133
|
+
uv publish # uploads them to PyPI
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Bump `version` in `pyproject.toml` before each release.
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# 🖼️ Py Bg Changer
|
|
2
|
+
|
|
3
|
+
Change your desktop wallpaper from the terminal, Win+R or any launcher.
|
|
4
|
+
Works on Windows, macOS and Linux. No dependencies.
|
|
5
|
+
|
|
6
|
+
The command is `py-bg-changer`, and `pbc` is the short alias for it.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
With [uv](https://docs.astral.sh/uv/) (recommended):
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
uv tool install py-bg-changer
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or try it without installing:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uvx --from py-bg-changer pbc --help
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Prefer pip? `pipx install py-bg-changer` or `pip install py-bg-changer` work too. Needs Python 3.9+.
|
|
23
|
+
|
|
24
|
+
## Use
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pbc add night ~/Pictures/night.jpg # save an image under a name
|
|
28
|
+
pbc add night img.jpg -a n -a dark # ...and give it extra names (aliases)
|
|
29
|
+
pbc alias night moon # add more aliases later
|
|
30
|
+
pbc rename night midnight # rename a wallpaper or an alias
|
|
31
|
+
pbc night # set it by name (or any alias: pbc n)
|
|
32
|
+
pbc ~/Pictures/any.png # or set any image directly
|
|
33
|
+
pbc list # see what you saved (* marks the default); `pbc ls` works too
|
|
34
|
+
pbc default night # choose what a plain "pbc" sets
|
|
35
|
+
pbc remove night # forget a name (or just an alias)
|
|
36
|
+
pbc # set the default wallpaper
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
If a saved name clashes with a command (say, one called `list`, `ls` or `default`), use `pbc set list`.
|
|
40
|
+
|
|
41
|
+
### Win+R and launchers (no console window)
|
|
42
|
+
|
|
43
|
+
Running `pbc` from Win+R opens a terminal that closes right away. Use `pbcw` (or
|
|
44
|
+
`py-bg-changer-w`) instead: it takes the same arguments, opens no window, and shows a
|
|
45
|
+
small message box only if something goes wrong.
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pbcw night # from Win+R: set it silently
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
It prints nothing on success, so use `pbc` in a terminal for `list`, `default` and
|
|
52
|
+
the like.
|
|
53
|
+
|
|
54
|
+
### Aliases
|
|
55
|
+
|
|
56
|
+
A wallpaper can have as many aliases as you like, and every alias works anywhere a name
|
|
57
|
+
does (`pbc n`, `pbc default n`, `pbc alias n moon`). Add them while saving with
|
|
58
|
+
`-a` (repeat it for more) or later with `pbc alias NAME ALIAS...`. `pbc list` shows
|
|
59
|
+
them in brackets after the name.
|
|
60
|
+
|
|
61
|
+
Removing an alias leaves the wallpaper alone; removing a wallpaper also removes its
|
|
62
|
+
aliases. If you replace the image saved under a name, its aliases follow the new image.
|
|
63
|
+
|
|
64
|
+
Rename either one with `pbc rename OLD NEW`. Renaming a wallpaper keeps its aliases, its
|
|
65
|
+
image and its place as the default.
|
|
66
|
+
|
|
67
|
+
### Default wallpaper
|
|
68
|
+
|
|
69
|
+
Run `pbc` with no arguments and it sets your default wallpaper. Pick the default with
|
|
70
|
+
`pbc default NAME`, or do it while saving an image with `pbc add NAME IMAGE --default`.
|
|
71
|
+
`pbc default` on its own shows the current choice.
|
|
72
|
+
|
|
73
|
+
### Your images are copied
|
|
74
|
+
|
|
75
|
+
When you save an image, `py-bg-changer` keeps its own copy next to the config file and
|
|
76
|
+
gives that copy to the system. If you later move or delete the original, the wallpaper
|
|
77
|
+
still works. The copy is removed when you remove the name (or replace its image), and
|
|
78
|
+
your original file is never touched.
|
|
79
|
+
|
|
80
|
+
## Supported desktops
|
|
81
|
+
|
|
82
|
+
| OS | Notes |
|
|
83
|
+
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
84
|
+
| Windows | Built in, nothing extra needed |
|
|
85
|
+
| macOS | Built in (uses `osascript`) |
|
|
86
|
+
| Linux | GNOME, Budgie, Cinnamon, MATE, KDE Plasma, XFCE and Sway work out of the box. Anything else (i3, bspwm, ...) falls back to `feh` or `nitrogen` if installed. |
|
|
87
|
+
|
|
88
|
+
## Config
|
|
89
|
+
|
|
90
|
+
Saved names live in a small JSON file, and the copied images in a `wallpapers` folder
|
|
91
|
+
next to it. Run `pbc path` to see where. Set `PBC_CONFIG` to use a different file.
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"wallpapers": {
|
|
96
|
+
"night": "/home/me/.config/py-bg-changer/wallpapers/3e23e8160039594a.png"
|
|
97
|
+
},
|
|
98
|
+
"aliases": { "n": "night", "dark": "night" },
|
|
99
|
+
"default": "night"
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
The project is managed with [uv](https://docs.astral.sh/uv/).
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
uv sync # create the virtualenv and install everything
|
|
109
|
+
uv run pytest # run the tests
|
|
110
|
+
uv run pbc --help # run the CLI from source
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Releasing
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
uv build # builds the sdist and wheel into dist/
|
|
117
|
+
uv publish # uploads them to PyPI
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Bump `version` in `pyproject.toml` before each release.
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "py-bg-changer"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A simple cross-platform wallpaper changer made with Python"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"wallpaper",
|
|
11
|
+
"background",
|
|
12
|
+
"desktop",
|
|
13
|
+
"cli",
|
|
14
|
+
"cross-platform",
|
|
15
|
+
]
|
|
16
|
+
dependencies = []
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Environment :: Console",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[[project.authors]]
|
|
24
|
+
name = "Mr MRF Dev"
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/Mr-MRF-Dev/Py-Bg-Changer"
|
|
28
|
+
Issues = "https://github.com/Mr-MRF-Dev/Py-Bg-Changer/issues"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
py-bg-changer = "py_bg_changer.cli:main"
|
|
32
|
+
pbc = "py_bg_changer.cli:main"
|
|
33
|
+
|
|
34
|
+
[project.gui-scripts]
|
|
35
|
+
py-bg-changer-w = "py_bg_changer.gui:main"
|
|
36
|
+
pbcw = "py_bg_changer.gui:main"
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
dev = ["pytest>=9.0.3"]
|
|
40
|
+
|
|
41
|
+
[build-system]
|
|
42
|
+
requires = ["uv_build>=0.13"]
|
|
43
|
+
build-backend = "uv_build"
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "py-bg-changer"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A simple cross-platform wallpaper changer made with Python"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "Mr MRF Dev" }]
|
|
10
|
+
keywords = ["wallpaper", "background", "desktop", "cli", "cross-platform"]
|
|
11
|
+
dependencies = []
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://github.com/Mr-MRF-Dev/Py-Bg-Changer"
|
|
20
|
+
Issues = "https://github.com/Mr-MRF-Dev/Py-Bg-Changer/issues"
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
py-bg-changer = "py_bg_changer.cli:main"
|
|
24
|
+
pbc = "py_bg_changer.cli:main"
|
|
25
|
+
|
|
26
|
+
# Same commands without a console window.
|
|
27
|
+
[project.gui-scripts]
|
|
28
|
+
py-bg-changer-w = "py_bg_changer.gui:main"
|
|
29
|
+
pbcw = "py_bg_changer.gui:main"
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = ["pytest>=9.0.3"]
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["uv_build>=0.13"]
|
|
36
|
+
build-backend = "uv_build"
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Our own copy of every saved wallpaper.
|
|
2
|
+
|
|
3
|
+
When an image is added we copy it next to the config file and hand that copy to
|
|
4
|
+
the operating system, so the wallpaper keeps working even if the original file
|
|
5
|
+
is moved or deleted.
|
|
6
|
+
|
|
7
|
+
Copies are named after a hash of their content: adding the same image under two
|
|
8
|
+
names stores it once, and the copy is deleted when no saved name uses it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import shutil
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import config
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def cache_dir() -> Path:
|
|
21
|
+
return config.config_path().expanduser().resolve().parent / "wallpapers"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def store(source: Path) -> Path:
|
|
25
|
+
"""Copy `source` into the cache (if it isn't there yet) and return the copy."""
|
|
26
|
+
digest = hashlib.sha256()
|
|
27
|
+
with source.open("rb") as f:
|
|
28
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
29
|
+
digest.update(chunk)
|
|
30
|
+
dest = cache_dir() / f"{digest.hexdigest()[:16]}{source.suffix.lower()}"
|
|
31
|
+
if not dest.exists():
|
|
32
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
tmp = dest.with_name(dest.name + ".tmp")
|
|
34
|
+
shutil.copyfile(source, tmp)
|
|
35
|
+
tmp.replace(dest)
|
|
36
|
+
return dest
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def release(path: str, wallpapers: dict[str, str]) -> None:
|
|
40
|
+
"""Delete a cached copy once no saved name points to it any more."""
|
|
41
|
+
if path in wallpapers.values():
|
|
42
|
+
return
|
|
43
|
+
try:
|
|
44
|
+
target = Path(path).expanduser().resolve()
|
|
45
|
+
if target.parent == cache_dir():
|
|
46
|
+
target.unlink()
|
|
47
|
+
except OSError:
|
|
48
|
+
pass
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Command line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import difflib
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import __version__, cache, config
|
|
11
|
+
from .wallpaper import WallpaperError, set_wallpaper
|
|
12
|
+
|
|
13
|
+
COMMANDS = {
|
|
14
|
+
"set",
|
|
15
|
+
"list",
|
|
16
|
+
"ls",
|
|
17
|
+
"add",
|
|
18
|
+
"alias",
|
|
19
|
+
"rename",
|
|
20
|
+
"remove",
|
|
21
|
+
"rm",
|
|
22
|
+
"default",
|
|
23
|
+
"path",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
28
|
+
parser = argparse.ArgumentParser(
|
|
29
|
+
prog="py-bg-changer",
|
|
30
|
+
description="Change your desktop wallpaper from the command line.",
|
|
31
|
+
epilog=(
|
|
32
|
+
"'pbc' is a short alias for 'py-bg-changer'.\n\n"
|
|
33
|
+
"examples:\n"
|
|
34
|
+
" pbc add night ~/Pictures/night.jpg save an image under a name\n"
|
|
35
|
+
" pbc add night img.jpg -a n -a dark ...with extra names (aliases)\n"
|
|
36
|
+
" pbc alias night moon add more aliases later\n"
|
|
37
|
+
" pbc rename night midnight rename a wallpaper or an alias\n"
|
|
38
|
+
" pbc night set it by name or alias\n"
|
|
39
|
+
" pbc ~/Pictures/any.png set any image directly\n"
|
|
40
|
+
" pbc default night choose what a plain 'pbc' sets\n"
|
|
41
|
+
" pbc set the default wallpaper"
|
|
42
|
+
),
|
|
43
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--version", action="version", version=f"%(prog)s {__version__}"
|
|
47
|
+
)
|
|
48
|
+
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
|
49
|
+
|
|
50
|
+
p = sub.add_parser("set", help="set the wallpaper (this is the default command)")
|
|
51
|
+
p.add_argument(
|
|
52
|
+
"target",
|
|
53
|
+
nargs="?",
|
|
54
|
+
help="a saved name, an alias or a path to an image (omit it to use the default)",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
sub.add_parser(
|
|
58
|
+
"list", aliases=["ls"], help="show saved wallpapers (* marks the default)"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
p = sub.add_parser("add", help="save an image under a name")
|
|
62
|
+
p.add_argument("name")
|
|
63
|
+
p.add_argument("path")
|
|
64
|
+
p.add_argument(
|
|
65
|
+
"-a",
|
|
66
|
+
"--alias",
|
|
67
|
+
action="append",
|
|
68
|
+
dest="aliases",
|
|
69
|
+
metavar="ALIAS",
|
|
70
|
+
help="another name for the same image (repeat it for more)",
|
|
71
|
+
)
|
|
72
|
+
p.add_argument(
|
|
73
|
+
"-d",
|
|
74
|
+
"--default",
|
|
75
|
+
action="store_true",
|
|
76
|
+
dest="make_default",
|
|
77
|
+
help="also make it the default wallpaper",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
p = sub.add_parser("alias", help="give a saved wallpaper extra names")
|
|
81
|
+
p.add_argument("name", help="a saved name (or one of its aliases)")
|
|
82
|
+
p.add_argument("aliases", nargs="+", metavar="ALIAS")
|
|
83
|
+
|
|
84
|
+
p = sub.add_parser("rename", help="rename a saved wallpaper or an alias")
|
|
85
|
+
p.add_argument("old", help="a saved name or an alias")
|
|
86
|
+
p.add_argument("new")
|
|
87
|
+
|
|
88
|
+
p = sub.add_parser("default", help="choose the wallpaper that a plain 'pbc' sets")
|
|
89
|
+
p.add_argument(
|
|
90
|
+
"name", nargs="?", help="a saved name or alias (omit it to show the default)"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
p = sub.add_parser("remove", aliases=["rm"], help="forget a saved name or alias")
|
|
94
|
+
p.add_argument("name")
|
|
95
|
+
|
|
96
|
+
sub.add_parser("path", help="print where the config file lives")
|
|
97
|
+
return parser
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _error(message: str) -> int:
|
|
101
|
+
print(message, file=sys.stderr)
|
|
102
|
+
return 1
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _dedupe(items: list[str]) -> list[str]:
|
|
106
|
+
return list(dict.fromkeys(items))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _resolve(
|
|
110
|
+
name: str, wallpapers: dict[str, str], aliases: dict[str, str]
|
|
111
|
+
) -> str | None:
|
|
112
|
+
"""The saved wallpaper a name or alias stands for, or None."""
|
|
113
|
+
if name in wallpapers:
|
|
114
|
+
return name
|
|
115
|
+
target = aliases.get(name)
|
|
116
|
+
return target if target in wallpapers else None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _aliases_of(name: str, aliases: dict[str, str]) -> list[str]:
|
|
120
|
+
return [alias for alias, target in aliases.items() if target == name]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _label(name: str, aliases: dict[str, str]) -> str:
|
|
124
|
+
extra = _aliases_of(name, aliases)
|
|
125
|
+
return f"{name} ({', '.join(extra)})" if extra else name
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _check_aliases(
|
|
129
|
+
owner: str,
|
|
130
|
+
requested: list[str],
|
|
131
|
+
wallpapers: dict[str, str],
|
|
132
|
+
aliases: dict[str, str],
|
|
133
|
+
) -> str | None:
|
|
134
|
+
"""An error message if one of the aliases can't be used for `owner`."""
|
|
135
|
+
for alias in requested:
|
|
136
|
+
if alias == owner:
|
|
137
|
+
return f"'{alias}' is already the name of this wallpaper."
|
|
138
|
+
if alias in wallpapers:
|
|
139
|
+
return f"'{alias}' is already a saved wallpaper name."
|
|
140
|
+
if alias in aliases and aliases[alias] != owner:
|
|
141
|
+
return f"'{alias}' is already an alias of '{aliases[alias]}'."
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _unknown(target: str, wallpapers: dict[str, str], aliases: dict[str, str]) -> int:
|
|
146
|
+
message = f"Unknown wallpaper: '{target}'"
|
|
147
|
+
close = difflib.get_close_matches(target, [*wallpapers, *aliases], n=1)
|
|
148
|
+
if close:
|
|
149
|
+
message += f"\nDid you mean '{close[0]}'?"
|
|
150
|
+
if wallpapers:
|
|
151
|
+
labels = ", ".join(_label(name, aliases) for name in wallpapers)
|
|
152
|
+
message += f"\nAvailable: {labels}"
|
|
153
|
+
else:
|
|
154
|
+
message += "\nNothing is saved yet. Save one with: pbc add NAME IMAGE"
|
|
155
|
+
return _error(message)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _default_name(wallpapers: dict[str, str], aliases: dict[str, str]) -> str | None:
|
|
159
|
+
chosen = config.get_default()
|
|
160
|
+
if chosen:
|
|
161
|
+
resolved = _resolve(chosen, wallpapers, aliases)
|
|
162
|
+
if resolved:
|
|
163
|
+
return resolved
|
|
164
|
+
# Configs from the original bg.py use a wallpaper literally named "default".
|
|
165
|
+
if "default" in wallpapers:
|
|
166
|
+
return "default"
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def cmd_set(target: str | None) -> int:
|
|
171
|
+
wallpapers = config.load()
|
|
172
|
+
aliases = config.load_aliases()
|
|
173
|
+
|
|
174
|
+
if target is None:
|
|
175
|
+
target = _default_name(wallpapers, aliases)
|
|
176
|
+
if target is None:
|
|
177
|
+
if not wallpapers:
|
|
178
|
+
return _error(
|
|
179
|
+
"Nothing is saved yet. Save one with: pbc add NAME IMAGE --default"
|
|
180
|
+
)
|
|
181
|
+
labels = ", ".join(_label(name, aliases) for name in wallpapers)
|
|
182
|
+
return _error(
|
|
183
|
+
"No default wallpaper is set. Choose one with: pbc default NAME\n"
|
|
184
|
+
f"Available: {labels}"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
saved = _resolve(target, wallpapers, aliases)
|
|
188
|
+
if saved is not None:
|
|
189
|
+
image = Path(wallpapers[saved]).expanduser()
|
|
190
|
+
label = f"'{saved}'"
|
|
191
|
+
elif Path(target).expanduser().is_file():
|
|
192
|
+
image = Path(target).expanduser()
|
|
193
|
+
label = str(image)
|
|
194
|
+
else:
|
|
195
|
+
return _unknown(target, wallpapers, aliases)
|
|
196
|
+
|
|
197
|
+
if not image.is_file():
|
|
198
|
+
message = f"File not found: {image}"
|
|
199
|
+
if saved is not None:
|
|
200
|
+
message += f"\nAdd it again with: pbc add {saved} IMAGE"
|
|
201
|
+
return _error(message)
|
|
202
|
+
|
|
203
|
+
set_wallpaper(image)
|
|
204
|
+
print(f"Wallpaper set to {label}")
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def cmd_list() -> int:
|
|
209
|
+
wallpapers = config.load()
|
|
210
|
+
aliases = config.load_aliases()
|
|
211
|
+
if not wallpapers:
|
|
212
|
+
print("Nothing saved yet. Save one with: pbc add NAME IMAGE")
|
|
213
|
+
return 0
|
|
214
|
+
default = _default_name(wallpapers, aliases)
|
|
215
|
+
labels = {name: _label(name, aliases) for name in wallpapers}
|
|
216
|
+
width = max(len(label) for label in labels.values())
|
|
217
|
+
for name, path in wallpapers.items():
|
|
218
|
+
mark = "*" if name == default else " "
|
|
219
|
+
note = "" if Path(path).expanduser().is_file() else " (missing)"
|
|
220
|
+
print(f"{mark} {labels[name]:<{width}} {path}{note}")
|
|
221
|
+
return 0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def cmd_add(
|
|
225
|
+
name: str, path: str, make_default: bool, alias_names: list[str] | None
|
|
226
|
+
) -> int:
|
|
227
|
+
source = Path(path).expanduser().resolve()
|
|
228
|
+
if not source.is_file():
|
|
229
|
+
return _error(f"File not found: {source}")
|
|
230
|
+
|
|
231
|
+
wallpapers = config.load()
|
|
232
|
+
aliases = config.load_aliases()
|
|
233
|
+
if name in aliases:
|
|
234
|
+
return _error(
|
|
235
|
+
f"'{name}' is already an alias of '{aliases[name]}'.\n"
|
|
236
|
+
f"Remove the alias first with: pbc remove {name}"
|
|
237
|
+
)
|
|
238
|
+
requested = _dedupe(alias_names or [])
|
|
239
|
+
problem = _check_aliases(name, requested, wallpapers, aliases)
|
|
240
|
+
if problem:
|
|
241
|
+
return _error(problem)
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
stored = cache.store(source)
|
|
245
|
+
except OSError as exc:
|
|
246
|
+
return _error(f"Could not copy {source}: {exc}")
|
|
247
|
+
|
|
248
|
+
previous = wallpapers.get(name)
|
|
249
|
+
wallpapers[name] = str(stored)
|
|
250
|
+
for alias in requested:
|
|
251
|
+
aliases[alias] = name
|
|
252
|
+
config.save(wallpapers, aliases)
|
|
253
|
+
if previous is not None:
|
|
254
|
+
cache.release(previous, wallpapers)
|
|
255
|
+
|
|
256
|
+
print(f"Saved '{name}' -> {stored}")
|
|
257
|
+
if requested:
|
|
258
|
+
print(f"Aliases: {', '.join(requested)}")
|
|
259
|
+
if make_default:
|
|
260
|
+
config.set_default(name)
|
|
261
|
+
print(f"'{name}' is now the default wallpaper")
|
|
262
|
+
return 0
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def cmd_alias(name: str, new_aliases: list[str]) -> int:
|
|
266
|
+
wallpapers = config.load()
|
|
267
|
+
aliases = config.load_aliases()
|
|
268
|
+
real = _resolve(name, wallpapers, aliases)
|
|
269
|
+
if real is None:
|
|
270
|
+
return _unknown(name, wallpapers, aliases)
|
|
271
|
+
|
|
272
|
+
requested = _dedupe(new_aliases)
|
|
273
|
+
problem = _check_aliases(real, requested, wallpapers, aliases)
|
|
274
|
+
if problem:
|
|
275
|
+
return _error(problem)
|
|
276
|
+
|
|
277
|
+
added = [alias for alias in requested if alias not in aliases]
|
|
278
|
+
if not added:
|
|
279
|
+
print(f"'{real}' already has those aliases")
|
|
280
|
+
return 0
|
|
281
|
+
for alias in added:
|
|
282
|
+
aliases[alias] = real
|
|
283
|
+
config.save(wallpapers, aliases)
|
|
284
|
+
print(f"'{real}' is now also called: {', '.join(added)}")
|
|
285
|
+
return 0
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def cmd_rename(old: str, new: str) -> int:
|
|
289
|
+
wallpapers = config.load()
|
|
290
|
+
aliases = config.load_aliases()
|
|
291
|
+
if old not in wallpapers and old not in aliases:
|
|
292
|
+
return _unknown(old, wallpapers, aliases)
|
|
293
|
+
if new == old:
|
|
294
|
+
print(f"'{old}' is already called that")
|
|
295
|
+
return 0
|
|
296
|
+
if new in wallpapers:
|
|
297
|
+
return _error(f"'{new}' is already a saved wallpaper name.")
|
|
298
|
+
if new in aliases:
|
|
299
|
+
return _error(f"'{new}' is already an alias of '{aliases[new]}'.")
|
|
300
|
+
|
|
301
|
+
# Rebuild the dicts so the renamed entry keeps its place in the list.
|
|
302
|
+
if old in wallpapers:
|
|
303
|
+
wallpapers = {
|
|
304
|
+
(new if name == old else name): path for name, path in wallpapers.items()
|
|
305
|
+
}
|
|
306
|
+
aliases = {
|
|
307
|
+
alias: (new if target == old else target)
|
|
308
|
+
for alias, target in aliases.items()
|
|
309
|
+
}
|
|
310
|
+
what = f"'{old}'"
|
|
311
|
+
else:
|
|
312
|
+
aliases = {
|
|
313
|
+
(new if alias == old else alias): target
|
|
314
|
+
for alias, target in aliases.items()
|
|
315
|
+
}
|
|
316
|
+
what = f"alias '{old}'"
|
|
317
|
+
config.save(wallpapers, aliases)
|
|
318
|
+
if config.get_default() == old:
|
|
319
|
+
config.set_default(new)
|
|
320
|
+
print(f"Renamed {what} to '{new}'")
|
|
321
|
+
return 0
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def cmd_default(name: str | None) -> int:
|
|
325
|
+
wallpapers = config.load()
|
|
326
|
+
aliases = config.load_aliases()
|
|
327
|
+
if name is None:
|
|
328
|
+
current = config.get_default()
|
|
329
|
+
resolved = _resolve(current, wallpapers, aliases) if current else None
|
|
330
|
+
if resolved:
|
|
331
|
+
print(resolved)
|
|
332
|
+
else:
|
|
333
|
+
print("No default set. Choose one with: pbc default NAME")
|
|
334
|
+
return 0
|
|
335
|
+
resolved = _resolve(name, wallpapers, aliases)
|
|
336
|
+
if resolved is None:
|
|
337
|
+
return _unknown(name, wallpapers, aliases)
|
|
338
|
+
config.set_default(resolved)
|
|
339
|
+
print(f"'{resolved}' is now the default wallpaper")
|
|
340
|
+
return 0
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def cmd_remove(name: str) -> int:
|
|
344
|
+
wallpapers = config.load()
|
|
345
|
+
aliases = config.load_aliases()
|
|
346
|
+
|
|
347
|
+
if name in wallpapers:
|
|
348
|
+
removed = wallpapers.pop(name)
|
|
349
|
+
gone = _aliases_of(name, aliases)
|
|
350
|
+
for alias in gone:
|
|
351
|
+
del aliases[alias]
|
|
352
|
+
config.save(wallpapers, aliases)
|
|
353
|
+
if config.get_default() == name:
|
|
354
|
+
config.set_default(None)
|
|
355
|
+
cache.release(removed, wallpapers)
|
|
356
|
+
message = f"Removed '{name}'"
|
|
357
|
+
if gone:
|
|
358
|
+
message += f" (and its aliases: {', '.join(gone)})"
|
|
359
|
+
print(message)
|
|
360
|
+
return 0
|
|
361
|
+
|
|
362
|
+
if name in aliases:
|
|
363
|
+
target = aliases.pop(name)
|
|
364
|
+
config.save(wallpapers, aliases)
|
|
365
|
+
print(f"Removed alias '{name}' (the wallpaper '{target}' is still saved)")
|
|
366
|
+
return 0
|
|
367
|
+
|
|
368
|
+
return _error(f"Unknown wallpaper: '{name}'")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def main(argv: list[str] | None = None) -> int:
|
|
372
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
373
|
+
|
|
374
|
+
# "pbc night" is shorthand for "pbc set night".
|
|
375
|
+
if not argv or (argv[0] not in COMMANDS and not argv[0].startswith("-")):
|
|
376
|
+
argv.insert(0, "set")
|
|
377
|
+
|
|
378
|
+
args = build_parser().parse_args(argv)
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
if args.command == "set":
|
|
382
|
+
return cmd_set(args.target)
|
|
383
|
+
if args.command in ("list", "ls"):
|
|
384
|
+
return cmd_list()
|
|
385
|
+
if args.command == "add":
|
|
386
|
+
return cmd_add(args.name, args.path, args.make_default, args.aliases)
|
|
387
|
+
if args.command == "alias":
|
|
388
|
+
return cmd_alias(args.name, args.aliases)
|
|
389
|
+
if args.command == "rename":
|
|
390
|
+
return cmd_rename(args.old, args.new)
|
|
391
|
+
if args.command == "default":
|
|
392
|
+
return cmd_default(args.name)
|
|
393
|
+
if args.command in ("remove", "rm"):
|
|
394
|
+
return cmd_remove(args.name)
|
|
395
|
+
if args.command == "path":
|
|
396
|
+
print(config.config_path())
|
|
397
|
+
return 0
|
|
398
|
+
except (config.ConfigError, WallpaperError) as exc:
|
|
399
|
+
return _error(str(exc))
|
|
400
|
+
return 0
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Reading and writing the saved wallpapers and the chosen default.
|
|
2
|
+
|
|
3
|
+
The file format is the same as the original bg.py, plus an optional default and
|
|
4
|
+
optional aliases (extra names for a saved wallpaper):
|
|
5
|
+
|
|
6
|
+
{
|
|
7
|
+
"wallpapers": {"night": "/path/to/image.jpg"},
|
|
8
|
+
"aliases": {"n": "night", "dark": "night"},
|
|
9
|
+
"default": "night"
|
|
10
|
+
}
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
APP_NAME = "py-bg-changer"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ConfigError(Exception):
|
|
24
|
+
"""The config file exists but cannot be used."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def config_dir() -> Path:
|
|
28
|
+
system = platform.system()
|
|
29
|
+
if system == "Windows":
|
|
30
|
+
base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming")
|
|
31
|
+
elif system == "Darwin":
|
|
32
|
+
base = Path.home() / "Library" / "Application Support"
|
|
33
|
+
else:
|
|
34
|
+
base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
|
|
35
|
+
return base / APP_NAME
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def config_path() -> Path:
|
|
39
|
+
"""Default location, or whatever PBC_CONFIG points to."""
|
|
40
|
+
override = os.environ.get("PBC_CONFIG")
|
|
41
|
+
if override:
|
|
42
|
+
return Path(override).expanduser()
|
|
43
|
+
return config_dir() / "config.json"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _read_raw() -> dict:
|
|
47
|
+
path = config_path()
|
|
48
|
+
if not path.exists():
|
|
49
|
+
return {}
|
|
50
|
+
try:
|
|
51
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
52
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
53
|
+
raise ConfigError(f"Could not read {path}: {exc}") from exc
|
|
54
|
+
if not isinstance(data, dict):
|
|
55
|
+
raise ConfigError(f"{path} has an unexpected format (expected a JSON object).")
|
|
56
|
+
return data
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _write(data: dict) -> None:
|
|
60
|
+
path = config_path()
|
|
61
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
63
|
+
tmp.write_text(
|
|
64
|
+
json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
|
65
|
+
)
|
|
66
|
+
tmp.replace(path)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load() -> dict[str, str]:
|
|
70
|
+
"""Return the saved wallpapers as {name: path}. Empty if nothing is saved yet."""
|
|
71
|
+
wallpapers = _read_raw().get("wallpapers", {})
|
|
72
|
+
if not isinstance(wallpapers, dict):
|
|
73
|
+
raise ConfigError(
|
|
74
|
+
f"{config_path()}: 'wallpapers' must be an object of name -> path."
|
|
75
|
+
)
|
|
76
|
+
return {str(name): str(path) for name, path in wallpapers.items()}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_aliases() -> dict[str, str]:
|
|
80
|
+
"""Return the aliases as {alias: wallpaper name}. Empty if there are none."""
|
|
81
|
+
aliases = _read_raw().get("aliases", {})
|
|
82
|
+
if not isinstance(aliases, dict):
|
|
83
|
+
raise ConfigError(
|
|
84
|
+
f"{config_path()}: 'aliases' must be an object of alias -> name."
|
|
85
|
+
)
|
|
86
|
+
return {str(alias): str(name) for alias, name in aliases.items()}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def save(wallpapers: dict[str, str], aliases: dict[str, str] | None = None) -> None:
|
|
90
|
+
"""Write the wallpapers back, keeping any other keys already in the file.
|
|
91
|
+
|
|
92
|
+
The aliases are only touched when they are passed in.
|
|
93
|
+
"""
|
|
94
|
+
data = _read_raw()
|
|
95
|
+
data["wallpapers"] = wallpapers
|
|
96
|
+
if aliases is not None:
|
|
97
|
+
if aliases:
|
|
98
|
+
data["aliases"] = aliases
|
|
99
|
+
else:
|
|
100
|
+
data.pop("aliases", None)
|
|
101
|
+
_write(data)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def get_default() -> str | None:
|
|
105
|
+
"""Name of the wallpaper that a plain `pbc` sets, if one was chosen."""
|
|
106
|
+
value = _read_raw().get("default")
|
|
107
|
+
return value if isinstance(value, str) and value else None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def set_default(name: str | None) -> None:
|
|
111
|
+
"""Choose the default wallpaper, or pass None to clear the choice."""
|
|
112
|
+
data = _read_raw()
|
|
113
|
+
if name is None:
|
|
114
|
+
data.pop("default", None)
|
|
115
|
+
else:
|
|
116
|
+
data["default"] = name
|
|
117
|
+
_write(data)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Entry point without a console window, for Win+R and other launchers.
|
|
2
|
+
|
|
3
|
+
It runs the same commands as `pbc`, but stays silent when everything works and
|
|
4
|
+
shows a small message box when something goes wrong (there is no console to
|
|
5
|
+
print errors to).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import io
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from .cli import main as cli_main
|
|
15
|
+
|
|
16
|
+
TITLE = "py-bg-changer"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _show_error(message: str) -> None:
|
|
20
|
+
if sys.platform == "win32":
|
|
21
|
+
import ctypes
|
|
22
|
+
|
|
23
|
+
MB_ICONERROR = 0x10
|
|
24
|
+
MB_TOPMOST = 0x40000
|
|
25
|
+
ctypes.windll.user32.MessageBoxW(
|
|
26
|
+
None, message, TITLE, MB_ICONERROR | MB_TOPMOST
|
|
27
|
+
)
|
|
28
|
+
else:
|
|
29
|
+
print(message, file=sys.stderr)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(argv: list[str] | None = None) -> int:
|
|
33
|
+
out, err = io.StringIO(), io.StringIO()
|
|
34
|
+
code = 0
|
|
35
|
+
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
|
36
|
+
try:
|
|
37
|
+
code = cli_main(argv)
|
|
38
|
+
except SystemExit as exc: # argparse exits on bad arguments, --help, --version
|
|
39
|
+
code = exc.code if isinstance(exc.code, int) else int(exc.code is not None)
|
|
40
|
+
except Exception as exc: # never let a traceback vanish into a missing console
|
|
41
|
+
code = 1
|
|
42
|
+
err.write(f"Unexpected error: {exc}")
|
|
43
|
+
if code != 0:
|
|
44
|
+
message = err.getvalue().strip() or out.getvalue().strip()
|
|
45
|
+
_show_error(message or f"Something went wrong (exit code {code}).")
|
|
46
|
+
return code
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Set the desktop wallpaper on Windows, macOS and Linux."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WallpaperError(Exception):
|
|
13
|
+
"""The wallpaper could not be applied."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def set_wallpaper(path: Path) -> None:
|
|
17
|
+
path = Path(path).expanduser().resolve()
|
|
18
|
+
system = platform.system()
|
|
19
|
+
if system == "Windows":
|
|
20
|
+
_windows(path)
|
|
21
|
+
elif system == "Darwin":
|
|
22
|
+
_macos(path)
|
|
23
|
+
elif system == "Linux":
|
|
24
|
+
_linux(path)
|
|
25
|
+
else:
|
|
26
|
+
raise WallpaperError(f"Unsupported operating system: {system}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _run(cmd: list[str], *, check: bool = True) -> subprocess.CompletedProcess:
|
|
30
|
+
if shutil.which(cmd[0]) is None:
|
|
31
|
+
raise WallpaperError(f"'{cmd[0]}' was not found on this system.")
|
|
32
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
33
|
+
if check and result.returncode != 0:
|
|
34
|
+
detail = result.stderr.strip() or f"exit code {result.returncode}"
|
|
35
|
+
raise WallpaperError(f"{cmd[0]} failed: {detail}")
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# --- Windows ---------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _windows(path: Path) -> None:
|
|
43
|
+
import ctypes
|
|
44
|
+
|
|
45
|
+
SPI_SETDESKWALLPAPER = 0x0014
|
|
46
|
+
SPIF_UPDATEINIFILE = 0x01
|
|
47
|
+
SPIF_SENDCHANGE = 0x02
|
|
48
|
+
ok = ctypes.windll.user32.SystemParametersInfoW(
|
|
49
|
+
SPI_SETDESKWALLPAPER, 0, str(path), SPIF_UPDATEINIFILE | SPIF_SENDCHANGE
|
|
50
|
+
)
|
|
51
|
+
if not ok:
|
|
52
|
+
raise WallpaperError("Windows refused to change the wallpaper.")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --- macOS -----------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _macos(path: Path) -> None:
|
|
59
|
+
escaped = str(path).replace("\\", "\\\\").replace('"', '\\"')
|
|
60
|
+
script = (
|
|
61
|
+
'tell application "System Events" to tell every desktop '
|
|
62
|
+
f'to set picture to "{escaped}"'
|
|
63
|
+
)
|
|
64
|
+
_run(["osascript", "-e", script])
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --- Linux -----------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _linux(path: Path) -> None:
|
|
71
|
+
desktop = ":".join(
|
|
72
|
+
filter(
|
|
73
|
+
None,
|
|
74
|
+
[os.environ.get("XDG_CURRENT_DESKTOP"), os.environ.get("DESKTOP_SESSION")],
|
|
75
|
+
)
|
|
76
|
+
).lower()
|
|
77
|
+
uri = path.as_uri()
|
|
78
|
+
|
|
79
|
+
if "cinnamon" in desktop:
|
|
80
|
+
_run(
|
|
81
|
+
["gsettings", "set", "org.cinnamon.desktop.background", "picture-uri", uri]
|
|
82
|
+
)
|
|
83
|
+
elif "mate" in desktop:
|
|
84
|
+
_run(["gsettings", "set", "org.mate.background", "picture-filename", str(path)])
|
|
85
|
+
elif any(name in desktop for name in ("gnome", "budgie", "unity", "pantheon")):
|
|
86
|
+
_run(["gsettings", "set", "org.gnome.desktop.background", "picture-uri", uri])
|
|
87
|
+
# Newer GNOME has a separate key for dark mode; older versions don't.
|
|
88
|
+
_run(
|
|
89
|
+
[
|
|
90
|
+
"gsettings",
|
|
91
|
+
"set",
|
|
92
|
+
"org.gnome.desktop.background",
|
|
93
|
+
"picture-uri-dark",
|
|
94
|
+
uri,
|
|
95
|
+
],
|
|
96
|
+
check=False,
|
|
97
|
+
)
|
|
98
|
+
elif "kde" in desktop or "plasma" in desktop:
|
|
99
|
+
_run(["plasma-apply-wallpaperimage", str(path)])
|
|
100
|
+
elif "xfce" in desktop:
|
|
101
|
+
_xfce(path)
|
|
102
|
+
elif "sway" in desktop:
|
|
103
|
+
_run(["swaymsg", "output", "*", "bg", str(path), "fill"])
|
|
104
|
+
else:
|
|
105
|
+
_linux_fallback(path)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _xfce(path: Path) -> None:
|
|
109
|
+
props = _run(["xfconf-query", "-c", "xfce4-desktop", "-l"]).stdout.splitlines()
|
|
110
|
+
targets = [p for p in props if p.endswith("/last-image")]
|
|
111
|
+
if not targets:
|
|
112
|
+
raise WallpaperError("Could not find the XFCE wallpaper settings.")
|
|
113
|
+
for prop in targets:
|
|
114
|
+
_run(["xfconf-query", "-c", "xfce4-desktop", "-p", prop, "-s", str(path)])
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _linux_fallback(path: Path) -> None:
|
|
118
|
+
for cmd in (
|
|
119
|
+
["feh", "--bg-fill", str(path)],
|
|
120
|
+
["nitrogen", "--set-zoom-fill", "--save", str(path)],
|
|
121
|
+
):
|
|
122
|
+
if shutil.which(cmd[0]):
|
|
123
|
+
_run(cmd)
|
|
124
|
+
return
|
|
125
|
+
raise WallpaperError(
|
|
126
|
+
"Could not detect your desktop environment. "
|
|
127
|
+
"Install 'feh' (or 'nitrogen') and try again."
|
|
128
|
+
)
|