mirror of
https://github.com/caelestia-dots/cli.git
synced 2026-06-19 15:30:00 -05:00
Compare commits
143 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 702baa117b | |||
| 2e9a387951 | |||
| cfc62f683e | |||
| 096e583618 | |||
| 8535338e6f | |||
| 6c3b69cb84 | |||
| f53b3d036f | |||
| 7ff0913826 | |||
| 32a88d4d62 | |||
| 3d51f46b21 | |||
| 91e55a322f | |||
| 844f6d22b3 | |||
| c09cd1a609 | |||
| 0410fed68c | |||
| d83a85745d | |||
| 68a758a53b | |||
| 338c78f789 | |||
| be13e5897b | |||
| 4824483bba | |||
| a0aa37bb9b | |||
| 710cba39c3 | |||
| c8e18ef6ed | |||
| 222023f6d5 | |||
| 7def47d120 | |||
| 5e2335feb9 | |||
| be61b8b072 | |||
| 0980986ed4 | |||
| 63a6e5a6f2 | |||
| ecf6c2723d | |||
| 342dfc71e1 | |||
| 51e858b73f | |||
| 7d9b685918 | |||
| 1f5b39281c | |||
| a8f0dc3271 | |||
| e02fc7427d | |||
| 56f2e94d5b | |||
| d1ed5d9db1 | |||
| 73bc3aadab | |||
| d55647fd03 | |||
| 1fc51410fc | |||
| a8d67b44ee | |||
| c93fa1488e | |||
| 024df497d1 | |||
| 994f2d86f5 | |||
| efd59b79d9 | |||
| e6031ad544 | |||
| 5c062e6897 | |||
| f85103eac5 | |||
| aef48072ec | |||
| 216547c9c1 | |||
| 8627b7b96f | |||
| 44df61b22d | |||
| 36a6029a2c | |||
| 4090c4fc91 | |||
| 393dbf6363 | |||
| 586f4d9665 | |||
| 14732e9850 | |||
| 1c707d3a16 | |||
| c236823b76 | |||
| 002a9c287f | |||
| d7b65b5946 | |||
| c860b389c3 | |||
| 3f3229aed4 | |||
| b790c32715 | |||
| 6ddbb4f1c3 | |||
| 05b7714289 | |||
| 84790f8fc3 | |||
| 505a02f5ab | |||
| d1c8c8fc09 | |||
| ad533a0dd4 | |||
| ccd2712982 | |||
| 1ea661859d | |||
| 64a5507e74 | |||
| 7fa3fc1bd0 | |||
| 7f30062670 | |||
| 04d286eaff | |||
| 2ce6213698 | |||
| 4b3ffcd644 | |||
| 2621724c55 | |||
| 7b8a4281aa | |||
| 7452974dc9 | |||
| 544b567668 | |||
| 1f523c7556 | |||
| a00e71d6b7 | |||
| 1ec969d9ec | |||
| 5273ed514f | |||
| f3b13affc3 | |||
| 5c9ce66c03 | |||
| c18f749f24 | |||
| 96fcdf5bce | |||
| eddee4deca | |||
| 68bc03bc17 | |||
| 023a30b83c | |||
| a192efae9c | |||
| 463f36544a | |||
| 5f1d008cce | |||
| 66bce26841 | |||
| e1531f3c9e | |||
| 7f59ca9656 | |||
| 4cf9e8603f | |||
| b4b26ab5d5 | |||
| b4758901f9 | |||
| 60284ca41e | |||
| a019c42244 | |||
| 950c40bbd4 | |||
| d054129db8 | |||
| e497ca87eb | |||
| e81b1b87f0 | |||
| 1b823554ad | |||
| 4538e9cb50 | |||
| e5c161d43a | |||
| 11bdbc9e80 | |||
| 5bb3276b14 | |||
| 3930ca09b9 | |||
| 305f02d8aa | |||
| 8c7eea556d | |||
| 6bb09e7703 | |||
| b023936e1d | |||
| 3cdc2ce464 | |||
| 534d11d66a | |||
| 0d8be30138 | |||
| 8c6d933267 | |||
| b00c601d0a | |||
| c930bd2604 | |||
| cc155cf432 | |||
| 6e59149fbf | |||
| 8d2b737f15 | |||
| 4bcd42f482 | |||
| 51cecd481c | |||
| c9312f3928 | |||
| bfaf4fc373 | |||
| 6e711ec289 | |||
| 7899f8348f | |||
| b0d68f0a1c | |||
| b0325a1898 | |||
| a6defd2921 | |||
| 0b9e416175 | |||
| 8ce97ea3f5 | |||
| 25c473c18e | |||
| c22916fe45 | |||
| 011989e3ca | |||
| d88cc7ff79 | |||
| a550eb79ed |
@@ -18,32 +18,6 @@ The main control script for the Caelestia dotfiles.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary id="optional-dependencies">Optional dependencies</summary>
|
||||
|
||||
- [`papirus-folders`](https://github.com/PapirusDevelopmentTeam/papirus-folders) - automatic folder icon color syncing with theme
|
||||
|
||||
> [!NOTE]
|
||||
> For automatic Papirus folder icon color syncing, `papirus-folders` needs to be able to run with `sudo` without a password prompt.
|
||||
>
|
||||
> **Recommended** - Create a sudoers file:
|
||||
> ```fish
|
||||
> # Fish shell
|
||||
> echo "$USER ALL=(ALL) NOPASSWD: "(which papirus-folders) | sudo tee /etc/sudoers.d/papirus-folders
|
||||
> sudo chmod 440 /etc/sudoers.d/papirus-folders
|
||||
> ```
|
||||
> ```sh
|
||||
> # Bash/other shells
|
||||
> echo "$USER ALL=(ALL) NOPASSWD: $(which papirus-folders)" | sudo tee /etc/sudoers.d/papirus-folders
|
||||
> sudo chmod 440 /etc/sudoers.d/papirus-folders
|
||||
> ```
|
||||
>
|
||||
> **Alternatively** - Edit the main sudoers file by running `sudo visudo` and adding at the end:
|
||||
> ```
|
||||
> your_username ALL=(ALL) NOPASSWD: /usr/bin/papirus-folders
|
||||
> ```
|
||||
|
||||
</details>
|
||||
|
||||
## Installation
|
||||
|
||||
### Arch linux
|
||||
@@ -119,6 +93,45 @@ sudo python -m installer dist/*.whl
|
||||
sudo cp completions/caelestia.fish /usr/share/fish/vendor_completions.d/caelestia.fish
|
||||
```
|
||||
|
||||
### Additional steps
|
||||
|
||||
#### Auto folder colour theming
|
||||
|
||||
For automatic Papirus folder icon colour syncing, you must have [`papirus-folders`](https://github.com/PapirusDevelopmentTeam/papirus-folders)
|
||||
installed, and `papirus-folders` must to be able to run with `sudo` without a password prompt.
|
||||
|
||||
You can allow this by creating a sudoers file:
|
||||
|
||||
```sh
|
||||
echo "$USER ALL=(ALL) NOPASSWD: $(which papirus-folders)" | sudo tee /etc/sudoers.d/papirus-folders
|
||||
sudo chmod 440 /etc/sudoers.d/papirus-folders
|
||||
```
|
||||
|
||||
#### Chromium-based browser theming
|
||||
|
||||
For live Chromium-based browser theming, the CLI must be allowed to create certain directories in `/etc`
|
||||
and write to them via `sudo` without a password prompt.
|
||||
|
||||
You can allow this by creating a sudoers file:
|
||||
|
||||
```fish
|
||||
# Fish shell
|
||||
for dir in /etc/chromium/policies/managed /etc/brave/policies/managed /etc/opt/chrome/policies/managed
|
||||
echo "$USER ALL=(ALL) NOPASSWD: $(which mkdir) -p $dir" | sudo tee -a /etc/sudoers.d/caelestia-chromium
|
||||
echo "$USER ALL=(ALL) NOPASSWD: $(which tee) $dir/caelestia.json" | sudo tee -a /etc/sudoers.d/caelestia-chromium
|
||||
end
|
||||
sudo chmod 440 /etc/sudoers.d/caelestia-chromium
|
||||
```
|
||||
|
||||
```sh
|
||||
# Bash/other shells
|
||||
for dir in /etc/chromium/policies/managed /etc/brave/policies/managed /etc/opt/chrome/policies/managed; do
|
||||
echo "$USER ALL=(ALL) NOPASSWD: $(which mkdir) -p $dir" | sudo tee -a /etc/sudoers.d/caelestia-chromium
|
||||
echo "$USER ALL=(ALL) NOPASSWD: $(which tee) $dir/caelestia.json" | sudo tee -a /etc/sudoers.d/caelestia-chromium
|
||||
done
|
||||
sudo chmod 440 /etc/sudoers.d/caelestia-chromium
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
All subcommands/options can be explored via the help flag.
|
||||
@@ -148,6 +161,24 @@ subcommands:
|
||||
resizer window resizer daemon
|
||||
```
|
||||
|
||||
### User templates
|
||||
|
||||
Custom user templates can be defined in `~/.config/caelestia/templates/`.
|
||||
|
||||
#### Template syntax
|
||||
|
||||
`{{ <color>.<format> }}`
|
||||
|
||||
- `<color>` is a theme color role derived from the Material You color system (e.g. `primary`, `secondary`, `background`)
|
||||
- `<format>` is the output format: `hex` or `rgb`
|
||||
|
||||
#### Examples
|
||||
|
||||
- `{{ primary.hex }}` outputs `3f4ba2`
|
||||
- `{{ primary.rgb }}` outputs `rgb(193, 132, 207)`
|
||||
|
||||
Output files are written to `~/.local/state/caelestia/theme/`. You can symlink them to your desired locations.
|
||||
|
||||
## Configuring
|
||||
|
||||
All configuration options are in `~/.config/caelestia/cli.json`.
|
||||
@@ -160,17 +191,28 @@ All configuration options are in `~/.config/caelestia/cli.json`.
|
||||
"extraArgs": []
|
||||
},
|
||||
"wallpaper": {
|
||||
"postHook": "echo $WALLPAPER_PATH"
|
||||
"postHook": "echo $WALLPAPER_PATH $SCHEME_NAME $SCHEME_FLAVOUR $SCHEME_MODE $SCHEME_VARIANT $SCHEME_COLOURS"
|
||||
},
|
||||
"theme": {
|
||||
"enableTerm": true,
|
||||
"enableHypr": true,
|
||||
"enableDiscord": true,
|
||||
"enableSpicetify": true,
|
||||
"enablePandora": true,
|
||||
"enableFuzzel": true,
|
||||
"enableBtop": true,
|
||||
"enableNvtop": true,
|
||||
"enableHtop": true,
|
||||
"enableGtk": true,
|
||||
"enableQt": true
|
||||
"enableQt": true,
|
||||
"enableWarp": true,
|
||||
"enableChromium": true,
|
||||
"enableZed": true,
|
||||
"enableCava": true,
|
||||
"iconTheme": "Papirus-Dark",
|
||||
"iconThemeLight": "Papirus-Light",
|
||||
"iconThemeDark": "Papirus-Dark",
|
||||
"postHook": "echo $SCHEME_NAME $SCHEME_FLAVOUR $SCHEME_MODE $SCHEME_VARIANT $SCHEME_COLOURS"
|
||||
},
|
||||
"toggles": {
|
||||
"communication": {
|
||||
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
export HOME=/tmp/install-test
|
||||
export XDG_CONFIG_HOME=$HOME/.config
|
||||
export XDG_DATA_HOME=$HOME/.local/share
|
||||
export XDG_STATE_HOME=$HOME/.local/state
|
||||
export XDG_CACHE_HOME=$HOME/.cache
|
||||
|
||||
"$@"
|
||||
@@ -1,7 +1,7 @@
|
||||
set -l seen '__fish_seen_subcommand_from'
|
||||
set -l has_opt '__fish_contains_opt'
|
||||
|
||||
set -l commands shell toggle scheme screenshot record clipboard emoji-picker wallpaper resizer
|
||||
set -l commands shell toggle scheme screenshot record clipboard emoji-picker wallpaper resizer install update
|
||||
set -l not_seen "not $seen $commands"
|
||||
|
||||
# Disable file completions
|
||||
@@ -20,6 +20,8 @@ complete -c caelestia -n $not_seen -a 'clipboard' -d 'Open clipboard history'
|
||||
complete -c caelestia -n $not_seen -a 'emoji' -d 'Emoji/glyph utilities'
|
||||
complete -c caelestia -n $not_seen -a 'wallpaper' -d 'Manage the wallpaper'
|
||||
complete -c caelestia -n $not_seen -a 'resizer' -d 'Window resizer'
|
||||
complete -c caelestia -n $not_seen -a 'install' -d 'Install the Caelestia dotfiles'
|
||||
complete -c caelestia -n $not_seen -a 'update' -d 'Update the Caelestia dotfiles'
|
||||
|
||||
# Shell
|
||||
set -l commands mpris drawers wallpaper notifs
|
||||
@@ -126,3 +128,13 @@ complete -c caelestia -n "$seen emoji" -s 'f' -l 'fetch' -d 'Fetch emoji/glyph d
|
||||
complete -c caelestia -n "$seen resizer" -s 'd' -l 'daemon' -d 'Start in daemon mode'
|
||||
complete -c caelestia -n "$seen resizer" -a 'pip' -d 'Quick pip mode'
|
||||
complete -c caelestia -n "$seen resizer" -a 'active' -d 'Select the active window'
|
||||
|
||||
# Install (component flags come from the manifest, so are not completed statically)
|
||||
complete -c caelestia -n "$seen install" -l 'aur-helper' -d 'The AUR helper to use' -a 'yay paru' -r
|
||||
complete -c caelestia -n "$seen install" -l 'enable-components' -d 'List of components to enable' -r
|
||||
complete -c caelestia -n "$seen install" -l 'disable-components' -d 'List of components to disable' -r
|
||||
complete -c caelestia -n "$seen install" -l 'noconfirm' -d 'Use defaults for all prompts'
|
||||
|
||||
# Update
|
||||
complete -c caelestia -n "$seen update" -l 'aur-helper' -d 'The AUR helper to use' -a 'yay paru' -r
|
||||
complete -c caelestia -n "$seen update" -l 'noconfirm' -d 'Use defaults for all prompts'
|
||||
|
||||
+1
-1
@@ -72,7 +72,7 @@ python3.pkgs.buildPythonApplication {
|
||||
--replace-fail 'app2unit' ${app2unit}/bin/app2unit
|
||||
|
||||
# Use config style instead of darkly
|
||||
substituteInPlace src/caelestia/data/templates/qtct.conf \
|
||||
substituteInPlace src/caelestia/data/templates/qtengine.json \
|
||||
--replace-fail 'Darkly' '${qtctStyle}'
|
||||
'';
|
||||
|
||||
|
||||
Generated
+28
-10
@@ -3,17 +3,18 @@
|
||||
"caelestia-shell": {
|
||||
"inputs": {
|
||||
"caelestia-cli": [],
|
||||
"m3shapes": "m3shapes",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"quickshell": "quickshell"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1770949235,
|
||||
"narHash": "sha256-OFeud9FjaOk6xHp/9igYl/+Zw6FJDyZNrIDNi47gsG0=",
|
||||
"lastModified": 1781682211,
|
||||
"narHash": "sha256-ssOMe5pJaZo3LpHFKpKVs6QlTM5P90pScdSFyB/Oyr8=",
|
||||
"owner": "caelestia-dots",
|
||||
"repo": "shell",
|
||||
"rev": "93e8880842b03e251bf59d1ba316f2393c68574f",
|
||||
"rev": "f4ec2283a53d0ae6b02938e9a42422195099eda4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -22,13 +23,30 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"m3shapes": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1781017666,
|
||||
"narHash": "sha256-kfHyzZaPHgqZML48OA+5JwBOsLdQJ2ci/aGPShvUB4Y=",
|
||||
"owner": "soramanew",
|
||||
"repo": "m3shapes",
|
||||
"rev": "bdc327b29f95394a732baf3c9b19658ba23755b6",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "soramanew",
|
||||
"repo": "m3shapes",
|
||||
"rev": "bdc327b29f95394a732baf3c9b19658ba23755b6",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1770841267,
|
||||
"narHash": "sha256-9xejG0KoqsoKEGp2kVbXRlEYtFFcDTHjidiuX8hGO44=",
|
||||
"lastModified": 1781577229,
|
||||
"narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "ec7c70d12ce2fc37cb92aff673dcdca89d187bae",
|
||||
"rev": "567a49d1913ce81ac6e9582e3553dd90a955875f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -46,11 +64,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769593411,
|
||||
"narHash": "sha256-WW00FaBiUmQyxvSbefvgxIjwf/WmRrEGBbwMHvW/7uQ=",
|
||||
"lastModified": 1781053488,
|
||||
"narHash": "sha256-P4WEBaKgl8flRckHxXGHzT0potPvB3x8ZFIp9gLEAMY=",
|
||||
"ref": "refs/heads/master",
|
||||
"rev": "1e4d804e7f3fa7465811030e8da2bf10d544426a",
|
||||
"revCount": 732,
|
||||
"rev": "d99d87d5e5ec4e696815348692fdaaf0b6be1b2c",
|
||||
"revCount": 822,
|
||||
"type": "git",
|
||||
"url": "https://git.outfoxxed.me/outfoxxed/quickshell"
|
||||
},
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from caelestia.parser import parse_args
|
||||
from caelestia.utils.io import log
|
||||
from caelestia.utils.version import print_version
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
parser, args = parse_args()
|
||||
if args.version:
|
||||
print_version()
|
||||
@@ -10,3 +12,5 @@ def main() -> None:
|
||||
args.cls(args).run()
|
||||
else:
|
||||
parser.print_help()
|
||||
except KeyboardInterrupt:
|
||||
log("Exiting...")
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
background 0a0f0f
|
||||
onBackground dce8e6
|
||||
surface 0a0f0f
|
||||
surfaceDim 0a0f0f
|
||||
surfaceBright 242e2d
|
||||
surfaceContainerLowest 000000
|
||||
surfaceContainerLow 0e1514
|
||||
surfaceContainer 131b1a
|
||||
surfaceContainerHigh 192120
|
||||
surfaceContainerHighest 1d2827
|
||||
onSurface dce8e6
|
||||
surfaceVariant 1d2827
|
||||
onSurfaceVariant a2adac
|
||||
outline 6d7876
|
||||
outlineVariant 3f4a49
|
||||
inverseSurface f6faf9
|
||||
inverseOnSurface 515655
|
||||
shadow 000000
|
||||
scrim 000000
|
||||
surfaceTint 9bd0cc
|
||||
primary 9bd0cc
|
||||
primaryDim 8ec2bf
|
||||
onPrimary 0d4845
|
||||
primaryContainer 255b58
|
||||
onPrimaryContainer b8ede9
|
||||
inversePrimary 336764
|
||||
primaryFixed b7ede9
|
||||
primaryFixedDim a9deda
|
||||
onPrimaryFixed 0c4744
|
||||
onPrimaryFixedVariant 306461
|
||||
secondary b0ccc9
|
||||
secondaryDim a3bebc
|
||||
onSecondary 2c4543
|
||||
secondaryContainer 27403e
|
||||
onSecondaryContainer a9c5c2
|
||||
secondaryFixed cce8e5
|
||||
secondaryFixedDim bedad7
|
||||
onSecondaryFixed 2b4442
|
||||
onSecondaryFixedVariant 47605e
|
||||
tertiary d5efff
|
||||
tertiaryDim b6e3fe
|
||||
onTertiary 2e5c72
|
||||
tertiaryContainer b6e3fe
|
||||
onTertiaryContainer 255369
|
||||
tertiaryFixed b6e3fe
|
||||
tertiaryFixedDim a8d5ef
|
||||
onTertiaryFixed 0b4156
|
||||
onTertiaryFixedVariant 2f5d73
|
||||
error fa746f
|
||||
errorDim c54d4a
|
||||
onError 490006
|
||||
errorContainer 871f21
|
||||
onErrorContainer ff9993
|
||||
primaryPaletteKeyColor 4c807d
|
||||
secondaryPaletteKeyColor 627c7a
|
||||
tertiaryPaletteKeyColor 517d94
|
||||
neutralPaletteKeyColor 737877
|
||||
neutralVariantPaletteKeyColor 6e7978
|
||||
errorPaletteKeyColor c84f4c
|
||||
primary_paletteKeyColor 4c807d
|
||||
secondary_paletteKeyColor 627c7a
|
||||
tertiary_paletteKeyColor 517d94
|
||||
neutral_paletteKeyColor 737877
|
||||
neutral_variant_paletteKeyColor 6e7978
|
||||
term0 343434
|
||||
term1 769e00
|
||||
term2 56e2c0
|
||||
term3 81fcce
|
||||
term4 76b6b3
|
||||
term5 7aaee9
|
||||
term6 83d8c9
|
||||
term7 cddcd3
|
||||
term8 9aa59e
|
||||
term9 85b900
|
||||
term10 41f7d0
|
||||
term11 cdffe9
|
||||
term12 a3c8c3
|
||||
term13 a2c0f7
|
||||
term14 8bedd9
|
||||
term15 ffffff
|
||||
rosewater f1f3e5
|
||||
flamingo e3e4c5
|
||||
pink bae2ff
|
||||
mauve 60cfe8
|
||||
red 8ab5ff
|
||||
maroon abbef0
|
||||
peach a9daac
|
||||
yellow d3fae8
|
||||
green 8df1df
|
||||
teal 9feee7
|
||||
sky 93eae9
|
||||
sapphire 70d7db
|
||||
blue 57cdda
|
||||
lavender 86d9e7
|
||||
klink 00969e
|
||||
klinkSelection 00969e
|
||||
kvisited 008ca9
|
||||
kvisitedSelection 008ca9
|
||||
knegative 838f00
|
||||
knegativeSelection 838f00
|
||||
kneutral 34c359
|
||||
kneutralSelection 34c359
|
||||
kpositive 00beab
|
||||
kpositiveSelection 00beab
|
||||
text dce8e6
|
||||
subtext1 a2adac
|
||||
subtext0 6d7876
|
||||
overlay2 5f6967
|
||||
overlay1 505958
|
||||
overlay0 434b4a
|
||||
surface2 353d3c
|
||||
surface1 282e2e
|
||||
surface0 191f1e
|
||||
base 0a0f0f
|
||||
mantle 0a0f0f
|
||||
crust 090e0e
|
||||
success B5CCBA
|
||||
onSuccess 213528
|
||||
successContainer 374B3E
|
||||
onSuccessContainer D1E9D6
|
||||
@@ -0,0 +1,120 @@
|
||||
background f6faf9
|
||||
onBackground 2a3433
|
||||
surface f6faf9
|
||||
surfaceDim d1dcdb
|
||||
surfaceBright f6faf9
|
||||
surfaceContainerLowest ffffff
|
||||
surfaceContainerLow eef5f3
|
||||
surfaceContainer e7f0ee
|
||||
surfaceContainerHigh e1eae8
|
||||
surfaceContainerHighest d9e5e3
|
||||
onSurface 2a3433
|
||||
surfaceVariant d9e5e3
|
||||
onSurfaceVariant 566160
|
||||
outline 727d7c
|
||||
outlineVariant a9b4b3
|
||||
inverseSurface 0a0f0f
|
||||
inverseOnSurface 999e9d
|
||||
shadow 000000
|
||||
scrim 000000
|
||||
surfaceTint 1c6a66
|
||||
primary 1c6a66
|
||||
primaryDim 045d5a
|
||||
onPrimary e1fffc
|
||||
primaryContainer a8f0eb
|
||||
onPrimaryContainer 015c59
|
||||
inversePrimary b0f8f3
|
||||
primaryFixed a8f0eb
|
||||
primaryFixedDim 9ae1dc
|
||||
onPrimaryFixed 004845
|
||||
onPrimaryFixedVariant 166663
|
||||
secondary 4a6462
|
||||
secondaryDim 3e5856
|
||||
onSecondary e2fffc
|
||||
secondaryContainer cce8e5
|
||||
onSecondaryContainer 3d5654
|
||||
secondaryFixed cce8e5
|
||||
secondaryFixedDim bedad7
|
||||
onSecondaryFixed 2b4442
|
||||
onSecondaryFixedVariant 47605e
|
||||
tertiary 37647b
|
||||
tertiaryDim 2a586e
|
||||
onTertiary f4faff
|
||||
tertiaryContainer b6e3fe
|
||||
onTertiaryContainer 255369
|
||||
tertiaryFixed b6e3fe
|
||||
tertiaryFixedDim a8d5ef
|
||||
onTertiaryFixed 0b4156
|
||||
onTertiaryFixedVariant 2f5d73
|
||||
error a83836
|
||||
errorDim 67040d
|
||||
onError fff7f6
|
||||
errorContainer fa746f
|
||||
onErrorContainer 6e0a12
|
||||
primaryPaletteKeyColor 3a827e
|
||||
secondaryPaletteKeyColor 627c7a
|
||||
tertiaryPaletteKeyColor 517d94
|
||||
neutralPaletteKeyColor 737877
|
||||
neutralVariantPaletteKeyColor 6e7978
|
||||
errorPaletteKeyColor c84f4c
|
||||
primary_paletteKeyColor 3a827e
|
||||
secondary_paletteKeyColor 627c7a
|
||||
tertiary_paletteKeyColor 517d94
|
||||
neutral_paletteKeyColor 737877
|
||||
neutral_variant_paletteKeyColor 6e7978
|
||||
term0 9a9b99
|
||||
term1 005bcc
|
||||
term2 00907c
|
||||
term3 427d3b
|
||||
term4 269a7a
|
||||
term5 0071a3
|
||||
term6 128f8d
|
||||
term7 1f2324
|
||||
term8 0f0f0f
|
||||
term9 0071fa
|
||||
term10 00b49c
|
||||
term11 5d9954
|
||||
term12 52be9c
|
||||
term13 008cca
|
||||
term14 45b0ae
|
||||
term15 25292a
|
||||
rosewater 6b8647
|
||||
flamingo 6f7c1e
|
||||
pink 0085c0
|
||||
mauve 005d6c
|
||||
red 515900
|
||||
maroon 606c00
|
||||
peach 198900
|
||||
yellow 008f67
|
||||
green 007d6d
|
||||
teal 007573
|
||||
sky 00878d
|
||||
sapphire 008080
|
||||
blue 00636d
|
||||
lavender 007e8b
|
||||
klink 00969d
|
||||
klinkSelection 00969e
|
||||
kvisited 008ca9
|
||||
kvisitedSelection 008ca9
|
||||
knegative 838f00
|
||||
knegativeSelection 838f00
|
||||
kneutral 34c359
|
||||
kneutralSelection 34c359
|
||||
kpositive 00beab
|
||||
kpositiveSelection 00beac
|
||||
text 2a3433
|
||||
subtext1 566160
|
||||
subtext0 727d7c
|
||||
overlay2 828c8b
|
||||
overlay1 949d9c
|
||||
overlay0 a5aead
|
||||
surface2 b8bfbe
|
||||
surface1 cbd1d0
|
||||
surface0 e1e6e5
|
||||
base f6faf9
|
||||
mantle eef1f0
|
||||
crust e9eceb
|
||||
success 4F6354
|
||||
onSuccess FFFFFF
|
||||
successContainer D1E8D5
|
||||
onSuccessContainer 0C1F13
|
||||
@@ -2,8 +2,6 @@
|
||||
# Optimized for smooth and responsive visualization
|
||||
|
||||
[general]
|
||||
# Number of bars (20-200) - fewer bars = better performance
|
||||
bars = 64
|
||||
# Framerate (1-144) - higher = smoother but more CPU intensive
|
||||
framerate = 60
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-component/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Caelestia",
|
||||
"author": "Unrectified",
|
||||
"url": "https://github.com/caelestia-dots/cli",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Caelestia",
|
||||
"mode": "{{ $mode }}",
|
||||
"colors": {
|
||||
"accent.background": "{{ $surfaceContainerHigh }}",
|
||||
"accent.foreground": "{{ $onSurface }}",
|
||||
"background": "{{ $background }}",
|
||||
"border": "{{ $outlineVariant }}",
|
||||
"danger.background": "{{ $error }}",
|
||||
"foreground": "{{ $onBackground }}",
|
||||
"input.border": "{{ $outline }}",
|
||||
"link.active.foreground": "{{ $primary }}",
|
||||
"link.foreground": "{{ $primary }}",
|
||||
"link.hover.foreground": "{{ $primaryFixed }}",
|
||||
"list.active.background": "{{ $secondaryContainer }}",
|
||||
"list.active.border": "{{ $secondary }}",
|
||||
"list.even.background": "{{ $surfaceContainerLowest }}",
|
||||
"muted.background": "{{ $surfaceVariant }}",
|
||||
"muted.foreground": "{{ $onSurfaceVariant }}",
|
||||
"panel.background": "{{ $surfaceContainer }}",
|
||||
"popover.background": "{{ $surfaceContainerHigh }}",
|
||||
"popover.foreground": "{{ $onSurface }}",
|
||||
"primary.active.background": "{{ $primaryFixedDim }}",
|
||||
"primary.background": "{{ $primary }}",
|
||||
"primary.foreground": "{{ $onPrimary }}",
|
||||
"primary.hover.background": "{{ $primaryFixed }}",
|
||||
"scrollbar.background": "{{ $surface }}",
|
||||
"scrollbar.thumb.background": "{{ $outline }}",
|
||||
"secondary.background": "{{ $secondaryContainer }}",
|
||||
"secondary.active.background": "{{ $secondaryFixedDim }}",
|
||||
"secondary.foreground": "{{ $onSecondary }}",
|
||||
"secondary.hover.background": "{{ $secondaryFixed }}",
|
||||
"tab.active.background": "{{ $surface }}",
|
||||
"tab.active.foreground": "{{ $onSurface }}",
|
||||
"tab.background": "{{ $surfaceContainerLowest }}",
|
||||
"tab.foreground": "{{ $onSurfaceVariant }}",
|
||||
"tab_bar.background": "{{ $surface }}",
|
||||
"table.background": "{{ $surfaceContainer }}",
|
||||
"table.head.foreground": "{{ $onSurfaceVariant }}",
|
||||
"table.row.border": "{{ $outlineVariant }}",
|
||||
"title_bar.background": "{{ $surfaceDim }}",
|
||||
"ring": "{{ $primary }}",
|
||||
"base.red": "{{ $red }}",
|
||||
"base.red.light": "{{ $peach }}",
|
||||
"base.green": "{{ $green }}",
|
||||
"base.green.light": "{{ $teal }}",
|
||||
"base.blue": "{{ $blue }}",
|
||||
"base.blue.light": "{{ $sky }}",
|
||||
"base.cyan": "{{ $teal }}",
|
||||
"base.cyan.light": "{{ $sky }}",
|
||||
"base.magenta": "{{ $mauve }}",
|
||||
"base.magenta.light": "{{ $pink }}",
|
||||
"base.yellow": "{{ $yellow }}",
|
||||
"base.yellow.light": "{{ $peach }}"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "{{ $onSurface }}",
|
||||
"editor.background": "{{ $surface }}",
|
||||
"editor.active_line.background": "{{ $surfaceContainerLow }}",
|
||||
"editor.line_number": "{{ $onSurfaceVariant }}",
|
||||
"editor.active_line_number": "{{ $onSurface }}",
|
||||
"editor.invisible": "{{ $outlineVariant }}",
|
||||
"conflict": "{{ $red }}",
|
||||
"created": "{{ $green }}",
|
||||
"deleted": "{{ $red }}",
|
||||
"error": "{{ $error }}",
|
||||
"hidden": "{{ $outline }}",
|
||||
"hint": "{{ $success }}",
|
||||
"ignored": "{{ $outline }}",
|
||||
"info": "{{ $blue }}",
|
||||
"modified": "{{ $yellow }}",
|
||||
"predictive": "{{ $overlay1 }}",
|
||||
"renamed": "{{ $green }}",
|
||||
"success": "{{ $success }}",
|
||||
"unreachable": "{{ $outlineVariant }}",
|
||||
"warning": "{{ $yellow }}",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "{{ $yellow }}"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "{{ $green }}"
|
||||
},
|
||||
"comment": {
|
||||
"color": "{{ $subtext0 }}",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "{{ $subtext0 }}",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "{{ $red }}"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "{{ $yellow }}"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "{{ $onSurface }}"
|
||||
},
|
||||
"function": {
|
||||
"color": "{{ $green }}"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "{{ $mauve }}"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "{{ $sky }}",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "{{ $klink }}",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "{{ $red }}"
|
||||
},
|
||||
"string": {
|
||||
"color": "{{ $green }}"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "{{ $green }}"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "{{ $green }}"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "{{ $yellow }}"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "{{ $yellow }}"
|
||||
},
|
||||
"tag": {
|
||||
"color": "{{ $yellow }}"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "{{ $red }}"
|
||||
},
|
||||
"title": {
|
||||
"color": "{{ $sky }}",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "{{ $yellow }}"
|
||||
},
|
||||
"property": {
|
||||
"color": "{{ $onSurface }}"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "{{ $red }}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
[Appearance]
|
||||
color_scheme_path={{ $config }}/colors/caelestia.colors
|
||||
custom_palette=true
|
||||
icon_theme=Papirus-{{ $mode }}
|
||||
standard_dialogs=default
|
||||
style=Darkly
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"theme": {
|
||||
"colorScheme": "~/.config/qtengine/caelestia.colors",
|
||||
"iconTheme": "Papirus-{{ $mode }}",
|
||||
"style": "Darkly",
|
||||
"font": {
|
||||
"family": "Sans Serif",
|
||||
"size": 12,
|
||||
"weight": -1
|
||||
},
|
||||
"fontFixed": {
|
||||
"family": "Monospace",
|
||||
"size": 12,
|
||||
"weight": -1
|
||||
}
|
||||
},
|
||||
"misc": {
|
||||
"menusHaveIcons": true,
|
||||
"singleClickActivate": false,
|
||||
"shortcutsForContextMenus": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
{
|
||||
"$schema": "https://zed.dev/schema/themes/v0.2.0.json",
|
||||
"name": "Caelestia",
|
||||
"author": "Caelestia",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Caelestia",
|
||||
"appearance": "{{ mode }}",
|
||||
"style": {
|
||||
"background": "#{{ surface.hex }}",
|
||||
"border": "#{{ outlineVariant.hex }}40",
|
||||
"border.variant": "#{{ outlineVariant.hex }}60",
|
||||
"border.focused": "#{{ primary.hex }}",
|
||||
"border.selected": "#{{ primary.hex }}80",
|
||||
"border.transparent": "#00000000",
|
||||
"border.disabled": "#{{ outlineVariant.hex }}30",
|
||||
|
||||
"elevated_surface.background": "#{{ surfaceContainerHigh.hex }}",
|
||||
"surface.background": "#{{ surface.hex }}",
|
||||
|
||||
"element.background": "#{{ outlineVariant.hex }}40",
|
||||
"element.hover": "#{{ outlineVariant.hex }}60",
|
||||
"element.active": "#{{ primary.hex }}30",
|
||||
"element.selected": "#{{ primary.hex }}20",
|
||||
"element.disabled": "#{{ outlineVariant.hex }}20",
|
||||
|
||||
"drop_target.background": "#{{ primary.hex }}20",
|
||||
|
||||
"ghost_element.background": "#00000000",
|
||||
"ghost_element.hover": "#{{ outlineVariant.hex }}40",
|
||||
"ghost_element.active": "#{{ primary.hex }}30",
|
||||
"ghost_element.selected": "#{{ primary.hex }}20",
|
||||
"ghost_element.disabled": "#{{ outlineVariant.hex }}20",
|
||||
|
||||
"text": "#{{ onSurface.hex }}",
|
||||
"text.muted": "#{{ onSurfaceVariant.hex }}",
|
||||
"text.placeholder": "#{{ outline.hex }}",
|
||||
"text.disabled": "#{{ outline.hex }}80",
|
||||
"text.accent": "#{{ primary.hex }}",
|
||||
|
||||
"icon": "#{{ onSurface.hex }}",
|
||||
"icon.muted": "#{{ onSurfaceVariant.hex }}",
|
||||
"icon.disabled": "#{{ outlineVariant.hex }}60",
|
||||
"icon.placeholder": "#{{ onSurfaceVariant.hex }}",
|
||||
"icon.accent": "#{{ primary.hex }}",
|
||||
|
||||
"status_bar.background": "#{{ surface.hex }}",
|
||||
"title_bar.background": "#{{ surface.hex }}",
|
||||
"title_bar.inactive_background": "#{{ surface.hex }}",
|
||||
"toolbar.background": "#{{ surface.hex }}",
|
||||
"tab_bar.background": "#{{ surface.hex }}",
|
||||
"tab.inactive_background": "#{{ surface.hex }}",
|
||||
"tab.active_background": "#{{ surfaceContainerHigh.hex }}",
|
||||
|
||||
"search.match_background": "#{{ yellow.hex }}40",
|
||||
|
||||
"panel.background": "#{{ surface.hex }}",
|
||||
"panel.focused_border": "#{{ primary.hex }}",
|
||||
|
||||
"pane.focused_border": "#{{ primary.hex }}",
|
||||
|
||||
"scrollbar.thumb.background": "#{{ outlineVariant.hex }}30",
|
||||
"scrollbar.thumb.hover_background": "#{{ outlineVariant.hex }}60",
|
||||
"scrollbar.thumb.border": "#{{ outlineVariant.hex }}20",
|
||||
"scrollbar.track.background": "#00000000",
|
||||
"scrollbar.track.border": "#00000000",
|
||||
|
||||
"editor.foreground": "#{{ onSurface.hex }}",
|
||||
"editor.background": "#{{ surface.hex }}",
|
||||
"editor.gutter.background": "#{{ surface.hex }}",
|
||||
"editor.subheader.background": "#{{ surfaceContainer.hex }}",
|
||||
"editor.active_line.background": "#{{ surfaceContainerHigh.hex }}60",
|
||||
"editor.highlighted_line.background": "#{{ primary.hex }}15",
|
||||
"editor.line_number": "#{{ onSurfaceVariant.hex }}",
|
||||
"editor.active_line_number": "#{{ onSurface.hex }}",
|
||||
"editor.invisible": "#{{ outlineVariant.hex }}40",
|
||||
"editor.wrap_guide": "#{{ outlineVariant.hex }}30",
|
||||
"editor.active_wrap_guide": "#{{ outlineVariant.hex }}60",
|
||||
"editor.document_highlight.read_background": "#{{ primary.hex }}20",
|
||||
"editor.document_highlight.write_background": "#{{ primary.hex }}30",
|
||||
|
||||
"terminal.background": "#{{ surface.hex }}",
|
||||
"terminal.foreground": "#{{ onSurface.hex }}",
|
||||
"terminal.bright_foreground": "#{{ onSurface.hex }}",
|
||||
"terminal.dim_foreground": "#{{ onSurfaceVariant.hex }}",
|
||||
"terminal.ansi.black": "#{{ surface.hex }}",
|
||||
"terminal.ansi.bright_black": "#{{ onSurfaceVariant.hex }}",
|
||||
"terminal.ansi.dim_black": "#{{ surface.hex }}80",
|
||||
"terminal.ansi.red": "#{{ red.hex }}",
|
||||
"terminal.ansi.bright_red": "#{{ maroon.hex }}",
|
||||
"terminal.ansi.dim_red": "#{{ red.hex }}80",
|
||||
"terminal.ansi.green": "#{{ green.hex }}",
|
||||
"terminal.ansi.bright_green": "#{{ teal.hex }}",
|
||||
"terminal.ansi.dim_green": "#{{ green.hex }}80",
|
||||
"terminal.ansi.yellow": "#{{ yellow.hex }}",
|
||||
"terminal.ansi.bright_yellow": "#{{ peach.hex }}",
|
||||
"terminal.ansi.dim_yellow": "#{{ yellow.hex }}80",
|
||||
"terminal.ansi.blue": "#{{ blue.hex }}",
|
||||
"terminal.ansi.bright_blue": "#{{ sapphire.hex }}",
|
||||
"terminal.ansi.dim_blue": "#{{ blue.hex }}80",
|
||||
"terminal.ansi.magenta": "#{{ mauve.hex }}",
|
||||
"terminal.ansi.bright_magenta": "#{{ pink.hex }}",
|
||||
"terminal.ansi.dim_magenta": "#{{ mauve.hex }}80",
|
||||
"terminal.ansi.cyan": "#{{ teal.hex }}",
|
||||
"terminal.ansi.bright_cyan": "#{{ sky.hex }}",
|
||||
"terminal.ansi.dim_cyan": "#{{ teal.hex }}80",
|
||||
"terminal.ansi.white": "#{{ onSurface.hex }}",
|
||||
"terminal.ansi.bright_white": "#{{ onSurface.hex }}",
|
||||
"terminal.ansi.dim_white": "#{{ onSurface.hex }}80",
|
||||
|
||||
"link_text.hover": "#{{ primary.hex }}",
|
||||
|
||||
"conflict": "#{{ yellow.hex }}",
|
||||
"conflict.background": "#{{ yellow.hex }}15",
|
||||
"conflict.border": "#{{ yellow.hex }}",
|
||||
|
||||
"created": "#{{ green.hex }}",
|
||||
"created.background": "#{{ green.hex }}15",
|
||||
"created.border": "#{{ green.hex }}",
|
||||
|
||||
"deleted": "#{{ red.hex }}",
|
||||
"deleted.background": "#{{ red.hex }}15",
|
||||
"deleted.border": "#{{ red.hex }}",
|
||||
|
||||
"error": "#{{ error.hex }}",
|
||||
"error.background": "#{{ error.hex }}15",
|
||||
"error.border": "#{{ error.hex }}",
|
||||
|
||||
"hidden": "#{{ outline.hex }}",
|
||||
"hidden.background": "#{{ outline.hex }}15",
|
||||
"hidden.border": "#{{ outline.hex }}",
|
||||
|
||||
"hint": "#{{ success.hex }}",
|
||||
"hint.background": "#{{ success.hex }}15",
|
||||
"hint.border": "#{{ success.hex }}",
|
||||
|
||||
"ignored": "#{{ outline.hex }}",
|
||||
"ignored.background": "#{{ outline.hex }}15",
|
||||
"ignored.border": "#{{ outline.hex }}",
|
||||
|
||||
"info": "#{{ blue.hex }}",
|
||||
"info.background": "#{{ blue.hex }}15",
|
||||
"info.border": "#{{ blue.hex }}",
|
||||
|
||||
"modified": "#{{ peach.hex }}",
|
||||
"modified.background": "#{{ peach.hex }}15",
|
||||
"modified.border": "#{{ peach.hex }}",
|
||||
|
||||
"predictive": "#{{ onSurfaceVariant.hex }}",
|
||||
"predictive.background": "#{{ onSurfaceVariant.hex }}15",
|
||||
"predictive.border": "#{{ outlineVariant.hex }}40",
|
||||
|
||||
"renamed": "#{{ teal.hex }}",
|
||||
"renamed.background": "#{{ teal.hex }}15",
|
||||
"renamed.border": "#{{ teal.hex }}",
|
||||
|
||||
"success": "#{{ success.hex }}",
|
||||
"success.background": "#{{ success.hex }}15",
|
||||
"success.border": "#{{ success.hex }}",
|
||||
|
||||
"unreachable": "#{{ outline.hex }}",
|
||||
"unreachable.background": "#{{ outline.hex }}15",
|
||||
"unreachable.border": "#{{ outline.hex }}",
|
||||
|
||||
"warning": "#{{ yellow.hex }}",
|
||||
"warning.background": "#{{ yellow.hex }}15",
|
||||
"warning.border": "#{{ yellow.hex }}",
|
||||
|
||||
"players": [
|
||||
{
|
||||
"cursor": "#{{ onSurface.hex }}",
|
||||
"selection": "#{{ onSurface.hex }}60",
|
||||
"background": "#{{ primary.hex }}"
|
||||
},
|
||||
{
|
||||
"cursor": "#{{ teal.hex }}",
|
||||
"selection": "#{{ teal.hex }}40",
|
||||
"background": "#{{ teal.hex }}"
|
||||
},
|
||||
{
|
||||
"cursor": "#{{ pink.hex }}",
|
||||
"selection": "#{{ pink.hex }}40",
|
||||
"background": "#{{ pink.hex }}"
|
||||
},
|
||||
{
|
||||
"cursor": "#{{ yellow.hex }}",
|
||||
"selection": "#{{ yellow.hex }}40",
|
||||
"background": "#{{ yellow.hex }}"
|
||||
},
|
||||
{
|
||||
"cursor": "#{{ green.hex }}",
|
||||
"selection": "#{{ green.hex }}40",
|
||||
"background": "#{{ green.hex }}"
|
||||
},
|
||||
{
|
||||
"cursor": "#{{ red.hex }}",
|
||||
"selection": "#{{ red.hex }}40",
|
||||
"background": "#{{ red.hex }}"
|
||||
},
|
||||
{
|
||||
"cursor": "#{{ blue.hex }}",
|
||||
"selection": "#{{ blue.hex }}40",
|
||||
"background": "#{{ blue.hex }}"
|
||||
},
|
||||
{
|
||||
"cursor": "#{{ maroon.hex }}",
|
||||
"selection": "#{{ maroon.hex }}40",
|
||||
"background": "#{{ maroon.hex }}"
|
||||
}
|
||||
],
|
||||
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#{{ yellow.hex }}",
|
||||
"font_style": "italic",
|
||||
"font_weight": null
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#{{ peach.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"comment": {
|
||||
"color": "#{{ subtext0.hex }}",
|
||||
"font_style": "italic",
|
||||
"font_weight": null
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#{{ subtext0.hex }}",
|
||||
"font_style": "italic",
|
||||
"font_weight": null
|
||||
},
|
||||
"constant": {
|
||||
"color": "#{{ peach.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#{{ yellow.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#{{ onSurface.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"emphasis": {
|
||||
"color": "#{{ red.hex }}",
|
||||
"font_style": "italic",
|
||||
"font_weight": null
|
||||
},
|
||||
"emphasis.strong": {
|
||||
"color": "#{{ red.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": 700
|
||||
},
|
||||
"enum": {
|
||||
"color": "#{{ yellow.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"function": {
|
||||
"color": "#{{ blue.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"function.builtin": {
|
||||
"color": "#{{ teal.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"function.definition": {
|
||||
"color": "#{{ blue.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"function.method": {
|
||||
"color": "#{{ blue.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"function.special.definition": {
|
||||
"color": "#{{ blue.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"hint": {
|
||||
"color": "#{{ onSurfaceVariant.hex }}",
|
||||
"font_style": "italic",
|
||||
"font_weight": null
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#{{ pink.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"label": {
|
||||
"color": "#{{ yellow.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#{{ blue.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#{{ teal.hex }}",
|
||||
"font_style": "underline",
|
||||
"font_weight": null
|
||||
},
|
||||
"number": {
|
||||
"color": "#{{ peach.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"operator": {
|
||||
"color": "#{{ sapphire.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"predictive": {
|
||||
"color": "#{{ onSurfaceVariant.hex }}",
|
||||
"font_style": "italic",
|
||||
"font_weight": null
|
||||
},
|
||||
"preproc": {
|
||||
"color": "#{{ teal.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"primary": {
|
||||
"color": "#{{ onSurface.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"property": {
|
||||
"color": "#{{ teal.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation": {
|
||||
"color": "#{{ subtext1.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.bracket": {
|
||||
"color": "#{{ subtext1.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.delimiter": {
|
||||
"color": "#{{ subtext1.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.list_marker": {
|
||||
"color": "#{{ teal.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"punctuation.special": {
|
||||
"color": "#{{ sapphire.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"string": {
|
||||
"color": "#{{ green.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#{{ pink.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#{{ sky.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#{{ green.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#{{ teal.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"tag": {
|
||||
"color": "#{{ yellow.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#{{ green.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"title": {
|
||||
"color": "#{{ blue.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": 700
|
||||
},
|
||||
"type": {
|
||||
"color": "#{{ yellow.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"type.builtin": {
|
||||
"color": "#{{ onSurface.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"type.interface": {
|
||||
"color": "#{{ yellow.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"type.super": {
|
||||
"color": "#{{ yellow.hex }}",
|
||||
"font_style": "italic",
|
||||
"font_weight": null
|
||||
},
|
||||
"variable": {
|
||||
"color": "#{{ onSurface.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"variable.member": {
|
||||
"color": "#{{ teal.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
},
|
||||
"variable.parameter": {
|
||||
"color": "#{{ teal.hex }}",
|
||||
"font_style": "italic",
|
||||
"font_weight": null
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#{{ onSurface.hex }}",
|
||||
"font_style": "italic",
|
||||
"font_weight": null
|
||||
},
|
||||
"variant": {
|
||||
"color": "#{{ peach.hex }}",
|
||||
"font_style": null,
|
||||
"font_weight": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+77
-2
@@ -1,12 +1,29 @@
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from caelestia.subcommands import clipboard, emoji, record, resizer, scheme, screenshot, shell, toggle, wallpaper
|
||||
from caelestia.subcommands import (
|
||||
clipboard,
|
||||
emoji,
|
||||
install,
|
||||
record,
|
||||
resizer,
|
||||
scheme,
|
||||
screenshot,
|
||||
shell,
|
||||
toggle,
|
||||
update,
|
||||
wallpaper,
|
||||
)
|
||||
from caelestia.utils.dots.manifest import Manifest
|
||||
from caelestia.utils.dots.packages import AUR_HELPERS
|
||||
from caelestia.utils.dots.source import DotsSource
|
||||
from caelestia.utils.io import warn
|
||||
from caelestia.utils.paths import wallpapers_dir
|
||||
from caelestia.utils.scheme import get_scheme_names, scheme_variants
|
||||
from caelestia.utils.wallpaper import get_wallpaper
|
||||
|
||||
|
||||
def parse_args() -> (argparse.ArgumentParser, argparse.Namespace):
|
||||
def parse_args() -> tuple[argparse.ArgumentParser, argparse.Namespace]:
|
||||
parser = argparse.ArgumentParser(prog="caelestia", description="Main control script for the Caelestia dotfiles")
|
||||
parser.add_argument("-v", "--version", action="store_true", help="print the current version")
|
||||
|
||||
@@ -128,4 +145,62 @@ def parse_args() -> (argparse.ArgumentParser, argparse.Namespace):
|
||||
resizer_parser.add_argument("height", nargs="?", help="height to resize to")
|
||||
resizer_parser.add_argument("actions", nargs="?", help="comma-separated actions to apply (float,center,pip)")
|
||||
|
||||
# Create parser for install opts
|
||||
install_parser = command_parser.add_parser(
|
||||
"install",
|
||||
help="install the Caelestia dotfiles",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
install_parser.set_defaults(cls=install.Command)
|
||||
install_parser.add_argument("--aur-helper", choices=AUR_HELPERS, help="the AUR helper to use")
|
||||
install_parser.add_argument(
|
||||
"--enable-components", metavar="LIST", help="comma-separated list of components to enable"
|
||||
)
|
||||
install_parser.add_argument(
|
||||
"--disable-components", metavar="LIST", help="comma-separated list of components to disable"
|
||||
)
|
||||
install_parser.add_argument("--noconfirm", action="store_true", help="use defaults for all prompts")
|
||||
_set_install_epilog(install_parser)
|
||||
|
||||
# Create parser for update opts
|
||||
update_parser = command_parser.add_parser("update", help="update the Caelestia dotfiles")
|
||||
update_parser.set_defaults(cls=update.Command)
|
||||
update_parser.add_argument("--aur-helper", choices=AUR_HELPERS, help="the AUR helper to use")
|
||||
update_parser.add_argument("--noconfirm", action="store_true", help="use defaults for all prompts")
|
||||
|
||||
return parser, parser.parse_args()
|
||||
|
||||
|
||||
def _set_install_epilog(install_parser: argparse.ArgumentParser) -> None:
|
||||
"""Add components if using install subcommand"""
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "install":
|
||||
manifest = _load_install_manifest()
|
||||
if manifest is not None and manifest.components:
|
||||
install_parser.epilog = _components_epilog(manifest)
|
||||
|
||||
|
||||
def _load_install_manifest() -> Manifest | None:
|
||||
source = DotsSource()
|
||||
try:
|
||||
source.ensure()
|
||||
return source.manifest_at(source.remote_ref)
|
||||
except Exception as e:
|
||||
warn(f"failed to load manifest from dots repo ({e})\n", prefix=False)
|
||||
return None
|
||||
|
||||
|
||||
def _components_epilog(manifest: Manifest) -> str:
|
||||
def e(*v: int) -> str:
|
||||
return f"\033[{';'.join(str(c) for c in v)}m"
|
||||
|
||||
def b(c: int) -> str:
|
||||
return e(1, c)
|
||||
|
||||
reset = e(0)
|
||||
|
||||
width = max(len(name) for name in manifest.components)
|
||||
lines = [f"{b(34)}available components (for --enable-components / --disable-components):{reset}"]
|
||||
for name, comp in manifest.components.items():
|
||||
lines.append(f" {b(32)}{name:<{width}}{reset}\t{'(default)' if comp.default else '(off)'}")
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import shutil
|
||||
import textwrap
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
|
||||
from caelestia.utils.dots.deployer import Deployer
|
||||
from caelestia.utils.dots.legacy import (
|
||||
LEGACY_META_PKG,
|
||||
detect_legacy_repo,
|
||||
legacy_config_symlinks,
|
||||
legacy_symlinks,
|
||||
legacy_to_delete,
|
||||
)
|
||||
from caelestia.utils.dots.manifest import ComponentError, Manifest, ManifestError
|
||||
from caelestia.utils.dots.misc import build_local_packages, run_hooks
|
||||
from caelestia.utils.dots.packages import DEFAULT_AUR_HELPER, PackageError, PackageInstaller
|
||||
from caelestia.utils.dots.source import DotsSource, SourceError
|
||||
from caelestia.utils.dots.state import DotsState
|
||||
from caelestia.utils.io import confirm, disable_input, fatal, info, log, pause, prompt_selection, warn
|
||||
from caelestia.utils.paths import (
|
||||
config_backup_dir,
|
||||
config_dir,
|
||||
)
|
||||
|
||||
|
||||
def _parse_list_arg(value: str | None) -> list[str] | None:
|
||||
if value is None:
|
||||
return None
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
def _deref_symlink(link: Path, target: Path) -> None:
|
||||
"""Replace symlink `link` with a real copy of `target`'s content."""
|
||||
|
||||
bak = link.rename(link.parent / f"{link.name}.bak")
|
||||
try:
|
||||
if target.is_dir():
|
||||
shutil.copytree(target, link, symlinks=True)
|
||||
else:
|
||||
shutil.copy2(target, link)
|
||||
except OSError:
|
||||
bak.rename(link)
|
||||
raise
|
||||
bak.unlink()
|
||||
|
||||
|
||||
class Command:
|
||||
args: Namespace
|
||||
|
||||
def __init__(self, args: Namespace) -> None:
|
||||
self.args = args
|
||||
|
||||
def run(self) -> None:
|
||||
if self.args.noconfirm:
|
||||
disable_input()
|
||||
|
||||
self.print_greeting()
|
||||
self.create_backup()
|
||||
legacy_dir = detect_legacy_repo() # Detect legacy repo first cause deploy overwrites legacy syms
|
||||
|
||||
source, tip, manifest = self.fetch_manifest()
|
||||
try:
|
||||
installer, packages, local_packages = self.install_packages(source, manifest)
|
||||
except PackageError as e:
|
||||
fatal(e)
|
||||
run_hooks(manifest, "post_package")
|
||||
self.dereference_legacy(legacy_dir) # Copy legacy content into place before deploy overwrites the symlinks
|
||||
deployed = self.deploy_configs(source, manifest)
|
||||
run_hooks(manifest, "post_install")
|
||||
|
||||
DotsState(
|
||||
aur_helper=getattr(installer, "helper", DEFAULT_AUR_HELPER),
|
||||
applied_rev=tip,
|
||||
enabled_components=manifest.enabled_components,
|
||||
packages=packages,
|
||||
local_packages=local_packages,
|
||||
deployed_files=deployed,
|
||||
).save()
|
||||
|
||||
self.migrate_legacy(installer, legacy_dir)
|
||||
self.print_done()
|
||||
|
||||
def print_greeting(self) -> None:
|
||||
print(
|
||||
"\033[38;2;150;241;241m" # Caelestia colour
|
||||
+ textwrap.dedent(
|
||||
r"""
|
||||
╭─────────────────────────────────────────────────╮
|
||||
│ ______ __ __ _ │
|
||||
│ / ____/___ ____ / /__ _____/ /_(_)___ _ │
|
||||
│ / / / __ `/ _ \/ / _ \/ ___/ __/ / __ `/ │
|
||||
│ / /___/ /_/ / __/ / __(__ ) /_/ / /_/ / │
|
||||
│ \____/\__,_/\___/_/\___/____/\__/_/\__,_/ │
|
||||
│ │
|
||||
╰─────────────────────────────────────────────────╯
|
||||
"""
|
||||
)
|
||||
+ "\033[0m"
|
||||
)
|
||||
info("Welcome to the Caelestia dotfiles installer!")
|
||||
info("Here's a quick overview on what this command is going to do:")
|
||||
info(" - Install dependencies")
|
||||
info(" - Install config files")
|
||||
info("The installer does NOT set up hardware/system level configs (e.g. drivers). Please do this yourself.")
|
||||
pause()
|
||||
print()
|
||||
|
||||
def create_backup(self) -> None:
|
||||
if config_dir.exists():
|
||||
if not confirm("Back up the config directory?", default=True):
|
||||
return
|
||||
|
||||
log(f"Creating a backup of {config_dir}...")
|
||||
if config_backup_dir.exists():
|
||||
if not confirm("A backup already exists, overwrite?", default=False):
|
||||
info("Not creating backup.")
|
||||
return
|
||||
|
||||
log("Deleting old backup...")
|
||||
shutil.rmtree(config_backup_dir)
|
||||
|
||||
shutil.copytree(config_dir, config_backup_dir, symlinks=True)
|
||||
info(f"Created backup at {config_backup_dir}")
|
||||
|
||||
def fetch_manifest(self) -> tuple[DotsSource, str, Manifest]:
|
||||
print()
|
||||
log("Fetching dots repo...")
|
||||
source = DotsSource()
|
||||
try:
|
||||
source.ensure()
|
||||
tip = source.checkout_tip()
|
||||
except SourceError as e:
|
||||
fatal(e)
|
||||
|
||||
enable = _parse_list_arg(self.args.enable_components)
|
||||
disable = _parse_list_arg(self.args.disable_components)
|
||||
try:
|
||||
manifest = source.manifest_at(tip)
|
||||
|
||||
# No flags given, prompt user for non-default components
|
||||
if enable is None and disable is None:
|
||||
optional = [name for name, comp in manifest.components.items() if not comp.default]
|
||||
if optional:
|
||||
enable = prompt_selection(optional, "Components to enable?")
|
||||
|
||||
manifest.resolve_components(enable=enable, disable=disable)
|
||||
except (SourceError, ManifestError, ComponentError) as e:
|
||||
fatal(e)
|
||||
|
||||
names = ", ".join(manifest.enabled_components) or "none"
|
||||
info(f"Enabled components: {names}")
|
||||
|
||||
return source, tip, manifest
|
||||
|
||||
def deploy_configs(self, source: DotsSource, manifest: Manifest) -> dict[str, str]:
|
||||
print()
|
||||
log("Installing configs...")
|
||||
deployer = Deployer()
|
||||
for entry in manifest.enabled_entries():
|
||||
src = source.working_path(entry.expanded_src())
|
||||
if not src.exists():
|
||||
warn(f"missing in source, skipping: {entry.src}")
|
||||
continue
|
||||
|
||||
dests = entry.expanded_dests()
|
||||
if not dests:
|
||||
warn(f"dest glob matched nothing, skipping: {entry.dest}")
|
||||
continue
|
||||
|
||||
for dest in dests:
|
||||
deployer.place(src, Path(dest))
|
||||
info(f"{entry.src} -> {dest}")
|
||||
|
||||
return deployer.deployed_files
|
||||
|
||||
def install_packages(
|
||||
self, source: DotsSource, manifest: Manifest
|
||||
) -> tuple[PackageInstaller, list[str], dict[str, list[str]]]:
|
||||
installer = PackageInstaller.get(self.args.aur_helper, self.args.noconfirm)
|
||||
|
||||
packages = manifest.enabled_packages()
|
||||
if packages:
|
||||
print()
|
||||
log("Installing packages...")
|
||||
installer.install(packages)
|
||||
|
||||
local_packages = {}
|
||||
local_dirs = manifest.enabled_local_packages()
|
||||
if local_dirs:
|
||||
print()
|
||||
log("Building local packages...")
|
||||
local_packages = build_local_packages(installer, source, local_dirs)
|
||||
|
||||
return installer, packages, local_packages
|
||||
|
||||
def dereference_legacy(self, legacy_dir: Path | None) -> None:
|
||||
"""Replace legacy symlinks with real copies of their targets."""
|
||||
|
||||
symlinks = legacy_symlinks(legacy_dir)
|
||||
if not symlinks:
|
||||
return
|
||||
|
||||
print()
|
||||
log("Preserving content from legacy symlinks...")
|
||||
for path in symlinks:
|
||||
target = path.resolve()
|
||||
if not target.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
_deref_symlink(path, target)
|
||||
info(f"Copied {target} -> {path}")
|
||||
except OSError as e:
|
||||
warn(f"failed to preserve {path}: {e}")
|
||||
|
||||
def deref_backup_syms(self, legacy_dir: Path | None) -> None:
|
||||
"""Deref the backup's legacy symlinks before the repo is cleared, so the backup keeps real content."""
|
||||
|
||||
if not config_backup_dir.is_dir():
|
||||
return
|
||||
|
||||
for link in legacy_config_symlinks(config_backup_dir, legacy_dir):
|
||||
target = link.resolve()
|
||||
if not target.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
_deref_symlink(link, target)
|
||||
except OSError as e:
|
||||
warn(f"failed to preserve {link} in backup: {e}")
|
||||
|
||||
def migrate_legacy(self, installer: PackageInstaller, legacy_dir: Path | None) -> None:
|
||||
"""Clean up a previous install.fish setup (repo, symlinks and metapackage)."""
|
||||
|
||||
to_delete = legacy_to_delete(legacy_dir)
|
||||
meta_installed = installer.is_installed(LEGACY_META_PKG)
|
||||
if not to_delete and not meta_installed:
|
||||
return
|
||||
|
||||
print()
|
||||
log("Found a legacy Caelestia installation...")
|
||||
if not confirm("Clear legacy installation?"):
|
||||
return
|
||||
|
||||
deployer = Deployer()
|
||||
try:
|
||||
self.deref_backup_syms(legacy_dir)
|
||||
for path in to_delete:
|
||||
deployer.remove(path)
|
||||
info(f"Deleted {path}")
|
||||
|
||||
if meta_installed:
|
||||
log("Removing legacy meta package...")
|
||||
installer.remove([LEGACY_META_PKG])
|
||||
except (OSError, PackageError) as e:
|
||||
warn(f"could not fully clear the legacy installation: {e}")
|
||||
|
||||
def print_done(self) -> None:
|
||||
print()
|
||||
info("All done! Caelestia has been installed.")
|
||||
info("A few things to finish up:")
|
||||
info(" - A reboot is recommended for all changes take effect")
|
||||
info(" - Edit `~/.config/caelestia/hypr-vars.conf` to set default apps, keybinds and much more")
|
||||
info(" - Edit `~/.config/caelestia/hypr-user.conf` to set your monitor layout and other Hyprland configs")
|
||||
info(" - Run `caelestia update` later to pull in the latest changes")
|
||||
info("Enjoy! For support (or to just hang out), join our Discord server: https://discord.gg/BGDCFCmMBk")
|
||||
@@ -1,14 +1,14 @@
|
||||
from pathlib import Path
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from argparse import Namespace
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from caelestia.utils import hypr
|
||||
from caelestia.utils.notify import close_notification, notify
|
||||
from caelestia.utils.paths import recording_notif_path, recording_path, recordings_dir, user_config_path
|
||||
from caelestia.utils.paths import get_config, recording_notif_path, recording_path, recordings_dir
|
||||
|
||||
RECORDER = "gpu-screen-recorder"
|
||||
|
||||
@@ -36,7 +36,7 @@ class Command:
|
||||
def start(self) -> None:
|
||||
args = ["-w"]
|
||||
|
||||
monitors = json.loads(subprocess.check_output(["hyprctl", "monitors", "-j"]))
|
||||
monitors = hypr.message("monitors")
|
||||
if self.args.region:
|
||||
if self.args.region == "slurp":
|
||||
region = subprocess.check_output(["slurp", "-f", "%wx%h+%x+%y"], text=True)
|
||||
@@ -64,12 +64,10 @@ class Command:
|
||||
if self.args.sound:
|
||||
args += ["-a", "default_output"]
|
||||
|
||||
config = get_config()
|
||||
try:
|
||||
config = json.loads(user_config_path.read_text())
|
||||
if "record" in config and "extraArgs" in config["record"]:
|
||||
args += config["record"]["extraArgs"]
|
||||
except (json.JSONDecodeError, FileNotFoundError):
|
||||
pass
|
||||
except TypeError as e:
|
||||
raise ValueError(f"Config option 'record.extraArgs' should be an array: {e}")
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from caelestia.utils import hypr
|
||||
from caelestia.utils.logging import log_message
|
||||
from caelestia.utils.paths import user_config_path
|
||||
from caelestia.utils.io import error, fatal, info, log, warn
|
||||
from caelestia.utils.paths import get_config
|
||||
|
||||
|
||||
class WindowRule:
|
||||
@@ -26,16 +26,34 @@ class Command:
|
||||
self.timeout_tracker: dict[str, float] = {}
|
||||
self.window_rules = self._load_window_rules()
|
||||
|
||||
def _make_resize_cmd(self, width: int | str, height: int | str, address: str) -> str:
|
||||
if hypr.is_lua_config():
|
||||
return f'dispatch hl.dsp.window.resize({{x = {width}, y = {height}, exact = true, window = "address:{address}"}})'
|
||||
return f"dispatch resizewindowpixel exact {width} {height},address:{address}"
|
||||
|
||||
def _make_move_cmd(self, x: int, y: int, address: str) -> str:
|
||||
if hypr.is_lua_config():
|
||||
return f'dispatch hl.dsp.window.move({{x = {x}, y = {y}, window = "address:{address}"}})'
|
||||
return f"dispatch movewindowpixel exact {x} {y},address:{address}"
|
||||
|
||||
def _make_float_cmd(self, address: str) -> str:
|
||||
if hypr.is_lua_config():
|
||||
return f'dispatch hl.dsp.window.float({{action = "toggle", window = "address:{address}"}})'
|
||||
return f"dispatch togglefloating address:{address}"
|
||||
|
||||
def _make_center_cmd(self) -> str:
|
||||
if hypr.is_lua_config():
|
||||
return "dispatch hl.dsp.window.center()"
|
||||
return "dispatch centerwindow"
|
||||
|
||||
def _load_window_rules(self) -> list[WindowRule]:
|
||||
default_rules = [
|
||||
WindowRule("(Bitwarden", "titleContains", "20%", "54%", ["float", "center"]),
|
||||
WindowRule("Sign in - Google Accounts", "titleContains", "35%", "65%", ["float", "center"]),
|
||||
WindowRule("oauth", "titleContains", "30%", "60%", ["float", "center"]),
|
||||
WindowRule("^[Pp]icture(-| )in(-| )[Pp]icture$", "titleRegex", "", "", ["pip"]),
|
||||
]
|
||||
|
||||
config = get_config()
|
||||
try:
|
||||
config = json.loads(user_config_path.read_text())
|
||||
if "resizer" in config and "rules" in config["resizer"]:
|
||||
rules = []
|
||||
for rule_config in config["resizer"]["rules"]:
|
||||
@@ -49,8 +67,8 @@ class Command:
|
||||
)
|
||||
)
|
||||
return rules
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
log_message("ERROR: invalid config")
|
||||
except KeyError:
|
||||
warn("invalid config, falling back to default rules")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
@@ -140,7 +158,10 @@ class Command:
|
||||
monitor_x = monitor.get("x")
|
||||
monitor_y = monitor.get("y")
|
||||
|
||||
if not all(isinstance(x, (int, float)) for x in [monitor_height, monitor_width, monitor_scale, monitor_x, monitor_y]):
|
||||
if not all(
|
||||
isinstance(x, (int, float))
|
||||
for x in [monitor_height, monitor_width, monitor_scale, monitor_x, monitor_y]
|
||||
):
|
||||
return
|
||||
|
||||
monitor_height = monitor_height / monitor_scale
|
||||
@@ -163,16 +184,14 @@ class Command:
|
||||
move_x = monitor_x + monitor_width - scaled_width - offset
|
||||
move_y = monitor_y + monitor_height - scaled_height - offset
|
||||
|
||||
command1 = f"dispatch resizewindowpixel exact {scaled_width} {scaled_height},address:{address}"
|
||||
command2 = f"dispatch movewindowpixel exact {int(move_x)} {int(move_y)},address:{address}"
|
||||
command1 = self._make_resize_cmd(scaled_width, scaled_height, address)
|
||||
command2 = self._make_move_cmd(int(move_x), int(move_y), address)
|
||||
hypr.batch(command1, command2)
|
||||
|
||||
log_message(
|
||||
f"Applied PiP action to window {address}: {scaled_width}x{scaled_height} at ({move_x}, {move_y})"
|
||||
)
|
||||
info(f"Applied PiP action to window {address}: {scaled_width}x{scaled_height} at ({move_x}, {move_y})")
|
||||
|
||||
except Exception as e:
|
||||
log_message(f"ERROR: Failed to apply PiP action to window 0x{window_id}: {e}")
|
||||
error(f"failed to apply PiP action to window 0x{window_id}: {e}")
|
||||
|
||||
def _apply_window_actions(self, window_id: str, width: str, height: str, actions: list[str]) -> bool:
|
||||
dispatch_commands = []
|
||||
@@ -180,23 +199,23 @@ class Command:
|
||||
if "float" in actions:
|
||||
window_info = self._get_window_info(window_id)
|
||||
if window_info and not window_info.get("floating", False):
|
||||
dispatch_commands.append(f"dispatch togglefloating address:0x{window_id}")
|
||||
dispatch_commands.append(self._make_float_cmd(f"0x{window_id}"))
|
||||
|
||||
if "pip" in actions:
|
||||
self._apply_pip_action(window_id)
|
||||
return True
|
||||
|
||||
dispatch_commands.append(f"dispatch resizewindowpixel exact {width} {height},address:0x{window_id}")
|
||||
dispatch_commands.append(self._make_resize_cmd(width, height, f"0x{window_id}"))
|
||||
|
||||
if "center" in actions:
|
||||
dispatch_commands.append("dispatch centerwindow")
|
||||
dispatch_commands.append(self._make_center_cmd())
|
||||
|
||||
try:
|
||||
hypr.batch(*dispatch_commands)
|
||||
log_message(f"Applied actions to window 0x{window_id}: {width} x {height} ({', '.join(actions)})")
|
||||
info(f"Applied actions to window 0x{window_id}: {width} x {height} ({', '.join(actions)})")
|
||||
return True
|
||||
except Exception as e:
|
||||
log_message(f"ERROR: Failed to apply window actions for window 0x{window_id}: {e}")
|
||||
error(f"failed to apply window actions for window 0x{window_id}: {e}")
|
||||
return False
|
||||
|
||||
def _match_window_rule(self, window_title: str, initial_title: str) -> WindowRule | None:
|
||||
@@ -215,7 +234,7 @@ class Command:
|
||||
if re.search(rule.name, window_title):
|
||||
return rule
|
||||
except re.error:
|
||||
log_message(f"ERROR: Invalid regex pattern in rule '{rule.name}'")
|
||||
warn(f"invalid regex pattern in rule '{rule.name}'")
|
||||
|
||||
return None
|
||||
|
||||
@@ -237,7 +256,7 @@ class Command:
|
||||
window_id = window_id.lstrip(">")
|
||||
|
||||
if not all(c in "0123456789abcdefABCDEF" for c in window_id):
|
||||
log_message(f"ERROR: Invalid window ID format: {window_id}")
|
||||
warn(f"invalid window ID format: {window_id}")
|
||||
return
|
||||
|
||||
window_info = self._get_window_info(window_id)
|
||||
@@ -247,19 +266,19 @@ class Command:
|
||||
window_title = window_info.get("title", "")
|
||||
initial_title = window_info.get("initialTitle", "")
|
||||
|
||||
log_message(f"DEBUG: Window 0x{window_id} - Title: '{window_title}' | Initial: '{initial_title}'")
|
||||
log(f"Window 0x{window_id} - Title: '{window_title}' | Initial: '{initial_title}'")
|
||||
|
||||
rule = self._match_window_rule(window_title, initial_title)
|
||||
if rule:
|
||||
if self._is_rate_limited(window_id):
|
||||
log_message(f"Rate limited: skipping window 0x{window_id}")
|
||||
log(f"Rate limited: skipping window 0x{window_id}")
|
||||
return
|
||||
|
||||
log_message(f"Matched rule '{rule.name}' for window 0x{window_id}")
|
||||
info(f"Matched rule '{rule.name}' for window 0x{window_id}")
|
||||
self._apply_window_actions(window_id, rule.width, rule.height, rule.actions)
|
||||
|
||||
except (IndexError, ValueError) as e:
|
||||
log_message(f"ERROR: Failed to parse window title event: {e}")
|
||||
warn(f"failed to parse window title event: {e}")
|
||||
|
||||
def _handle_open_event(self, event: str) -> None:
|
||||
try:
|
||||
@@ -275,22 +294,22 @@ class Command:
|
||||
window_id = window_id.lstrip(">")
|
||||
|
||||
if not all(c in "0123456789abcdefABCDEF" for c in window_id):
|
||||
log_message(f"ERROR: Invalid window ID format: {window_id}")
|
||||
warn(f"invalid window ID format: {window_id}")
|
||||
return
|
||||
|
||||
log_message(f"DEBUG: New window 0x{window_id} - Title: '{title}' | Class: '{window_class}'")
|
||||
log(f"New window 0x{window_id} - Title: '{title}' | Class: '{window_class}'")
|
||||
|
||||
rule = self._match_window_rule(title, title)
|
||||
if rule:
|
||||
if self._is_rate_limited(window_id):
|
||||
log_message(f"Rate limited: skipping window 0x{window_id}")
|
||||
log(f"Rate limited: skipping window 0x{window_id}")
|
||||
return
|
||||
|
||||
log_message(f"Matched rule '{rule.name}' for new window 0x{window_id}")
|
||||
info(f"Matched rule '{rule.name}' for new window 0x{window_id}")
|
||||
self._apply_window_actions(window_id, rule.width, rule.height, rule.actions)
|
||||
|
||||
except (IndexError, ValueError) as e:
|
||||
log_message(f"ERROR: Failed to parse window open event: {e}")
|
||||
warn(f"failed to parse window open event: {e}")
|
||||
|
||||
def run(self) -> None:
|
||||
if self.args.daemon:
|
||||
@@ -303,7 +322,7 @@ class Command:
|
||||
):
|
||||
self._run_active_mode()
|
||||
else:
|
||||
print(
|
||||
info(
|
||||
"Resizer daemon - use --daemon to start, 'pip' for quick pip mode, or provide pattern, match_type, width, height, and actions for active mode"
|
||||
)
|
||||
|
||||
@@ -312,28 +331,27 @@ class Command:
|
||||
try:
|
||||
active_window_result = hypr.message("activewindow")
|
||||
if not isinstance(active_window_result, dict) or not active_window_result.get("address"):
|
||||
print("ERROR: No active window found")
|
||||
error("no active window found")
|
||||
return
|
||||
|
||||
address = active_window_result.get("address", "")
|
||||
if not isinstance(address, str) or not address.startswith("0x"):
|
||||
print("ERROR: Invalid window address")
|
||||
error("invalid window address")
|
||||
return
|
||||
|
||||
window_id = address[2:] # Remove "0x" prefix
|
||||
window_title = active_window_result.get("title", "")
|
||||
|
||||
if not active_window_result.get("floating", False):
|
||||
print(f"Window '{window_title}' is not floating. PIP only works on floating windows.")
|
||||
print("Try making it floating first with: hyprctl dispatch togglefloating")
|
||||
warn(f"window '{window_title}' is not floating; PiP only works on floating windows.")
|
||||
return
|
||||
|
||||
print(f"Applying PIP to active window: '{window_title}'")
|
||||
info(f"Applying PiP to active window: '{window_title}'")
|
||||
self._apply_pip_action(window_id)
|
||||
print("PIP applied successfully")
|
||||
info("PiP applied successfully")
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to apply PIP to active window: {e}")
|
||||
error(f"failed to apply PiP to active window: {e}")
|
||||
|
||||
def _run_active_mode(self) -> None:
|
||||
try:
|
||||
@@ -350,10 +368,10 @@ class Command:
|
||||
matching_windows = self._find_matching_windows(temp_rule)
|
||||
|
||||
if not matching_windows:
|
||||
print(f"No windows found matching pattern '{temp_rule.name}' with match type '{temp_rule.match_type}'")
|
||||
warn(f"no windows found matching pattern '{temp_rule.name}' with match type '{temp_rule.match_type}'")
|
||||
return
|
||||
|
||||
print(f"Found {len(matching_windows)} matching window(s)")
|
||||
info(f"Found {len(matching_windows)} matching window(s)")
|
||||
|
||||
# Apply rule to all matching windows
|
||||
success_count = 0
|
||||
@@ -361,41 +379,41 @@ class Command:
|
||||
window_id = window["address"][2:] # Remove "0x" prefix
|
||||
window_title = window.get("title", "")
|
||||
|
||||
print(f"Applying rule to window 0x{window_id}: '{window_title}'")
|
||||
info(f"Applying rule to window 0x{window_id}: '{window_title}'")
|
||||
success = self._apply_window_actions(window_id, temp_rule.width, temp_rule.height, temp_rule.actions)
|
||||
if success:
|
||||
success_count += 1
|
||||
|
||||
print(f"Successfully applied rule to {success_count}/{len(matching_windows)} windows")
|
||||
info(f"Successfully applied rule to {success_count}/{len(matching_windows)} windows")
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to apply rule: {e}")
|
||||
error(f"failed to apply rule: {e}")
|
||||
|
||||
def _apply_to_active_window(self, temp_rule: WindowRule) -> None:
|
||||
"""Apply rule only to the currently active window"""
|
||||
try:
|
||||
active_window_result = hypr.message("activewindow")
|
||||
if not isinstance(active_window_result, dict) or not active_window_result.get("address"):
|
||||
print("ERROR: No active window found")
|
||||
error("no active window found")
|
||||
return
|
||||
|
||||
window_title = active_window_result.get("title", "")
|
||||
address = active_window_result.get("address", "")
|
||||
if not isinstance(address, str) or not address.startswith("0x"):
|
||||
print("ERROR: Invalid window address")
|
||||
error("invalid window address")
|
||||
return
|
||||
|
||||
window_id = address[2:] # Remove "0x" prefix
|
||||
|
||||
print(f"Applying rule to active window 0x{window_id}: '{window_title}'")
|
||||
info(f"Applying rule to active window 0x{window_id}: '{window_title}'")
|
||||
success = self._apply_window_actions(window_id, temp_rule.width, temp_rule.height, temp_rule.actions)
|
||||
if success:
|
||||
print("Rule applied successfully")
|
||||
info("Rule applied successfully")
|
||||
else:
|
||||
print("Failed to apply rule")
|
||||
error("failed to apply rule")
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to apply rule to active window: {e}")
|
||||
error(f"failed to apply rule to active window: {e}")
|
||||
|
||||
def _find_matching_windows(self, temp_rule: WindowRule) -> list:
|
||||
"""Find all windows that match the given rule pattern"""
|
||||
@@ -424,7 +442,7 @@ class Command:
|
||||
try:
|
||||
matches = bool(re.search(temp_rule.name, window_title))
|
||||
except re.error:
|
||||
print(f"ERROR: Invalid regex pattern '{temp_rule.name}'")
|
||||
warn(f"invalid regex pattern '{temp_rule.name}'")
|
||||
return []
|
||||
|
||||
if matches:
|
||||
@@ -433,23 +451,22 @@ class Command:
|
||||
return matching_windows
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to find matching windows: {e}")
|
||||
error(f"failed to find matching windows: {e}")
|
||||
return []
|
||||
|
||||
def _run_daemon(self) -> None:
|
||||
log_message("Hyprland window resizer started")
|
||||
log_message(f"Loaded {len(self.window_rules)} window rules")
|
||||
info("Hyprland window resizer started")
|
||||
info(f"Loaded {len(self.window_rules)} window rules")
|
||||
|
||||
socket_path = Path(hypr.socket2_path)
|
||||
if not socket_path.exists():
|
||||
log_message(f"ERROR: Hyprland socket not found at {socket_path}")
|
||||
return
|
||||
fatal(f"Hyprland socket not found at {socket_path}")
|
||||
|
||||
try:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||
sock.connect(hypr.socket2_path)
|
||||
|
||||
log_message("Connected to Hyprland socket, listening for events...")
|
||||
info("Connected to Hyprland socket, listening for events...")
|
||||
|
||||
while True:
|
||||
data = sock.recv(4096).decode()
|
||||
@@ -459,6 +476,6 @@ class Command:
|
||||
self._handle_window_event(line)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
log_message("Resizer daemon stopped")
|
||||
info("Resizer daemon stopped")
|
||||
except Exception as e:
|
||||
log_message(f"ERROR: {e}")
|
||||
error(str(e))
|
||||
|
||||
@@ -2,6 +2,7 @@ import subprocess
|
||||
from argparse import Namespace
|
||||
from datetime import datetime
|
||||
|
||||
from caelestia.utils import hypr
|
||||
from caelestia.utils.notify import notify
|
||||
from caelestia.utils.paths import screenshots_cache_dir, screenshots_dir
|
||||
|
||||
@@ -26,11 +27,19 @@ class Command:
|
||||
else:
|
||||
sc_data = subprocess.check_output(["grim", "-l", "0", "-g", self.args.region.strip(), "-"])
|
||||
swappy = subprocess.Popen(["swappy", "-f", "-"], stdin=subprocess.PIPE, start_new_session=True)
|
||||
|
||||
# Ensure stdin is not None for the type checker
|
||||
if swappy.stdin:
|
||||
swappy.stdin.write(sc_data)
|
||||
swappy.stdin.close()
|
||||
|
||||
def fullscreen(self) -> None:
|
||||
sc_data = subprocess.check_output(["grim", "-"])
|
||||
cmd = ["grim"]
|
||||
focused_monitor = next(monitor for monitor in hypr.message("monitors") if monitor["focused"])
|
||||
if focused_monitor:
|
||||
cmd += ["-o", focused_monitor["name"]]
|
||||
cmd += ["-"]
|
||||
sc_data = subprocess.check_output(cmd)
|
||||
|
||||
subprocess.run(["wl-copy"], input=sc_data)
|
||||
|
||||
|
||||
@@ -33,11 +33,14 @@ class Command:
|
||||
subprocess.run(args)
|
||||
else:
|
||||
shell = subprocess.Popen(args, stdout=subprocess.PIPE, universal_newlines=True)
|
||||
|
||||
# Ensure stdout is not None for the type checker
|
||||
if shell.stdout:
|
||||
for line in shell.stdout:
|
||||
if self.filter_log(line):
|
||||
print(line, end="")
|
||||
|
||||
def shell(self, *args: list[str]) -> str:
|
||||
def shell(self, *args: str) -> str:
|
||||
return subprocess.check_output(["qs", "-c", "caelestia", *args], text=True)
|
||||
|
||||
def filter_log(self, line: str) -> bool:
|
||||
|
||||
@@ -3,9 +3,10 @@ import shlex
|
||||
import shutil
|
||||
from argparse import Namespace
|
||||
from collections import ChainMap
|
||||
from typing import Any, Callable, cast
|
||||
|
||||
from caelestia.utils import hypr
|
||||
from caelestia.utils.paths import user_config_path
|
||||
from caelestia.utils.paths import get_config
|
||||
|
||||
|
||||
def is_subset(superset, subset):
|
||||
@@ -52,8 +53,8 @@ class DeepChainMap(ChainMap):
|
||||
|
||||
class Command:
|
||||
args: Namespace
|
||||
cfg: dict[str, dict[str, dict[str, any]]] | DeepChainMap
|
||||
clients: list[dict[str, any]] = None
|
||||
cfg: dict[str, dict[str, dict[str, Any]]] | DeepChainMap
|
||||
clients: list[dict[str, Any]] | None = None
|
||||
|
||||
def __init__(self, args: Namespace) -> None:
|
||||
self.args = args
|
||||
@@ -102,8 +103,8 @@ class Command:
|
||||
},
|
||||
}
|
||||
try:
|
||||
self.cfg = DeepChainMap(json.loads(user_config_path.read_text())["toggles"], self.cfg)
|
||||
except (FileNotFoundError, json.JSONDecodeError, KeyError):
|
||||
self.cfg = DeepChainMap(get_config()["toggles"], self.cfg)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def run(self) -> None:
|
||||
@@ -120,27 +121,27 @@ class Command:
|
||||
if not spawned:
|
||||
hypr.dispatch("togglespecialworkspace", self.args.workspace)
|
||||
|
||||
def get_clients(self) -> list[dict[str, any]]:
|
||||
def get_clients(self) -> list[dict[str, Any]]:
|
||||
if self.clients is None:
|
||||
self.clients = hypr.message("clients")
|
||||
|
||||
self.clients = cast(list[dict[str, Any]], hypr.message("clients"))
|
||||
return self.clients
|
||||
|
||||
def move_client(self, selector: callable, workspace: str) -> None:
|
||||
def move_client(self, selector: Callable, workspace: str) -> None:
|
||||
for client in self.get_clients():
|
||||
if selector(client) and client["workspace"]["name"] != f"special:{workspace}":
|
||||
hypr.dispatch("movetoworkspacesilent", f"special:{workspace},address:{client['address']}")
|
||||
|
||||
def spawn_client(self, selector: callable, spawn: list[str]) -> bool:
|
||||
def spawn_client(self, selector: Callable, spawn: list[str]) -> bool:
|
||||
if (spawn[0].endswith(".desktop") or shutil.which(spawn[0])) and not any(
|
||||
selector(client) for client in self.get_clients()
|
||||
):
|
||||
hypr.dispatch("exec", f"[workspace special:{self.args.workspace}] app2unit -- {shlex.join(spawn)}")
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def handle_client_config(self, client: dict[str, any]) -> bool:
|
||||
def selector(c: dict[str, any]) -> bool:
|
||||
def handle_client_config(self, client: dict[str, Any]) -> bool:
|
||||
def selector(c: dict[str, Any]) -> bool:
|
||||
# Each match is or, inside matches is and
|
||||
for match in client["match"]:
|
||||
if is_subset(c, match):
|
||||
@@ -156,5 +157,8 @@ class Command:
|
||||
return spawned
|
||||
|
||||
def specialws(self) -> None:
|
||||
special = next(m for m in hypr.message("monitors") if m["focused"])["specialWorkspace"]["name"]
|
||||
hypr.dispatch("togglespecialworkspace", special[8:] or "special")
|
||||
monitors = cast(list[dict[str, Any]], hypr.message("monitors"))
|
||||
target = next((m for m in monitors if m.get("focused")), None)
|
||||
if target:
|
||||
special = target.get("specialWorkspace", {}).get("name", "")[8:] or "special"
|
||||
hypr.dispatch("togglespecialworkspace", special)
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import sys
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
|
||||
from caelestia.utils.dots.deployer import Deployer
|
||||
from caelestia.utils.dots.diff import Changeset
|
||||
from caelestia.utils.dots.manifest import ComponentError, Manifest, ManifestError
|
||||
from caelestia.utils.dots.misc import build_local_packages, run_hooks
|
||||
from caelestia.utils.dots.packages import PackageError, PackageInstaller
|
||||
from caelestia.utils.dots.source import DotsSource, SourceError
|
||||
from caelestia.utils.dots.state import DotsState
|
||||
from caelestia.utils.io import disable_input, fatal, info, log, prompt_selection, warn
|
||||
|
||||
|
||||
class Command:
|
||||
args: Namespace
|
||||
|
||||
def __init__(self, args: Namespace) -> None:
|
||||
self.args = args
|
||||
|
||||
def run(self) -> None:
|
||||
if self.args.noconfirm:
|
||||
disable_input()
|
||||
|
||||
state = DotsState.load()
|
||||
if state.applied_rev is None:
|
||||
fatal("dots not installed yet. Run `caelestia install` first.")
|
||||
|
||||
# Run system update
|
||||
try:
|
||||
installer = PackageInstaller.get(self.args.aur_helper or state.aur_helper, self.args.noconfirm)
|
||||
installer.system_update()
|
||||
except PackageError as e:
|
||||
fatal(e)
|
||||
|
||||
# Get manifest or exit if up to date
|
||||
source, tip, manifest = self.fetch_manifest(state, state.applied_rev)
|
||||
|
||||
# Apply file changes
|
||||
entries = manifest.enabled_entries()
|
||||
try:
|
||||
changeset = Changeset.compute(source, state.applied_rev, tip, entries, state.deployed_files)
|
||||
source.checkout_tip()
|
||||
except SourceError as e:
|
||||
fatal(e)
|
||||
new_files, revived_files, placed = self.deploy_changeset(source, changeset)
|
||||
|
||||
# Persist file changes immediately so a later failure can't lose track of them
|
||||
deployed = dict(state.deployed_files)
|
||||
for dest in (*changeset.deletes, *changeset.stale, *changeset.untracked):
|
||||
deployed.pop(str(dest), None)
|
||||
for repofile, dest in changeset.remap:
|
||||
deployed[str(dest)] = repofile
|
||||
deployed.update(placed)
|
||||
state.deployed_files = deployed
|
||||
state.save()
|
||||
|
||||
# Install new/remove old packages
|
||||
desired = manifest.enabled_packages()
|
||||
desired_local = manifest.enabled_local_packages()
|
||||
try:
|
||||
state.packages = self.sync_packages(installer, state.packages, desired)
|
||||
state.save()
|
||||
state.local_packages = self.sync_local_packages(installer, source, state.local_packages, desired_local)
|
||||
state.save()
|
||||
except PackageError as e:
|
||||
fatal(e)
|
||||
|
||||
# Run hooks
|
||||
run_hooks(manifest, "post_update")
|
||||
|
||||
# Mark the new revision applied
|
||||
state.applied_rev = tip
|
||||
state.enabled_components = manifest.enabled_components
|
||||
state.aur_helper = getattr(installer, "helper", state.aur_helper)
|
||||
state.save()
|
||||
|
||||
self.summarize(changeset, new_files, revived_files)
|
||||
|
||||
def fetch_manifest(self, state: DotsState, applied_rev: str) -> tuple[DotsSource, str, Manifest]:
|
||||
print()
|
||||
log("Fetching dots repo...")
|
||||
source = DotsSource()
|
||||
try:
|
||||
source.ensure()
|
||||
tip = source.tip_rev()
|
||||
if tip == applied_rev:
|
||||
info("Dots already up to date.")
|
||||
sys.exit(0)
|
||||
|
||||
manifest = source.manifest_at(tip)
|
||||
if source.has_rev(applied_rev):
|
||||
known = set(source.manifest_at(applied_rev).components)
|
||||
else:
|
||||
# Treat all components as known if rev is invalid so we don't overwrite existing prefs
|
||||
known = set(manifest.components)
|
||||
except (SourceError, ManifestError) as e:
|
||||
fatal(e)
|
||||
|
||||
# Enable components recorded at install time + any new components that are default on
|
||||
enabled = [
|
||||
name
|
||||
for name, comp in manifest.components.items()
|
||||
if name in state.enabled_components or (name not in known and comp.default)
|
||||
]
|
||||
|
||||
# Let the user opt into any new optional components
|
||||
new_comps = [name for name, comp in manifest.components.items() if name not in known and not comp.default]
|
||||
if new_comps:
|
||||
info(f"New components: {', '.join(new_comps)}")
|
||||
enabled += prompt_selection(new_comps, "Components to enable?")
|
||||
|
||||
disabled = [name for name in manifest.components if name not in enabled]
|
||||
try:
|
||||
manifest.resolve_components(enable=enabled, disable=disabled)
|
||||
except ComponentError as e:
|
||||
fatal(e)
|
||||
|
||||
info(f"Enabled components: {', '.join(enabled) or 'none'}")
|
||||
|
||||
return source, tip, manifest
|
||||
|
||||
def deploy_changeset(
|
||||
self, source: DotsSource, changeset: Changeset
|
||||
) -> tuple[list[Path], list[Path], dict[str, str]]:
|
||||
print()
|
||||
|
||||
if changeset.is_empty():
|
||||
info("No configs to update.")
|
||||
return [], [], {}
|
||||
|
||||
log("Updating configs...")
|
||||
deployer = Deployer()
|
||||
|
||||
for repofile, dest in changeset.place:
|
||||
src = source.working_path(repofile)
|
||||
if not src.exists():
|
||||
warn(f"missing in source, skipping: {repofile}")
|
||||
continue
|
||||
deployer.place_file(src, dest)
|
||||
info(f"{repofile} -> {dest}")
|
||||
|
||||
new_files = []
|
||||
for repofile, dest in changeset.conflicts:
|
||||
src = source.working_path(repofile)
|
||||
if not src.exists():
|
||||
warn(f"missing in source, skipping: {repofile}")
|
||||
continue
|
||||
new_path = deployer.write_new(src, dest)
|
||||
new_files.append(new_path)
|
||||
warn(f"{dest} has local changes; upstream version written as {new_path.name}")
|
||||
|
||||
revived_files = []
|
||||
for repofile, dest in changeset.deleted_changed:
|
||||
src = source.working_path(repofile)
|
||||
if not src.exists():
|
||||
warn(f"missing in source, skipping: {repofile}")
|
||||
continue
|
||||
new_path = deployer.write_new(src, dest)
|
||||
revived_files.append(new_path)
|
||||
warn(f"{dest} was removed but changed upstream; upstream version written as {new_path.name}")
|
||||
|
||||
for dest in changeset.deletes:
|
||||
deployer.remove(dest)
|
||||
deployer.prune_empty_dirs(dest, Path.home())
|
||||
info(f"Removed {dest}")
|
||||
|
||||
return new_files, revived_files, deployer.deployed_files
|
||||
|
||||
def sync_packages(self, installer: PackageInstaller, current: list[str], desired: list[str]) -> list[str]:
|
||||
to_install = [p for p in desired if p not in current]
|
||||
to_remove = [p for p in current if p not in desired]
|
||||
installed = list(current)
|
||||
|
||||
if to_install:
|
||||
print()
|
||||
info(f"Installing new packages: {', '.join(to_install)}")
|
||||
installer.install(to_install)
|
||||
installed.extend(p for p in to_install if p not in installed)
|
||||
|
||||
if to_remove:
|
||||
print()
|
||||
info(f"Packages no longer required: {', '.join(to_remove)}")
|
||||
selected = prompt_selection(to_remove, "Packages to remove?")
|
||||
if selected:
|
||||
installer.remove(selected)
|
||||
installed = [p for p in installed if p not in selected]
|
||||
|
||||
return installed
|
||||
|
||||
def sync_local_packages(
|
||||
self, installer: PackageInstaller, source: DotsSource, current: dict[str, list[str]], desired: list[str]
|
||||
) -> dict[str, list[str]]:
|
||||
to_build = [p for p in desired if p not in current]
|
||||
to_rebuild = self.outdated_local_packages(installer, source, current, desired)
|
||||
to_remove = [p for p in current if p not in desired]
|
||||
installed = dict(current)
|
||||
|
||||
if to_build:
|
||||
print()
|
||||
log(f"Building new local packages: {', '.join(to_build)}")
|
||||
installed.update(build_local_packages(installer, source, to_build))
|
||||
|
||||
if to_rebuild:
|
||||
print()
|
||||
log(f"Rebuilding updated local packages: {', '.join(to_rebuild)}")
|
||||
installed.update(build_local_packages(installer, source, to_rebuild))
|
||||
|
||||
if to_remove:
|
||||
print()
|
||||
info(f"Local packages no longer required: {', '.join(to_remove)}")
|
||||
selected = prompt_selection(to_remove, "Local packages to remove?")
|
||||
if selected:
|
||||
installer.remove([pkg for path in selected for pkg in current[path]])
|
||||
for path in selected:
|
||||
installed.pop(path, None)
|
||||
|
||||
return installed
|
||||
|
||||
def outdated_local_packages(
|
||||
self, installer: PackageInstaller, source: DotsSource, current: dict[str, list[str]], desired: list[str]
|
||||
) -> list[str]:
|
||||
"""Repo paths whose installed packages are older than what the repo would build (skipped when off Arch)."""
|
||||
|
||||
outdated = []
|
||||
for path in desired:
|
||||
if path not in current:
|
||||
continue
|
||||
|
||||
directory = source.working_path(path)
|
||||
if not directory.is_dir():
|
||||
continue
|
||||
|
||||
try:
|
||||
if installer.needs_rebuild(directory, current[path]):
|
||||
outdated.append(path)
|
||||
except PackageError as e:
|
||||
# Failed to read PKGBUILD, leave it as-is
|
||||
warn(f"could not check {path} for updates, leaving as-is: {e}")
|
||||
|
||||
return outdated
|
||||
|
||||
def summarize(self, changeset: Changeset, new_files: list[Path], revived_files: list[Path]) -> None:
|
||||
print()
|
||||
conflicts = len(new_files) + len(revived_files)
|
||||
info(f"Updated {len(changeset.place)} file(s), removed {len(changeset.deletes)}, {conflicts} conflict(s).")
|
||||
if new_files:
|
||||
info("The following files were changed upstream but you had edited them locally.")
|
||||
info("Your versions were kept; the upstream versions were written alongside as .new:")
|
||||
for path in new_files:
|
||||
info(f" {path}")
|
||||
if revived_files:
|
||||
info("These files were removed by you but changed upstream, so were not restored.")
|
||||
info("The upstream versions were written alongside as .new:")
|
||||
for path in revived_files:
|
||||
info(f" {path}")
|
||||
if changeset.stale:
|
||||
info("These files are no longer managed but differ from what was installed, so were kept:")
|
||||
for path in changeset.stale:
|
||||
info(f" {path}")
|
||||
@@ -11,8 +11,7 @@ def stddev(values: list[float], mean_val: float) -> float:
|
||||
return math.sqrt(sum((x - mean_val) ** 2 for x in values) / len(values)) if values else 0
|
||||
|
||||
|
||||
def calc_colourfulness(image: Image) -> float:
|
||||
width, height = image.size
|
||||
def calc_colourfulness(image: Image.Image) -> float:
|
||||
pixels = list(image.getdata()) # List of (R, G, B) tuples
|
||||
|
||||
rg_diffs = []
|
||||
@@ -32,7 +31,7 @@ def calc_colourfulness(image: Image) -> float:
|
||||
return math.sqrt(std_rg**2 + std_yb**2) + 0.3 * math.sqrt(mean_rg**2 + mean_yb**2)
|
||||
|
||||
|
||||
def get_variant(image: Image) -> str:
|
||||
def get_variant(image: Image.Image) -> str:
|
||||
colourfulness = calc_colourfulness(image)
|
||||
|
||||
if colourfulness < 10:
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from caelestia.utils.paths import cache_dir, config_dir, data_dir, dots_dir, state_dir
|
||||
|
||||
# Dirs to never prune even if empty
|
||||
_PROTECTED_DIRS = frozenset({Path.home(), config_dir, data_dir, state_dir, cache_dir})
|
||||
|
||||
|
||||
class Deployer:
|
||||
"""Places files from the dots clone into their destinations."""
|
||||
|
||||
def __init__(self):
|
||||
self.deployed_files: dict[str, str] = {}
|
||||
|
||||
def place(self, src: Path, dest: Path) -> None:
|
||||
"""Place a whole entry (file or directory tree), replacing any existing dest."""
|
||||
|
||||
if src.is_dir():
|
||||
self.place_dir(src, dest)
|
||||
else:
|
||||
self.place_file(src, dest)
|
||||
|
||||
def place_dir(self, src: Path, dest: Path) -> None:
|
||||
"""Place a directory tree recursively, overwriting any existing dest files."""
|
||||
|
||||
if dest.is_symlink() or dest.is_file():
|
||||
self.remove(dest)
|
||||
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
for path in src.rglob("*"):
|
||||
if path.is_file():
|
||||
self.place_file(path, dest / path.relative_to(src))
|
||||
elif path.is_dir():
|
||||
(dest / path.relative_to(src)).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def place_file(self, src: Path, dest: Path, record: bool = True) -> None:
|
||||
"""Atomically place a single file, replacing any existing dest."""
|
||||
|
||||
if dest.is_dir() and not dest.is_symlink():
|
||||
self.remove(dest)
|
||||
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
f = tempfile.NamedTemporaryFile(dir=dest.parent, delete=False)
|
||||
f.close()
|
||||
try:
|
||||
shutil.copyfile(src, f.name)
|
||||
shutil.copymode(src, f.name)
|
||||
Path(f.name).replace(dest)
|
||||
except BaseException:
|
||||
Path(f.name).unlink()
|
||||
raise
|
||||
|
||||
if record:
|
||||
# Keep relative to dots dir
|
||||
self.deployed_files[str(dest)] = str(src.relative_to(dots_dir))
|
||||
|
||||
def write_new(self, src: Path, dest: Path) -> Path:
|
||||
"""Write the upstream version alongside dest as <dest>.new and return that path."""
|
||||
|
||||
new_path = dest.parent / f"{dest.name}.new"
|
||||
self.place_file(src, new_path, record=False)
|
||||
return new_path
|
||||
|
||||
def remove(self, path: Path) -> None:
|
||||
if path.is_symlink() or path.is_file():
|
||||
path.unlink()
|
||||
elif path.is_dir():
|
||||
shutil.rmtree(path)
|
||||
|
||||
def prune_empty_dirs(self, start: Path, stop: Path) -> None:
|
||||
"""Removes dirs recursively from start to stop.
|
||||
|
||||
Will never prune protected dirs (home, config, cache, etc).
|
||||
"""
|
||||
|
||||
parent = start.parent
|
||||
while parent != stop and stop in parent.parents and parent not in _PROTECTED_DIRS:
|
||||
try:
|
||||
parent.rmdir()
|
||||
except OSError:
|
||||
break
|
||||
parent = parent.parent
|
||||
@@ -0,0 +1,153 @@
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from caelestia.utils.dots.manifest import ManifestEntry
|
||||
from caelestia.utils.dots.source import DotsSource, SourceError
|
||||
from caelestia.utils.io import warn
|
||||
|
||||
|
||||
class _Continue(Exception):
|
||||
"""Signals the deployed-files loop to skip to the next entry."""
|
||||
|
||||
|
||||
def _read_local(path: Path) -> bytes | None:
|
||||
"""Read a local file, returning None if it can't be read (perms, is a dir, etc.)."""
|
||||
|
||||
try:
|
||||
return path.read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Changeset:
|
||||
place: list[tuple[str, Path]] = field(default_factory=list) # (repofile, dest) to fast-forward
|
||||
conflicts: list[tuple[str, Path]] = field(default_factory=list) # (repofile, dest) -> write .new
|
||||
deletes: list[Path] = field(default_factory=list) # We placed it, upstream removed it, unmodified
|
||||
stale: list[Path] = field(default_factory=list) # Upstream removed it but user modified it
|
||||
deleted_changed: list[tuple[str, Path]] = field(default_factory=list) # User deleted it, upstream changed -> .new
|
||||
untracked: list[Path] = field(default_factory=list) # Gone + no longer managed; drop from state
|
||||
remap: list[tuple[str, Path]] = field(default_factory=list) # Up to date but source path moved; restate mapping
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
return not (self.place or self.conflicts or self.deletes or self.stale or self.deleted_changed)
|
||||
|
||||
@staticmethod
|
||||
def compute(
|
||||
source: DotsSource,
|
||||
applied_rev: str,
|
||||
tip: str,
|
||||
entries: list[ManifestEntry],
|
||||
deployed: dict[str, str],
|
||||
) -> "Changeset":
|
||||
"""Collect all file changes needed into a Changeset."""
|
||||
|
||||
has_base = source.has_rev(applied_rev)
|
||||
if not has_base:
|
||||
warn(
|
||||
"the previously applied revision is missing from the dots clone; files that differ "
|
||||
"from the latest version will be written as .new instead of updated in place."
|
||||
)
|
||||
|
||||
changed = set(source.changed_files(applied_rev, tip)) if has_base else set()
|
||||
place: list[tuple[str, Path]] = []
|
||||
conflicts: list[tuple[str, Path]] = []
|
||||
deletes: list[Path] = []
|
||||
stale: list[Path] = []
|
||||
deleted_changed: list[tuple[str, Path]] = []
|
||||
untracked: list[Path] = []
|
||||
remap: list[tuple[str, Path]] = []
|
||||
|
||||
# Collect all files to deploy (entry sources can be dirs so we recurse into them)
|
||||
to_deploy: dict[Path, str] = {}
|
||||
for entry in entries:
|
||||
src_root = str(entry.expanded_src())
|
||||
repo_files = source.files_at(tip, src_root)
|
||||
for dest in entry.expanded_dests():
|
||||
for repo_file in repo_files:
|
||||
to_deploy[dest / Path(repo_file).relative_to(src_root)] = repo_file
|
||||
files_to_deploy = set(to_deploy)
|
||||
|
||||
# Already deployed files
|
||||
for dest, src in deployed.items():
|
||||
dest_path = Path(dest)
|
||||
|
||||
def try_read(rev: str, path: str) -> bytes:
|
||||
try:
|
||||
return source.blob_at(rev, path)
|
||||
except SourceError:
|
||||
# Read failed, keep it just in case
|
||||
stale.append(dest_path)
|
||||
raise _Continue
|
||||
|
||||
try:
|
||||
if dest_path not in files_to_deploy: # No longer managed by any entry
|
||||
if not dest_path.exists():
|
||||
# Gone from disk and no entry manages it
|
||||
untracked.append(dest_path)
|
||||
continue
|
||||
|
||||
local = _read_local(dest_path)
|
||||
if local is not None and has_base and try_read(applied_rev, src) == local:
|
||||
deletes.append(dest_path)
|
||||
else:
|
||||
# Modified, or unreadable so we can't verify; keep it just in case
|
||||
stale.append(dest_path)
|
||||
else: # Still managed; `src` is what we last placed, `new_src` the current source
|
||||
new_src = to_deploy[dest_path]
|
||||
if not dest_path.exists():
|
||||
# User deleted a managed file locally
|
||||
if has_base and new_src == src and new_src not in changed:
|
||||
continue # Respect the deletion; upstream has nothing new to offer
|
||||
# Upstream changed it (or base is unknown): surface as .new, don't restore
|
||||
deleted_changed.append((new_src, dest_path))
|
||||
continue
|
||||
|
||||
if has_base and new_src == src and new_src not in changed:
|
||||
continue # Unchanged upstream
|
||||
|
||||
dest_content = _read_local(dest_path)
|
||||
if dest_content is None:
|
||||
# Unreadable (perms, became a dir, ...); surface upstream as .new, don't clobber
|
||||
conflicts.append((new_src, dest_path))
|
||||
continue
|
||||
|
||||
if try_read(tip, new_src) == dest_content:
|
||||
# Already up to date; restate the mapping if the source path moved
|
||||
if new_src != src:
|
||||
remap.append((new_src, dest_path))
|
||||
continue
|
||||
|
||||
# Fast-forward only when the user hasn't edited since last deploy
|
||||
if has_base and try_read(applied_rev, src) == dest_content:
|
||||
place.append((new_src, dest_path))
|
||||
else:
|
||||
conflicts.append((new_src, dest_path))
|
||||
except _Continue:
|
||||
continue
|
||||
|
||||
# New files to deploy
|
||||
for dest in files_to_deploy - set(Path(d) for d in deployed):
|
||||
src = to_deploy[dest]
|
||||
try:
|
||||
new_content = source.blob_at(tip, src)
|
||||
except SourceError:
|
||||
# Failed to read the upstream blob; skip rather than abort the whole update
|
||||
warn(f"could not read from source, skipping: {src}")
|
||||
continue
|
||||
if not dest.exists() or new_content == _read_local(dest):
|
||||
# Dest nonexistent or already equal to new content
|
||||
place.append((src, dest))
|
||||
else:
|
||||
# Differs, or exists but unreadable; surface upstream as .new
|
||||
conflicts.append((src, dest))
|
||||
|
||||
return Changeset(
|
||||
place=place,
|
||||
conflicts=conflicts,
|
||||
deletes=deletes,
|
||||
stale=stale,
|
||||
deleted_changed=deleted_changed,
|
||||
untracked=untracked,
|
||||
remap=remap,
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from caelestia.utils.paths import config_dir, data_dir
|
||||
|
||||
LEGACY_META_PKG = "caelestia-meta"
|
||||
|
||||
_confs = [
|
||||
"hypr",
|
||||
"starship.toml",
|
||||
"foot",
|
||||
"fish",
|
||||
"fastfetch",
|
||||
"uwsm",
|
||||
"btop",
|
||||
"spicetify",
|
||||
"Code/User/settings.json",
|
||||
"VSCodium/User/settings.json",
|
||||
"Code/User/keybindings.json",
|
||||
"VSCodium/User/keybindings.json",
|
||||
"code-flags.conf",
|
||||
"codium-flags.conf",
|
||||
]
|
||||
|
||||
|
||||
def _find_legacy_repo(path: Path) -> Path | None:
|
||||
try:
|
||||
remote = subprocess.check_output(["git", "-C", path, "remote", "get-url", "origin"], text=True)
|
||||
except subprocess.CalledProcessError:
|
||||
return
|
||||
|
||||
# Check remote
|
||||
if remote.strip() != "https://github.com/caelestia-dots/caelestia.git":
|
||||
return
|
||||
|
||||
# Ignore anything outside home
|
||||
if Path.home() not in path.parents:
|
||||
return
|
||||
|
||||
# Walk up parents (capped at home) to find the repo root
|
||||
while path != Path.home() and not (path / ".git").is_dir():
|
||||
path = path.parent
|
||||
|
||||
# Only return path if didn't hit home (we really don't want to nuke home)
|
||||
if path != Path.home():
|
||||
return path
|
||||
|
||||
|
||||
def _filter_candidates(candidates: list[Path], legacy_dir: Path) -> list[Path]:
|
||||
return [path for path in candidates if path.is_symlink() and legacy_dir in path.resolve().parents]
|
||||
|
||||
|
||||
def detect_legacy_repo() -> Path | None:
|
||||
for conf in _confs:
|
||||
path = config_dir / conf
|
||||
if not path.is_symlink():
|
||||
continue
|
||||
|
||||
legacy_dir = _find_legacy_repo(path.resolve())
|
||||
if legacy_dir:
|
||||
return legacy_dir
|
||||
|
||||
return _find_legacy_repo(data_dir / "caelestia")
|
||||
|
||||
|
||||
def legacy_config_symlinks(base: Path, legacy_dir: Path | None) -> list[Path]:
|
||||
"""Config-relative links install.fish created, resolved under `base` (the live config or a backup of it)."""
|
||||
|
||||
if not legacy_dir:
|
||||
return []
|
||||
|
||||
candidates = [base / conf for conf in _confs]
|
||||
return _filter_candidates(candidates, legacy_dir)
|
||||
|
||||
|
||||
def legacy_symlinks(legacy_dir: Path | None) -> list[Path]:
|
||||
"""All paths symlinked into the legacy repo (the links install.fish created)."""
|
||||
|
||||
if not legacy_dir:
|
||||
return []
|
||||
|
||||
extras = [
|
||||
*(Path.home() / ".zen").glob("*/chrome/userChrome.css"),
|
||||
Path.home() / ".local/lib/caelestia/caelestiafox",
|
||||
]
|
||||
|
||||
return [*legacy_config_symlinks(config_dir, legacy_dir), *_filter_candidates(extras, legacy_dir)]
|
||||
|
||||
|
||||
def legacy_to_delete(legacy_dir: Path | None) -> list[Path]:
|
||||
if not legacy_dir:
|
||||
return []
|
||||
|
||||
return [*legacy_symlinks(legacy_dir), legacy_dir]
|
||||
@@ -0,0 +1,231 @@
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from string import Template
|
||||
from typing import Any
|
||||
|
||||
from caelestia.utils.io import warn
|
||||
|
||||
_XDG_DEFAULTS = {
|
||||
"XDG_CONFIG_HOME": str(Path.home() / ".config"),
|
||||
"XDG_DATA_HOME": str(Path.home() / ".local/share"),
|
||||
"XDG_STATE_HOME": str(Path.home() / ".local/state"),
|
||||
"XDG_CACHE_HOME": str(Path.home() / ".cache"),
|
||||
}
|
||||
_GLOB_MAGIC = re.compile(r"[*?[]")
|
||||
_LOCAL_PREFIX = "local:"
|
||||
|
||||
|
||||
class ManifestError(Exception):
|
||||
"""Raised when manifest.toml is malformed."""
|
||||
|
||||
|
||||
class ComponentError(Exception):
|
||||
"""Raised when component flags are invalid or contradictory."""
|
||||
|
||||
|
||||
def _expand(text: str) -> Path:
|
||||
"""Expand $VAR/${VAR} env vars (with XDG defaults) and ~ in a path."""
|
||||
|
||||
env = {**_XDG_DEFAULTS, **os.environ}
|
||||
return Path(Template(text).safe_substitute(env)).expanduser()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManifestEntry:
|
||||
src: str
|
||||
dest: str
|
||||
|
||||
def expanded_src(self) -> Path:
|
||||
return _expand(self.src)
|
||||
|
||||
def expanded_dests(self) -> list[Path]:
|
||||
"""The dest path with globs expanded.
|
||||
|
||||
Globs from the start until the segment with the last glob so subdirs are
|
||||
created if they didn't exist previously.
|
||||
"""
|
||||
|
||||
expanded = _expand(self.dest)
|
||||
if not _GLOB_MAGIC.search(str(expanded)):
|
||||
return [expanded]
|
||||
|
||||
parts = expanded.parts
|
||||
glob_idx = max(i for i, part in enumerate(parts) if _GLOB_MAGIC.search(part))
|
||||
pattern = str(Path(*parts[: glob_idx + 1]))
|
||||
tail = parts[glob_idx + 1 :]
|
||||
matches = sorted(glob.glob(pattern))
|
||||
if tail: # Only match dirs if a tail exists
|
||||
matches = [match for match in matches if Path(match).is_dir()]
|
||||
return [Path(match, *tail) for match in matches]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManifestComponent:
|
||||
name: str
|
||||
default: bool = False
|
||||
packages: list[str] = field(default_factory=list)
|
||||
entries: list[ManifestEntry] = field(default_factory=list)
|
||||
post_package: list[str] = field(default_factory=list)
|
||||
post_install: list[str] = field(default_factory=list)
|
||||
post_update: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ManifestData:
|
||||
enabled_comps: list[str] = field(default_factory=list)
|
||||
disabled_comps: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Manifest:
|
||||
components: dict[str, ManifestComponent] = field(default_factory=dict)
|
||||
packages: list[str] = field(default_factory=list)
|
||||
post_package: list[str] = field(default_factory=list) # Post package install (install cmd only)
|
||||
post_install: list[str] = field(default_factory=list) # Very end of install cmd
|
||||
post_update: list[str] = field(default_factory=list) # Very end of update cmd
|
||||
_data: _ManifestData = field(default_factory=_ManifestData, init=False, repr=False)
|
||||
|
||||
@property
|
||||
def enabled_components(self) -> list[str]:
|
||||
return self._data.enabled_comps
|
||||
|
||||
@property
|
||||
def disabled_components(self) -> list[str]:
|
||||
return self._data.disabled_comps
|
||||
|
||||
@staticmethod
|
||||
def parse(text: str) -> "Manifest":
|
||||
try:
|
||||
raw = tomllib.loads(text)
|
||||
except tomllib.TOMLDecodeError as e:
|
||||
raise ManifestError(f"invalid TOML: {e}") from e
|
||||
|
||||
post_package = _validate_str_list(raw.get("post_package", []), "post_package")
|
||||
post_install = _validate_str_list(raw.get("post_install", []), "post_install")
|
||||
post_update = _validate_str_list(raw.get("post_update", []), "post_update")
|
||||
|
||||
packages = _validate_str_list(raw.get("packages", []), "packages")
|
||||
|
||||
components = {}
|
||||
for comp in raw.get("components", []):
|
||||
parsed = _parse_component(comp)
|
||||
if parsed.name in components:
|
||||
warn(f"duplicate component '{parsed.name}'; using the last definition")
|
||||
components[parsed.name] = parsed
|
||||
|
||||
return Manifest(
|
||||
components=components,
|
||||
packages=packages,
|
||||
post_package=post_package,
|
||||
post_install=post_install,
|
||||
post_update=post_update,
|
||||
)
|
||||
|
||||
def resolve_components(
|
||||
self,
|
||||
enable: list[str] | None = None,
|
||||
disable: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Resolves enabled/disabled components. This MUST be called before calling any other method."""
|
||||
|
||||
enable_set = set(enable or [])
|
||||
disable_set = set(disable or [])
|
||||
known = set(self.components)
|
||||
|
||||
for name in enable_set | disable_set:
|
||||
if name not in known:
|
||||
raise ComponentError(f"unknown component: {name}")
|
||||
|
||||
conflict = enable_set & disable_set
|
||||
if conflict:
|
||||
raise ComponentError(f"component(s) both enabled and disabled: {', '.join(sorted(conflict))}")
|
||||
|
||||
enabled = {name for name, comp in self.components.items() if comp.default}
|
||||
enabled |= enable_set
|
||||
enabled -= disable_set
|
||||
|
||||
self._data.enabled_comps.clear()
|
||||
self._data.disabled_comps.clear()
|
||||
for name in self.components:
|
||||
if name in enabled:
|
||||
self._data.enabled_comps.append(name)
|
||||
else:
|
||||
self._data.disabled_comps.append(name)
|
||||
|
||||
def enabled_entries(self) -> list[ManifestEntry]:
|
||||
"""The entries of every enabled component."""
|
||||
|
||||
entries: list[ManifestEntry] = []
|
||||
for name in self._data.enabled_comps:
|
||||
entries.extend(self.components[name].entries)
|
||||
return entries
|
||||
|
||||
def enabled_hooks(self, kind: str) -> list[str]:
|
||||
"""Global + enabled components' hooks of the given kind."""
|
||||
|
||||
hooks = list(getattr(self, kind))
|
||||
for name in self._data.enabled_comps:
|
||||
hooks.extend(getattr(self.components[name], kind))
|
||||
return hooks
|
||||
|
||||
def enabled_packages(self) -> list[str]:
|
||||
"""Repo/AUR packages to install."""
|
||||
return [p for p in self._all_packages() if not p.startswith(_LOCAL_PREFIX)]
|
||||
|
||||
def enabled_local_packages(self) -> list[str]:
|
||||
"""Local PKGBUILD dirs to build.
|
||||
|
||||
Local packages are determined by a local: prefix and are
|
||||
relative dirs instead of package names.
|
||||
"""
|
||||
return [p[len(_LOCAL_PREFIX) :] for p in self._all_packages() if p.startswith(_LOCAL_PREFIX)]
|
||||
|
||||
def _all_packages(self) -> list[str]:
|
||||
"""The manifest's top-level packages plus enabled components', in manifest order.
|
||||
|
||||
Top-level packages come first, then each enabled component's packages in
|
||||
component order. Only the first occurrence of each package is kept.
|
||||
"""
|
||||
|
||||
seen: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
for pkg in (*self.packages, *(p for c in self._data.enabled_comps for p in self.components[c].packages)):
|
||||
if pkg not in seen:
|
||||
seen.add(pkg)
|
||||
ordered.append(pkg)
|
||||
return ordered
|
||||
|
||||
|
||||
def _require_key(d: dict[str, Any], key: str, ctx: str) -> Any:
|
||||
if key not in d:
|
||||
raise ManifestError(f"{ctx}: missing required key '{key}'")
|
||||
return d[key]
|
||||
|
||||
|
||||
def _validate_str_list(value: Any, ctx: str) -> list[str]:
|
||||
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
||||
raise ManifestError(f"{ctx}: expected a list of strings")
|
||||
return value
|
||||
|
||||
|
||||
def _parse_entry(d: Any) -> ManifestEntry:
|
||||
if not isinstance(d, dict):
|
||||
raise ManifestError("entry: expected a table")
|
||||
return ManifestEntry(src=_require_key(d, "src", "entry"), dest=_require_key(d, "dest", "entry"))
|
||||
|
||||
|
||||
def _parse_component(d: dict[str, Any]) -> ManifestComponent:
|
||||
name = _require_key(d, "name", "component")
|
||||
return ManifestComponent(
|
||||
name=name,
|
||||
default=bool(d.get("default", False)),
|
||||
packages=_validate_str_list(d.get("packages", []), f"component '{name}' packages"),
|
||||
entries=[_parse_entry(e) for e in d.get("entries", [])],
|
||||
post_package=_validate_str_list(d.get("post_package", []), f"component '{name}' post_package"),
|
||||
post_install=_validate_str_list(d.get("post_install", []), f"component '{name}' post_install"),
|
||||
post_update=_validate_str_list(d.get("post_update", []), f"component '{name}' post_update"),
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from caelestia.utils.dots.manifest import Manifest
|
||||
from caelestia.utils.dots.packages import PackageInstaller
|
||||
from caelestia.utils.dots.source import DotsSource
|
||||
from caelestia.utils.io import info, log, warn
|
||||
from caelestia.utils.paths import dots_dir
|
||||
|
||||
|
||||
def build_local_packages(installer: PackageInstaller, source: DotsSource, paths: list[str]) -> dict[str, list[str]]:
|
||||
"""Build and install each local PKGBUILD dir, returning {path: installed package names}."""
|
||||
|
||||
built: dict[str, list[str]] = {}
|
||||
for path in paths:
|
||||
directory = source.working_path(path)
|
||||
if not directory.is_dir():
|
||||
warn(f"missing in repo, skipping: {path}")
|
||||
continue
|
||||
log(f"Building {path}...")
|
||||
built[path] = installer.build_install(directory)
|
||||
return built
|
||||
|
||||
|
||||
def run_hooks(manifest: Manifest, kind: str) -> None:
|
||||
"""Run the global + enabled components' hooks of the given kind (e.g. "post_install")."""
|
||||
|
||||
hooks = manifest.enabled_hooks(kind)
|
||||
if not hooks:
|
||||
return
|
||||
|
||||
print()
|
||||
log(f"Running {kind.replace('_', '-')} hooks...")
|
||||
env = {**os.environ, "CAELESTIA_DOTS": str(dots_dir)}
|
||||
for hook in hooks:
|
||||
info(f"Running hook: {hook}")
|
||||
result = subprocess.run(hook, shell=True, env=env)
|
||||
if result.returncode != 0:
|
||||
warn(f"hook exited with {result.returncode}")
|
||||
@@ -0,0 +1,260 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
|
||||
from caelestia.utils.io import fatal, info, warn
|
||||
|
||||
DEFAULT_AUR_HELPER = "paru"
|
||||
AUR_HELPERS = DEFAULT_AUR_HELPER, "yay"
|
||||
|
||||
|
||||
class PackageError(Exception):
|
||||
"""Raised when a package operation (install/remove/build/update) fails."""
|
||||
|
||||
|
||||
def _try_run(cmd: list[str], error_msg: str, **kwargs) -> None:
|
||||
"""Run a subprocess, raising `PackageError` if it fails."""
|
||||
|
||||
try:
|
||||
subprocess.run(cmd, check=True, **kwargs)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
||||
raise PackageError(error_msg) from e
|
||||
|
||||
|
||||
def _read_srcinfo(directory: Path) -> dict[str, list[str]]:
|
||||
"""Run `makepkg --printsrcinfo` in `directory`, grouping each key to its list of values."""
|
||||
|
||||
try:
|
||||
srcinfo = subprocess.check_output(["makepkg", "--printsrcinfo"], cwd=directory, text=True)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
||||
raise PackageError(f"failed to read package metadata in {directory}") from e
|
||||
|
||||
fields: dict[str, list[str]] = {}
|
||||
for line in srcinfo.splitlines():
|
||||
key, sep, value = line.partition("=")
|
||||
if not sep:
|
||||
continue
|
||||
fields.setdefault(key.strip(), []).append(value.strip())
|
||||
return fields
|
||||
|
||||
|
||||
def _srcinfo_version(fields: dict[str, list[str]]) -> str | None:
|
||||
"""Build the `[epoch:]pkgver-pkgrel` version string from parsed .SRCINFO fields, or None if absent."""
|
||||
|
||||
pkgver = next(iter(fields.get("pkgver", [])), None)
|
||||
pkgrel = next(iter(fields.get("pkgrel", [])), None)
|
||||
if pkgver is None or pkgrel is None:
|
||||
return None
|
||||
|
||||
version = f"{pkgver}-{pkgrel}"
|
||||
epoch = next(iter(fields.get("epoch", [])), None)
|
||||
return f"{epoch}:{version}" if epoch else version
|
||||
|
||||
|
||||
def _vercmp(a: str, b: str) -> int:
|
||||
"""Use pacman's `vercmp` to compare to package versions."""
|
||||
|
||||
try:
|
||||
return int(subprocess.check_output(["vercmp", a, b], text=True).strip())
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, ValueError) as e:
|
||||
warn(f"vercmp failed, assuming equal: {e}")
|
||||
return 0 # Don't rebuild when unable to check version
|
||||
|
||||
|
||||
def _install_aur_helper(helper: str, noconfirm: bool = False) -> None:
|
||||
pacman_cmd = ["sudo", "pacman", "-S", "--needed", "git", "base-devel"]
|
||||
if noconfirm:
|
||||
pacman_cmd.append("--noconfirm")
|
||||
_try_run(pacman_cmd, "failed to install AUR helper build dependencies")
|
||||
|
||||
repo_url = f"https://aur.archlinux.org/{helper}.git"
|
||||
with tempfile.TemporaryDirectory() as repo_dir:
|
||||
_try_run(["git", "clone", repo_url, repo_dir], f"failed to clone {helper} from the AUR")
|
||||
|
||||
makepkg_cmd = ["makepkg", "-si"]
|
||||
if noconfirm:
|
||||
makepkg_cmd.append("--noconfirm")
|
||||
_try_run(makepkg_cmd, f"failed to build and install {helper}", cwd=repo_dir)
|
||||
|
||||
try:
|
||||
if helper == "yay":
|
||||
subprocess.run(["yay", "-Y", "--gendb"], check=True)
|
||||
subprocess.run(["yay", "-Y", "--devel", "--save"], check=True)
|
||||
elif helper == "paru":
|
||||
subprocess.run(["paru", "--gendb"], check=True)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
||||
warn(f"failed to run AUR helper post install actions: {e}")
|
||||
|
||||
|
||||
class PackageInstaller(ABC):
|
||||
@staticmethod
|
||||
def get(helper: str | None = None, noconfirm: bool = False) -> "PackageInstaller":
|
||||
"""Pick a package installer: the requested/detected AUR helper on Arch, else a no-op."""
|
||||
|
||||
# Not on Arch, can't install packages
|
||||
if shutil.which("pacman") is None:
|
||||
return NoopInstaller()
|
||||
|
||||
# Explicitly given
|
||||
if helper:
|
||||
if not shutil.which(helper):
|
||||
if helper not in AUR_HELPERS:
|
||||
fatal(f"given AUR helper {helper} is not installed and is unable to be installed automatically.")
|
||||
|
||||
info(f"Given AUR helper not installed. Installing {helper}...")
|
||||
_install_aur_helper(helper, noconfirm)
|
||||
return ArchInstaller(helper, noconfirm)
|
||||
|
||||
# Not given, find installed one
|
||||
for candidate in AUR_HELPERS:
|
||||
if shutil.which(candidate):
|
||||
return ArchInstaller(candidate, noconfirm)
|
||||
|
||||
info(f"No AUR helper found. Installing {DEFAULT_AUR_HELPER}...")
|
||||
_install_aur_helper(DEFAULT_AUR_HELPER, noconfirm)
|
||||
return ArchInstaller(DEFAULT_AUR_HELPER, noconfirm)
|
||||
|
||||
# --- Abstract methods ---
|
||||
|
||||
@abstractmethod
|
||||
def install(self, packages: list[str]) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def remove(self, packages: list[str]) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def build_install(self, directory: Path) -> list[str]:
|
||||
"""Build and install the PKGBUILD in `directory`, returning the installed package names."""
|
||||
|
||||
@abstractmethod
|
||||
def installed_version(self, package: str) -> str | None:
|
||||
"""Return the installed version of `package`, or None if it is not installed."""
|
||||
|
||||
def is_installed(self, package: str) -> bool:
|
||||
return self.installed_version(package) is not None
|
||||
|
||||
@abstractmethod
|
||||
def needs_rebuild(self, directory: Path, packages: list[str]) -> bool:
|
||||
"""Whether the PKGBUILD in `directory` would build a version differing from the installed `packages`."""
|
||||
|
||||
@abstractmethod
|
||||
def system_update(self) -> None: ...
|
||||
|
||||
|
||||
class NoopInstaller(PackageInstaller):
|
||||
"""Used off Arch, where the dots' packages are not available via pacman/AUR."""
|
||||
|
||||
def install(self, packages: list[str]) -> None:
|
||||
if packages:
|
||||
info(f"Skipping package install (not on Arch): {', '.join(packages)}")
|
||||
|
||||
def remove(self, packages: list[str]) -> None:
|
||||
if packages:
|
||||
info(f"Skipping package removal (not on Arch): {', '.join(packages)}")
|
||||
|
||||
def build_install(self, directory: Path) -> list[str]:
|
||||
info(f"Skipping local package build (not on Arch): {directory}")
|
||||
return []
|
||||
|
||||
def installed_version(self, package: str) -> str | None:
|
||||
return None
|
||||
|
||||
def needs_rebuild(self, directory: Path, packages: list[str]) -> bool:
|
||||
return False
|
||||
|
||||
def system_update(self) -> None:
|
||||
info("Skipping system update (not on Arch)")
|
||||
|
||||
|
||||
class ArchInstaller(PackageInstaller):
|
||||
def __init__(self, helper: str, noconfirm: bool = False) -> None:
|
||||
self.helper = helper
|
||||
self.flags = ["--noconfirm"] if noconfirm else []
|
||||
|
||||
def install(self, packages: list[str], explicit: bool = True) -> None:
|
||||
if not packages:
|
||||
return
|
||||
|
||||
cmd = [self.helper, "-S", "--needed", *self.flags]
|
||||
if not explicit:
|
||||
cmd.append("--asdeps") # Set install reason to dep (does not affect already installed packages)
|
||||
_try_run(cmd + packages, f"failed to install packages: {', '.join(packages)}")
|
||||
|
||||
# Force install reason to explicit install
|
||||
if explicit:
|
||||
# `-D` only accepts real installed names, so resolve any virtual/`provides` names (e.g. awk -> gawk)
|
||||
resolved = [self._installed_name(pkg) for pkg in packages]
|
||||
try:
|
||||
subprocess.run([self.helper, "-D", "--asexplicit", *self.flags, *resolved], check=True)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
warn(f"failed to mark packages as explicitly installed: {', '.join(resolved)}")
|
||||
|
||||
def remove(self, packages: list[str]) -> None:
|
||||
if not packages:
|
||||
return
|
||||
_try_run([self.helper, "-Rns", *self.flags, *packages], f"failed to remove packages: {', '.join(packages)}")
|
||||
|
||||
def build_install(self, directory: Path) -> list[str]:
|
||||
fields = _read_srcinfo(directory)
|
||||
names = fields.get("pkgname", [])
|
||||
depends = fields.get("depends", [])
|
||||
|
||||
self.install(depends, explicit=False)
|
||||
|
||||
# Stop makepkg from resetting sudo
|
||||
env = {**os.environ, "PACMAN_AUTH": "sudo"}
|
||||
# -f = force, -s = sync deps, -i = install
|
||||
_try_run(
|
||||
["makepkg", "-fsi", *self.flags], f"failed to build local package in {directory}", cwd=directory, env=env
|
||||
)
|
||||
|
||||
# Clean build artifacts
|
||||
for artifact in directory.glob("*.pkg.tar*"):
|
||||
try:
|
||||
artifact.unlink()
|
||||
except OSError as e:
|
||||
warn(f"failed to remove build artifact {artifact}: {e}")
|
||||
|
||||
return names
|
||||
|
||||
def _query(self, package: str) -> tuple[str, str] | None:
|
||||
"""Return the installed (name, version) of `package`, resolving `provides` (e.g. awk -> gawk), or None."""
|
||||
|
||||
result = subprocess.run(
|
||||
["pacman", "-Q", package],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
# `pacman -Q` resolves provides and prints "<real name> <version>"
|
||||
parts = result.stdout.split()
|
||||
return (parts[0], parts[1]) if len(parts) >= 2 else None
|
||||
|
||||
def _installed_name(self, package: str) -> str:
|
||||
"""Resolve `package` to its real installed name (handles provides), falling back to the given name."""
|
||||
|
||||
query = self._query(package)
|
||||
return query[0] if query else package
|
||||
|
||||
def installed_version(self, package: str) -> str | None:
|
||||
query = self._query(package)
|
||||
return query[1] if query else None
|
||||
|
||||
def needs_rebuild(self, directory: Path, packages: list[str]) -> bool:
|
||||
built = _srcinfo_version(_read_srcinfo(directory))
|
||||
if built is None:
|
||||
return False # Can't determine the source version, leave as is
|
||||
|
||||
# Rebuild when installed version < repo version
|
||||
# Don't rebuild packages that have been removed
|
||||
return any(
|
||||
(installed := self.installed_version(pkg)) is not None and _vercmp(built, installed) > 0 for pkg in packages
|
||||
)
|
||||
|
||||
def system_update(self) -> None:
|
||||
_try_run([self.helper, "-Syu", *self.flags], "failed to perform system update")
|
||||
@@ -0,0 +1,123 @@
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from caelestia.utils.dots.manifest import Manifest
|
||||
from caelestia.utils.paths import dots_dir, get_config
|
||||
|
||||
|
||||
class SourceError(Exception):
|
||||
"""Raised when a git operation against the dots clone fails."""
|
||||
|
||||
|
||||
class DotsSource:
|
||||
_fetched_source: bool = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
cfg = get_config().get("dots", {})
|
||||
self.url = cfg.get("url", "https://github.com/caelestia-dots/caelestia.git")
|
||||
self.branch = cfg.get("branch", "main")
|
||||
# Cache git blobs by (ref, relpath); objects are immutable for a given rev
|
||||
self._blob_cache: dict[tuple[str, str], bytes] = {}
|
||||
|
||||
@property
|
||||
def remote_ref(self) -> str:
|
||||
return f"origin/{self.branch}"
|
||||
|
||||
def exists(self) -> bool:
|
||||
return (dots_dir / ".git").is_dir()
|
||||
|
||||
def working_path(self, relpath: str | Path) -> Path:
|
||||
"""Get a Path relative to the dots dir."""
|
||||
return dots_dir / relpath
|
||||
|
||||
def ensure(self) -> None:
|
||||
"""Clone the repo if absent, otherwise fetch the latest refs.
|
||||
|
||||
If the configured url changed, the stale clone is removed and re-cloned
|
||||
from the new source.
|
||||
"""
|
||||
|
||||
if self.exists():
|
||||
if self.current_url() == self.url:
|
||||
if DotsSource._fetched_source:
|
||||
return
|
||||
|
||||
self._git("fetch", "--prune", "origin", self.branch)
|
||||
DotsSource._fetched_source = True
|
||||
return
|
||||
shutil.rmtree(dots_dir)
|
||||
|
||||
dots_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._run("git", "clone", "--branch", self.branch, self.url, str(dots_dir))
|
||||
DotsSource._fetched_source = True
|
||||
|
||||
def current_url(self) -> str:
|
||||
return self._git("remote", "get-url", "origin").strip()
|
||||
|
||||
def checkout_tip(self) -> str:
|
||||
"""Reset the working tree to the fetched tip and return its commit hash."""
|
||||
|
||||
self._git("reset", "--hard", self.remote_ref)
|
||||
return self.tip_rev()
|
||||
|
||||
def tip_rev(self) -> str:
|
||||
return self._git("rev-parse", self.remote_ref).strip()
|
||||
|
||||
def changed_files(self, base: str, head: str) -> list[str]:
|
||||
"""Repo-relative paths that differ between two revisions."""
|
||||
|
||||
out = self._git("diff", "--name-only", base, head)
|
||||
return [line for line in out.splitlines() if line]
|
||||
|
||||
def has_rev(self, rev: str) -> bool:
|
||||
"""Whether `rev` resolves to a commit."""
|
||||
|
||||
try:
|
||||
self._git("rev-parse", "--verify", "--quiet", f"{rev}^{{commit}}")
|
||||
return True
|
||||
except SourceError:
|
||||
return False
|
||||
|
||||
def clean(self) -> None:
|
||||
"""Remove all untracked files in the git repo."""
|
||||
self._git("clean", "-fdx")
|
||||
|
||||
# --- Accessors ---
|
||||
|
||||
def manifest_at(self, ref: str) -> Manifest:
|
||||
return Manifest.parse(self.text_at(ref, "manifest.toml"))
|
||||
|
||||
def text_at(self, ref: str, relpath: str) -> str:
|
||||
return self._git("show", f"{ref}:{relpath}")
|
||||
|
||||
def blob_at(self, ref: str, relpath: str) -> bytes:
|
||||
key = (ref, relpath)
|
||||
if key not in self._blob_cache:
|
||||
self._blob_cache[key] = self._git_bytes("show", f"{ref}:{relpath}")
|
||||
return self._blob_cache[key]
|
||||
|
||||
def files_at(self, ref: str, relpath: str) -> list[str]:
|
||||
"""Repo-relative paths of all files under relpath at ref (the path itself if it is a file)."""
|
||||
|
||||
out = self._git("ls-tree", "-r", "--name-only", ref, "--", relpath)
|
||||
return [line for line in out.splitlines() if line]
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
def _git(self, *args: str) -> str:
|
||||
# core.quotePath=false so non-ASCII paths come back verbatim, not octal-escaped
|
||||
return self._run("git", "-C", str(dots_dir), "-c", "core.quotePath=false", *args)
|
||||
|
||||
def _git_bytes(self, *args: str) -> bytes:
|
||||
cmd = ["git", "-C", str(dots_dir), "-c", "core.quotePath=false", *args]
|
||||
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if result.returncode != 0:
|
||||
raise SourceError(result.stderr.decode().strip() or f"git {' '.join(args)} failed")
|
||||
return result.stdout
|
||||
|
||||
def _run(self, *cmd: str) -> str:
|
||||
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
if result.returncode != 0:
|
||||
raise SourceError(result.stderr.strip() or f"{' '.join(cmd)} failed")
|
||||
return result.stdout
|
||||
@@ -0,0 +1,58 @@
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from caelestia.utils.dots.packages import DEFAULT_AUR_HELPER
|
||||
from caelestia.utils.io import warn
|
||||
from caelestia.utils.paths import atomic_dump, dots_state_path
|
||||
|
||||
|
||||
@dataclass
|
||||
class DotsState:
|
||||
# The AUR helper selected selected at install time
|
||||
aur_helper: str = "paru"
|
||||
|
||||
# The git rev of currently applied dots version
|
||||
applied_rev: str | None = None
|
||||
|
||||
# The currently enabled components
|
||||
enabled_components: list[str] = field(default_factory=list)
|
||||
|
||||
# Previously installed packages/local packages
|
||||
packages: list[str] = field(default_factory=list)
|
||||
local_packages: dict[str, list[str]] = field(default_factory=dict)
|
||||
|
||||
# Files placed by the last deploy. Only files, not directories
|
||||
# Maps dest -> src
|
||||
deployed_files: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
@staticmethod
|
||||
def load() -> "DotsState":
|
||||
try:
|
||||
data = json.loads(dots_state_path.read_text())
|
||||
except FileNotFoundError:
|
||||
return DotsState()
|
||||
except json.JSONDecodeError:
|
||||
warn("failed to parse current dots state.")
|
||||
return DotsState()
|
||||
|
||||
return DotsState(
|
||||
aur_helper=data.get("aur_helper", DEFAULT_AUR_HELPER),
|
||||
applied_rev=data.get("applied_rev"),
|
||||
enabled_components=data.get("enabled_components", []),
|
||||
packages=data.get("packages", []),
|
||||
local_packages=data.get("local_packages", {}),
|
||||
deployed_files=data.get("deployed_files", {}),
|
||||
)
|
||||
|
||||
def save(self) -> None:
|
||||
atomic_dump(
|
||||
dots_state_path,
|
||||
{
|
||||
"aur_helper": self.aur_helper,
|
||||
"applied_rev": self.applied_rev,
|
||||
"enabled_components": self.enabled_components,
|
||||
"packages": self.packages,
|
||||
"local_packages": self.local_packages,
|
||||
"deployed_files": self.deployed_files,
|
||||
},
|
||||
)
|
||||
+44
-10
@@ -1,17 +1,19 @@
|
||||
import json as j
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
from typing import Any
|
||||
|
||||
socket_base = f"{os.getenv('XDG_RUNTIME_DIR')}/hypr/{os.getenv('HYPRLAND_INSTANCE_SIGNATURE')}"
|
||||
socket_path = f"{socket_base}/.socket.sock"
|
||||
socket2_path = f"{socket_base}/.socket2.sock"
|
||||
|
||||
_lua_config_cache: bool | None = None
|
||||
|
||||
def message(msg: str, json: bool = True) -> str | dict[str, any]:
|
||||
def message(msg: str, is_json: bool = True) -> str | dict[str, Any]:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||
sock.connect(socket_path)
|
||||
|
||||
if json:
|
||||
if is_json:
|
||||
msg = f"j/{msg}"
|
||||
sock.send(msg.encode())
|
||||
|
||||
@@ -22,14 +24,46 @@ def message(msg: str, json: bool = True) -> str | dict[str, any]:
|
||||
break
|
||||
resp += new_resp.decode()
|
||||
|
||||
return j.loads(resp) if json else resp
|
||||
return json.loads(resp) if is_json else resp
|
||||
|
||||
|
||||
def dispatch(dispatcher: str, *args: list[any]) -> bool:
|
||||
return message(f"dispatch {dispatcher} {' '.join(map(str, args))}".rstrip(), json=False) == "ok"
|
||||
def is_lua_config() -> bool:
|
||||
global _lua_config_cache
|
||||
if _lua_config_cache is not None:
|
||||
return _lua_config_cache
|
||||
try:
|
||||
result = message("systeminfo", is_json=False)
|
||||
for line in result.splitlines():
|
||||
if "configProvider:" in line:
|
||||
_lua_config_cache = "lua" in line.lower()
|
||||
return _lua_config_cache
|
||||
_lua_config_cache = False
|
||||
return False
|
||||
except Exception:
|
||||
_lua_config_cache = False
|
||||
return False
|
||||
|
||||
|
||||
def batch(*msgs: list[str], json: bool = False) -> str | dict[str, any]:
|
||||
if json:
|
||||
msgs = (f"j/{m.strip()}" for m in msgs)
|
||||
return message(f"[[BATCH]]{';'.join(msgs)}", json=False)
|
||||
DISPATCHER_MAP_LUA = {
|
||||
"togglespecialworkspace": lambda *a: f'hl.dsp.workspace.toggle_special("{a[0]}")' if a else 'hl.dsp.workspace.toggle_special()',
|
||||
"movetoworkspacesilent": lambda *a: (
|
||||
f'hl.dsp.window.move({{window = "address:{a[0].split(",")[1].replace("address:", "")}", workspace = "{a[0].split(",")[0]}", follow = false}})'
|
||||
),
|
||||
"exec": lambda *a: 'hl.dsp.exec_cmd("' + ' '.join(a).replace('\\', '\\\\').replace('"', '\\"') + '")',
|
||||
}
|
||||
|
||||
|
||||
def dispatch(dispatcher: str, *args: str) -> bool:
|
||||
if is_lua_config() and dispatcher in DISPATCHER_MAP_LUA:
|
||||
lua_dispatch = DISPATCHER_MAP_LUA[dispatcher](*args)
|
||||
return message(f"dispatch {lua_dispatch}", is_json=False) == "ok"
|
||||
return message(f"dispatch {dispatcher} {' '.join(map(str, args))}".rstrip(), is_json=False) == "ok"
|
||||
|
||||
|
||||
def batch(*msgs: str, is_json: bool = False) -> str | dict[str, Any]:
|
||||
formatted_msgs = msgs
|
||||
|
||||
if is_json:
|
||||
formatted_msgs = [f"j/{m.strip()}" for m in msgs]
|
||||
|
||||
return message(f"[[BATCH]]{';'.join(formatted_msgs)}", is_json=False)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import sys
|
||||
from typing import Never
|
||||
|
||||
LOG_COLOUR: int = 2
|
||||
INFO_COLOUR: int = 0
|
||||
PROMPT_COLOUR: int = 36
|
||||
WARNING_COLOUR: int = 33
|
||||
ERROR_COLOUR: int = 31
|
||||
|
||||
_disable_input: bool = False
|
||||
|
||||
|
||||
def disable_input() -> None:
|
||||
global _disable_input
|
||||
_disable_input = True
|
||||
|
||||
|
||||
def log_exception(func):
|
||||
"""Log exceptions to stderr instead of raising.
|
||||
|
||||
Used by the `apply_()` functions so that an exception, when applying
|
||||
a theme, does not prevent the other themes from being applied.
|
||||
"""
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
error(f'exception during "{func.__name__}()": {str(e)}')
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def format_msg(colour: int, prefix: bool, msg: str) -> str:
|
||||
return f"\033[{colour}m{':: ' if prefix else ''}{msg}\033[0m"
|
||||
|
||||
|
||||
def log(msg: str, prefix: bool = True) -> None:
|
||||
print(format_msg(LOG_COLOUR, prefix, msg))
|
||||
|
||||
|
||||
def info(msg: str, prefix: bool = True) -> None:
|
||||
print(format_msg(INFO_COLOUR, prefix, msg))
|
||||
|
||||
|
||||
def warn(msg: str, prefix: bool = True) -> None:
|
||||
print(format_msg(WARNING_COLOUR, prefix, f"Warning: {msg}"))
|
||||
|
||||
|
||||
def error(err: str | Exception, prefix: bool = True) -> None:
|
||||
print(format_msg(ERROR_COLOUR, prefix, f"Error: {err}"), file=sys.stderr)
|
||||
|
||||
|
||||
def fatal(err: str | Exception, prefix: bool = True) -> Never:
|
||||
print(format_msg(ERROR_COLOUR, prefix, f"Fatal: {err}"), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _input(prompt: str) -> str:
|
||||
if _disable_input:
|
||||
print(prompt, end="")
|
||||
return ""
|
||||
|
||||
try:
|
||||
return input(prompt)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
|
||||
def prompt(msg: str, prefix: bool = True, end: str = " ") -> str:
|
||||
return _input(format_msg(PROMPT_COLOUR, prefix, msg) + end)
|
||||
|
||||
|
||||
def confirm(msg: str, prefix: bool = True, default: bool = True) -> bool:
|
||||
suffix = " [Y/n]" if default else " [y/N]"
|
||||
answer = prompt(msg + suffix, prefix=prefix).strip().lower()
|
||||
if not answer:
|
||||
return default
|
||||
return answer in ("y", "yes")
|
||||
|
||||
|
||||
def prompt_selection(items: list[str], header: str) -> list[str]:
|
||||
"""Prompt the user to pick from a numbered list, returning the selected items.
|
||||
|
||||
Accepts `[A]ll`/`a`, single indices, ranges (`1-3`) and exclusions (`^4`).
|
||||
Empty input selects nothing. Re-prompts until the input parses.
|
||||
"""
|
||||
|
||||
print(format_msg(PROMPT_COLOUR, True, header))
|
||||
max_idx_w = len(str(len(items)))
|
||||
for i, item in enumerate(items):
|
||||
print(format_msg(PROMPT_COLOUR, True, f" {i + 1:<{max_idx_w}}\t{item}"))
|
||||
print(format_msg(PROMPT_COLOUR, True, "[A]ll or (1 2 3, 1-3, ^4)"))
|
||||
|
||||
def valid_idx(v: str) -> int:
|
||||
try:
|
||||
idx = int(v, base=10) - 1 # -1 to translate to 0 index
|
||||
except ValueError:
|
||||
raise ValueError(f'Given value "{v}" must be an integer.')
|
||||
if idx < 0 or idx >= len(items):
|
||||
raise ValueError(f'Given value "{v}" must be between 1 and {len(items)} inclusive.')
|
||||
return idx
|
||||
|
||||
def parse(ans: str) -> list[str]:
|
||||
if ans in ("a", "all"):
|
||||
return list(items)
|
||||
if not ans:
|
||||
return []
|
||||
|
||||
selected: list[str] = []
|
||||
for tok in ans.split():
|
||||
fr, sep, to = tok.partition("-")
|
||||
if sep:
|
||||
lo, hi = valid_idx(fr), valid_idx(to)
|
||||
if lo > hi:
|
||||
raise ValueError(f'Given range "{tok}" must be lo-hi.')
|
||||
selected += items[lo : hi + 1]
|
||||
elif tok.startswith("^"):
|
||||
t = valid_idx(tok[1:])
|
||||
selected += items[:t] + items[t + 1 :]
|
||||
else:
|
||||
selected.append(items[valid_idx(tok)])
|
||||
return list(set(selected))
|
||||
|
||||
while True:
|
||||
ans = prompt("", end="").lower().strip()
|
||||
try:
|
||||
return parse(ans)
|
||||
except ValueError as e:
|
||||
warn(f"invalid input. {e} Please try again.")
|
||||
|
||||
|
||||
def pause() -> None:
|
||||
if _disable_input:
|
||||
return
|
||||
|
||||
_input("\n\033[2m\033[3m(Ctrl+C to exit, enter to continue)\033[0m")
|
||||
print("\033[1A\r\033[2K\033[1A\r\033[2K", end="") # Clear pause prompt
|
||||
@@ -1,20 +0,0 @@
|
||||
from time import strftime
|
||||
|
||||
|
||||
def log_message(message: str) -> None:
|
||||
timestamp = strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"[{timestamp}] {message}")
|
||||
|
||||
|
||||
def log_exception(func):
|
||||
"""Log exceptions to stdout instead of raising
|
||||
|
||||
Used by the `apply_()` functions so that an exception, when applying
|
||||
a theme, does not prevent the other themes from being applied.
|
||||
"""
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
log_message(f'Error during execution of "{func.__name__}()": {str(e)}')
|
||||
return wrapper
|
||||
@@ -1,8 +1,5 @@
|
||||
from materialyoucolor.blend import Blend
|
||||
from materialyoucolor.dynamiccolor.material_dynamic_colors import (
|
||||
DynamicScheme,
|
||||
MaterialDynamicColors,
|
||||
)
|
||||
from materialyoucolor.dynamiccolor.material_dynamic_colors import MaterialDynamicColors
|
||||
from materialyoucolor.hct import Hct
|
||||
from materialyoucolor.scheme.scheme_content import SchemeContent
|
||||
from materialyoucolor.scheme.scheme_expressive import SchemeExpressive
|
||||
@@ -14,6 +11,19 @@ from materialyoucolor.scheme.scheme_rainbow import SchemeRainbow
|
||||
from materialyoucolor.scheme.scheme_tonal_spot import SchemeTonalSpot
|
||||
from materialyoucolor.scheme.scheme_vibrant import SchemeVibrant
|
||||
from materialyoucolor.utils.math_utils import difference_degrees, rotation_direction, sanitize_degrees_double
|
||||
from typing import Protocol, Any
|
||||
|
||||
|
||||
# The base DynamicScheme class requires a 'variant' argument, but the specific
|
||||
# subclasses in get_scheme() handle that internally. This Protocol tells the type
|
||||
# checker to expect our specific 3-argument setup instead of the base class signature.
|
||||
class SchemeConstructor(Protocol):
|
||||
def __call__(self, source_color_hct: Any, is_dark: bool, contrast_level: float) -> "DynamicScheme": ...
|
||||
|
||||
try:
|
||||
from materialyoucolor.dynamiccolor.dynamic_scheme import DynamicScheme
|
||||
except ImportError:
|
||||
from materialyoucolor.scheme.dynamic_scheme import DynamicScheme
|
||||
|
||||
|
||||
def hex_to_hct(hex: str) -> Hct:
|
||||
@@ -145,7 +155,7 @@ def darken(colour: Hct, amount: float) -> Hct:
|
||||
return Hct.from_hct(colour.hue, colour.chroma - diff / 5, colour.tone - diff)
|
||||
|
||||
|
||||
def get_scheme(scheme: str) -> DynamicScheme:
|
||||
def get_scheme(scheme: str) -> SchemeConstructor:
|
||||
if scheme == "content":
|
||||
return SchemeContent
|
||||
if scheme == "expressive":
|
||||
@@ -166,12 +176,12 @@ def get_scheme(scheme: str) -> DynamicScheme:
|
||||
|
||||
|
||||
def gen_scheme(scheme, primary: Hct) -> dict[str, str]:
|
||||
light = scheme.mode == "light"
|
||||
is_light = scheme.mode == "light"
|
||||
|
||||
colours = {}
|
||||
|
||||
# Material colours
|
||||
primary_scheme = get_scheme(scheme.variant)(primary, not light, 0)
|
||||
primary_scheme = get_scheme(scheme.variant)(source_color_hct=primary, is_dark=not is_light, contrast_level=0.0)
|
||||
if hasattr(MaterialDynamicColors, "all_colors"): # materialyoucolor-python >= 3.0.0
|
||||
dyn_colours = MaterialDynamicColors()
|
||||
for colour in dyn_colours.all_colors:
|
||||
@@ -189,28 +199,28 @@ def gen_scheme(scheme, primary: Hct) -> dict[str, str]:
|
||||
colours["neutral_variant_paletteKeyColor"] = colours["neutralVariantPaletteKeyColor"]
|
||||
|
||||
# Harmonize terminal colours
|
||||
for i, hct in enumerate(light_gruvbox if light else dark_gruvbox):
|
||||
for i, hct in enumerate(light_gruvbox if is_light else dark_gruvbox):
|
||||
if scheme.variant == "monochrome":
|
||||
colours[f"term{i}"] = grayscale(hct, light)
|
||||
colours[f"term{i}"] = grayscale(hct, is_light)
|
||||
else:
|
||||
colours[f"term{i}"] = harmonize(
|
||||
hct, colours["primary_paletteKeyColor"], (0.35 if i < 8 else 0.2) * (-1 if light else 1)
|
||||
hct, colours["primary_paletteKeyColor"], (0.35 if i < 8 else 0.2) * (-1 if is_light else 1)
|
||||
)
|
||||
|
||||
# Harmonize named colours
|
||||
for i, hct in enumerate(light_catppuccin if light else dark_catppuccin):
|
||||
for i, hct in enumerate(light_catppuccin if is_light else dark_catppuccin):
|
||||
if scheme.variant == "monochrome":
|
||||
colours[colour_names[i]] = grayscale(hct, light)
|
||||
colours[colour_names[i]] = grayscale(hct, is_light)
|
||||
else:
|
||||
colours[colour_names[i]] = harmonize(hct, colours["primary_paletteKeyColor"], (-0.2 if light else 0.05))
|
||||
colours[colour_names[i]] = harmonize(hct, colours["primary_paletteKeyColor"], (-0.2 if is_light else 0.05))
|
||||
|
||||
# KColours
|
||||
for colour in kcolours:
|
||||
colours[colour["name"]] = harmonize(colour["hct"], colours["primary"], 0.1)
|
||||
colours[f"{colour['name']}Selection"] = harmonize(colour["hct"], colours["onPrimaryFixedVariant"], 0.1)
|
||||
if scheme.variant == "monochrome":
|
||||
colours[colour["name"]] = grayscale(colours[colour["name"]], light)
|
||||
colours[f"{colour['name']}Selection"] = grayscale(colours[f"{colour['name']}Selection"], light)
|
||||
colours[colour["name"]] = grayscale(colours[colour["name"]], is_light)
|
||||
colours[f"{colour['name']}Selection"] = grayscale(colours[f"{colour['name']}Selection"], is_light)
|
||||
|
||||
if scheme.variant == "neutral":
|
||||
for name, hct in colours.items():
|
||||
@@ -219,8 +229,8 @@ def gen_scheme(scheme, primary: Hct) -> dict[str, str]:
|
||||
# Darken surfaces for hard flavour
|
||||
if scheme.flavour == "hard":
|
||||
for colour in "background", *(k for k in colours.keys() if k.startswith("surface")):
|
||||
colours[colour] = lighten(colours[colour], 0.4) if light else darken(colours[colour], 0.8)
|
||||
colours["term0"] = lighten(colours["term0"], 0.4) if light else darken(colours["term0"], 0.9)
|
||||
colours[colour] = lighten(colours[colour], 0.4) if is_light else darken(colours[colour], 0.8)
|
||||
colours["term0"] = lighten(colours["term0"], 0.4) if is_light else darken(colours["term0"], 0.9)
|
||||
|
||||
# FIXME: deprecated stuff
|
||||
colours["text"] = colours["onBackground"]
|
||||
@@ -239,13 +249,13 @@ def gen_scheme(scheme, primary: Hct) -> dict[str, str]:
|
||||
# More darkening if hard flavour
|
||||
if scheme.flavour == "hard":
|
||||
for colour in "base", "mantle", "crust":
|
||||
colours[colour] = lighten(colours[colour], 0.4) if light else darken(colours[colour], 0.9)
|
||||
colours[colour] = lighten(colours[colour], 0.4) if is_light else darken(colours[colour], 0.9)
|
||||
for i in range(3):
|
||||
colours[f"overlay{i}"] = (
|
||||
lighten(colours[f"overlay{i}"], 0.4) if light else darken(colours[f"overlay{i}"], 0.8)
|
||||
lighten(colours[f"overlay{i}"], 0.4) if is_light else darken(colours[f"overlay{i}"], 0.8)
|
||||
)
|
||||
colours[f"surface{i}"] = (
|
||||
lighten(colours[f"surface{i}"], 0.4) if light else darken(colours[f"surface{i}"], 0.8)
|
||||
lighten(colours[f"surface{i}"], 0.4) if is_light else darken(colours[f"surface{i}"], 0.8)
|
||||
)
|
||||
|
||||
# For debugging
|
||||
@@ -254,7 +264,7 @@ def gen_scheme(scheme, primary: Hct) -> dict[str, str]:
|
||||
colours = {k: hex(v.to_int())[4:] for k, v in colours.items()}
|
||||
|
||||
# Extended material
|
||||
if light:
|
||||
if is_light:
|
||||
colours["success"] = "4F6354"
|
||||
colours["onSuccess"] = "FFFFFF"
|
||||
colours["successContainer"] = "D1E8D5"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import subprocess
|
||||
|
||||
|
||||
def notify(*args: list[str]) -> str:
|
||||
def notify(*args: str) -> str:
|
||||
return subprocess.check_output(["notify-send", "-a", "caelestia-cli", *args], text=True).strip()
|
||||
|
||||
|
||||
|
||||
@@ -1,44 +1,50 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
config_dir = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config"))
|
||||
data_dir = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
|
||||
state_dir = Path(os.getenv("XDG_STATE_HOME", Path.home() / ".local/state"))
|
||||
cache_dir = Path(os.getenv("XDG_CACHE_HOME", Path.home() / ".cache"))
|
||||
pictures_dir = Path(os.getenv("XDG_PICTURES_DIR", Path.home() / "Pictures"))
|
||||
videos_dir = Path(os.getenv("XDG_VIDEOS_DIR", Path.home() / "Videos"))
|
||||
from caelestia.utils.io import warn
|
||||
|
||||
c_config_dir = config_dir / "caelestia"
|
||||
c_data_dir = data_dir / "caelestia"
|
||||
c_state_dir = state_dir / "caelestia"
|
||||
c_cache_dir = cache_dir / "caelestia"
|
||||
config_dir: Path = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config"))
|
||||
data_dir: Path = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
|
||||
state_dir: Path = Path(os.getenv("XDG_STATE_HOME", Path.home() / ".local/state"))
|
||||
cache_dir: Path = Path(os.getenv("XDG_CACHE_HOME", Path.home() / ".cache"))
|
||||
pictures_dir: Path = Path(os.getenv("XDG_PICTURES_DIR", Path.home() / "Pictures"))
|
||||
videos_dir: Path = Path(os.getenv("XDG_VIDEOS_DIR", Path.home() / "Videos"))
|
||||
|
||||
user_config_path = c_config_dir / "cli.json"
|
||||
cli_data_dir = Path(__file__).parent.parent / "data"
|
||||
templates_dir = cli_data_dir / "templates"
|
||||
user_templates_dir = c_config_dir / "templates"
|
||||
theme_dir = c_state_dir / "theme"
|
||||
c_config_dir: Path = config_dir / "caelestia"
|
||||
c_data_dir: Path = data_dir / "caelestia"
|
||||
c_state_dir: Path = state_dir / "caelestia"
|
||||
c_cache_dir: Path = cache_dir / "caelestia"
|
||||
|
||||
scheme_path = c_state_dir / "scheme.json"
|
||||
scheme_data_dir = cli_data_dir / "schemes"
|
||||
scheme_cache_dir = c_cache_dir / "schemes"
|
||||
user_config_path: Path = c_config_dir / "cli.json"
|
||||
cli_data_dir: Path = Path(__file__).parent.parent / "data"
|
||||
templates_dir: Path = cli_data_dir / "templates"
|
||||
user_templates_dir: Path = c_config_dir / "templates"
|
||||
theme_dir: Path = c_state_dir / "theme"
|
||||
|
||||
wallpapers_dir = os.getenv("CAELESTIA_WALLPAPERS_DIR", pictures_dir / "Wallpapers")
|
||||
wallpaper_path_path = c_state_dir / "wallpaper/path.txt"
|
||||
wallpaper_link_path = c_state_dir / "wallpaper/current"
|
||||
wallpaper_thumbnail_path = c_state_dir / "wallpaper/thumbnail.jpg"
|
||||
wallpapers_cache_dir = c_cache_dir / "wallpapers"
|
||||
config_backup_dir: Path = config_dir.parent / f"{config_dir.name}.bak"
|
||||
dots_dir: Path = c_state_dir / "dots"
|
||||
dots_state_path: Path = c_state_dir / "dots-state.json"
|
||||
|
||||
screenshots_dir = os.getenv("CAELESTIA_SCREENSHOTS_DIR", pictures_dir / "Screenshots")
|
||||
screenshots_cache_dir = c_cache_dir / "screenshots"
|
||||
scheme_path: Path = c_state_dir / "scheme.json"
|
||||
scheme_data_dir: Path = cli_data_dir / "schemes"
|
||||
scheme_cache_dir: Path = c_cache_dir / "schemes"
|
||||
|
||||
recordings_dir = os.getenv("CAELESTIA_RECORDINGS_DIR", videos_dir / "Recordings")
|
||||
recording_path = c_state_dir / "record/recording.mp4"
|
||||
recording_notif_path = c_state_dir / "record/notifid.txt"
|
||||
wallpapers_dir: Path = Path(os.getenv("CAELESTIA_WALLPAPERS_DIR", pictures_dir / "Wallpapers"))
|
||||
wallpaper_path_path: Path = c_state_dir / "wallpaper/path.txt"
|
||||
wallpaper_link_path: Path = c_state_dir / "wallpaper/current"
|
||||
wallpaper_thumbnail_path: Path = c_state_dir / "wallpaper/thumbnail.jpg"
|
||||
wallpapers_cache_dir: Path = c_cache_dir / "wallpapers"
|
||||
|
||||
screenshots_dir: Path = Path(os.getenv("CAELESTIA_SCREENSHOTS_DIR", pictures_dir / "Screenshots"))
|
||||
screenshots_cache_dir: Path = c_cache_dir / "screenshots"
|
||||
|
||||
recordings_dir: Path = Path(os.getenv("CAELESTIA_RECORDINGS_DIR", videos_dir / "Recordings"))
|
||||
recording_path: Path = c_state_dir / "record/recording.mp4"
|
||||
recording_notif_path: Path = c_state_dir / "record/notifid.txt"
|
||||
|
||||
|
||||
def compute_hash(path: Path | str) -> str:
|
||||
@@ -51,8 +57,29 @@ def compute_hash(path: Path | str) -> str:
|
||||
return sha.hexdigest()
|
||||
|
||||
|
||||
def atomic_dump(path: Path, content: dict[str, any]) -> None:
|
||||
with tempfile.NamedTemporaryFile("w") as f:
|
||||
json.dump(content, f)
|
||||
def atomic_write(path: Path, content: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
f = tempfile.NamedTemporaryFile("w", dir=path.parent, delete=False)
|
||||
try:
|
||||
with f:
|
||||
f.write(content)
|
||||
f.flush()
|
||||
shutil.move(f.name, path)
|
||||
os.fsync(f.fileno())
|
||||
os.replace(f.name, path)
|
||||
except BaseException:
|
||||
os.unlink(f.name)
|
||||
raise
|
||||
|
||||
|
||||
def atomic_dump(path: Path, content: dict[str, Any]) -> None:
|
||||
atomic_write(path, json.dumps(content))
|
||||
|
||||
|
||||
def get_config() -> dict[str, Any]:
|
||||
try:
|
||||
return json.loads(user_config_path.read_text())
|
||||
except json.JSONDecodeError:
|
||||
warn("failed to parse config, invalid JSON")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return {}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import random
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from caelestia.utils.notify import notify
|
||||
from caelestia.utils.paths import atomic_dump, scheme_data_dir, scheme_path
|
||||
@@ -14,19 +15,19 @@ class Scheme:
|
||||
_colours: dict[str, str]
|
||||
notify: bool
|
||||
|
||||
def __init__(self, json: dict[str, any] | None) -> None:
|
||||
if json is None:
|
||||
def __init__(self, scheme_json: dict[str, Any] | None) -> None:
|
||||
if scheme_json is None:
|
||||
self._name = "catppuccin"
|
||||
self._flavour = "mocha"
|
||||
self._mode = "dark"
|
||||
self._variant = "tonalspot"
|
||||
self._colours = read_colours_from_file(self.get_colours_path())
|
||||
else:
|
||||
self._name = json["name"]
|
||||
self._flavour = json["flavour"]
|
||||
self._mode = json["mode"]
|
||||
self._variant = json["variant"]
|
||||
self._colours = json["colours"]
|
||||
self._name = scheme_json["name"]
|
||||
self._flavour = scheme_json["flavour"]
|
||||
self._mode = scheme_json["mode"]
|
||||
self._variant = scheme_json["variant"]
|
||||
self._colours = scheme_json["colours"]
|
||||
self.notify = False
|
||||
|
||||
@property
|
||||
@@ -196,7 +197,7 @@ scheme_variants = [
|
||||
"content",
|
||||
]
|
||||
|
||||
scheme: Scheme = None
|
||||
scheme: Scheme | None = None
|
||||
|
||||
|
||||
def read_colours_from_file(path: Path) -> dict[str, str]:
|
||||
@@ -225,7 +226,7 @@ def get_scheme_names() -> list[str]:
|
||||
return [*(f.name for f in scheme_data_dir.iterdir() if f.is_dir()), "dynamic"]
|
||||
|
||||
|
||||
def get_scheme_flavours(name: str = None) -> list[str]:
|
||||
def get_scheme_flavours(name: str | None = None) -> list[str]:
|
||||
if name is None:
|
||||
name = get_scheme().name
|
||||
|
||||
@@ -234,11 +235,11 @@ def get_scheme_flavours(name: str = None) -> list[str]:
|
||||
)
|
||||
|
||||
|
||||
def get_scheme_modes(name: str = None, flavour: str = None) -> list[str]:
|
||||
if name is None:
|
||||
def get_scheme_modes(name: str | None = None, flavour: str | None = None) -> list[str]:
|
||||
if name is None or flavour is None:
|
||||
scheme = get_scheme()
|
||||
name = scheme.name
|
||||
flavour = scheme.flavour
|
||||
name = name or scheme.name
|
||||
flavour = flavour or scheme.flavour
|
||||
|
||||
if name == "dynamic":
|
||||
return ["light", "dark"]
|
||||
|
||||
+154
-97
@@ -1,23 +1,26 @@
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import shutil
|
||||
import fcntl
|
||||
import sys
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from caelestia.utils.colour import get_dynamic_colours
|
||||
from caelestia.utils.logging import log_exception
|
||||
from caelestia.utils.hypr import is_lua_config
|
||||
from caelestia.utils.io import log_exception
|
||||
from caelestia.utils.paths import (
|
||||
atomic_write,
|
||||
c_state_dir,
|
||||
config_dir,
|
||||
data_dir,
|
||||
get_config,
|
||||
templates_dir,
|
||||
theme_dir,
|
||||
user_config_path,
|
||||
user_templates_dir,
|
||||
)
|
||||
from caelestia.utils.scheme import get_scheme
|
||||
|
||||
|
||||
def gen_conf(colours: dict[str, str]) -> str:
|
||||
@@ -27,6 +30,14 @@ def gen_conf(colours: dict[str, str]) -> str:
|
||||
return conf
|
||||
|
||||
|
||||
def gen_lua(colours: dict[str, str]) -> str:
|
||||
lua = "return {\n"
|
||||
for name, colour in colours.items():
|
||||
lua += f' {name} = "{colour}",\n'
|
||||
lua += "}"
|
||||
return lua
|
||||
|
||||
|
||||
def gen_scss(colours: dict[str, str]) -> str:
|
||||
scss = ""
|
||||
for name, colour in colours.items():
|
||||
@@ -35,10 +46,10 @@ def gen_scss(colours: dict[str, str]) -> str:
|
||||
|
||||
|
||||
def gen_replace(colours: dict[str, str], template: Path, hash: bool = False) -> str:
|
||||
template = template.read_text()
|
||||
new_template = template.read_text()
|
||||
for name, colour in colours.items():
|
||||
template = template.replace(f"{{{{ ${name} }}}}", f"#{colour}" if hash else colour)
|
||||
return template
|
||||
new_template = new_template.replace(f"{{{{ ${name} }}}}", f"#{colour}" if hash else colour)
|
||||
return new_template
|
||||
|
||||
|
||||
def gen_replace_dynamic(colours: dict[str, str], template: Path, mode: str) -> str:
|
||||
@@ -66,7 +77,7 @@ def gen_replace_dynamic(colours: dict[str, str], template: Path, mode: str) -> s
|
||||
return template_filled
|
||||
|
||||
|
||||
def c2s(c: str, *i: list[int]) -> str:
|
||||
def hex_to_ansi(c: str, *i: int) -> str:
|
||||
"""Hex to ANSI sequence (e.g. ffffff, 11 -> \x1b]11;rgb:ff/ff/ff\x1b\\)"""
|
||||
return f"\x1b]{';'.join(map(str, i))};rgb:{c[0:2]}/{c[2:4]}/{c[4:6]}\x1b\\"
|
||||
|
||||
@@ -83,40 +94,32 @@ def gen_sequences(colours: dict[str, str]) -> str:
|
||||
16+: 256 colours
|
||||
"""
|
||||
return (
|
||||
c2s(colours["onSurface"], 10)
|
||||
+ c2s(colours["surface"], 11)
|
||||
+ c2s(colours["secondary"], 12)
|
||||
+ c2s(colours["secondary"], 17)
|
||||
+ c2s(colours["term0"], 4, 0)
|
||||
+ c2s(colours["term1"], 4, 1)
|
||||
+ c2s(colours["term2"], 4, 2)
|
||||
+ c2s(colours["term3"], 4, 3)
|
||||
+ c2s(colours["term4"], 4, 4)
|
||||
+ c2s(colours["term5"], 4, 5)
|
||||
+ c2s(colours["term6"], 4, 6)
|
||||
+ c2s(colours["term7"], 4, 7)
|
||||
+ c2s(colours["term8"], 4, 8)
|
||||
+ c2s(colours["term9"], 4, 9)
|
||||
+ c2s(colours["term10"], 4, 10)
|
||||
+ c2s(colours["term11"], 4, 11)
|
||||
+ c2s(colours["term12"], 4, 12)
|
||||
+ c2s(colours["term13"], 4, 13)
|
||||
+ c2s(colours["term14"], 4, 14)
|
||||
+ c2s(colours["term15"], 4, 15)
|
||||
+ c2s(colours["primary"], 4, 16)
|
||||
+ c2s(colours["secondary"], 4, 17)
|
||||
+ c2s(colours["tertiary"], 4, 18)
|
||||
hex_to_ansi(colours["onSurface"], 10)
|
||||
+ hex_to_ansi(colours["surface"], 11)
|
||||
+ hex_to_ansi(colours["secondary"], 12)
|
||||
+ hex_to_ansi(colours["secondary"], 17)
|
||||
+ hex_to_ansi(colours["term0"], 4, 0)
|
||||
+ hex_to_ansi(colours["term1"], 4, 1)
|
||||
+ hex_to_ansi(colours["term2"], 4, 2)
|
||||
+ hex_to_ansi(colours["term3"], 4, 3)
|
||||
+ hex_to_ansi(colours["term4"], 4, 4)
|
||||
+ hex_to_ansi(colours["term5"], 4, 5)
|
||||
+ hex_to_ansi(colours["term6"], 4, 6)
|
||||
+ hex_to_ansi(colours["term7"], 4, 7)
|
||||
+ hex_to_ansi(colours["term8"], 4, 8)
|
||||
+ hex_to_ansi(colours["term9"], 4, 9)
|
||||
+ hex_to_ansi(colours["term10"], 4, 10)
|
||||
+ hex_to_ansi(colours["term11"], 4, 11)
|
||||
+ hex_to_ansi(colours["term12"], 4, 12)
|
||||
+ hex_to_ansi(colours["term13"], 4, 13)
|
||||
+ hex_to_ansi(colours["term14"], 4, 14)
|
||||
+ hex_to_ansi(colours["term15"], 4, 15)
|
||||
+ hex_to_ansi(colours["primary"], 4, 16)
|
||||
+ hex_to_ansi(colours["secondary"], 4, 17)
|
||||
+ hex_to_ansi(colours["tertiary"], 4, 18)
|
||||
)
|
||||
|
||||
|
||||
def write_file(path: Path, content: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with tempfile.NamedTemporaryFile("w") as f:
|
||||
f.write(content)
|
||||
f.flush()
|
||||
shutil.move(f.name, path)
|
||||
|
||||
@log_exception
|
||||
def apply_terms(sequences: str) -> None:
|
||||
state = c_state_dir / "sequences.txt"
|
||||
@@ -129,6 +132,7 @@ def apply_terms(sequences: str) -> None:
|
||||
try:
|
||||
# Use non-blocking write with timeout to prevent hangs
|
||||
import os
|
||||
|
||||
fd = os.open(str(pt), os.O_WRONLY | os.O_NONBLOCK | os.O_NOCTTY)
|
||||
try:
|
||||
os.write(fd, sequences.encode())
|
||||
@@ -141,61 +145,63 @@ def apply_terms(sequences: str) -> None:
|
||||
|
||||
@log_exception
|
||||
def apply_hypr(conf: str) -> None:
|
||||
write_file(config_dir / "hypr/scheme/current.conf", conf)
|
||||
ext = "lua" if is_lua_config() else "conf"
|
||||
atomic_write(config_dir / f"hypr/scheme/current.{ext}", conf)
|
||||
|
||||
|
||||
@log_exception
|
||||
def apply_discord(scss: str) -> None:
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory("w") as tmp_dir:
|
||||
(Path(tmp_dir) / "_colours.scss").write_text(scss)
|
||||
conf = subprocess.check_output(["sass", "-I", tmp_dir, templates_dir / "discord.scss"], text=True)
|
||||
|
||||
for client in "Equicord", "Vencord", "BetterDiscord", "equibop", "vesktop", "legcord":
|
||||
write_file(config_dir / client / "themes/caelestia.theme.css", conf)
|
||||
atomic_write(config_dir / client / "themes/caelestia.theme.css", conf)
|
||||
|
||||
|
||||
@log_exception
|
||||
def apply_pandora(colours: dict[str, str], mode: str) -> None:
|
||||
template = gen_replace(colours, templates_dir / "pandora.json", hash=True)
|
||||
template = template.replace("{{ $mode }}", mode)
|
||||
atomic_write(data_dir / "PandoraLauncher/themes/caelestia.json", template)
|
||||
|
||||
|
||||
@log_exception
|
||||
def apply_spicetify(colours: dict[str, str], mode: str) -> None:
|
||||
template = gen_replace(colours, templates_dir / f"spicetify-{mode}.ini")
|
||||
write_file(config_dir / "spicetify/Themes/caelestia/color.ini", template)
|
||||
atomic_write(config_dir / "spicetify/Themes/caelestia/color.ini", template)
|
||||
|
||||
|
||||
@log_exception
|
||||
def apply_fuzzel(colours: dict[str, str]) -> None:
|
||||
template = gen_replace(colours, templates_dir / "fuzzel.ini")
|
||||
write_file(config_dir / "fuzzel/fuzzel.ini", template)
|
||||
atomic_write(config_dir / "fuzzel/fuzzel.ini", template)
|
||||
|
||||
|
||||
@log_exception
|
||||
def apply_btop(colours: dict[str, str]) -> None:
|
||||
template = gen_replace(colours, templates_dir / "btop.theme", hash=True)
|
||||
write_file(config_dir / "btop/themes/caelestia.theme", template)
|
||||
atomic_write(config_dir / "btop/themes/caelestia.theme", template)
|
||||
subprocess.run(["killall", "-USR2", "btop"], stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
@log_exception
|
||||
def apply_nvtop(colours: dict[str, str]) -> None:
|
||||
template = gen_replace(colours, templates_dir / "nvtop.colors", hash=True)
|
||||
write_file(config_dir / "nvtop/nvtop.colors", template)
|
||||
atomic_write(config_dir / "nvtop/nvtop.colors", template)
|
||||
|
||||
|
||||
@log_exception
|
||||
def apply_htop(colours: dict[str, str]) -> None:
|
||||
template = gen_replace(colours, templates_dir / "htop.theme", hash=True)
|
||||
write_file(config_dir / "htop/htoprc", template)
|
||||
atomic_write(config_dir / "htop/htoprc", template)
|
||||
subprocess.run(["killall", "-USR2", "htop"], stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
def sync_papirus_colors(hex_color: str) -> None:
|
||||
"""Sync Papirus folder icon colors using hue/saturation analysis"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["which", "papirus-folders"],
|
||||
capture_output=True,
|
||||
check=False
|
||||
)
|
||||
result = subprocess.run(["which", "papirus-folders"], capture_output=True, check=False)
|
||||
if result.returncode != 0:
|
||||
return
|
||||
except Exception:
|
||||
@@ -241,7 +247,7 @@ def sync_papirus_colors(hex_color: str) -> None:
|
||||
["sudo", "-n", "papirus-folders", "-C", color, "-u"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
start_new_session=True
|
||||
start_new_session=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -298,47 +304,33 @@ def _determine_hue_color(r: int, g: int, b: int, brightness: int, use_pale: bool
|
||||
|
||||
|
||||
@log_exception
|
||||
def apply_gtk(colours: dict[str, str], mode: str) -> None:
|
||||
def apply_gtk(colours: dict[str, str], mode: str, icon_theme: str | None = None) -> None:
|
||||
gtk_template = gen_replace(colours, templates_dir / "gtk.css", hash=True)
|
||||
thunar_template = gen_replace(colours, templates_dir / "thunar.css", hash=True)
|
||||
|
||||
for gtk_version in ["gtk-3.0", "gtk-4.0"]:
|
||||
gtk_config_dir = config_dir / gtk_version
|
||||
write_file(gtk_config_dir / "gtk.css", gtk_template)
|
||||
write_file(gtk_config_dir / "thunar.css", thunar_template)
|
||||
atomic_write(gtk_config_dir / "gtk.css", gtk_template)
|
||||
atomic_write(gtk_config_dir / "thunar.css", thunar_template)
|
||||
|
||||
subprocess.run(["dconf", "write", "/org/gnome/desktop/interface/gtk-theme", "'adw-gtk3-dark'"])
|
||||
subprocess.run(["dconf", "write", "/org/gnome/desktop/interface/color-scheme", f"'prefer-{mode}'"])
|
||||
subprocess.run(["dconf", "write", "/org/gnome/desktop/interface/icon-theme", f"'Papirus-{mode.capitalize()}'"])
|
||||
gtk_icon_theme = icon_theme if icon_theme is not None else f"Papirus-{mode.capitalize()}"
|
||||
subprocess.run(["dconf", "write", "/org/gnome/desktop/interface/icon-theme", f"'{gtk_icon_theme}'"])
|
||||
|
||||
sync_papirus_colors(colours["primary"])
|
||||
|
||||
|
||||
@log_exception
|
||||
def apply_qt(colours: dict[str, str], mode: str) -> None:
|
||||
template = gen_replace(colours, templates_dir / f"qt{mode}.colors", hash=True)
|
||||
write_file(config_dir / "qt5ct/colors/caelestia.colors", template)
|
||||
write_file(config_dir / "qt6ct/colors/caelestia.colors", template)
|
||||
def apply_qt(colours: dict[str, str], mode: str, icon_theme: str | None = None) -> None:
|
||||
colours = gen_replace(colours, templates_dir / f"qt{mode}.colors", hash=True)
|
||||
atomic_write(config_dir / "qtengine/caelestia.colors", colours)
|
||||
|
||||
qtct = (templates_dir / "qtct.conf").read_text()
|
||||
qtct = qtct.replace("{{ $mode }}", mode.capitalize())
|
||||
|
||||
for ver in 5, 6:
|
||||
conf = qtct.replace("{{ $config }}", str(config_dir / f"qt{ver}ct"))
|
||||
|
||||
if ver == 5:
|
||||
conf += """
|
||||
[Fonts]
|
||||
fixed="Monospace,12,-1,5,50,0,0,0,0,0"
|
||||
general="Sans Serif,12,-1,5,50,0,0,0,0,0"
|
||||
"""
|
||||
else:
|
||||
conf += """
|
||||
[Fonts]
|
||||
fixed="Monospace,12,-1,5,400,0,0,0,0,0,0,0,0,0,0,1"
|
||||
general="Sans Serif,12,-1,5,400,0,0,0,0,0,0,0,0,0,0,1"
|
||||
"""
|
||||
write_file(config_dir / f"qt{ver}ct/qt{ver}ct.conf", conf)
|
||||
config = (templates_dir / "qtengine.json").read_text()
|
||||
config = config.replace("{{ $mode }}", mode.capitalize())
|
||||
if icon_theme is not None:
|
||||
config = config.replace(f'"iconTheme": "Papirus-{mode.capitalize()}"', f'"iconTheme": "{icon_theme}"')
|
||||
atomic_write(config_dir / "qtengine/config.json", config)
|
||||
|
||||
|
||||
@log_exception
|
||||
@@ -347,13 +339,58 @@ def apply_warp(colours: dict[str, str], mode: str) -> None:
|
||||
|
||||
template = gen_replace(colours, templates_dir / "warp.yaml", hash=True)
|
||||
template = template.replace("{{ $warp_mode }}", warp_mode)
|
||||
write_file(data_dir / "warp-terminal/themes/caelestia.yaml", template)
|
||||
atomic_write(data_dir / "warp-terminal/themes/caelestia.yaml", template)
|
||||
|
||||
|
||||
@log_exception
|
||||
def apply_chromium(colours: dict[str, str]) -> None:
|
||||
surface_hex = colours["surface"]
|
||||
theme_color = f"#{surface_hex}"
|
||||
browsers = [
|
||||
("chromium", Path("/etc/chromium/policies/managed")),
|
||||
("brave", Path("/etc/brave/policies/managed")),
|
||||
("google-chrome-stable", Path("/etc/opt/chrome/policies/managed")),
|
||||
]
|
||||
|
||||
for cmd, policy_dir in browsers:
|
||||
if shutil.which(cmd) is None:
|
||||
continue
|
||||
if not policy_dir.is_dir():
|
||||
subprocess.run(["sudo", "-n", "mkdir", "-p", str(policy_dir)], stderr=subprocess.DEVNULL)
|
||||
if not policy_dir.is_dir():
|
||||
print(f"Unable to create {policy_dir} directory")
|
||||
continue
|
||||
|
||||
# Use tee instead of atomic_write cause we need sudo
|
||||
subprocess.run(
|
||||
["sudo", "-n", "tee", str(policy_dir / "caelestia.json")],
|
||||
input=json.dumps({"BrowserThemeColor": theme_color, "BrowserColorScheme": "device"}),
|
||||
text=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
subprocess.run(
|
||||
[cmd, "--refresh-platform-policy", "--no-startup-window"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def apply_zed(colours: dict[str, str], mode: str) -> None:
|
||||
theme_path = config_dir / "zed/themes/caelestia.json"
|
||||
# Zed's file watcher does not detect changes through symlinks,
|
||||
# so resolve to a regular file before writing
|
||||
if theme_path.is_symlink():
|
||||
theme_path.unlink()
|
||||
|
||||
content = gen_replace_dynamic(colours, templates_dir / "zed.json", mode)
|
||||
atomic_write(theme_path, content)
|
||||
|
||||
|
||||
@log_exception
|
||||
def apply_cava(colours: dict[str, str]) -> None:
|
||||
template = gen_replace(colours, templates_dir / "cava.conf", hash=True)
|
||||
write_file(config_dir / "cava/config", template)
|
||||
atomic_write(config_dir / "cava/config", template)
|
||||
subprocess.run(["killall", "-USR2", "cava"], stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
@@ -365,7 +402,7 @@ def apply_user_templates(colours: dict[str, str], mode: str) -> None:
|
||||
for file in user_templates_dir.iterdir():
|
||||
if file.is_file():
|
||||
content = gen_replace_dynamic(colours, file, mode)
|
||||
write_file(theme_dir / file.name, content)
|
||||
atomic_write(theme_dir / file.name, content)
|
||||
|
||||
|
||||
def apply_colours(colours: dict[str, str], mode: str) -> None:
|
||||
@@ -374,16 +411,13 @@ def apply_colours(colours: dict[str, str], mode: str) -> None:
|
||||
c_state_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
with open(lock_file, 'w') as lock_fd:
|
||||
with open(lock_file, "w") as lock_fd:
|
||||
try:
|
||||
fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
return
|
||||
|
||||
try:
|
||||
cfg = json.loads(user_config_path.read_text())["theme"]
|
||||
except (FileNotFoundError, json.JSONDecodeError, KeyError):
|
||||
cfg = {}
|
||||
cfg = get_config().get("theme", {})
|
||||
|
||||
def check(key: str) -> bool:
|
||||
return cfg[key] if key in cfg else True
|
||||
@@ -391,11 +425,13 @@ def apply_colours(colours: dict[str, str], mode: str) -> None:
|
||||
if check("enableTerm"):
|
||||
apply_terms(gen_sequences(colours))
|
||||
if check("enableHypr"):
|
||||
apply_hypr(gen_conf(colours))
|
||||
apply_hypr(gen_lua(colours) if is_lua_config() else gen_conf(colours))
|
||||
if check("enableDiscord"):
|
||||
apply_discord(gen_scss(colours))
|
||||
if check("enableSpicetify"):
|
||||
apply_spicetify(colours, mode)
|
||||
if check("enablePandora"):
|
||||
apply_pandora(colours, mode)
|
||||
if check("enableFuzzel"):
|
||||
apply_fuzzel(colours)
|
||||
if check("enableBtop"):
|
||||
@@ -404,16 +440,37 @@ def apply_colours(colours: dict[str, str], mode: str) -> None:
|
||||
apply_nvtop(colours)
|
||||
if check("enableHtop"):
|
||||
apply_htop(colours)
|
||||
icon_theme = cfg.get(f"iconTheme{mode.capitalize()}") or cfg.get("iconTheme")
|
||||
if check("enableGtk"):
|
||||
apply_gtk(colours, mode)
|
||||
apply_gtk(colours, mode, icon_theme)
|
||||
if check("enableQt"):
|
||||
apply_qt(colours, mode)
|
||||
apply_qt(colours, mode, icon_theme)
|
||||
if check("enableWarp"):
|
||||
apply_warp(colours, mode)
|
||||
if check("enableChromium"):
|
||||
apply_chromium(colours)
|
||||
if check("enableZed"):
|
||||
apply_zed(colours, mode)
|
||||
if check("enableCava"):
|
||||
apply_cava(colours)
|
||||
apply_user_templates(colours, mode)
|
||||
|
||||
if post_hook := cfg.get("postHook"):
|
||||
scheme = get_scheme()
|
||||
subprocess.run(
|
||||
post_hook,
|
||||
shell=True,
|
||||
env={
|
||||
**os.environ,
|
||||
"SCHEME_NAME": scheme.name,
|
||||
"SCHEME_FLAVOUR": scheme.flavour,
|
||||
"SCHEME_MODE": scheme.mode,
|
||||
"SCHEME_VARIANT": scheme.variant,
|
||||
"SCHEME_COLOURS": json.dumps(scheme.colours),
|
||||
},
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
finally:
|
||||
try:
|
||||
lock_file.unlink()
|
||||
|
||||
@@ -4,16 +4,18 @@ import random
|
||||
import subprocess
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from materialyoucolor.hct import Hct
|
||||
from materialyoucolor.utils.color_utils import argb_from_rgb
|
||||
from PIL import Image
|
||||
|
||||
from caelestia.utils.colourfulness import get_variant
|
||||
from caelestia.utils.hypr import message
|
||||
from caelestia.utils.material import get_colours_for_image
|
||||
from caelestia.utils.paths import (
|
||||
compute_hash,
|
||||
user_config_path,
|
||||
get_config,
|
||||
wallpaper_link_path,
|
||||
wallpaper_path_path,
|
||||
wallpaper_thumbnail_path,
|
||||
@@ -24,7 +26,7 @@ from caelestia.utils.theme import apply_colours
|
||||
|
||||
|
||||
def is_valid_image(path: Path) -> bool:
|
||||
return path.is_file() and path.suffix in [".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff"]
|
||||
return path.is_file() and path.suffix in [".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff", ".gif"]
|
||||
|
||||
|
||||
def check_wall(wall: Path, filter_size: tuple[int, int], threshold: float) -> bool:
|
||||
@@ -33,7 +35,7 @@ def check_wall(wall: Path, filter_size: tuple[int, int], threshold: float) -> bo
|
||||
return width >= filter_size[0] * threshold and height >= filter_size[1] * threshold
|
||||
|
||||
|
||||
def get_wallpaper() -> str:
|
||||
def get_wallpaper() -> str | None:
|
||||
try:
|
||||
return wallpaper_path_path.read_text()
|
||||
except IOError:
|
||||
@@ -41,16 +43,16 @@ def get_wallpaper() -> str:
|
||||
|
||||
|
||||
def get_wallpapers(args: Namespace) -> list[Path]:
|
||||
dir = Path(args.random)
|
||||
if not dir.is_dir():
|
||||
directory = Path(args.random)
|
||||
if not directory.is_dir():
|
||||
return []
|
||||
|
||||
walls = [f for f in dir.rglob("*") if is_valid_image(f)]
|
||||
walls = [f for f in directory.rglob("*") if is_valid_image(f)]
|
||||
|
||||
if args.no_filter:
|
||||
return walls
|
||||
|
||||
monitors = message("monitors")
|
||||
monitors = cast(list[dict[str, int]], message("monitors"))
|
||||
filter_size = min(m["width"] for m in monitors), min(m["height"] for m in monitors)
|
||||
|
||||
return [f for f in walls if check_wall(f, filter_size, args.threshold)]
|
||||
@@ -62,14 +64,14 @@ def get_thumb(wall: Path, cache: Path) -> Path:
|
||||
if not thumb.exists():
|
||||
with Image.open(wall) as img:
|
||||
img = img.convert("RGB")
|
||||
img.thumbnail((128, 128), Image.NEAREST)
|
||||
img.thumbnail((128, 128), Image.Resampling.NEAREST)
|
||||
thumb.parent.mkdir(parents=True, exist_ok=True)
|
||||
img.save(thumb, "JPEG")
|
||||
|
||||
return thumb
|
||||
|
||||
|
||||
def get_smart_opts(wall: Path, cache: Path) -> str:
|
||||
def get_smart_opts(wall: Path, cache: Path) -> dict:
|
||||
opts_cache = cache / "smart.json"
|
||||
|
||||
try:
|
||||
@@ -77,15 +79,16 @@ def get_smart_opts(wall: Path, cache: Path) -> str:
|
||||
except (IOError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
from caelestia.utils.colourfulness import get_variant
|
||||
|
||||
opts = {}
|
||||
|
||||
with Image.open(get_thumb(wall, cache)) as img:
|
||||
opts["variant"] = get_variant(img)
|
||||
img.thumbnail((1, 1), Image.Resampling.LANCZOS)
|
||||
|
||||
# Cast the pixel to a tuple of 3 integers to safely unpack it
|
||||
pixel = cast(tuple[int, int, int], img.getpixel((0, 0)))
|
||||
hct = Hct.from_int(argb_from_rgb(*pixel))
|
||||
|
||||
img.thumbnail((1, 1), Image.LANCZOS)
|
||||
hct = Hct.from_int(argb_from_rgb(*img.getpixel((0, 0))))
|
||||
opts["mode"] = "light" if hct.tone > 60 else "dark"
|
||||
|
||||
opts_cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -96,9 +99,13 @@ def get_smart_opts(wall: Path, cache: Path) -> str:
|
||||
|
||||
|
||||
def get_colours_for_wall(wall: Path | str, no_smart: bool) -> None:
|
||||
wall = Path(wall)
|
||||
scheme = get_scheme()
|
||||
cache = wallpapers_cache_dir / compute_hash(wall)
|
||||
|
||||
if wall.suffix.lower() == ".gif":
|
||||
wall = convert_gif(wall)
|
||||
|
||||
name = "dynamic"
|
||||
|
||||
if not no_smart:
|
||||
@@ -122,13 +129,34 @@ def get_colours_for_wall(wall: Path | str, no_smart: bool) -> None:
|
||||
}
|
||||
|
||||
|
||||
def set_wallpaper(wall: Path | str, no_smart: bool) -> None:
|
||||
def convert_gif(wall: Path) -> Path:
|
||||
cache = wallpapers_cache_dir / compute_hash(wall)
|
||||
output_path = cache / "first_frame.png"
|
||||
|
||||
if not output_path.exists():
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with Image.open(wall) as img:
|
||||
try:
|
||||
img.seek(0)
|
||||
except EOFError:
|
||||
pass
|
||||
|
||||
img = img.convert("RGB")
|
||||
img.save(output_path, "PNG")
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
def set_wallpaper(wall: Path, no_smart: bool) -> None:
|
||||
# Make path absolute
|
||||
wall = Path(wall).resolve()
|
||||
|
||||
if not is_valid_image(wall):
|
||||
raise ValueError(f'"{wall}" is not a valid image')
|
||||
|
||||
# Use gif's 1st frame for thumb only
|
||||
wall_cache = convert_gif(wall) if wall.suffix.lower() == ".gif" else wall
|
||||
|
||||
# Update files
|
||||
wallpaper_path_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
wallpaper_path_path.write_text(str(wall))
|
||||
@@ -136,10 +164,10 @@ def set_wallpaper(wall: Path | str, no_smart: bool) -> None:
|
||||
wallpaper_link_path.unlink(missing_ok=True)
|
||||
wallpaper_link_path.symlink_to(wall)
|
||||
|
||||
cache = wallpapers_cache_dir / compute_hash(wall)
|
||||
cache = wallpapers_cache_dir / compute_hash(wall_cache)
|
||||
|
||||
# Generate thumbnail or get from cache
|
||||
thumb = get_thumb(wall, cache)
|
||||
thumb = get_thumb(wall_cache, cache)
|
||||
wallpaper_thumbnail_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
wallpaper_thumbnail_path.unlink(missing_ok=True)
|
||||
wallpaper_thumbnail_path.symlink_to(thumb)
|
||||
@@ -148,7 +176,7 @@ def set_wallpaper(wall: Path | str, no_smart: bool) -> None:
|
||||
|
||||
# Change mode and variant based on wallpaper colour
|
||||
if scheme.name == "dynamic" and not no_smart:
|
||||
smart_opts = get_smart_opts(wall, cache)
|
||||
smart_opts = get_smart_opts(wall_cache, cache)
|
||||
scheme.mode = smart_opts["mode"]
|
||||
scheme.variant = smart_opts["variant"]
|
||||
|
||||
@@ -157,17 +185,23 @@ def set_wallpaper(wall: Path | str, no_smart: bool) -> None:
|
||||
apply_colours(scheme.colours, scheme.mode)
|
||||
|
||||
# Run custom post-hook if configured
|
||||
try:
|
||||
cfg = json.loads(user_config_path.read_text()).get("wallpaper", {})
|
||||
cfg = get_config().get("wallpaper", {})
|
||||
if post_hook := cfg.get("postHook"):
|
||||
subprocess.run(
|
||||
post_hook,
|
||||
shell=True,
|
||||
env={**os.environ, "WALLPAPER_PATH": str(wall)},
|
||||
env={
|
||||
**os.environ,
|
||||
"WALLPAPER_PATH": str(wall),
|
||||
"SCHEME_NAME": scheme.name,
|
||||
"SCHEME_FLAVOUR": scheme.flavour,
|
||||
"SCHEME_MODE": scheme.mode,
|
||||
"SCHEME_VARIANT": scheme.variant,
|
||||
"SCHEME_COLOURS": json.dumps(scheme.colours),
|
||||
"THUMBNAIL_PATH": str(thumb),
|
||||
},
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
|
||||
def set_random(args: Namespace) -> None:
|
||||
|
||||
Reference in New Issue
Block a user