60 Commits

Author SHA1 Message Date
2 * r + 2 * t a00e71d6b7 docs: add iconTheme options to example conf 2026-05-02 22:51:35 +10:00
2 * r + 2 * t 1ec969d9ec fix: use auto bars for cava 2026-05-02 22:48:41 +10:00
2 * r + 2 * t 5273ed514f feat: add theme postHook 2026-05-02 22:24:26 +10:00
github-actions f3b13affc3 [CI] chore: update flake 2026-05-02 03:39:49 +00:00
Haikal 5c9ce66c03 feat: expose more environment variables in post-hook (#107)
* feat: expose more environment variables in post-hook

* fix: formatted
2026-04-29 23:56:07 +10:00
github-actions c18f749f24 [CI] chore: update flake 2026-04-29 03:43:15 +00:00
2 * r + 2 * t 96fcdf5bce fix: use hypr socket instead of hyprctl 2026-04-28 21:20:40 +10:00
github-actions eddee4deca [CI] chore: update flake 2026-04-25 03:06:00 +00:00
Foxlike Creature 68bc03bc17 feat: allow overriding icon theme via cli.json (#106)
* theme: allow overriding Qt icon theme via cli.json

Papirus colors XDG special folders (Downloads, Pictures, Music, etc.)
differently from regular ones - they end up a different color while
everything else stays neutral. With themes like breeze-dark, all folder
icons share the same style, so everything looks consistent.

Add optional `iconTheme` field to the `theme` section of cli.json.
When set, it replaces the Papirus icon theme in the generated qtengine
config with the specified theme.

Example usage in cli.json:
  "theme": { "iconTheme": "breeze-dark" }

* theme: allow overriding Qt and GTK icon theme via cli.json

Some folders in Dolphin end up with Papirus-style icons while others
use the default theme icons, resulting in two different icon styles
mixed together in the same view. Dolphin's default folder icons take
their color directly from the active color scheme, so they always match
the theme exactly - Papirus has a fixed, limited palette and does not
always match.

Add optional iconThemeDark and iconThemeLight fields to the theme
section of cli.json. When set, they override the Papirus icon theme in
both the generated qtengine config and the GTK dconf setting. A generic
iconTheme field is also supported as a fallback for both modes.

Example usage in cli.json:
  "theme": { "iconThemeDark": "breeze-dark", "iconThemeLight": "breeze" }

---------

Co-authored-by: Foxlike Creature <safonovkirill113@gmail.com>
2026-04-24 14:55:47 +10:00
github-actions 023a30b83c [CI] chore: update flake 2026-04-21 03:27:51 +00:00
github-actions a192efae9c [CI] chore: update flake 2026-04-20 03:35:55 +00:00
2 * r + 2 * t 463f36544a docs: add missing theme.enable* opts to example conf 2026-04-19 16:01:09 +10:00
Yuka 5f1d008cce theme: add live theming for chromium-based browsers (#103)
* theme: add live theming for chromium-based browsers

Writes the surface colour as an RGB triplet to chromium.theme and
applies it as a managed browser policy for chromium, brave, and
google-chrome-stable using the --refresh-platform-policy flag
(introduced in Chrome 142+).

Also fixes write_file to set 0644 permissions so browser processes
can read the policy files.

* feat: use sudo tee not chmod

* chore: update readme

---------

Co-authored-by: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>
2026-04-15 16:20:15 +10:00
2 * r + 2 * t 66bce26841 fix: remove troublesome resizer rules 2026-04-15 02:18:30 +10:00
Yuka e1531f3c9e theme: add zed editor theme support (#102)
- Add Zed theme template (zed.json) with full Material You syntax
  highlighting, UI colors, and terminal ANSI palette
- Apply theme via file overwrite instead of restarting Zed, so
  Zed's file watcher picks up changes automatically
- Resolve symlinks in apply_zed() only before writing, since
  Zed's file watcher does not detect changes through symlinks
  (other themed apps that rely on symlinks are unaffected)
2026-04-12 17:28:44 +10:00
github-actions 7f59ca9656 [CI] chore: update flake 2026-04-11 02:50:24 +00:00
2 * r + 2 * t 4cf9e8603f feat: add caelestia scheme
Closes #100
2026-04-10 21:24:51 +10:00
github-actions b4b26ab5d5 [CI] chore: update flake 2026-04-09 02:53:38 +00:00
github-actions b4758901f9 [CI] chore: update flake 2026-04-07 02:57:24 +00:00
github-actions 60284ca41e [CI] chore: update flake 2026-04-04 02:45:02 +00:00
github-actions a019c42244 [CI] chore: update flake 2026-04-03 02:54:20 +00:00
github-actions 950c40bbd4 [CI] chore: update flake 2026-04-02 02:51:37 +00:00
github-actions d054129db8 [CI] chore: update flake 2026-04-01 03:26:37 +00:00
github-actions e497ca87eb [CI] chore: update flake 2026-03-31 02:55:25 +00:00
github-actions e81b1b87f0 [CI] chore: update flake 2026-03-30 03:22:12 +00:00
github-actions 1b823554ad [CI] chore: update flake 2026-03-29 03:19:20 +00:00
github-actions 4538e9cb50 [CI] chore: update flake 2026-03-28 02:46:03 +00:00
github-actions e5c161d43a [CI] chore: update flake 2026-03-27 02:56:29 +00:00
github-actions 11bdbc9e80 [CI] chore: update flake 2026-03-26 02:52:54 +00:00
github-actions 5bb3276b14 [CI] chore: update flake 2026-03-24 02:49:18 +00:00
github-actions 3930ca09b9 [CI] chore: update flake 2026-03-23 02:50:37 +00:00
github-actions 305f02d8aa [CI] chore: update flake 2026-03-22 02:50:36 +00:00
github-actions 8c7eea556d [CI] chore: update flake 2026-03-21 02:34:07 +00:00
AteebXYZ 6bb09e7703 readme: document user templates (#99)
* document user_templates in README

* move user_template documentation to Usage

* format

---------

Co-authored-by: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>
2026-03-20 20:56:12 +11:00
github-actions b023936e1d [CI] chore: update flake 2026-03-20 02:40:16 +00:00
github-actions 3cdc2ce464 [CI] chore: update flake 2026-03-19 02:48:41 +00:00
github-actions 534d11d66a [CI] chore: update flake 2026-03-18 02:48:20 +00:00
github-actions 0d8be30138 [CI] chore: update flake 2026-03-17 02:42:09 +00:00
Samiyel Frazier 8c6d933267 nix: fix qtct sub (#98) 2026-03-16 14:14:46 +11:00
Kalagmitan b00c601d0a refactor: enforce stricter type hints (#91)
LSP was screaming at me so I decided to just address it to get it off my
screen.

+ Fixed the type hints
:= Modified and added type hints for certain functions and variables in
most of the files in the utils/ folder (and some in the subcommands/
folder) for clarity and so pyright's type checker wouldn't cry.
:+ To resolve certain type issues, I had to add a bit more tiny
additional code such as, additional checks if a variable is None, a tiny
class in utils/material/generator.py to resolve the constructor usage
mismatch between what the DynamicScheme accepts and what the code
actually passes, and etc.
- Renamed certain functions and variables for clarity and also for some
to not collide with pre-existing definitions from well-known library
imports.
+ PIL has reorganized their code a bit, so the code is made to reflect
their new definitions.
= Reorganized the single import statement for "colourfulness" in
utils/wallpaper.py to be close to the top.
(I think that's it)

Side Effects?:
Everything should work the same as no logic change was done whatsover
(unless we consider the added if statements for type checking as a logic
change). I've tested it, everything seems to be in urdir.
2026-03-15 22:56:05 +11:00
2 * r + 2 * t c930bd2604 feat: switch to qtengine 2026-03-09 21:36:44 +11:00
2 * r + 2 * t cc155cf432 fix: format 2026-03-09 21:26:37 +11:00
github-actions 6e59149fbf [CI] chore: update flake 2026-03-09 02:52:41 +00:00
github-actions 8d2b737f15 [CI] chore: update flake 2026-03-08 02:51:20 +00:00
github-actions 4bcd42f482 [CI] chore: update flake 2026-03-06 02:36:22 +00:00
xeisenberg 51cecd481c fix: wall not Path type (#89) 2026-03-04 19:30:11 +11:00
github-actions c9312f3928 [CI] chore: update flake 2026-03-04 02:36:02 +00:00
github-actions bfaf4fc373 [CI] chore: update flake 2026-03-03 02:43:42 +00:00
2 * r + 2 * t 6e711ec289 fix: dynamic scheme import <3.0.0 compat 2026-03-03 01:04:33 +11:00
github-actions 7899f8348f [CI] chore: update flake 2026-03-01 03:00:32 +00:00
github-actions b0d68f0a1c [CI] chore: update flake 2026-02-25 02:51:37 +00:00
github-actions b0325a1898 [CI] chore: update flake 2026-02-22 02:46:26 +00:00
github-actions a6defd2921 [CI] chore: update flake 2026-02-21 02:33:51 +00:00
2 * r + 2 * t 0b9e416175 fix: missing colon 2026-02-21 00:52:12 +11:00
Unrectified 8ce97ea3f5 feat: add GIF files support as wallpaper (#88)
* feat: add GIF files support as wallpaper (not animated tho)

* tweak: simplifying variable use

* fix: git is good but damn it's annoying

* fix

---------

Co-authored-by: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>
2026-02-21 00:17:16 +11:00
github-actions 25c473c18e [CI] chore: update flake 2026-02-20 02:40:44 +00:00
github-actions c22916fe45 [CI] chore: update flake 2026-02-19 02:45:57 +00:00
Soramane 011989e3ca fix: dynamic scheme import 2026-02-18 11:18:15 +11:00
Nathachou d88cc7ff79 feat: scheme support for Pandora Minecraft launcher (#87)
* added pandora theme template json

* feat: add support for Pandora theme integration
2026-02-17 11:36:42 +11:00
github-actions a550eb79ed [CI] chore: update flake 2026-02-15 02:56:44 +00:00
25 changed files with 1308 additions and 266 deletions
+81 -39
View File
@@ -4,43 +4,17 @@ The main control script for the Caelestia dotfiles.
<details><summary id="dependencies">External dependencies</summary> <details><summary id="dependencies">External dependencies</summary>
- [`libnotfy`](https://gitlab.gnome.org/GNOME/libnotify) - sending notifications - [`libnotfy`](https://gitlab.gnome.org/GNOME/libnotify) - sending notifications
- [`swappy`](https://github.com/jtheoof/swappy) - screenshot editor - [`swappy`](https://github.com/jtheoof/swappy) - screenshot editor
- [`grim`](https://gitlab.freedesktop.org/emersion/grim) - taking screenshots - [`grim`](https://gitlab.freedesktop.org/emersion/grim) - taking screenshots
- [`dart-sass`](https://github.com/sass/dart-sass) - discord theming - [`dart-sass`](https://github.com/sass/dart-sass) - discord theming
- [`app2unit`](https://github.com/Vladimir-csp/app2unit) - launching apps - [`app2unit`](https://github.com/Vladimir-csp/app2unit) - launching apps
- [`wl-clipboard`](https://github.com/bugaevc/wl-clipboard) - copying to clipboard - [`wl-clipboard`](https://github.com/bugaevc/wl-clipboard) - copying to clipboard
- [`slurp`](https://github.com/emersion/slurp) - selecting an area - [`slurp`](https://github.com/emersion/slurp) - selecting an area
- [`gpu-screen-recorder`](https://git.dec05eba.com/gpu-screen-recorder/about) - screen recording - [`gpu-screen-recorder`](https://git.dec05eba.com/gpu-screen-recorder/about) - screen recording
- `glib2` - closing notifications - `glib2` - closing notifications
- [`cliphist`](https://github.com/sentriz/cliphist) - clipboard history - [`cliphist`](https://github.com/sentriz/cliphist) - clipboard history
- [`fuzzel`](https://codeberg.org/dnkl/fuzzel) - clipboard history/emoji picker - [`fuzzel`](https://codeberg.org/dnkl/fuzzel) - clipboard history/emoji picker
</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> </details>
@@ -119,6 +93,45 @@ sudo python -m installer dist/*.whl
sudo cp completions/caelestia.fish /usr/share/fish/vendor_completions.d/caelestia.fish 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 ## Usage
All subcommands/options can be explored via the help flag. All subcommands/options can be explored via the help flag.
@@ -148,6 +161,24 @@ subcommands:
resizer window resizer daemon 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 ## Configuring
All configuration options are in `~/.config/caelestia/cli.json`. All configuration options are in `~/.config/caelestia/cli.json`.
@@ -160,17 +191,28 @@ All configuration options are in `~/.config/caelestia/cli.json`.
"extraArgs": [] "extraArgs": []
}, },
"wallpaper": { "wallpaper": {
"postHook": "echo $WALLPAPER_PATH" "postHook": "echo $WALLPAPER_PATH $SCHEME_NAME $SCHEME_FLAVOUR $SCHEME_MODE $SCHEME_VARIANT $SCHEME_COLOURS"
}, },
"theme": { "theme": {
"enableTerm": true, "enableTerm": true,
"enableHypr": true, "enableHypr": true,
"enableDiscord": true, "enableDiscord": true,
"enableSpicetify": true, "enableSpicetify": true,
"enablePandora": true,
"enableFuzzel": true, "enableFuzzel": true,
"enableBtop": true, "enableBtop": true,
"enableNvtop": true,
"enableHtop": true,
"enableGtk": 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": { "toggles": {
"communication": { "communication": {
+1 -1
View File
@@ -72,7 +72,7 @@ python3.pkgs.buildPythonApplication {
--replace-fail 'app2unit' ${app2unit}/bin/app2unit --replace-fail 'app2unit' ${app2unit}/bin/app2unit
# Use config style instead of darkly # Use config style instead of darkly
substituteInPlace src/caelestia/data/templates/qtct.conf \ substituteInPlace src/caelestia/data/templates/qtengine.json \
--replace-fail 'Darkly' '${qtctStyle}' --replace-fail 'Darkly' '${qtctStyle}'
''; '';
Generated
+10 -10
View File
@@ -9,11 +9,11 @@
"quickshell": "quickshell" "quickshell": "quickshell"
}, },
"locked": { "locked": {
"lastModified": 1770949235, "lastModified": 1777688289,
"narHash": "sha256-OFeud9FjaOk6xHp/9igYl/+Zw6FJDyZNrIDNi47gsG0=", "narHash": "sha256-2EaEVkT1oUpjLLp7uEY/hDYDOa2k5R1YgcJpHei+lUM=",
"owner": "caelestia-dots", "owner": "caelestia-dots",
"repo": "shell", "repo": "shell",
"rev": "93e8880842b03e251bf59d1ba316f2393c68574f", "rev": "4e9e1f4b723f7e3a87cb280d67a25ee92c87fbff",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -24,11 +24,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1770841267, "lastModified": 1777268161,
"narHash": "sha256-9xejG0KoqsoKEGp2kVbXRlEYtFFcDTHjidiuX8hGO44=", "narHash": "sha256-bxrdOn8SCOv8tN4JbTF/TXq7kjo9ag4M+C8yzzIRYbE=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "ec7c70d12ce2fc37cb92aff673dcdca89d187bae", "rev": "1c3fe55ad329cbcb28471bb30f05c9827f724c76",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -46,11 +46,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1769593411, "lastModified": 1777341401,
"narHash": "sha256-WW00FaBiUmQyxvSbefvgxIjwf/WmRrEGBbwMHvW/7uQ=", "narHash": "sha256-QEAVYeXxvTamsYJVBq8+qSJV9ml2MxqRaZvkobfuPWA=",
"ref": "refs/heads/master", "ref": "refs/heads/master",
"rev": "1e4d804e7f3fa7465811030e8da2bf10d544426a", "rev": "0baa81aa03559ca315668e5a306364cddf1a6f49",
"revCount": 732, "revCount": 812,
"type": "git", "type": "git",
"url": "https://git.outfoxxed.me/outfoxxed/quickshell" "url": "https://git.outfoxxed.me/outfoxxed/quickshell"
}, },
@@ -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
View File
@@ -2,8 +2,6 @@
# Optimized for smooth and responsive visualization # Optimized for smooth and responsive visualization
[general] [general]
# Number of bars (20-200) - fewer bars = better performance
bars = 64
# Framerate (1-144) - higher = smoother but more CPU intensive # Framerate (1-144) - higher = smoother but more CPU intensive
framerate = 60 framerate = 60
+162
View File
@@ -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 }}"
}
}
}
}
]
}
-6
View File
@@ -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
}
}
+457
View File
@@ -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
}
}
}
}
]
}
+1 -1
View File
@@ -6,7 +6,7 @@ from caelestia.utils.scheme import get_scheme_names, scheme_variants
from caelestia.utils.wallpaper import get_wallpaper 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 = 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") parser.add_argument("-v", "--version", action="store_true", help="print the current version")
+3 -2
View File
@@ -1,4 +1,3 @@
from pathlib import Path
import json import json
import re import re
import shutil import shutil
@@ -6,7 +5,9 @@ import subprocess
import time import time
from argparse import Namespace from argparse import Namespace
from datetime import datetime from datetime import datetime
from pathlib import Path
from caelestia.utils import hypr
from caelestia.utils.notify import close_notification, notify 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 recording_notif_path, recording_path, recordings_dir, user_config_path
@@ -36,7 +37,7 @@ class Command:
def start(self) -> None: def start(self) -> None:
args = ["-w"] args = ["-w"]
monitors = json.loads(subprocess.check_output(["hyprctl", "monitors", "-j"])) monitors = hypr.message("monitors")
if self.args.region: if self.args.region:
if self.args.region == "slurp": if self.args.region == "slurp":
region = subprocess.check_output(["slurp", "-f", "%wx%h+%x+%y"], text=True) region = subprocess.check_output(["slurp", "-f", "%wx%h+%x+%y"], text=True)
+13 -12
View File
@@ -29,8 +29,6 @@ class Command:
def _load_window_rules(self) -> list[WindowRule]: def _load_window_rules(self) -> list[WindowRule]:
default_rules = [ default_rules = [
WindowRule("(Bitwarden", "titleContains", "20%", "54%", ["float", "center"]), 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"]), WindowRule("^[Pp]icture(-| )in(-| )[Pp]icture$", "titleRegex", "", "", ["pip"]),
] ]
@@ -140,9 +138,12 @@ class Command:
monitor_x = monitor.get("x") monitor_x = monitor.get("x")
monitor_y = monitor.get("y") 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 return
monitor_height = monitor_height / monitor_scale monitor_height = monitor_height / monitor_scale
monitor_width = monitor_width / monitor_scale monitor_width = monitor_width / monitor_scale
@@ -232,7 +233,7 @@ class Command:
window_id = event.split(">>>")[1].split(",")[0] window_id = event.split(">>>")[1].split(",")[0]
else: else:
window_id = event.split(">>")[1].split(",")[0] window_id = event.split(">>")[1].split(",")[0]
# Remove any leading > characters # Remove any leading > characters
window_id = window_id.lstrip(">") window_id = window_id.lstrip(">")
@@ -268,9 +269,9 @@ class Command:
data = event[13:] # Remove "openwindow>>>" data = event[13:] # Remove "openwindow>>>"
else: else:
data = event[12:] # Remove "openwindow>>" data = event[12:] # Remove "openwindow>>"
window_id, workspace, window_class, title = data.split(",", 3) window_id, workspace, window_class, title = data.split(",", 3)
# Remove any leading > characters # Remove any leading > characters
window_id = window_id.lstrip(">") window_id = window_id.lstrip(">")
@@ -348,19 +349,19 @@ class Command:
# Find all windows that match the pattern # Find all windows that match the pattern
matching_windows = self._find_matching_windows(temp_rule) matching_windows = self._find_matching_windows(temp_rule)
if not matching_windows: if not matching_windows:
print(f"No windows found matching pattern '{temp_rule.name}' with match type '{temp_rule.match_type}'") print(f"No windows found matching pattern '{temp_rule.name}' with match type '{temp_rule.match_type}'")
return return
print(f"Found {len(matching_windows)} matching window(s)") print(f"Found {len(matching_windows)} matching window(s)")
# Apply rule to all matching windows # Apply rule to all matching windows
success_count = 0 success_count = 0
for window in matching_windows: for window in matching_windows:
window_id = window["address"][2:] # Remove "0x" prefix window_id = window["address"][2:] # Remove "0x" prefix
window_title = window.get("title", "") window_title = window.get("title", "")
print(f"Applying rule to window 0x{window_id}: '{window_title}'") print(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) success = self._apply_window_actions(window_id, temp_rule.width, temp_rule.height, temp_rule.actions)
if success: if success:
@@ -386,7 +387,7 @@ class Command:
return return
window_id = address[2:] # Remove "0x" prefix window_id = address[2:] # Remove "0x" prefix
print(f"Applying rule to active window 0x{window_id}: '{window_title}'") print(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) success = self._apply_window_actions(window_id, temp_rule.width, temp_rule.height, temp_rule.actions)
if success: if success:
@@ -411,7 +412,7 @@ class Command:
window_title = window.get("title", "") window_title = window.get("title", "")
initial_title = window.get("initialTitle", "") initial_title = window.get("initialTitle", "")
# Check if window matches the pattern # Check if window matches the pattern
matches = False matches = False
if temp_rule.match_type == "initialTitle": if temp_rule.match_type == "initialTitle":
+5 -2
View File
@@ -26,8 +26,11 @@ class Command:
else: else:
sc_data = subprocess.check_output(["grim", "-l", "0", "-g", self.args.region.strip(), "-"]) 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) swappy = subprocess.Popen(["swappy", "-f", "-"], stdin=subprocess.PIPE, start_new_session=True)
swappy.stdin.write(sc_data)
swappy.stdin.close() # Ensure stdin is not None for the type checker
if swappy.stdin:
swappy.stdin.write(sc_data)
swappy.stdin.close()
def fullscreen(self) -> None: def fullscreen(self) -> None:
sc_data = subprocess.check_output(["grim", "-"]) sc_data = subprocess.check_output(["grim", "-"])
+7 -4
View File
@@ -33,11 +33,14 @@ class Command:
subprocess.run(args) subprocess.run(args)
else: else:
shell = subprocess.Popen(args, stdout=subprocess.PIPE, universal_newlines=True) shell = subprocess.Popen(args, stdout=subprocess.PIPE, universal_newlines=True)
for line in shell.stdout:
if self.filter_log(line):
print(line, end="")
def shell(self, *args: list[str]) -> str: # 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: str) -> str:
return subprocess.check_output(["qs", "-c", "caelestia", *args], text=True) return subprocess.check_output(["qs", "-c", "caelestia", *args], text=True)
def filter_log(self, line: str) -> bool: def filter_log(self, line: str) -> bool:
+16 -12
View File
@@ -3,6 +3,7 @@ import shlex
import shutil import shutil
from argparse import Namespace from argparse import Namespace
from collections import ChainMap from collections import ChainMap
from typing import Any, Callable, cast
from caelestia.utils import hypr from caelestia.utils import hypr
from caelestia.utils.paths import user_config_path from caelestia.utils.paths import user_config_path
@@ -52,8 +53,8 @@ class DeepChainMap(ChainMap):
class Command: class Command:
args: Namespace args: Namespace
cfg: dict[str, dict[str, dict[str, any]]] | DeepChainMap cfg: dict[str, dict[str, dict[str, Any]]] | DeepChainMap
clients: list[dict[str, any]] = None clients: list[dict[str, Any]] | None = None
def __init__(self, args: Namespace) -> None: def __init__(self, args: Namespace) -> None:
self.args = args self.args = args
@@ -120,27 +121,27 @@ class Command:
if not spawned: if not spawned:
hypr.dispatch("togglespecialworkspace", self.args.workspace) 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: if self.clients is None:
self.clients = hypr.message("clients") self.clients = cast(list[dict[str, Any]], hypr.message("clients"))
return self.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(): for client in self.get_clients():
if selector(client) and client["workspace"]["name"] != f"special:{workspace}": if selector(client) and client["workspace"]["name"] != f"special:{workspace}":
hypr.dispatch("movetoworkspacesilent", f"special:{workspace},address:{client['address']}") 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( if (spawn[0].endswith(".desktop") or shutil.which(spawn[0])) and not any(
selector(client) for client in self.get_clients() selector(client) for client in self.get_clients()
): ):
hypr.dispatch("exec", f"[workspace special:{self.args.workspace}] app2unit -- {shlex.join(spawn)}") hypr.dispatch("exec", f"[workspace special:{self.args.workspace}] app2unit -- {shlex.join(spawn)}")
return True return True
return False else:
return False
def handle_client_config(self, client: dict[str, any]) -> bool: def handle_client_config(self, client: dict[str, Any]) -> bool:
def selector(c: dict[str, any]) -> bool: def selector(c: dict[str, Any]) -> bool:
# Each match is or, inside matches is and # Each match is or, inside matches is and
for match in client["match"]: for match in client["match"]:
if is_subset(c, match): if is_subset(c, match):
@@ -156,5 +157,8 @@ class Command:
return spawned return spawned
def specialws(self) -> None: def specialws(self) -> None:
special = next(m for m in hypr.message("monitors") if m["focused"])["specialWorkspace"]["name"] monitors = cast(list[dict[str, Any]], hypr.message("monitors"))
hypr.dispatch("togglespecialworkspace", special[8:] or "special") 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)
+2 -3
View File
@@ -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 return math.sqrt(sum((x - mean_val) ** 2 for x in values) / len(values)) if values else 0
def calc_colourfulness(image: Image) -> float: def calc_colourfulness(image: Image.Image) -> float:
width, height = image.size
pixels = list(image.getdata()) # List of (R, G, B) tuples pixels = list(image.getdata()) # List of (R, G, B) tuples
rg_diffs = [] 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) 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) colourfulness = calc_colourfulness(image)
if colourfulness < 10: if colourfulness < 10:
+14 -10
View File
@@ -1,17 +1,18 @@
import json as j import json
import os import os
import socket import socket
from typing import Any
socket_base = f"{os.getenv('XDG_RUNTIME_DIR')}/hypr/{os.getenv('HYPRLAND_INSTANCE_SIGNATURE')}" socket_base = f"{os.getenv('XDG_RUNTIME_DIR')}/hypr/{os.getenv('HYPRLAND_INSTANCE_SIGNATURE')}"
socket_path = f"{socket_base}/.socket.sock" socket_path = f"{socket_base}/.socket.sock"
socket2_path = f"{socket_base}/.socket2.sock" socket2_path = f"{socket_base}/.socket2.sock"
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: with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.connect(socket_path) sock.connect(socket_path)
if json: if is_json:
msg = f"j/{msg}" msg = f"j/{msg}"
sock.send(msg.encode()) sock.send(msg.encode())
@@ -22,14 +23,17 @@ def message(msg: str, json: bool = True) -> str | dict[str, any]:
break break
resp += new_resp.decode() 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: def dispatch(dispatcher: str, *args: str) -> bool:
return message(f"dispatch {dispatcher} {' '.join(map(str, args))}".rstrip(), json=False) == "ok" return message(f"dispatch {dispatcher} {' '.join(map(str, args))}".rstrip(), is_json=False) == "ok"
def batch(*msgs: list[str], json: bool = False) -> str | dict[str, any]: def batch(*msgs: str, is_json: bool = False) -> str | dict[str, Any]:
if json: formatted_msgs = msgs
msgs = (f"j/{m.strip()}" for m in msgs)
return message(f"[[BATCH]]{';'.join(msgs)}", json=False) if is_json:
formatted_msgs = [f"j/{m.strip()}" for m in msgs]
return message(f"[[BATCH]]{';'.join(formatted_msgs)}", is_json=False)
+2
View File
@@ -12,9 +12,11 @@ def log_exception(func):
Used by the `apply_()` functions so that an exception, when applying Used by the `apply_()` functions so that an exception, when applying
a theme, does not prevent the other themes from being applied. a theme, does not prevent the other themes from being applied.
""" """
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
try: try:
func(*args, **kwargs) func(*args, **kwargs)
except Exception as e: except Exception as e:
log_message(f'Error during execution of "{func.__name__}()": {str(e)}') log_message(f'Error during execution of "{func.__name__}()": {str(e)}')
return wrapper return wrapper
+31 -21
View File
@@ -1,8 +1,5 @@
from materialyoucolor.blend import Blend from materialyoucolor.blend import Blend
from materialyoucolor.dynamiccolor.material_dynamic_colors import ( from materialyoucolor.dynamiccolor.material_dynamic_colors import MaterialDynamicColors
DynamicScheme,
MaterialDynamicColors,
)
from materialyoucolor.hct import Hct from materialyoucolor.hct import Hct
from materialyoucolor.scheme.scheme_content import SchemeContent from materialyoucolor.scheme.scheme_content import SchemeContent
from materialyoucolor.scheme.scheme_expressive import SchemeExpressive 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_tonal_spot import SchemeTonalSpot
from materialyoucolor.scheme.scheme_vibrant import SchemeVibrant from materialyoucolor.scheme.scheme_vibrant import SchemeVibrant
from materialyoucolor.utils.math_utils import difference_degrees, rotation_direction, sanitize_degrees_double 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: 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) 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": if scheme == "content":
return SchemeContent return SchemeContent
if scheme == "expressive": if scheme == "expressive":
@@ -166,12 +176,12 @@ def get_scheme(scheme: str) -> DynamicScheme:
def gen_scheme(scheme, primary: Hct) -> dict[str, str]: def gen_scheme(scheme, primary: Hct) -> dict[str, str]:
light = scheme.mode == "light" is_light = scheme.mode == "light"
colours = {} colours = {}
# Material 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 if hasattr(MaterialDynamicColors, "all_colors"): # materialyoucolor-python >= 3.0.0
dyn_colours = MaterialDynamicColors() dyn_colours = MaterialDynamicColors()
for colour in dyn_colours.all_colors: 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"] colours["neutral_variant_paletteKeyColor"] = colours["neutralVariantPaletteKeyColor"]
# Harmonize terminal colours # 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": if scheme.variant == "monochrome":
colours[f"term{i}"] = grayscale(hct, light) colours[f"term{i}"] = grayscale(hct, is_light)
else: else:
colours[f"term{i}"] = harmonize( 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 # 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": if scheme.variant == "monochrome":
colours[colour_names[i]] = grayscale(hct, light) colours[colour_names[i]] = grayscale(hct, is_light)
else: 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 # KColours
for colour in kcolours: for colour in kcolours:
colours[colour["name"]] = harmonize(colour["hct"], colours["primary"], 0.1) colours[colour["name"]] = harmonize(colour["hct"], colours["primary"], 0.1)
colours[f"{colour['name']}Selection"] = harmonize(colour["hct"], colours["onPrimaryFixedVariant"], 0.1) colours[f"{colour['name']}Selection"] = harmonize(colour["hct"], colours["onPrimaryFixedVariant"], 0.1)
if scheme.variant == "monochrome": if scheme.variant == "monochrome":
colours[colour["name"]] = grayscale(colours[colour["name"]], light) colours[colour["name"]] = grayscale(colours[colour["name"]], is_light)
colours[f"{colour['name']}Selection"] = grayscale(colours[f"{colour['name']}Selection"], light) colours[f"{colour['name']}Selection"] = grayscale(colours[f"{colour['name']}Selection"], is_light)
if scheme.variant == "neutral": if scheme.variant == "neutral":
for name, hct in colours.items(): for name, hct in colours.items():
@@ -219,8 +229,8 @@ def gen_scheme(scheme, primary: Hct) -> dict[str, str]:
# Darken surfaces for hard flavour # Darken surfaces for hard flavour
if scheme.flavour == "hard": if scheme.flavour == "hard":
for colour in "background", *(k for k in colours.keys() if k.startswith("surface")): 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[colour] = lighten(colours[colour], 0.4) if is_light else darken(colours[colour], 0.8)
colours["term0"] = lighten(colours["term0"], 0.4) if light else darken(colours["term0"], 0.9) colours["term0"] = lighten(colours["term0"], 0.4) if is_light else darken(colours["term0"], 0.9)
# FIXME: deprecated stuff # FIXME: deprecated stuff
colours["text"] = colours["onBackground"] colours["text"] = colours["onBackground"]
@@ -239,13 +249,13 @@ def gen_scheme(scheme, primary: Hct) -> dict[str, str]:
# More darkening if hard flavour # More darkening if hard flavour
if scheme.flavour == "hard": if scheme.flavour == "hard":
for colour in "base", "mantle", "crust": 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): for i in range(3):
colours[f"overlay{i}"] = ( 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}"] = ( 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 # 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()} colours = {k: hex(v.to_int())[4:] for k, v in colours.items()}
# Extended material # Extended material
if light: if is_light:
colours["success"] = "4F6354" colours["success"] = "4F6354"
colours["onSuccess"] = "FFFFFF" colours["onSuccess"] = "FFFFFF"
colours["successContainer"] = "D1E8D5" colours["successContainer"] = "D1E8D5"
+1 -1
View File
@@ -1,7 +1,7 @@
import subprocess 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() return subprocess.check_output(["notify-send", "-a", "caelestia-cli", *args], text=True).strip()
+30 -29
View File
@@ -4,41 +4,42 @@ import os
import shutil import shutil
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Any
config_dir = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config")) config_dir: Path = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config"))
data_dir = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share")) data_dir: Path = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
state_dir = Path(os.getenv("XDG_STATE_HOME", Path.home() / ".local/state")) state_dir: Path = Path(os.getenv("XDG_STATE_HOME", Path.home() / ".local/state"))
cache_dir = Path(os.getenv("XDG_CACHE_HOME", Path.home() / ".cache")) cache_dir: Path = Path(os.getenv("XDG_CACHE_HOME", Path.home() / ".cache"))
pictures_dir = Path(os.getenv("XDG_PICTURES_DIR", Path.home() / "Pictures")) pictures_dir: Path = Path(os.getenv("XDG_PICTURES_DIR", Path.home() / "Pictures"))
videos_dir = Path(os.getenv("XDG_VIDEOS_DIR", Path.home() / "Videos")) videos_dir: Path = Path(os.getenv("XDG_VIDEOS_DIR", Path.home() / "Videos"))
c_config_dir = config_dir / "caelestia" c_config_dir: Path = config_dir / "caelestia"
c_data_dir = data_dir / "caelestia" c_data_dir: Path = data_dir / "caelestia"
c_state_dir = state_dir / "caelestia" c_state_dir: Path = state_dir / "caelestia"
c_cache_dir = cache_dir / "caelestia" c_cache_dir: Path = cache_dir / "caelestia"
user_config_path = c_config_dir / "cli.json" user_config_path: Path = c_config_dir / "cli.json"
cli_data_dir = Path(__file__).parent.parent / "data" cli_data_dir: Path = Path(__file__).parent.parent / "data"
templates_dir = cli_data_dir / "templates" templates_dir: Path = cli_data_dir / "templates"
user_templates_dir = c_config_dir / "templates" user_templates_dir: Path = c_config_dir / "templates"
theme_dir = c_state_dir / "theme" theme_dir: Path = c_state_dir / "theme"
scheme_path = c_state_dir / "scheme.json" scheme_path: Path = c_state_dir / "scheme.json"
scheme_data_dir = cli_data_dir / "schemes" scheme_data_dir: Path = cli_data_dir / "schemes"
scheme_cache_dir = c_cache_dir / "schemes" scheme_cache_dir: Path = c_cache_dir / "schemes"
wallpapers_dir = os.getenv("CAELESTIA_WALLPAPERS_DIR", pictures_dir / "Wallpapers") wallpapers_dir: Path = Path(os.getenv("CAELESTIA_WALLPAPERS_DIR", pictures_dir / "Wallpapers"))
wallpaper_path_path = c_state_dir / "wallpaper/path.txt" wallpaper_path_path: Path = c_state_dir / "wallpaper/path.txt"
wallpaper_link_path = c_state_dir / "wallpaper/current" wallpaper_link_path: Path = c_state_dir / "wallpaper/current"
wallpaper_thumbnail_path = c_state_dir / "wallpaper/thumbnail.jpg" wallpaper_thumbnail_path: Path = c_state_dir / "wallpaper/thumbnail.jpg"
wallpapers_cache_dir = c_cache_dir / "wallpapers" wallpapers_cache_dir: Path = c_cache_dir / "wallpapers"
screenshots_dir = os.getenv("CAELESTIA_SCREENSHOTS_DIR", pictures_dir / "Screenshots") screenshots_dir: Path = Path(os.getenv("CAELESTIA_SCREENSHOTS_DIR", pictures_dir / "Screenshots"))
screenshots_cache_dir = c_cache_dir / "screenshots" screenshots_cache_dir: Path = c_cache_dir / "screenshots"
recordings_dir = os.getenv("CAELESTIA_RECORDINGS_DIR", videos_dir / "Recordings") recordings_dir: Path = Path(os.getenv("CAELESTIA_RECORDINGS_DIR", videos_dir / "Recordings"))
recording_path = c_state_dir / "record/recording.mp4" recording_path: Path = c_state_dir / "record/recording.mp4"
recording_notif_path = c_state_dir / "record/notifid.txt" recording_notif_path: Path = c_state_dir / "record/notifid.txt"
def compute_hash(path: Path | str) -> str: def compute_hash(path: Path | str) -> str:
@@ -51,7 +52,7 @@ def compute_hash(path: Path | str) -> str:
return sha.hexdigest() return sha.hexdigest()
def atomic_dump(path: Path, content: dict[str, any]) -> None: def atomic_dump(path: Path, content: dict[str, Any]) -> None:
with tempfile.NamedTemporaryFile("w") as f: with tempfile.NamedTemporaryFile("w") as f:
json.dump(content, f) json.dump(content, f)
f.flush() f.flush()
+14 -13
View File
@@ -1,6 +1,7 @@
import json import json
import random import random
from pathlib import Path from pathlib import Path
from typing import Any
from caelestia.utils.notify import notify from caelestia.utils.notify import notify
from caelestia.utils.paths import atomic_dump, scheme_data_dir, scheme_path from caelestia.utils.paths import atomic_dump, scheme_data_dir, scheme_path
@@ -14,19 +15,19 @@ class Scheme:
_colours: dict[str, str] _colours: dict[str, str]
notify: bool notify: bool
def __init__(self, json: dict[str, any] | None) -> None: def __init__(self, scheme_json: dict[str, Any] | None) -> None:
if json is None: if scheme_json is None:
self._name = "catppuccin" self._name = "catppuccin"
self._flavour = "mocha" self._flavour = "mocha"
self._mode = "dark" self._mode = "dark"
self._variant = "tonalspot" self._variant = "tonalspot"
self._colours = read_colours_from_file(self.get_colours_path()) self._colours = read_colours_from_file(self.get_colours_path())
else: else:
self._name = json["name"] self._name = scheme_json["name"]
self._flavour = json["flavour"] self._flavour = scheme_json["flavour"]
self._mode = json["mode"] self._mode = scheme_json["mode"]
self._variant = json["variant"] self._variant = scheme_json["variant"]
self._colours = json["colours"] self._colours = scheme_json["colours"]
self.notify = False self.notify = False
@property @property
@@ -196,7 +197,7 @@ scheme_variants = [
"content", "content",
] ]
scheme: Scheme = None scheme: Scheme | None = None
def read_colours_from_file(path: Path) -> dict[str, str]: 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"] 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: if name is None:
name = get_scheme().name 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]: def get_scheme_modes(name: str | None = None, flavour: str | None = None) -> list[str]:
if name is None: if name is None or flavour is None:
scheme = get_scheme() scheme = get_scheme()
name = scheme.name name = name or scheme.name
flavour = scheme.flavour flavour = flavour or scheme.flavour
if name == "dynamic": if name == "dynamic":
return ["light", "dark"] return ["light", "dark"]
+141 -81
View File
@@ -1,11 +1,11 @@
import json
import re
import subprocess
from pathlib import Path
import tempfile
import shutil
import fcntl 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.colour import get_dynamic_colours
from caelestia.utils.logging import log_exception from caelestia.utils.logging import log_exception
@@ -18,6 +18,7 @@ from caelestia.utils.paths import (
user_config_path, user_config_path,
user_templates_dir, user_templates_dir,
) )
from caelestia.utils.scheme import get_scheme
def gen_conf(colours: dict[str, str]) -> str: def gen_conf(colours: dict[str, str]) -> str:
@@ -35,10 +36,10 @@ def gen_scss(colours: dict[str, str]) -> str:
def gen_replace(colours: dict[str, str], template: Path, hash: bool = False) -> 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(): for name, colour in colours.items():
template = template.replace(f"{{{{ ${name} }}}}", f"#{colour}" if hash else colour) new_template = new_template.replace(f"{{{{ ${name} }}}}", f"#{colour}" if hash else colour)
return template return new_template
def gen_replace_dynamic(colours: dict[str, str], template: Path, mode: str) -> str: def gen_replace_dynamic(colours: dict[str, str], template: Path, mode: str) -> str:
@@ -60,13 +61,13 @@ def gen_replace_dynamic(colours: dict[str, str], template: Path, mode: str) -> s
colours_dyn = get_dynamic_colours(colours) colours_dyn = get_dynamic_colours(colours)
template_content = template.read_text() template_content = template.read_text()
template_filled = re.sub(dotField, fill_colour, template_content) template_filled = re.sub(dotField, fill_colour, template_content)
template_filled = re.sub(modeField, mode, template_filled) template_filled = re.sub(modeField, mode, template_filled)
return template_filled 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\\)""" """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\\" return f"\x1b]{';'.join(map(str, i))};rgb:{c[0:2]}/{c[2:4]}/{c[4:6]}\x1b\\"
@@ -83,29 +84,29 @@ def gen_sequences(colours: dict[str, str]) -> str:
16+: 256 colours 16+: 256 colours
""" """
return ( return (
c2s(colours["onSurface"], 10) hex_to_ansi(colours["onSurface"], 10)
+ c2s(colours["surface"], 11) + hex_to_ansi(colours["surface"], 11)
+ c2s(colours["secondary"], 12) + hex_to_ansi(colours["secondary"], 12)
+ c2s(colours["secondary"], 17) + hex_to_ansi(colours["secondary"], 17)
+ c2s(colours["term0"], 4, 0) + hex_to_ansi(colours["term0"], 4, 0)
+ c2s(colours["term1"], 4, 1) + hex_to_ansi(colours["term1"], 4, 1)
+ c2s(colours["term2"], 4, 2) + hex_to_ansi(colours["term2"], 4, 2)
+ c2s(colours["term3"], 4, 3) + hex_to_ansi(colours["term3"], 4, 3)
+ c2s(colours["term4"], 4, 4) + hex_to_ansi(colours["term4"], 4, 4)
+ c2s(colours["term5"], 4, 5) + hex_to_ansi(colours["term5"], 4, 5)
+ c2s(colours["term6"], 4, 6) + hex_to_ansi(colours["term6"], 4, 6)
+ c2s(colours["term7"], 4, 7) + hex_to_ansi(colours["term7"], 4, 7)
+ c2s(colours["term8"], 4, 8) + hex_to_ansi(colours["term8"], 4, 8)
+ c2s(colours["term9"], 4, 9) + hex_to_ansi(colours["term9"], 4, 9)
+ c2s(colours["term10"], 4, 10) + hex_to_ansi(colours["term10"], 4, 10)
+ c2s(colours["term11"], 4, 11) + hex_to_ansi(colours["term11"], 4, 11)
+ c2s(colours["term12"], 4, 12) + hex_to_ansi(colours["term12"], 4, 12)
+ c2s(colours["term13"], 4, 13) + hex_to_ansi(colours["term13"], 4, 13)
+ c2s(colours["term14"], 4, 14) + hex_to_ansi(colours["term14"], 4, 14)
+ c2s(colours["term15"], 4, 15) + hex_to_ansi(colours["term15"], 4, 15)
+ c2s(colours["primary"], 4, 16) + hex_to_ansi(colours["primary"], 4, 16)
+ c2s(colours["secondary"], 4, 17) + hex_to_ansi(colours["secondary"], 4, 17)
+ c2s(colours["tertiary"], 4, 18) + hex_to_ansi(colours["tertiary"], 4, 18)
) )
@@ -117,6 +118,7 @@ def write_file(path: Path, content: str) -> None:
f.flush() f.flush()
shutil.move(f.name, path) shutil.move(f.name, path)
@log_exception @log_exception
def apply_terms(sequences: str) -> None: def apply_terms(sequences: str) -> None:
state = c_state_dir / "sequences.txt" state = c_state_dir / "sequences.txt"
@@ -129,6 +131,7 @@ def apply_terms(sequences: str) -> None:
try: try:
# Use non-blocking write with timeout to prevent hangs # Use non-blocking write with timeout to prevent hangs
import os import os
fd = os.open(str(pt), os.O_WRONLY | os.O_NONBLOCK | os.O_NOCTTY) fd = os.open(str(pt), os.O_WRONLY | os.O_NONBLOCK | os.O_NOCTTY)
try: try:
os.write(fd, sequences.encode()) os.write(fd, sequences.encode())
@@ -156,6 +159,13 @@ def apply_discord(scss: str) -> None:
write_file(config_dir / client / "themes/caelestia.theme.css", conf) write_file(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)
write_file(data_dir / "PandoraLauncher/themes/caelestia.json", template)
@log_exception @log_exception
def apply_spicetify(colours: dict[str, str], mode: str) -> None: def apply_spicetify(colours: dict[str, str], mode: str) -> None:
template = gen_replace(colours, templates_dir / f"spicetify-{mode}.ini") template = gen_replace(colours, templates_dir / f"spicetify-{mode}.ini")
@@ -191,36 +201,32 @@ def apply_htop(colours: dict[str, str]) -> None:
def sync_papirus_colors(hex_color: str) -> None: def sync_papirus_colors(hex_color: str) -> None:
"""Sync Papirus folder icon colors using hue/saturation analysis""" """Sync Papirus folder icon colors using hue/saturation analysis"""
try: try:
result = subprocess.run( result = subprocess.run(["which", "papirus-folders"], capture_output=True, check=False)
["which", "papirus-folders"],
capture_output=True,
check=False
)
if result.returncode != 0: if result.returncode != 0:
return return
except Exception: except Exception:
return return
papirus_paths = [ papirus_paths = [
Path("/usr/share/icons/Papirus"), Path("/usr/share/icons/Papirus"),
Path("/usr/share/icons/Papirus-Dark"), Path("/usr/share/icons/Papirus-Dark"),
Path.home() / ".local/share/icons/Papirus", Path.home() / ".local/share/icons/Papirus",
Path.home() / ".icons/Papirus", Path.home() / ".icons/Papirus",
] ]
if not any(p.exists() for p in papirus_paths): if not any(p.exists() for p in papirus_paths):
return return
r = int(hex_color[0:2], 16) r = int(hex_color[0:2], 16)
g = int(hex_color[2:4], 16) g = int(hex_color[2:4], 16)
b = int(hex_color[4:6], 16) b = int(hex_color[4:6], 16)
# Brightness and saturation # Brightness and saturation
max_val = max(r, g, b) max_val = max(r, g, b)
min_val = min(r, g, b) min_val = min(r, g, b)
brightness = max_val brightness = max_val
saturation = 0 if max_val == 0 else ((max_val - min_val) * 100) // max_val saturation = 0 if max_val == 0 else ((max_val - min_val) * 100) // max_val
# Low saturation = grayscale # Low saturation = grayscale
if saturation < 20: if saturation < 20:
if brightness < 85: if brightness < 85:
@@ -235,13 +241,13 @@ def sync_papirus_colors(hex_color: str) -> None:
color = _determine_hue_color(r, g, b, brightness, use_pale) color = _determine_hue_color(r, g, b, brightness, use_pale)
else: else:
color = _determine_hue_color(r, g, b, brightness, False) color = _determine_hue_color(r, g, b, brightness, False)
try: try:
subprocess.Popen( subprocess.Popen(
["sudo", "-n", "papirus-folders", "-C", color, "-u"], ["sudo", "-n", "papirus-folders", "-C", color, "-u"],
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
start_new_session=True start_new_session=True,
) )
except Exception: except Exception:
pass pass
@@ -253,7 +259,7 @@ def _determine_hue_color(r: int, g: int, b: int, brightness: int, use_pale: bool
r_ratio = (r * 100) // b if b > 0 else 0 r_ratio = (r * 100) // b if b > 0 else 0
g_ratio = (g * 100) // b if b > 0 else 0 g_ratio = (g * 100) // b if b > 0 else 0
rg_diff = abs(r - g) rg_diff = abs(r - g)
if r_ratio > 70 and g_ratio > 70: if r_ratio > 70 and g_ratio > 70:
# Both R and G high relative to B = light blue/periwinkle # Both R and G high relative to B = light blue/periwinkle
if rg_diff < 15: if rg_diff < 15:
@@ -298,10 +304,10 @@ def _determine_hue_color(r: int, g: int, b: int, brightness: int, use_pale: bool
@log_exception @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) gtk_template = gen_replace(colours, templates_dir / "gtk.css", hash=True)
thunar_template = gen_replace(colours, templates_dir / "thunar.css", hash=True) thunar_template = gen_replace(colours, templates_dir / "thunar.css", hash=True)
for gtk_version in ["gtk-3.0", "gtk-4.0"]: for gtk_version in ["gtk-3.0", "gtk-4.0"]:
gtk_config_dir = config_dir / gtk_version gtk_config_dir = config_dir / gtk_version
write_file(gtk_config_dir / "gtk.css", gtk_template) write_file(gtk_config_dir / "gtk.css", gtk_template)
@@ -309,36 +315,22 @@ def apply_gtk(colours: dict[str, str], mode: str) -> None:
subprocess.run(["dconf", "write", "/org/gnome/desktop/interface/gtk-theme", "'adw-gtk3-dark'"]) 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/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"]) sync_papirus_colors(colours["primary"])
@log_exception @log_exception
def apply_qt(colours: dict[str, str], mode: str) -> None: def apply_qt(colours: dict[str, str], mode: str, icon_theme: str | None = None) -> None:
template = gen_replace(colours, templates_dir / f"qt{mode}.colors", hash=True) colours = gen_replace(colours, templates_dir / f"qt{mode}.colors", hash=True)
write_file(config_dir / "qt5ct/colors/caelestia.colors", template) write_file(config_dir / "qtengine/caelestia.colors", colours)
write_file(config_dir / "qt6ct/colors/caelestia.colors", template)
qtct = (templates_dir / "qtct.conf").read_text() config = (templates_dir / "qtengine.json").read_text()
qtct = qtct.replace("{{ $mode }}", mode.capitalize()) config = config.replace("{{ $mode }}", mode.capitalize())
if icon_theme is not None:
for ver in 5, 6: config = config.replace(f'"iconTheme": "Papirus-{mode.capitalize()}"', f'"iconTheme": "{icon_theme}"')
conf = qtct.replace("{{ $config }}", str(config_dir / f"qt{ver}ct")) write_file(config_dir / "qtengine/config.json", config)
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)
@log_exception @log_exception
@@ -350,6 +342,51 @@ def apply_warp(colours: dict[str, str], mode: str) -> None:
write_file(data_dir / "warp-terminal/themes/caelestia.yaml", template) write_file(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 write_file 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)
write_file(theme_path, content)
@log_exception @log_exception
def apply_cava(colours: dict[str, str]) -> None: def apply_cava(colours: dict[str, str]) -> None:
template = gen_replace(colours, templates_dir / "cava.conf", hash=True) template = gen_replace(colours, templates_dir / "cava.conf", hash=True)
@@ -372,14 +409,14 @@ def apply_colours(colours: dict[str, str], mode: str) -> None:
# Use file-based lock to prevent concurrent theme changes # Use file-based lock to prevent concurrent theme changes
lock_file = c_state_dir / "theme.lock" lock_file = c_state_dir / "theme.lock"
c_state_dir.mkdir(parents=True, exist_ok=True) c_state_dir.mkdir(parents=True, exist_ok=True)
try: try:
with open(lock_file, 'w') as lock_fd: with open(lock_file, "w") as lock_fd:
try: try:
fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError: except BlockingIOError:
return return
try: try:
cfg = json.loads(user_config_path.read_text())["theme"] cfg = json.loads(user_config_path.read_text())["theme"]
except (FileNotFoundError, json.JSONDecodeError, KeyError): except (FileNotFoundError, json.JSONDecodeError, KeyError):
@@ -396,6 +433,8 @@ def apply_colours(colours: dict[str, str], mode: str) -> None:
apply_discord(gen_scss(colours)) apply_discord(gen_scss(colours))
if check("enableSpicetify"): if check("enableSpicetify"):
apply_spicetify(colours, mode) apply_spicetify(colours, mode)
if check("enablePandora"):
apply_pandora(colours, mode)
if check("enableFuzzel"): if check("enableFuzzel"):
apply_fuzzel(colours) apply_fuzzel(colours)
if check("enableBtop"): if check("enableBtop"):
@@ -404,16 +443,37 @@ def apply_colours(colours: dict[str, str], mode: str) -> None:
apply_nvtop(colours) apply_nvtop(colours)
if check("enableHtop"): if check("enableHtop"):
apply_htop(colours) apply_htop(colours)
icon_theme = cfg.get(f"iconTheme{mode.capitalize()}") or cfg.get("iconTheme")
if check("enableGtk"): if check("enableGtk"):
apply_gtk(colours, mode) apply_gtk(colours, mode, icon_theme)
if check("enableQt"): if check("enableQt"):
apply_qt(colours, mode) apply_qt(colours, mode, icon_theme)
if check("enableWarp"): if check("enableWarp"):
apply_warp(colours, mode) apply_warp(colours, mode)
if check("enableChromium"):
apply_chromium(colours)
if check("enableZed"):
apply_zed(colours, mode)
if check("enableCava"): if check("enableCava"):
apply_cava(colours) apply_cava(colours)
apply_user_templates(colours, mode) 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: finally:
try: try:
lock_file.unlink() lock_file.unlink()
+55 -17
View File
@@ -2,8 +2,10 @@ import json
import os import os
import random import random
import subprocess import subprocess
from argparse import Namespace from argparse import Namespace
from pathlib import Path from pathlib import Path
from typing import cast
from materialyoucolor.hct import Hct from materialyoucolor.hct import Hct
from materialyoucolor.utils.color_utils import argb_from_rgb from materialyoucolor.utils.color_utils import argb_from_rgb
@@ -11,6 +13,7 @@ from PIL import Image
from caelestia.utils.hypr import message from caelestia.utils.hypr import message
from caelestia.utils.material import get_colours_for_image from caelestia.utils.material import get_colours_for_image
from caelestia.utils.colourfulness import get_variant
from caelestia.utils.paths import ( from caelestia.utils.paths import (
compute_hash, compute_hash,
user_config_path, user_config_path,
@@ -24,7 +27,7 @@ from caelestia.utils.theme import apply_colours
def is_valid_image(path: Path) -> bool: 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: def check_wall(wall: Path, filter_size: tuple[int, int], threshold: float) -> bool:
@@ -33,7 +36,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 return width >= filter_size[0] * threshold and height >= filter_size[1] * threshold
def get_wallpaper() -> str: def get_wallpaper() -> str | None:
try: try:
return wallpaper_path_path.read_text() return wallpaper_path_path.read_text()
except IOError: except IOError:
@@ -41,16 +44,16 @@ def get_wallpaper() -> str:
def get_wallpapers(args: Namespace) -> list[Path]: def get_wallpapers(args: Namespace) -> list[Path]:
dir = Path(args.random) directory = Path(args.random)
if not dir.is_dir(): if not directory.is_dir():
return [] 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: if args.no_filter:
return walls 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) 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)] return [f for f in walls if check_wall(f, filter_size, args.threshold)]
@@ -62,14 +65,14 @@ def get_thumb(wall: Path, cache: Path) -> Path:
if not thumb.exists(): if not thumb.exists():
with Image.open(wall) as img: with Image.open(wall) as img:
img = img.convert("RGB") 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) thumb.parent.mkdir(parents=True, exist_ok=True)
img.save(thumb, "JPEG") img.save(thumb, "JPEG")
return thumb 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" opts_cache = cache / "smart.json"
try: try:
@@ -77,15 +80,16 @@ def get_smart_opts(wall: Path, cache: Path) -> str:
except (IOError, json.JSONDecodeError): except (IOError, json.JSONDecodeError):
pass pass
from caelestia.utils.colourfulness import get_variant
opts = {} opts = {}
with Image.open(get_thumb(wall, cache)) as img: with Image.open(get_thumb(wall, cache)) as img:
opts["variant"] = get_variant(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["mode"] = "light" if hct.tone > 60 else "dark"
opts_cache.parent.mkdir(parents=True, exist_ok=True) opts_cache.parent.mkdir(parents=True, exist_ok=True)
@@ -96,9 +100,13 @@ def get_smart_opts(wall: Path, cache: Path) -> str:
def get_colours_for_wall(wall: Path | str, no_smart: bool) -> None: def get_colours_for_wall(wall: Path | str, no_smart: bool) -> None:
wall = Path(wall)
scheme = get_scheme() scheme = get_scheme()
cache = wallpapers_cache_dir / compute_hash(wall) cache = wallpapers_cache_dir / compute_hash(wall)
if wall.suffix.lower() == ".gif":
wall = convert_gif(wall)
name = "dynamic" name = "dynamic"
if not no_smart: if not no_smart:
@@ -122,13 +130,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 # Make path absolute
wall = Path(wall).resolve() wall = Path(wall).resolve()
if not is_valid_image(wall): if not is_valid_image(wall):
raise ValueError(f'"{wall}" is not a valid image') 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 # Update files
wallpaper_path_path.parent.mkdir(parents=True, exist_ok=True) wallpaper_path_path.parent.mkdir(parents=True, exist_ok=True)
wallpaper_path_path.write_text(str(wall)) wallpaper_path_path.write_text(str(wall))
@@ -136,10 +165,10 @@ def set_wallpaper(wall: Path | str, no_smart: bool) -> None:
wallpaper_link_path.unlink(missing_ok=True) wallpaper_link_path.unlink(missing_ok=True)
wallpaper_link_path.symlink_to(wall) 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 # 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.parent.mkdir(parents=True, exist_ok=True)
wallpaper_thumbnail_path.unlink(missing_ok=True) wallpaper_thumbnail_path.unlink(missing_ok=True)
wallpaper_thumbnail_path.symlink_to(thumb) wallpaper_thumbnail_path.symlink_to(thumb)
@@ -148,7 +177,7 @@ def set_wallpaper(wall: Path | str, no_smart: bool) -> None:
# Change mode and variant based on wallpaper colour # Change mode and variant based on wallpaper colour
if scheme.name == "dynamic" and not no_smart: 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.mode = smart_opts["mode"]
scheme.variant = smart_opts["variant"] scheme.variant = smart_opts["variant"]
@@ -163,7 +192,16 @@ def set_wallpaper(wall: Path | str, no_smart: bool) -> None:
subprocess.run( subprocess.run(
post_hook, post_hook,
shell=True, 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, stderr=subprocess.DEVNULL,
) )
except (FileNotFoundError, json.JSONDecodeError): except (FileNotFoundError, json.JSONDecodeError):