diff --git a/.envrc b/.envrc
index 612703d4..a04e6171 100644
--- a/.envrc
+++ b/.envrc
@@ -3,3 +3,4 @@ if has nix; then
fi
PATH_add bin
+cls
diff --git a/README.md b/README.md
index 5c9e9608..82dcb1dd 100644
--- a/README.md
+++ b/README.md
@@ -4,17 +4,17 @@ The main control script for the Caelestia dotfiles.
External dependencies
-- [`libnotfy`](https://gitlab.gnome.org/GNOME/libnotify) - sending notifications
-- [`swappy`](https://github.com/jtheoof/swappy) - screenshot editor
-- [`grim`](https://gitlab.freedesktop.org/emersion/grim) - taking screenshots
-- [`dart-sass`](https://github.com/sass/dart-sass) - discord theming
-- [`app2unit`](https://github.com/Vladimir-csp/app2unit) - launching apps
-- [`wl-clipboard`](https://github.com/bugaevc/wl-clipboard) - copying to clipboard
-- [`slurp`](https://github.com/emersion/slurp) - selecting an area
-- [`gpu-screen-recorder`](https://git.dec05eba.com/gpu-screen-recorder/about) - screen recording
-- `glib2` - closing notifications
-- [`cliphist`](https://github.com/sentriz/cliphist) - clipboard history
-- [`fuzzel`](https://codeberg.org/dnkl/fuzzel) - clipboard history/emoji picker
+- [`libnotfy`](https://gitlab.gnome.org/GNOME/libnotify) - sending notifications
+- [`swappy`](https://github.com/jtheoof/swappy) - screenshot editor
+- [`grim`](https://gitlab.freedesktop.org/emersion/grim) - taking screenshots
+- [`dart-sass`](https://github.com/sass/dart-sass) - discord theming
+- [`app2unit`](https://github.com/Vladimir-csp/app2unit) - launching apps
+- [`wl-clipboard`](https://github.com/bugaevc/wl-clipboard) - copying to clipboard
+- [`slurp`](https://github.com/emersion/slurp) - selecting an area
+- [`gpu-screen-recorder`](https://git.dec05eba.com/gpu-screen-recorder/about) - screen recording
+- `glib2` - closing notifications
+- [`cliphist`](https://github.com/sentriz/cliphist) - clipboard history
+- [`fuzzel`](https://codeberg.org/dnkl/fuzzel) - clipboard history/emoji picker
@@ -93,6 +93,45 @@ sudo python -m installer dist/*.whl
sudo cp completions/caelestia.fish /usr/share/fish/vendor_completions.d/caelestia.fish
```
+### Additional steps
+
+#### Auto folder colour theming
+
+For automatic Papirus folder icon colour syncing, you must have [`papirus-folders`](https://github.com/PapirusDevelopmentTeam/papirus-folders)
+installed, and `papirus-folders` must to be able to run with `sudo` without a password prompt.
+
+You can allow this by creating a sudoers file:
+
+```sh
+echo "$USER ALL=(ALL) NOPASSWD: $(which papirus-folders)" | sudo tee /etc/sudoers.d/papirus-folders
+sudo chmod 440 /etc/sudoers.d/papirus-folders
+```
+
+#### Chromium-based browser theming
+
+For live Chromium-based browser theming, the CLI must be allowed to create certain directories in `/etc`
+and write to them via `sudo` without a password prompt.
+
+You can allow this by creating a sudoers file:
+
+```fish
+# Fish shell
+for dir in /etc/chromium/policies/managed /etc/brave/policies/managed /etc/opt/chrome/policies/managed
+ echo "$USER ALL=(ALL) NOPASSWD: $(which mkdir) -p $dir" | sudo tee -a /etc/sudoers.d/caelestia-chromium
+ echo "$USER ALL=(ALL) NOPASSWD: $(which tee) $dir/caelestia.json" | sudo tee -a /etc/sudoers.d/caelestia-chromium
+end
+sudo chmod 440 /etc/sudoers.d/caelestia-chromium
+```
+
+```sh
+# Bash/other shells
+for dir in /etc/chromium/policies/managed /etc/brave/policies/managed /etc/opt/chrome/policies/managed; do
+ echo "$USER ALL=(ALL) NOPASSWD: $(which mkdir) -p $dir" | sudo tee -a /etc/sudoers.d/caelestia-chromium
+ echo "$USER ALL=(ALL) NOPASSWD: $(which tee) $dir/caelestia.json" | sudo tee -a /etc/sudoers.d/caelestia-chromium
+done
+sudo chmod 440 /etc/sudoers.d/caelestia-chromium
+```
+
## Usage
All subcommands/options can be explored via the help flag.
@@ -122,6 +161,24 @@ subcommands:
resizer window resizer daemon
```
+### User templates
+
+Custom user templates can be defined in `~/.config/caelestia/templates/`.
+
+#### Template syntax
+
+`{{ . }}`
+
+- `` is a theme color role derived from the Material You color system (e.g. `primary`, `secondary`, `background`)
+- `` is the output format: `hex` or `rgb`
+
+#### Examples
+
+- `{{ primary.hex }}` outputs `3f4ba2`
+- `{{ primary.rgb }}` outputs `rgb(193, 132, 207)`
+
+Output files are written to `~/.local/state/caelestia/theme/`. You can symlink them to your desired locations.
+
## Configuring
All configuration options are in `~/.config/caelestia/cli.json`.
@@ -134,17 +191,24 @@ All configuration options are in `~/.config/caelestia/cli.json`.
"extraArgs": []
},
"wallpaper": {
- "postHook": "echo $WALLPAPER_PATH"
+ "postHook": "echo $WALLPAPER_PATH"
},
"theme": {
"enableTerm": true,
"enableHypr": true,
"enableDiscord": true,
"enableSpicetify": true,
+ "enablePandora": true,
"enableFuzzel": true,
"enableBtop": true,
+ "enableNvtop": true,
+ "enableHtop": true,
"enableGtk": true,
- "enableQt": true
+ "enableQt": true,
+ "enableWarp": true,
+ "enableChromium": true,
+ "enableZed": true,
+ "enableCava": true
},
"toggles": {
"communication": {
diff --git a/completions/caelestia.fish b/completions/caelestia.fish
index 5257f3f6..80f177cb 100644
--- a/completions/caelestia.fish
+++ b/completions/caelestia.fish
@@ -105,6 +105,7 @@ complete -c caelestia -n "$seen screenshot" -s 'f' -l 'freeze' -d 'Freeze while
# Record
complete -c caelestia -n "$seen record" -s 'r' -l 'region' -d 'Capture region'
complete -c caelestia -n "$seen record" -s 's' -l 'sound' -d 'Capture sound'
+complete -c caelestia -n "$seen record" -s 'c' -l 'clipboard' -d 'Copy recording path to clipboard'
# Clipboard
complete -c caelestia -n "$seen clipboard" -s 'd' -l 'delete' -d 'Delete from cliboard history'
diff --git a/default.nix b/default.nix
index e8ba844d..22fb0b64 100644
--- a/default.nix
+++ b/default.nix
@@ -68,11 +68,11 @@ python3.pkgs.buildPythonApplication {
# Use config bin instead of discord + fix todoist + fix app2unit
substituteInPlace src/caelestia/subcommands/toggle.py \
--replace-fail 'discord' ${discordBin} \
- --replace-fail 'todoist' 'todoist.desktop'\
+ --replace-fail '["todoist"]' '["todoist.desktop"]'\
--replace-fail 'app2unit' ${app2unit}/bin/app2unit
# Use config style instead of darkly
- substituteInPlace src/caelestia/data/templates/qtct.conf \
+ substituteInPlace src/caelestia/data/templates/qtengine.json \
--replace-fail 'Darkly' '${qtctStyle}'
'';
diff --git a/flake.lock b/flake.lock
index b1c4bf68..3d815362 100644
--- a/flake.lock
+++ b/flake.lock
@@ -9,11 +9,11 @@
"quickshell": "quickshell"
},
"locked": {
- "lastModified": 1765071049,
- "narHash": "sha256-HIJtxkYaGxUFZ03wOzF4pWhKWAvFuYBN9jAdhCzZvnI=",
+ "lastModified": 1776670101,
+ "narHash": "sha256-VmPWtG6H+k2tgGnpYwNO5YueHOBdOXXTiBTrjXqcHag=",
"owner": "caelestia-dots",
"repo": "shell",
- "rev": "982d64d5e5b9295d12dec37d45442ed6a05fe284",
+ "rev": "b94ee8d41bad1ea59395d6184425036fa7121bc5",
"type": "github"
},
"original": {
@@ -24,11 +24,11 @@
},
"nixpkgs": {
"locked": {
- "lastModified": 1765186076,
- "narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
+ "lastModified": 1776548001,
+ "narHash": "sha256-ZSK0NL4a1BwVbbTBoSnWgbJy9HeZFXLYQizjb2DPF24=",
"owner": "nixos",
"repo": "nixpkgs",
- "rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
+ "rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc",
"type": "github"
},
"original": {
@@ -46,11 +46,11 @@
]
},
"locked": {
- "lastModified": 1764663772,
- "narHash": "sha256-sHqLmm0wAt3PC4vczJeBozI1/f4rv9yp3IjkClHDXDs=",
+ "lastModified": 1772925576,
+ "narHash": "sha256-mMoiXABDtkSJxCYDrkhJ/TrrJf5M46oUfIlJvv2gkZ0=",
"ref": "refs/heads/master",
- "rev": "26531fc46ef17e9365b03770edd3fb9206fcb460",
- "revCount": 713,
+ "rev": "15a84097653593dd15fad59a56befc2b7bdc270d",
+ "revCount": 750,
"type": "git",
"url": "https://git.outfoxxed.me/outfoxxed/quickshell"
},
diff --git a/src/caelestia/data/schemes/caelestia/default/dark.txt b/src/caelestia/data/schemes/caelestia/default/dark.txt
new file mode 100644
index 00000000..26c30dcd
--- /dev/null
+++ b/src/caelestia/data/schemes/caelestia/default/dark.txt
@@ -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
diff --git a/src/caelestia/data/schemes/caelestia/default/light.txt b/src/caelestia/data/schemes/caelestia/default/light.txt
new file mode 100644
index 00000000..9e2a5b9d
--- /dev/null
+++ b/src/caelestia/data/schemes/caelestia/default/light.txt
@@ -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
diff --git a/src/caelestia/data/schemes/darkgreen/hard/dark.txt b/src/caelestia/data/schemes/darkgreen/hard/dark.txt
index 45ab558d..dea54c48 100644
--- a/src/caelestia/data/schemes/darkgreen/hard/dark.txt
+++ b/src/caelestia/data/schemes/darkgreen/hard/dark.txt
@@ -4,7 +4,7 @@ tertiary_paletteKeyColor 376942
neutral_paletteKeyColor 1E1E26
neutral_variant_paletteKeyColor 23252D
background 23262D
-onBackground 02200B
+onBackground F5F5F6
surface 050505
surfaceDim 1E1E24
surfaceBright 1E1E24
@@ -82,6 +82,16 @@ sky cefb97
sapphire 85ef77
blue 65eea0
lavender 90f79e
+klink 65eea0
+klinkSelection 65eea0
+kvisited 73fa90
+kvisitedSelection 73fa90
+knegative 8affab
+knegativeSelection 8affab
+kneutral d0f9f4
+kneutralSelection d0f9f4
+kpositive 8af797
+kpositiveSelection 8af797
text e0e3e4
subtext1 bec8cc
subtext0 889296
diff --git a/src/caelestia/data/schemes/darkgreen/medium/dark.txt b/src/caelestia/data/schemes/darkgreen/medium/dark.txt
index 30723ba0..1caff9e4 100644
--- a/src/caelestia/data/schemes/darkgreen/medium/dark.txt
+++ b/src/caelestia/data/schemes/darkgreen/medium/dark.txt
@@ -4,7 +4,7 @@ tertiary_paletteKeyColor 376942
neutral_paletteKeyColor 1E1E26
neutral_variant_paletteKeyColor 23252D
background 23262D
-onBackground 02200B
+onBackground F5F5F6
surface 1E1E24
surfaceDim 1E1E24
surfaceBright 1E1E24
@@ -82,6 +82,16 @@ sky cefb97
sapphire 85ef77
blue 65eea0
lavender 90f79e
+klink 65eea0
+klinkSelection 65eea0
+kvisited 73fa90
+kvisitedSelection 73fa90
+knegative 8affab
+knegativeSelection 8affab
+kneutral d0f9f4
+kneutralSelection d0f9f4
+kpositive 8af797
+kpositiveSelection 8af797
text e0e3e4
subtext1 bec8cc
subtext0 889296
diff --git a/src/caelestia/data/schemes/dracula/medium/dark.txt b/src/caelestia/data/schemes/dracula/medium/dark.txt
new file mode 100644
index 00000000..4195f452
--- /dev/null
+++ b/src/caelestia/data/schemes/dracula/medium/dark.txt
@@ -0,0 +1,110 @@
+primary_paletteKeyColor BD93F9
+secondary_paletteKeyColor 50FA7B
+tertiary_paletteKeyColor FF79C6
+neutral_paletteKeyColor 282A36
+neutral_variant_paletteKeyColor 44475A
+background 282A36
+onBackground F8F8F2
+surface 343746
+surfaceDim 21222C
+surfaceBright 4D4F66
+surfaceContainerLowest 191A21
+surfaceContainerLow 3C3F4E
+surfaceContainer 3E4153
+surfaceContainerHigh 4D4F66
+surfaceContainerHighest 565970
+onSurface F8F8F2
+surfaceVariant 3E4153
+onSurfaceVariant F8F8F2
+inverseSurface F8F8F2
+inverseOnSurface 282A36
+outline 6272A4
+outlineVariant 4D4F66
+shadow 000000
+scrim 000000
+surfaceTint BD93F9
+primary BD93F9
+onPrimary 282A36
+primaryContainer 4D4F66
+onPrimaryContainer BD93F9
+inversePrimary 9D73D9
+secondary 50FA7B
+onSecondary 282A36
+secondaryContainer 4D4F66
+onSecondaryContainer 50FA7B
+tertiary FF79C6
+onTertiary 282A36
+tertiaryContainer 4D4F66
+onTertiaryContainer FF79C6
+error FF5555
+onError 282A36
+errorContainer 4C3743
+onErrorContainer FF5555
+primaryFixed BD93F9
+primaryFixedDim 9D73D9
+onPrimaryFixed 282A36
+onPrimaryFixedVariant 3E4153
+secondaryFixed 50FA7B
+secondaryFixedDim 30DA5B
+onSecondaryFixed 282A36
+onSecondaryFixedVariant 3E4153
+tertiaryFixed FF79C6
+tertiaryFixedDim DF59A6
+onTertiaryFixed 282A36
+onTertiaryFixedVariant 3E4153
+term0 282A36
+term1 FF5555
+term2 50FA7B
+term3 F1FA8C
+term4 BD93F9
+term5 FF79C6
+term6 8BE9FD
+term7 F8F8F2
+term8 6272A4
+term9 FF6E6E
+term10 69FF94
+term11 FFFFA5
+term12 D6ACFF
+term13 FF92DF
+term14 A4FFFF
+term15 FFFFFF
+rosewater F8F8F2
+flamingo FFB86C
+pink FF79C6
+mauve BD93F9
+red FF5555
+maroon FF6E6E
+peach FFB86C
+yellow F1FA8C
+green 50FA7B
+teal 8BE9FD
+sky 8BE9FD
+sapphire 8BE9FD
+blue BD93F9
+lavender BD93F9
+klink BD93F9
+klinkSelection BD93F9
+kvisited FF79C6
+kvisitedSelection FF79C6
+knegative FF5555
+knegativeSelection FF5555
+kneutral F1FA8C
+kneutralSelection F1FA8C
+kpositive 50FA7B
+kpositiveSelection 50FA7B
+text F8F8F2
+subtext1 F8F8F2
+subtext0 E6E6E6
+overlay2 A0A0A0
+overlay1 8A8A8A
+overlay0 6272A4
+surface2 3E4153
+surface1 343746
+surface0 282A36
+base 282A36
+mantle 21222C
+crust 191A21
+success 50FA7B
+onSuccess 282A36
+successContainer 4D4F66
+onSuccessContainer F8F8F2
diff --git a/src/caelestia/data/schemes/everblush/medium/dark.txt b/src/caelestia/data/schemes/everblush/medium/dark.txt
new file mode 100644
index 00000000..fefd5ac4
--- /dev/null
+++ b/src/caelestia/data/schemes/everblush/medium/dark.txt
@@ -0,0 +1,110 @@
+primary_paletteKeyColor 8CCFB0
+secondary_paletteKeyColor E5C76B
+tertiary_paletteKeyColor E5A5C5
+neutral_paletteKeyColor 2D3139
+neutral_variant_paletteKeyColor 3A3F4B
+background 141B1E
+onBackground E8E8E8
+surface 232A2D
+surfaceDim 0F1416
+surfaceBright 3A4145
+surfaceContainerLowest 0A0E10
+surfaceContainerLow 2A3235
+surfaceContainer 2E3538
+surfaceContainerHigh 3A4145
+surfaceContainerHighest 434A4E
+onSurface E8E8E8
+surfaceVariant 2E3538
+onSurfaceVariant B3B9BE
+inverseSurface E8E8E8
+inverseOnSurface 141B1E
+outline 8A8F94
+outlineVariant 3A4145
+shadow 000000
+scrim 000000
+surfaceTint 8CCFB0
+primary 8CCFB0
+onPrimary 141B1E
+primaryContainer 3A4145
+onPrimaryContainer 8CCFB0
+inversePrimary 6FA98C
+secondary E5C76B
+onSecondary 141B1E
+secondaryContainer 3A4145
+onSecondaryContainer E5C76B
+tertiary E5A5C5
+onTertiary 141B1E
+tertiaryContainer 3A4145
+onTertiaryContainer E5A5C5
+error E57474
+onError 141B1E
+errorContainer 4A2C2C
+onErrorContainer E57474
+primaryFixed 8CCFB0
+primaryFixedDim 6FA98C
+onPrimaryFixed 141B1E
+onPrimaryFixedVariant 3A3F4B
+secondaryFixed E5C76B
+secondaryFixedDim C4A855
+onSecondaryFixed 141B1E
+onSecondaryFixedVariant 3A3F4B
+tertiaryFixed E5A5C5
+tertiaryFixedDim C888A4
+onTertiaryFixed 141B1E
+onTertiaryFixedVariant 3A3F4B
+term0 141B1E
+term1 E57474
+term2 8CCFB0
+term3 E5C76B
+term4 67B0E8
+term5 C47FD5
+term6 6CBFBF
+term7 E8E8E8
+term8 8A8F94
+term9 E57474
+term10 8CCFB0
+term11 E5C76B
+term12 67B0E8
+term13 C47FD5
+term14 6CBFBF
+term15 E8E8E8
+rosewater E8E8E8
+flamingo E5A5C5
+pink E5A5C5
+mauve C47FD5
+red E57474
+maroon E57474
+peach E59A84
+yellow E5C76B
+green 8CCFB0
+teal 6CBFBF
+sky 67B0E8
+sapphire 67B0E8
+blue 67B0E8
+lavender 67B0E8
+klink 67B0E8
+klinkSelection 67B0E8
+kvisited C47FD5
+kvisitedSelection C47FD5
+knegative E57474
+knegativeSelection E57474
+kneutral E5C76B
+kneutralSelection E5C76B
+kpositive 8CCFB0
+kpositiveSelection 8CCFB0
+text E8E8E8
+subtext1 B3B9BE
+subtext0 8A8F94
+overlay2 7A7F84
+overlay1 6A6F74
+overlay0 5A5F64
+surface2 2E3538
+surface1 232A2D
+surface0 1A2023
+base 141B1E
+mantle 0F1416
+crust 0A0E10
+success 8CCFB0
+onSuccess 141B1E
+successContainer 3A4145
+onSuccessContainer E8E8E8
diff --git a/src/caelestia/data/schemes/everforest/hard/dark.txt b/src/caelestia/data/schemes/everforest/hard/dark.txt
new file mode 100644
index 00000000..d4823504
--- /dev/null
+++ b/src/caelestia/data/schemes/everforest/hard/dark.txt
@@ -0,0 +1,110 @@
+primary_paletteKeyColor 7FBBB3
+secondary_paletteKeyColor 83C092
+tertiary_paletteKeyColor A7C080
+neutral_paletteKeyColor 2E383C
+neutral_variant_paletteKeyColor 374145
+background 1E2326
+onBackground D3C6AA
+surface 252B2E
+surfaceDim 15191C
+surfaceBright 343E43
+surfaceContainerLowest 11161A
+surfaceContainerLow 2A3338
+surfaceContainer 2E383C
+surfaceContainerHigh 343E43
+surfaceContainerHighest 3A4448
+onSurface D3C6AA
+surfaceVariant 374145
+onSurfaceVariant 9DA9A0
+inverseSurface D3C6AA
+inverseOnSurface 1E2326
+outline 859289
+outlineVariant 414B50
+shadow 000000
+scrim 000000
+surfaceTint 7FBBB3
+primary 7FBBB3
+onPrimary 1E2326
+primaryContainer 414B50
+onPrimaryContainer A7C080
+inversePrimary 5A9A8F
+secondary 83C092
+onSecondary 1E2326
+secondaryContainer 414B50
+onSecondaryContainer A7C080
+tertiary A7C080
+onTertiary 1E2326
+tertiaryContainer 414B50
+onTertiaryContainer D3C6AA
+error E67E80
+onError 1E2326
+errorContainer 4C3743
+onErrorContainer E67E80
+primaryFixed 7FBBB3
+primaryFixedDim 5A9A8F
+onPrimaryFixed 1E2326
+onPrimaryFixedVariant 374145
+secondaryFixed 83C092
+secondaryFixedDim 5F8C6F
+onSecondaryFixed 1E2326
+onSecondaryFixedVariant 374145
+tertiaryFixed A7C080
+tertiaryFixedDim 7F9D5F
+onTertiaryFixed 1E2326
+onTertiaryFixedVariant 374145
+term0 1E2326
+term1 E67E80
+term2 A7C080
+term3 DBBC7F
+term4 7FBBB3
+term5 D699B6
+term6 83C092
+term7 D3C6AA
+term8 859289
+term9 E67E80
+term10 A7C080
+term11 DBBC7F
+term12 7FBBB3
+term13 D699B6
+term14 83C092
+term15 D3C6AA
+rosewater D3C6AA
+flamingo D699B6
+pink D699B6
+mauve D699B6
+red E67E80
+maroon E67E80
+peach E69875
+yellow DBBC7F
+green A7C080
+teal 83C092
+sky 7FBBB3
+sapphire 7FBBB3
+blue 7FBBB3
+lavender 7FBBB3
+klink 7FBBB3
+klinkSelection 7FBBB3
+kvisited 83C092
+kvisitedSelection 83C092
+knegative E67E80
+knegativeSelection E67E80
+kneutral DBBC7F
+kneutralSelection DBBC7F
+kpositive A7C080
+kpositiveSelection A7C080
+text D3C6AA
+subtext1 9DA9A0
+subtext0 859289
+overlay2 7A8478
+overlay1 6F7A6F
+overlay0 5F6A5F
+surface2 2E383C
+surface1 252B2E
+surface0 1E2326
+base 1E2326
+mantle 15191C
+crust 11161A
+success A7C080
+onSuccess 1E2326
+successContainer 414B50
+onSuccessContainer D3C6AA
diff --git a/src/caelestia/data/schemes/everforest/medium/dark.txt b/src/caelestia/data/schemes/everforest/medium/dark.txt
new file mode 100644
index 00000000..86167436
--- /dev/null
+++ b/src/caelestia/data/schemes/everforest/medium/dark.txt
@@ -0,0 +1,110 @@
+primary_paletteKeyColor 7FBBB3
+secondary_paletteKeyColor 83C092
+tertiary_paletteKeyColor A7C080
+neutral_paletteKeyColor 2E383C
+neutral_variant_paletteKeyColor 374145
+background 2D353B
+onBackground D3C6AA
+surface 343F44
+surfaceDim 232A2E
+surfaceBright 475258
+surfaceContainerLowest 1E2326
+surfaceContainerLow 3B474E
+surfaceContainer 3D484D
+surfaceContainerHigh 475258
+surfaceContainerHighest 4C5258
+onSurface D3C6AA
+surfaceVariant 3D484D
+onSurfaceVariant 9DA9A0
+inverseSurface D3C6AA
+inverseOnSurface 2D353B
+outline 859289
+outlineVariant 475258
+shadow 000000
+scrim 000000
+surfaceTint 7FBBB3
+primary 7FBBB3
+onPrimary 2D353B
+primaryContainer 475258
+onPrimaryContainer A7C080
+inversePrimary 5A9A8F
+secondary 83C092
+onSecondary 2D353B
+secondaryContainer 475258
+onSecondaryContainer A7C080
+tertiary A7C080
+onTertiary 2D353B
+tertiaryContainer 475258
+onTertiaryContainer D3C6AA
+error E67E80
+onError 2D353B
+errorContainer 4C3743
+onErrorContainer E67E80
+primaryFixed 7FBBB3
+primaryFixedDim 5A9A8F
+onPrimaryFixed 2D353B
+onPrimaryFixedVariant 374145
+secondaryFixed 83C092
+secondaryFixedDim 5F8C6F
+onSecondaryFixed 2D353B
+onSecondaryFixedVariant 374145
+tertiaryFixed A7C080
+tertiaryFixedDim 7F9D5F
+onTertiaryFixed 2D353B
+onTertiaryFixedVariant 374145
+term0 2D353B
+term1 E67E80
+term2 A7C080
+term3 DBBC7F
+term4 7FBBB3
+term5 D699B6
+term6 83C092
+term7 D3C6AA
+term8 859289
+term9 E67E80
+term10 A7C080
+term11 DBBC7F
+term12 7FBBB3
+term13 D699B6
+term14 83C092
+term15 D3C6AA
+rosewater D3C6AA
+flamingo D699B6
+pink D699B6
+mauve D699B6
+red E67E80
+maroon E67E80
+peach E69875
+yellow DBBC7F
+green A7C080
+teal 83C092
+sky 7FBBB3
+sapphire 7FBBB3
+blue 7FBBB3
+lavender 7FBBB3
+klink 7FBBB3
+klinkSelection 7FBBB3
+kvisited 83C092
+kvisitedSelection 83C092
+knegative E67E80
+knegativeSelection E67E80
+kneutral DBBC7F
+kneutralSelection DBBC7F
+kpositive A7C080
+kpositiveSelection A7C080
+text D3C6AA
+subtext1 9DA9A0
+subtext0 859289
+overlay2 7A8478
+overlay1 6F7A6F
+overlay0 5F6A5F
+surface2 3D484D
+surface1 343F44
+surface0 2D353B
+base 2D353B
+mantle 232A2E
+crust 1E2326
+success A7C080
+onSuccess 2D353B
+successContainer 475258
+onSuccessContainer D3C6AA
diff --git a/src/caelestia/data/schemes/everforest/medium/light.txt b/src/caelestia/data/schemes/everforest/medium/light.txt
new file mode 100644
index 00000000..92fd1dc1
--- /dev/null
+++ b/src/caelestia/data/schemes/everforest/medium/light.txt
@@ -0,0 +1,110 @@
+primary_paletteKeyColor 3A94C5
+secondary_paletteKeyColor 35A77C
+tertiary_paletteKeyColor 8DA101
+neutral_paletteKeyColor E6E2CC
+neutral_variant_paletteKeyColor E0DCC7
+background FDF6E3
+onBackground 5C6A72
+surface F3EAD3
+surfaceDim FDF6E3
+surfaceBright FFFBF0
+surfaceContainerLowest FFFBF0
+surfaceContainerLow FDF6E3
+surfaceContainer F3EAD3
+surfaceContainerHigh EAE4CA
+surfaceContainerHighest E0DCC7
+onSurface 5C6A72
+surfaceVariant EAE4CA
+onSurfaceVariant 6F7C84
+inverseSurface 5C6A72
+inverseOnSurface FDF6E3
+outline 939F91
+outlineVariant E0DCC7
+shadow 000000
+scrim 000000
+surfaceTint 3A94C5
+primary 3A94C5
+onPrimary FFFBF0
+primaryContainer E0DCC7
+onPrimaryContainer 8DA101
+inversePrimary 5FAFD7
+secondary 35A77C
+onSecondary FFFBF0
+secondaryContainer E0DCC7
+onSecondaryContainer 8DA101
+tertiary 8DA101
+onTertiary FFFBF0
+tertiaryContainer E0DCC7
+onTertiaryContainer 5C6A72
+error F85552
+onError FFFBF0
+errorContainer E6E2CC
+onErrorContainer F85552
+primaryFixed 3A94C5
+primaryFixedDim 5FAFD7
+onPrimaryFixed FFFBF0
+onPrimaryFixedVariant E0DCC7
+secondaryFixed 35A77C
+secondaryFixedDim 5FC198
+onSecondaryFixed FFFBF0
+onSecondaryFixedVariant E0DCC7
+tertiaryFixed 8DA101
+tertiaryFixedDim A7C080
+onTertiaryFixed FFFBF0
+onTertiaryFixedVariant E0DCC7
+term0 5C6A72
+term1 F85552
+term2 8DA101
+term3 DFA000
+term4 3A94C5
+term5 DF69BA
+term6 35A77C
+term7 5C6A72
+term8 939F91
+term9 F85552
+term10 8DA101
+term11 DFA000
+term12 3A94C5
+term13 DF69BA
+term14 35A77C
+term15 5C6A72
+rosewater 5C6A72
+flamingo DF69BA
+pink DF69BA
+mauve DF69BA
+red F85552
+maroon F85552
+peach E66868
+yellow DFA000
+green 8DA101
+teal 35A77C
+sky 3A94C5
+sapphire 3A94C5
+blue 3A94C5
+lavender 3A94C5
+klink 3A94C5
+klinkSelection 3A94C5
+kvisited 35A77C
+kvisitedSelection 35A77C
+knegative F85552
+knegativeSelection F85552
+kneutral DFA000
+kneutralSelection DFA000
+kpositive 8DA101
+kpositiveSelection 8DA101
+text 5C6A72
+subtext1 6F7C84
+subtext0 939F91
+overlay2 A6B0A0
+overlay1 B9C0B0
+overlay0 CCD3C2
+surface2 EAE4CA
+surface1 F3EAD3
+surface0 FDF6E3
+base FDF6E3
+mantle FFFBF0
+crust FFFEF9
+success 8DA101
+onSuccess FFFBF0
+successContainer E0DCC7
+onSuccessContainer 5C6A72
diff --git a/src/caelestia/data/schemes/everforest/soft/dark.txt b/src/caelestia/data/schemes/everforest/soft/dark.txt
new file mode 100644
index 00000000..817425b6
--- /dev/null
+++ b/src/caelestia/data/schemes/everforest/soft/dark.txt
@@ -0,0 +1,110 @@
+primary_paletteKeyColor 7FBBB3
+secondary_paletteKeyColor 83C092
+tertiary_paletteKeyColor A7C080
+neutral_paletteKeyColor 2E383C
+neutral_variant_paletteKeyColor 374145
+background 323C41
+onBackground D3C6AA
+surface 3A454A
+surfaceDim 282F34
+surfaceBright 4D585D
+surfaceContainerLowest 232A2E
+surfaceContainerLow 414D54
+surfaceContainer 434E53
+surfaceContainerHigh 4D585D
+surfaceContainerHighest 525C61
+onSurface D3C6AA
+surfaceVariant 434E53
+onSurfaceVariant 9DA9A0
+inverseSurface D3C6AA
+inverseOnSurface 323C41
+outline 859289
+outlineVariant 4D585D
+shadow 000000
+scrim 000000
+surfaceTint 7FBBB3
+primary 7FBBB3
+onPrimary 323C41
+primaryContainer 4D585D
+onPrimaryContainer A7C080
+inversePrimary 5A9A8F
+secondary 83C092
+onSecondary 323C41
+secondaryContainer 4D585D
+onSecondaryContainer A7C080
+tertiary A7C080
+onTertiary 323C41
+tertiaryContainer 4D585D
+onTertiaryContainer D3C6AA
+error E67E80
+onError 323C41
+errorContainer 4C3743
+onErrorContainer E67E80
+primaryFixed 7FBBB3
+primaryFixedDim 5A9A8F
+onPrimaryFixed 323C41
+onPrimaryFixedVariant 374145
+secondaryFixed 83C092
+secondaryFixedDim 5F8C6F
+onSecondaryFixed 323C41
+onSecondaryFixedVariant 374145
+tertiaryFixed A7C080
+tertiaryFixedDim 7F9D5F
+onTertiaryFixed 323C41
+onTertiaryFixedVariant 374145
+term0 323C41
+term1 E67E80
+term2 A7C080
+term3 DBBC7F
+term4 7FBBB3
+term5 D699B6
+term6 83C092
+term7 D3C6AA
+term8 859289
+term9 E67E80
+term10 A7C080
+term11 DBBC7F
+term12 7FBBB3
+term13 D699B6
+term14 83C092
+term15 D3C6AA
+rosewater D3C6AA
+flamingo D699B6
+pink D699B6
+mauve D699B6
+red E67E80
+maroon E67E80
+peach E69875
+yellow DBBC7F
+green A7C080
+teal 83C092
+sky 7FBBB3
+sapphire 7FBBB3
+blue 7FBBB3
+lavender 7FBBB3
+klink 7FBBB3
+klinkSelection 7FBBB3
+kvisited 83C092
+kvisitedSelection 83C092
+knegative E67E80
+knegativeSelection E67E80
+kneutral DBBC7F
+kneutralSelection DBBC7F
+kpositive A7C080
+kpositiveSelection A7C080
+text D3C6AA
+subtext1 9DA9A0
+subtext0 859289
+overlay2 7A8478
+overlay1 6F7A6F
+overlay0 5F6A5F
+surface2 434E53
+surface1 3A454A
+surface0 323C41
+base 323C41
+mantle 282F34
+crust 232A2E
+success A7C080
+onSuccess 323C41
+successContainer 4D585D
+onSuccessContainer D3C6AA
diff --git a/src/caelestia/data/schemes/nord/medium/dark.txt b/src/caelestia/data/schemes/nord/medium/dark.txt
new file mode 100644
index 00000000..9e0cc02d
--- /dev/null
+++ b/src/caelestia/data/schemes/nord/medium/dark.txt
@@ -0,0 +1,110 @@
+primary_paletteKeyColor 88C0D0
+secondary_paletteKeyColor 81A1C1
+tertiary_paletteKeyColor 5E81AC
+neutral_paletteKeyColor 3B4252
+neutral_variant_paletteKeyColor 434C5E
+background 2E3440
+onBackground ECEFF4
+surface 3B4252
+surfaceDim 242933
+surfaceBright 4C566A
+surfaceContainerLowest 1F232C
+surfaceContainerLow 424A5E
+surfaceContainer 434C5E
+surfaceContainerHigh 4C566A
+surfaceContainerHighest 55606E
+onSurface ECEFF4
+surfaceVariant 434C5E
+onSurfaceVariant D8DEE9
+inverseSurface ECEFF4
+inverseOnSurface 2E3440
+outline 616E88
+outlineVariant 4C566A
+shadow 000000
+scrim 000000
+surfaceTint 88C0D0
+primary 88C0D0
+onPrimary 2E3440
+primaryContainer 4C566A
+onPrimaryContainer 88C0D0
+inversePrimary 6FA3B3
+secondary 81A1C1
+onSecondary 2E3440
+secondaryContainer 4C566A
+onSecondaryContainer 81A1C1
+tertiary 5E81AC
+onTertiary 2E3440
+tertiaryContainer 4C566A
+onTertiaryContainer 5E81AC
+error BF616A
+onError 2E3440
+errorContainer 4C3743
+onErrorContainer BF616A
+primaryFixed 88C0D0
+primaryFixedDim 6FA3B3
+onPrimaryFixed 2E3440
+onPrimaryFixedVariant 434C5E
+secondaryFixed 81A1C1
+secondaryFixedDim 6A84A4
+onSecondaryFixed 2E3440
+onSecondaryFixedVariant 434C5E
+tertiaryFixed 5E81AC
+tertiaryFixedDim 4A6A8F
+onTertiaryFixed 2E3440
+onTertiaryFixedVariant 434C5E
+term0 3B4252
+term1 BF616A
+term2 A3BE8C
+term3 EBCB8B
+term4 81A1C1
+term5 B48EAD
+term6 88C0D0
+term7 E5E9F0
+term8 4C566A
+term9 BF616A
+term10 A3BE8C
+term11 EBCB8B
+term12 81A1C1
+term13 B48EAD
+term14 8FBCBB
+term15 ECEFF4
+rosewater ECEFF4
+flamingo B48EAD
+pink B48EAD
+mauve B48EAD
+red BF616A
+maroon BF616A
+peach D08770
+yellow EBCB8B
+green A3BE8C
+teal 8FBCBB
+sky 88C0D0
+sapphire 81A1C1
+blue 5E81AC
+lavender 5E81AC
+klink 88C0D0
+klinkSelection 88C0D0
+kvisited 81A1C1
+kvisitedSelection 81A1C1
+knegative BF616A
+knegativeSelection BF616A
+kneutral EBCB8B
+kneutralSelection EBCB8B
+kpositive A3BE8C
+kpositiveSelection A3BE8C
+text ECEFF4
+subtext1 D8DEE9
+subtext0 616E88
+overlay2 5A677E
+overlay1 4F5B73
+overlay0 434C5E
+surface2 434C5E
+surface1 3B4252
+surface0 2E3440
+base 2E3440
+mantle 242933
+crust 1F232C
+success A3BE8C
+onSuccess 2E3440
+successContainer 4C566A
+onSuccessContainer ECEFF4
diff --git a/src/caelestia/data/schemes/solarized/medium/dark.txt b/src/caelestia/data/schemes/solarized/medium/dark.txt
new file mode 100644
index 00000000..7000788f
--- /dev/null
+++ b/src/caelestia/data/schemes/solarized/medium/dark.txt
@@ -0,0 +1,110 @@
+primary_paletteKeyColor 268BD2
+secondary_paletteKeyColor 2AA198
+tertiary_paletteKeyColor 6C71C4
+neutral_paletteKeyColor 002B36
+neutral_variant_paletteKeyColor 073642
+background 002B36
+onBackground FDF6E3
+surface 073642
+surfaceDim 001F29
+surfaceBright 0D4250
+surfaceContainerLowest 00151D
+surfaceContainerLow 0A404E
+surfaceContainer 094B59
+surfaceContainerHigh 0D4250
+surfaceContainerHighest 11505E
+onSurface FDF6E3
+surfaceVariant 094B59
+onSurfaceVariant 93A1A1
+inverseSurface FDF6E3
+inverseOnSurface 002B36
+outline 586E75
+outlineVariant 0D4250
+shadow 000000
+scrim 000000
+surfaceTint 268BD2
+primary 268BD2
+onPrimary 002B36
+primaryContainer 0D4250
+onPrimaryContainer 268BD2
+inversePrimary 2075B2
+secondary 2AA198
+onSecondary 002B36
+secondaryContainer 0D4250
+onSecondaryContainer 2AA198
+tertiary 6C71C4
+onTertiary 002B36
+tertiaryContainer 0D4250
+onTertiaryContainer 6C71C4
+error DC322F
+onError 002B36
+errorContainer 4C3743
+onErrorContainer DC322F
+primaryFixed 268BD2
+primaryFixedDim 2075B2
+onPrimaryFixed 002B36
+onPrimaryFixedVariant 094B59
+secondaryFixed 2AA198
+secondaryFixedDim 228178
+onSecondaryFixed 002B36
+onSecondaryFixedVariant 094B59
+tertiaryFixed 6C71C4
+tertiaryFixedDim 5C61A4
+onTertiaryFixed 002B36
+onTertiaryFixedVariant 094B59
+term0 002B36
+term1 DC322F
+term2 859900
+term3 B58900
+term4 268BD2
+term5 D33682
+term6 2AA198
+term7 EEE8D5
+term8 586E75
+term9 CB4B16
+term10 859900
+term11 B58900
+term12 268BD2
+term13 6C71C4
+term14 2AA198
+term15 FDF6E3
+rosewater FDF6E3
+flamingo EEE8D5
+pink D33682
+mauve 6C71C4
+red DC322F
+maroon CB4B16
+peach CB4B16
+yellow B58900
+green 859900
+teal 2AA198
+sky 2AA198
+sapphire 268BD2
+blue 268BD2
+lavender 6C71C4
+klink 268BD2
+klinkSelection 268BD2
+kvisited 6C71C4
+kvisitedSelection 6C71C4
+knegative DC322F
+knegativeSelection DC322F
+kneutral B58900
+kneutralSelection B58900
+kpositive 859900
+kpositiveSelection 859900
+text FDF6E3
+subtext1 93A1A1
+subtext0 839496
+overlay2 657B83
+overlay1 586E75
+overlay0 073642
+surface2 094B59
+surface1 073642
+surface0 002B36
+base 002B36
+mantle 001F29
+crust 00151D
+success 859900
+onSuccess 002B36
+successContainer 0D4250
+onSuccessContainer FDF6E3
diff --git a/src/caelestia/data/schemes/tokyonight/medium/dark.txt b/src/caelestia/data/schemes/tokyonight/medium/dark.txt
new file mode 100644
index 00000000..0fbb87ac
--- /dev/null
+++ b/src/caelestia/data/schemes/tokyonight/medium/dark.txt
@@ -0,0 +1,110 @@
+primary_paletteKeyColor 7AA2F7
+secondary_paletteKeyColor 9ECE6A
+tertiary_paletteKeyColor BB9AF7
+neutral_paletteKeyColor 1A1B26
+neutral_variant_paletteKeyColor 292E42
+background 1A1B26
+onBackground C0CAF5
+surface 24283B
+surfaceDim 16161E
+surfaceBright 3B4261
+surfaceContainerLowest 0F0F14
+surfaceContainerLow 2B3048
+surfaceContainer 2A2F41
+surfaceContainerHigh 3B4261
+surfaceContainerHighest 414868
+onSurface C0CAF5
+surfaceVariant 2A2F41
+onSurfaceVariant A9B1D6
+inverseSurface C0CAF5
+inverseOnSurface 1A1B26
+outline 565F89
+outlineVariant 3B4261
+shadow 000000
+scrim 000000
+surfaceTint 7AA2F7
+primary 7AA2F7
+onPrimary 1A1B26
+primaryContainer 3B4261
+onPrimaryContainer 7AA2F7
+inversePrimary 5A7FD7
+secondary 9ECE6A
+onSecondary 1A1B26
+secondaryContainer 3B4261
+onSecondaryContainer 9ECE6A
+tertiary BB9AF7
+onTertiary 1A1B26
+tertiaryContainer 3B4261
+onTertiaryContainer BB9AF7
+error F7768E
+onError 1A1B26
+errorContainer 4C3743
+onErrorContainer F7768E
+primaryFixed 7AA2F7
+primaryFixedDim 5A7FD7
+onPrimaryFixed 1A1B26
+onPrimaryFixedVariant 2A2F41
+secondaryFixed 9ECE6A
+secondaryFixedDim 7EAE4A
+onSecondaryFixed 1A1B26
+onSecondaryFixedVariant 2A2F41
+tertiaryFixed BB9AF7
+tertiaryFixedDim 9B7AD7
+onTertiaryFixed 1A1B26
+onTertiaryFixedVariant 2A2F41
+term0 1A1B26
+term1 F7768E
+term2 9ECE6A
+term3 E0AF68
+term4 7AA2F7
+term5 BB9AF7
+term6 7DCFFF
+term7 C0CAF5
+term8 565F89
+term9 F7768E
+term10 9ECE6A
+term11 E0AF68
+term12 7AA2F7
+term13 BB9AF7
+term14 7DCFFF
+term15 C0CAF5
+rosewater C0CAF5
+flamingo BB9AF7
+pink F7768E
+mauve BB9AF7
+red F7768E
+maroon E0AF68
+peach FF9E64
+yellow E0AF68
+green 9ECE6A
+teal 1ABC9C
+sky 7DCFFF
+sapphire 2AC3DE
+blue 7AA2F7
+lavender 7DCFFF
+klink 7AA2F7
+klinkSelection 7AA2F7
+kvisited BB9AF7
+kvisitedSelection BB9AF7
+knegative F7768E
+knegativeSelection F7768E
+kneutral E0AF68
+kneutralSelection E0AF68
+kpositive 9ECE6A
+kpositiveSelection 9ECE6A
+text C0CAF5
+subtext1 A9B1D6
+subtext0 9AA5CE
+overlay2 787C99
+overlay1 696D85
+overlay0 565F89
+surface2 2A2F41
+surface1 24283B
+surface0 1A1B26
+base 1A1B26
+mantle 16161E
+crust 0F0F14
+success 9ECE6A
+onSuccess 1A1B26
+successContainer 3B4261
+onSuccessContainer C0CAF5
diff --git a/src/caelestia/data/templates/discord.scss b/src/caelestia/data/templates/discord.scss
index 91d0cfb1..5582741f 100644
--- a/src/caelestia/data/templates/discord.scss
+++ b/src/caelestia/data/templates/discord.scss
@@ -15,8 +15,10 @@
@import url("https://refact0r.github.io/midnight-discord/build/midnight.css");
body {
- /* font, change to '' for default discord font */
- --font: "figtree";
+ /* font options */
+ --font: "figtree"; /* change to '' for default discord font */
+ --code-font: "JetBrainsMono NF"; /* change to '' for default discord font */
+ font-weight: 400; /* normal text font weight. DOES NOT AFFECT BOLD TEXT */
/* sizes */
--gap: 12px; /* spacing between panels */
@@ -27,13 +29,14 @@ body {
--animations: on; /* turn off to disable all midnight animations/transitions */
--list-item-transition: 0.2s ease; /* transition for list items */
--dms-icon-svg-transition: 0.4s ease; /* transition for the dms icon */
+ --border-hover-transition: 0.2s ease; /* transition for borders when hovered */
/* top bar options */
--top-bar-height: var(
--gap
); /* height of the titlebar/top bar (discord default is 36px, 24px recommended if moving/hiding top bar buttons) */
- --top-bar-button-position: hide; /* off: default position, hide: hide inbox/support buttons completely, serverlist: move inbox button to server list, titlebar: move inbox button to titlebar (will hide title) */
- --top-bar-title-position: hide; /* off: default centered position, hide: hide title completely, left: left align title (like old discord) */
+ --top-bar-button-position: titlebar; /* off: default position, hide: hide inbox/support buttons completely, serverlist: move inbox button to server list, titlebar: move inbox button to titlebar (will hide title) */
+ --top-bar-title-position: off; /* off: default centered position, hide: hide title completely, left: left align title (like old discord) */
--subtle-top-bar-title: off; /* off: default, on: hide the icon and use subtle text color (like old discord) */
/* window controls */
@@ -42,9 +45,9 @@ body {
/* dms button icon options */
--custom-dms-icon: custom; /* off: use default discord icon, hide: remove icon entirely, custom: use custom icon */
- --dms-icon-svg-url: url("https://upload.wikimedia.org/wikipedia/commons/c/c4/Font_Awesome_5_solid_moon.svg"); /* icon svg url. MUST BE A SVG. */
+ --dms-icon-svg-url: url("https://refact0r.github.io/midnight-discord/assets/Font_Awesome_5_solid_moon.svg"); /* icon svg url. MUST BE A SVG. */
--dms-icon-svg-size: 90%; /* size of the svg (css mask-size) */
- --dms-icon-color-before: var(--icon-secondary); /* normal icon color */
+ --dms-icon-color-before: var(--icon-subtle); /* normal icon color */
--dms-icon-color-after: var(--white); /* icon color when button is hovered/selected */
/* dms button background options */
@@ -71,12 +74,11 @@ body {
--bg-floating: #{c.$surface}; /* you can set this to a more opaque color if floating panels look too transparent */
/* chatbar options */
- --custom-chatbar: aligned; /* off: default chatbar, aligned: chatbar aligned with the user panel, separated: chatbar separated from chat */
- --chatbar-height: 47px; /* height of the chatbar (52px by default, 47px recommended for aligned, 56px recommended for separated) */
- --chatbar-padding: 8px; /* padding of the chatbar. only applies in aligned mode. */
+ --custom-chatbar: off; /* off: default chatbar, separated: chatbar separated from chat */
+ --chatbar-height: 47px; /* height of the chatbar (56px by default, 47px to align with user panel, 56px recommended for separated) */
/* other options */
- --small-user-panel: off; /* turn on to make the user panel smaller like in old discord */
+ --small-user-panel: off; /* off: default user panel, on: smaller user panel like in old discord */
}
/* color options */
diff --git a/src/caelestia/data/templates/gtk.css b/src/caelestia/data/templates/gtk.css
index bc9e5581..35d16a11 100644
--- a/src/caelestia/data/templates/gtk.css
+++ b/src/caelestia/data/templates/gtk.css
@@ -15,3 +15,7 @@
@define-color sidebar_fg_color @window_fg_color;
@define-color sidebar_border_color @window_bg_color;
@define-color sidebar_backdrop_color @window_bg_color;
+@define-color theme_selected_bg_color alpha(@accent_color, 0.15);
+@define-color theme_selected_fg_color @primary;
+
+@import "thunar.css";
diff --git a/src/caelestia/data/templates/pandora.json b/src/caelestia/data/templates/pandora.json
new file mode 100644
index 00000000..c105c482
--- /dev/null
+++ b/src/caelestia/data/templates/pandora.json
@@ -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 }}"
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/src/caelestia/data/templates/qtct.conf b/src/caelestia/data/templates/qtct.conf
deleted file mode 100644
index 578085a8..00000000
--- a/src/caelestia/data/templates/qtct.conf
+++ /dev/null
@@ -1,6 +0,0 @@
-[Appearance]
-color_scheme_path={{ $config }}/colors/caelestia.colors
-custom_palette=true
-icon_theme=Papirus-{{ $mode }}
-standard_dialogs=default
-style=Darkly
diff --git a/src/caelestia/data/templates/qtengine.json b/src/caelestia/data/templates/qtengine.json
new file mode 100644
index 00000000..a340a7cf
--- /dev/null
+++ b/src/caelestia/data/templates/qtengine.json
@@ -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
+ }
+}
diff --git a/src/caelestia/data/templates/thunar.css b/src/caelestia/data/templates/thunar.css
new file mode 100644
index 00000000..548a9e0e
--- /dev/null
+++ b/src/caelestia/data/templates/thunar.css
@@ -0,0 +1,202 @@
+/* Thunar theme */
+
+/* =============================================================================
+ Global Resets
+ ============================================================================= */
+
+.thunar * {
+ outline: none;
+ border: none;
+}
+
+/* =============================================================================
+ Window & Background
+ ============================================================================= */
+
+.thunar.background {
+ background: {{ $surface }};
+}
+
+.thunar .titlebar {
+ background: inherit;
+}
+
+.thunar .titlebutton.close {
+ margin: 0 15px 0 0;
+}
+
+/* =============================================================================
+ Layout Containers
+ ============================================================================= */
+
+/* Paned separator between sidebar and main view */
+.thunar paned > separator {
+ min-width: 4px;
+ margin-right: -7px;
+ margin-left: -7px;
+ background: none;
+ background-image: none;
+ box-shadow: none;
+}
+
+
+/* Main file view frame */
+.thunar .frame.standard-view {
+ padding: 10px;
+ margin: 10px 15px 0 0;
+ border-radius: 15px;
+ background-color: {{ $surfaceContainerLow }};
+ animation: fading 400ms ease forwards;
+ opacity: 0;
+ animation-delay: 250ms;
+}
+
+.thunar .frame.standard-view .view:not(.rubberband),
+.thunar .frame.standard-view .view *:not(.rubberband) {
+ background-color: transparent;
+}
+
+.thunar .frame.standard-view .view *:selected {
+ color: {{ $primary }};
+}
+
+.thunar .rubberband {
+ background-color: alpha({{ $primary }}, 0.15);
+ border: 1px solid alpha({{ $primary }}, 0.15);
+}
+
+
+/* Tabs */
+.thunar header.top {
+ background: none;
+ padding: 0 10px 0 0;
+ margin: 3px 0 -3px -2px;
+}
+
+.thunar header.top tabs .reorderable-page {
+ margin: 0;
+ transition: all ease 300ms;
+}
+.thunar header.top tabs .reorderable-page + .reorderable-page {
+ margin: 0 0 0 10px;
+}
+
+.thunar header.top tabs .reorderable-page:hover {
+ background-color: alpha({{ $primary }}, 0.08);
+
+}
+.thunar header.top tabs .reorderable-page:checked {
+ color: {{ $primary }};
+ background-color: alpha({{ $primary }}, 0.15);
+
+}
+
+/* =============================================================================
+ Sidebar Navigation
+ ============================================================================= */
+
+.thunar .sidebar {
+ padding: 0 20px;
+ background: none;
+ animation: fading 600ms ease forwards;
+ animation-delay: 100ms;
+ opacity: 0;
+}
+
+.thunar .sidebar .view {
+ padding: 8px 4px;
+ border-radius: 10px;
+ background: none;
+ transition: all ease 300ms;
+}
+
+.thunar .sidebar .view:hover {
+ background: alpha({{ $onSurface }}, 0.1);
+}
+
+.thunar .sidebar .view:selected {
+ background: alpha({{ $primary }}, 0.15);
+ color: {{ $primary }};
+}
+
+/* =============================================================================
+ Path Bar & Location Buttons
+ ============================================================================= */
+
+.thunar .path-bar-button {
+ margin: 0;
+ padding: 8px 5px;
+ transition: all ease 0.4s;
+}
+
+.thunar .location-button.toggle:checked,
+.thunar .path-bar-button.toggle:checked {
+ padding: 8px 25px;
+ background: alpha({{ $primary }}, 0.15);
+ color: {{ $primary }};
+ box-shadow: none;
+}
+
+.thunar .location-button.path-bar-button:not(:checked) {
+ background-color: {{ $surfaceContainerLow }};
+}
+
+.thunar .location-button.path-bar-button:not(:checked):hover {
+ background: alpha({{ $primary }}, 0.08);
+ color: alpha({{ $primary }}, 0.8);
+}
+
+.thunar .location-button.toggle+.location-button.toggle:checked {
+ margin-left: 0px;
+ padding: 0 25px;
+}
+
+.thunar .titlebar {
+ padding: 15px 0 5px 0;
+}
+
+/* =============================================================================
+ Buttons
+ ============================================================================= */
+
+.thunar button.toggle:checked {
+ color: {{ $primary }};
+}
+
+.thunar .image-button {
+ padding: 8px;
+ margin: 0 0 0 8px;
+ transition: all ease 0.4s;
+}
+
+/* =============================================================================
+ Status Bar
+ ============================================================================= */
+
+.thunar statusbar {
+ background-color: {{ $surfaceContainerLow }};
+ border-radius: 15px;
+ padding: 10px 10px;
+ margin: 15px 5px 15px -10px;
+ color: {{ $onSurface }};
+}
+
+
+/* =============================================================================
+ Image preview
+ ============================================================================= */
+
+.thunar box.vertical .image {
+ margin: 15px;
+}
+
+
+/* =============================================================================
+ Animation
+ ============================================================================= */
+
+@keyframes fading {
+ to {
+ opacity: 1;
+ }
+}
diff --git a/src/caelestia/data/templates/zed.json b/src/caelestia/data/templates/zed.json
new file mode 100644
index 00000000..58eb4117
--- /dev/null
+++ b/src/caelestia/data/templates/zed.json
@@ -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
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/src/caelestia/parser.py b/src/caelestia/parser.py
index 840ead5c..4f5afca2 100644
--- a/src/caelestia/parser.py
+++ b/src/caelestia/parser.py
@@ -1,32 +1,61 @@
import argparse
-from caelestia.subcommands import clipboard, emoji, record, resizer, scheme, screenshot, shell, toggle, wallpaper
+from caelestia.subcommands import (
+ clipboard,
+ emoji,
+ record,
+ resizer,
+ scheme,
+ screenshot,
+ shell,
+ toggle,
+ wallpaper,
+)
from caelestia.utils.paths import wallpapers_dir
from caelestia.utils.scheme import get_scheme_names, scheme_variants
from caelestia.utils.wallpaper import get_wallpaper
def parse_args() -> (argparse.ArgumentParser, argparse.Namespace):
- 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 = 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"
+ )
# Add subcommand parsers
command_parser = parser.add_subparsers(
- title="subcommands", description="valid subcommands", metavar="COMMAND", help="the subcommand to run"
+ title="subcommands",
+ description="valid subcommands",
+ metavar="COMMAND",
+ help="the subcommand to run",
)
# Create parser for shell opts
shell_parser = command_parser.add_parser("shell", help="start or message the shell")
shell_parser.set_defaults(cls=shell.Command)
- shell_parser.add_argument("message", nargs="*", help="a message to send to the shell")
- shell_parser.add_argument("-d", "--daemon", action="store_true", help="start the shell detached")
- shell_parser.add_argument("-s", "--show", action="store_true", help="print all shell IPC commands")
- shell_parser.add_argument("-l", "--log", action="store_true", help="print the shell log")
- shell_parser.add_argument("-k", "--kill", action="store_true", help="kill the shell")
+ shell_parser.add_argument(
+ "message", nargs="*", help="a message to send to the shell"
+ )
+ shell_parser.add_argument(
+ "-d", "--daemon", action="store_true", help="start the shell detached"
+ )
+ shell_parser.add_argument(
+ "-s", "--show", action="store_true", help="print all shell IPC commands"
+ )
+ shell_parser.add_argument(
+ "-l", "--log", action="store_true", help="print the shell log"
+ )
+ shell_parser.add_argument(
+ "-k", "--kill", action="store_true", help="kill the shell"
+ )
shell_parser.add_argument("--log-rules", metavar="RULES", help="log rules to apply")
# Create parser for toggle opts
- toggle_parser = command_parser.add_parser("toggle", help="toggle a special workspace")
+ toggle_parser = command_parser.add_parser(
+ "toggle", help="toggle a special workspace"
+ )
toggle_parser.set_defaults(cls=toggle.Command)
toggle_parser.add_argument("workspace", help="the workspace to toggle")
@@ -34,66 +63,162 @@ def parse_args() -> (argparse.ArgumentParser, argparse.Namespace):
scheme_parser = command_parser.add_parser("scheme", help="manage the colour scheme")
scheme_command_parser = scheme_parser.add_subparsers(title="subcommands")
- list_parser = scheme_command_parser.add_parser("list", help="list available schemes")
+ list_parser = scheme_command_parser.add_parser(
+ "list", help="list available schemes"
+ )
list_parser.set_defaults(cls=scheme.List)
- list_parser.add_argument("-n", "--names", action="store_true", help="list scheme names")
- list_parser.add_argument("-f", "--flavours", action="store_true", help="list scheme flavours")
- list_parser.add_argument("-m", "--modes", action="store_true", help="list scheme modes")
- list_parser.add_argument("-v", "--variants", action="store_true", help="list scheme variants")
+ list_parser.add_argument(
+ "-n", "--names", action="store_true", help="list scheme names"
+ )
+ list_parser.add_argument(
+ "-f", "--flavours", action="store_true", help="list scheme flavours"
+ )
+ list_parser.add_argument(
+ "-m", "--modes", action="store_true", help="list scheme modes"
+ )
+ list_parser.add_argument(
+ "-v", "--variants", action="store_true", help="list scheme variants"
+ )
get_parser = scheme_command_parser.add_parser("get", help="get scheme properties")
get_parser.set_defaults(cls=scheme.Get)
- get_parser.add_argument("-n", "--name", action="store_true", help="print the current scheme name")
- get_parser.add_argument("-f", "--flavour", action="store_true", help="print the current scheme flavour")
- get_parser.add_argument("-m", "--mode", action="store_true", help="print the current scheme mode")
- get_parser.add_argument("-v", "--variant", action="store_true", help="print the current scheme variant")
+ get_parser.add_argument(
+ "-n", "--name", action="store_true", help="print the current scheme name"
+ )
+ get_parser.add_argument(
+ "-f", "--flavour", action="store_true", help="print the current scheme flavour"
+ )
+ get_parser.add_argument(
+ "-m", "--mode", action="store_true", help="print the current scheme mode"
+ )
+ get_parser.add_argument(
+ "-v", "--variant", action="store_true", help="print the current scheme variant"
+ )
set_parser = scheme_command_parser.add_parser("set", help="set the current scheme")
set_parser.set_defaults(cls=scheme.Set)
- set_parser.add_argument("--notify", action="store_true", help="send a notification on error")
- set_parser.add_argument("-r", "--random", action="store_true", help="switch to a random scheme")
- set_parser.add_argument("-n", "--name", choices=get_scheme_names(), help="the name of the scheme to switch to")
+ set_parser.add_argument(
+ "--notify", action="store_true", help="send a notification on error"
+ )
+ set_parser.add_argument(
+ "-r", "--random", action="store_true", help="switch to a random scheme"
+ )
+ set_parser.add_argument(
+ "-n",
+ "--name",
+ choices=get_scheme_names(),
+ help="the name of the scheme to switch to",
+ )
set_parser.add_argument("-f", "--flavour", help="the flavour to switch to")
- set_parser.add_argument("-m", "--mode", choices=["dark", "light"], help="the mode to switch to")
- set_parser.add_argument("-v", "--variant", choices=scheme_variants, help="the variant to switch to")
+ set_parser.add_argument(
+ "-m", "--mode", choices=["dark", "light"], help="the mode to switch to"
+ )
+ set_parser.add_argument(
+ "-v", "--variant", choices=scheme_variants, help="the variant to switch to"
+ )
# Create parser for screenshot opts
- screenshot_parser = command_parser.add_parser("screenshot", help="take a screenshot")
+ screenshot_parser = command_parser.add_parser(
+ "screenshot", help="take a screenshot"
+ )
screenshot_parser.set_defaults(cls=screenshot.Command)
- screenshot_parser.add_argument("-r", "--region", nargs="?", const="slurp", help="take a screenshot of a region")
screenshot_parser.add_argument(
- "-f", "--freeze", action="store_true", help="freeze the screen while selecting a region"
+ "-r", "--region", nargs="?", const="slurp", help="take a screenshot of a region"
+ )
+ screenshot_parser.add_argument(
+ "-f",
+ "--freeze",
+ action="store_true",
+ help="freeze the screen while selecting a region",
)
# Create parser for record opts
record_parser = command_parser.add_parser("record", help="start a screen recording")
record_parser.set_defaults(cls=record.Command)
- record_parser.add_argument("-r", "--region", nargs="?", const="slurp", help="record a region")
- record_parser.add_argument("-s", "--sound", action="store_true", help="record audio")
- record_parser.add_argument("-p", "--pause", action="store_true", help="pause/resume the recording")
+
+ # Recording mode options - separate video and audio modes
+ record_parser.add_argument(
+ "-m",
+ "--mode",
+ choices=["fullscreen", "region", "window"],
+ default="fullscreen",
+ help="video recording mode (default: fullscreen)",
+ )
+
+ record_parser.add_argument(
+ "-a",
+ "--audio",
+ choices=["none", "mic", "system", "combined"],
+ default="none",
+ help="Audio recording mode",
+ )
+
+ # Region option (only used with region mode)
+ record_parser.add_argument(
+ "-r",
+ "--region",
+ nargs="?",
+ const="slurp",
+ help="record a region (only for region mode)",
+ )
+
+ # Control options
+ record_parser.add_argument(
+ "-p", "--pause", action="store_true", help="pause/resume the recording"
+ )
+ record_parser.add_argument(
+ "-s", "--stop", action="store_true", help="stop the current recording"
+ )
+ record_parser.add_argument(
+ "--status", action="store_true", help="check recording status"
+ )
# Create parser for clipboard opts
- clipboard_parser = command_parser.add_parser("clipboard", help="open clipboard history")
+ clipboard_parser = command_parser.add_parser(
+ "clipboard", help="open clipboard history"
+ )
clipboard_parser.set_defaults(cls=clipboard.Command)
- clipboard_parser.add_argument("-d", "--delete", action="store_true", help="delete from clipboard history")
+ clipboard_parser.add_argument(
+ "-d", "--delete", action="store_true", help="delete from clipboard history"
+ )
# Create parser for emoji-picker opts
emoji_parser = command_parser.add_parser("emoji", help="emoji/glyph utilities")
emoji_parser.set_defaults(cls=emoji.Command)
- emoji_parser.add_argument("-p", "--picker", action="store_true", help="open the emoji/glyph picker")
- emoji_parser.add_argument("-f", "--fetch", action="store_true", help="fetch emoji/glyph data from remote")
+ emoji_parser.add_argument(
+ "-p", "--picker", action="store_true", help="open the emoji/glyph picker"
+ )
+ emoji_parser.add_argument(
+ "-f", "--fetch", action="store_true", help="fetch emoji/glyph data from remote"
+ )
# Create parser for wallpaper opts
- wallpaper_parser = command_parser.add_parser("wallpaper", help="manage the wallpaper")
+ wallpaper_parser = command_parser.add_parser(
+ "wallpaper", help="manage the wallpaper"
+ )
wallpaper_parser.set_defaults(cls=wallpaper.Command)
wallpaper_parser.add_argument(
- "-p", "--print", nargs="?", const=get_wallpaper(), metavar="PATH", help="print the scheme for a wallpaper"
+ "-p",
+ "--print",
+ nargs="?",
+ const=get_wallpaper(),
+ metavar="PATH",
+ help="print the scheme for a wallpaper",
+ )
+ wallpaper_parser.add_argument(
+ "-r",
+ "--random",
+ nargs="?",
+ const=wallpapers_dir,
+ metavar="DIR",
+ help="switch to a random wallpaper",
)
wallpaper_parser.add_argument(
- "-r", "--random", nargs="?", const=wallpapers_dir, metavar="DIR", help="switch to a random wallpaper"
+ "-f", "--file", help="the path to the wallpaper to switch to"
+ )
+ wallpaper_parser.add_argument(
+ "-n", "--no-filter", action="store_true", help="do not filter by size"
)
- wallpaper_parser.add_argument("-f", "--file", help="the path to the wallpaper to switch to")
- wallpaper_parser.add_argument("-n", "--no-filter", action="store_true", help="do not filter by size")
wallpaper_parser.add_argument(
"-t",
"--threshold",
@@ -110,7 +235,9 @@ def parse_args() -> (argparse.ArgumentParser, argparse.Namespace):
# Create parser for resizer opts
resizer_parser = command_parser.add_parser("resizer", help="window resizer daemon")
resizer_parser.set_defaults(cls=resizer.Command)
- resizer_parser.add_argument("-d", "--daemon", action="store_true", help="start the resizer daemon")
+ resizer_parser.add_argument(
+ "-d", "--daemon", action="store_true", help="start the resizer daemon"
+ )
resizer_parser.add_argument(
"pattern",
nargs="?",
@@ -125,6 +252,8 @@ def parse_args() -> (argparse.ArgumentParser, argparse.Namespace):
)
resizer_parser.add_argument("width", nargs="?", help="width to resize to")
resizer_parser.add_argument("height", nargs="?", help="height to resize to")
- resizer_parser.add_argument("actions", nargs="?", help="comma-separated actions to apply (float,center,pip)")
+ resizer_parser.add_argument(
+ "actions", nargs="?", help="comma-separated actions to apply (float,center,pip)"
+ )
return parser, parser.parse_args()
diff --git a/src/caelestia/subcommands/record.py b/src/caelestia/subcommands/record.py
index 867eb1b5..03295d6b 100644
--- a/src/caelestia/subcommands/record.py
+++ b/src/caelestia/subcommands/record.py
@@ -5,12 +5,32 @@
import time
from argparse import Namespace
from datetime import datetime
+from pathlib import Path
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,
+)
RECORDER = "gpu-screen-recorder"
+AUDIO_MODES = {
+ "mic": "default_input",
+ "system": "default_output",
+ "combined": "default_output|default_input",
+}
+
+# PipeWire/PulseAudio symbolic aliases — never appear literally in `pactl list
+# sources short` but are always valid; skip availability checks for these.
+SYMBOLIC_DEFAULTS = {"default_input", "default_output", "default_output|default_input"}
+
+# Maximum time (in seconds) to wait for the recorder process to exit cleanly.
+STOP_TIMEOUT = 5.0
+STOP_POLL_INTERVAL = 0.1
+
class Command:
args: Namespace
@@ -19,63 +39,204 @@ def __init__(self, args: Namespace) -> None:
self.args = args
def run(self) -> None:
- if self.args.pause:
- subprocess.run(["pkill", "-USR2", "-f", RECORDER], stdout=subprocess.DEVNULL)
+ if getattr(self.args, "status", False):
+ self.status()
+ elif getattr(self.args, "stop", False):
+ self.stop()
+ elif self.args.pause:
+ subprocess.run(
+ ["pkill", "-USR2", "-f", RECORDER], stdout=subprocess.DEVNULL
+ )
elif self.proc_running():
self.stop()
else:
self.start()
+ def status(self) -> None:
+ """Print the current recording status."""
+ if self.proc_running():
+ print("Recording: RUNNING")
+ else:
+ print("Recording: STOPPED")
+
def proc_running(self) -> bool:
- return subprocess.run(["pidof", RECORDER], stdout=subprocess.DEVNULL).returncode == 0
+ return (
+ subprocess.run(["pidof", RECORDER], stdout=subprocess.DEVNULL).returncode
+ == 0
+ )
+
+ def intersects(
+ self, a: tuple[int, int, int, int], b: tuple[int, int, int, int]
+ ) -> bool:
+ return (
+ a[0] < b[0] + b[2]
+ and a[0] + a[2] > b[0]
+ and a[1] < b[1] + b[3]
+ and a[1] + a[3] > b[1]
+ )
+
+ def get_audio_device(self, audio_mode: str | None) -> str:
+ """Return the audio device string for the given mode, with fallback handling.
+
+ Returns an empty string when no audio should be recorded.
+ """
+ if not audio_mode or audio_mode == "none":
+ return ""
+
+ device = AUDIO_MODES.get(audio_mode, "")
+
+ try:
+ result = subprocess.run(
+ ["pactl", "list", "sources", "short"],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ available_devices = [
+ line.split("\t")[1]
+ for line in result.stdout.strip().split("\n")
+ if line
+ ]
+
+ # Symbolic defaults are PipeWire aliases — skip the availability
+ # check for them since they'll never appear in the source list.
+ if device and device not in SYMBOLIC_DEFAULTS and device not in available_devices:
+ print(
+ f"Warning: audio device '{device}' not available, falling back to default"
+ )
+ if audio_mode == "mic":
+ candidates = [
+ d for d in available_devices
+ if "input" in d.lower() or "mic" in d.lower()
+ ]
+ device = candidates[0] if candidates else ""
+ elif audio_mode == "system":
+ candidates = [
+ d for d in available_devices
+ if "output" in d.lower() or "monitor" in d.lower()
+ ]
+ device = candidates[0] if candidates else ""
+
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ print("Warning: could not check audio devices, audio recording may fail")
+
+ return device
+
+ def get_window_region(self) -> str | None:
+ """Select a window via slurp and return its region string."""
+ try:
+ clients = json.loads(subprocess.check_output(["hyprctl", "clients", "-j"]))
+
+ if not clients:
+ print("No windows found")
+ return None
+
+ slurp_regions = [
+ f"{c['at'][0]},{c['at'][1]} {c['size'][0]}x{c['size'][1]}"
+ for c in clients
+ ]
- def intersects(self, a: tuple[int, int, int, int], b: tuple[int, int, int, int]) -> bool:
- return a[0] < b[0] + b[2] and a[0] + a[2] > b[0] and a[1] < b[1] + b[3] and a[1] + a[3] > b[1]
+ result = subprocess.run(
+ ["slurp", "-f", "%wx%h+%x+%y"],
+ input="\n".join(slurp_regions),
+ capture_output=True,
+ text=True,
+ )
+
+ return result.stdout.strip() if result.returncode == 0 else None
+
+ except (subprocess.CalledProcessError, json.JSONDecodeError, KeyError) as e:
+ print(f"Error getting window region: {e}")
+ return None
+
+ def _parse_region(self, region_str: str) -> tuple[int, int, int, int]:
+ """Parse a ``WxH+X+Y`` region string into ``(x, y, w, h)``."""
+ m = re.match(r"(\d+)x(\d+)\+(\d+)\+(\d+)", region_str)
+ if not m:
+ raise ValueError(f"Invalid region format: {region_str!r}")
+ w, h, x, y = map(int, m.groups())
+ return x, y, w, h
+
+ def _max_refresh_rate_for_region(
+ self,
+ monitors: list[dict],
+ region: tuple[int, int, int, int],
+ ) -> int:
+ """Return the highest refresh rate among monitors that overlap *region*."""
+ max_rr = 0
+ for monitor in monitors:
+ if self.intersects(
+ (monitor["x"], monitor["y"], monitor["width"], monitor["height"]),
+ region,
+ ):
+ max_rr = max(max_rr, round(monitor["refreshRate"]))
+ return max_rr
def start(self) -> None:
args = ["-w"]
+ video_mode = getattr(self.args, "mode", "fullscreen")
+ audio_mode = getattr(self.args, "audio", None)
+
monitors = json.loads(subprocess.check_output(["hyprctl", "monitors", "-j"]))
- if self.args.region:
- if self.args.region == "slurp":
- region = subprocess.check_output(["slurp", "-f", "%wx%h+%x+%y"], text=True)
+
+ # --- Video mode ---
+ if video_mode == "region" or self.args.region:
+ if self.args.region == "slurp" or not self.args.region:
+ region_str = subprocess.check_output(
+ ["slurp", "-f", "%wx%h+%x+%y"], text=True
+ ).strip()
else:
- region = self.args.region.strip()
- args += ["region", "-region", region]
-
- m = re.match(r"(\d+)x(\d+)\+(\d+)\+(\d+)", region)
- if not m:
- raise ValueError(f"Invalid region: {region}")
-
- w, h, x, y = map(int, m.groups())
- r = x, y, w, h
- max_rr = 0
- for monitor in monitors:
- if self.intersects((monitor["x"], monitor["y"], monitor["width"], monitor["height"]), r):
- rr = round(monitor["refreshRate"])
- max_rr = max(max_rr, rr)
- args += ["-f", str(max_rr)]
- else:
- focused_monitor = next(monitor for monitor in monitors if monitor["focused"])
- if focused_monitor:
- args += [focused_monitor["name"], "-f", str(round(focused_monitor["refreshRate"]))]
+ region_str = self.args.region.strip()
+
+ x, y, w, h = self._parse_region(region_str)
+ max_rr = self._max_refresh_rate_for_region(monitors, (x, y, w, h))
+ args += ["region", "-region", region_str, "-f", str(max_rr)]
+
+ elif video_mode == "window":
+ window_info = self.get_window_region()
+ if not window_info:
+ print("Window selection cancelled")
+ return
+
+ x, y, w, h = self._parse_region(window_info)
+ max_rr = self._max_refresh_rate_for_region(monitors, (x, y, w, h))
+ args += ["region", "-region", window_info, "-f", str(max_rr)]
+
+ else: # fullscreen
+ focused = next((m for m in monitors if m["focused"]), None)
+ if focused:
+ args += [focused["name"], "-f", str(round(focused["refreshRate"]))]
- if self.args.sound:
+ # --- Audio mode ---
+ audio_device = self.get_audio_device(audio_mode)
+ if audio_device:
+ args += ["-a", audio_device, "-ac", "opus", "-ab", "192k"]
+ print(f"Recording with audio: {audio_device} ({audio_mode})")
+ elif getattr(self.args, "sound", False):
args += ["-a", "default_output"]
+ else:
+ print("Recording without audio")
+ # --- Extra args from user config ---
try:
config = json.loads(user_config_path.read_text())
- if "record" in config and "extraArgs" in config["record"]:
- args += config["record"]["extraArgs"]
+ extra = config.get("record", {}).get("extraArgs", [])
+ if not isinstance(extra, list):
+ raise ValueError("Config option 'record.extraArgs' must be an array")
+ args += extra
except (json.JSONDecodeError, FileNotFoundError):
pass
- except TypeError as e:
- raise ValueError(f"Config option 'record.extraArgs' should be an array: {e}")
+ # --- Launch recorder ---
recording_path.parent.mkdir(parents=True, exist_ok=True)
- proc = subprocess.Popen([RECORDER, *args, "-o", str(recording_path)], start_new_session=True)
+ proc = subprocess.Popen(
+ [RECORDER, *args, "-o", str(recording_path)], start_new_session=True
+ )
- notif = notify("-p", "Recording started", "Recording...")
+ audio_label = audio_mode if audio_device else "no audio"
+ mode_text = f"{video_mode} with {audio_label}"
+ notif = notify("-p", "Recording started", f"Recording {mode_text}...")
recording_notif_path.write_text(notif)
try:
@@ -87,27 +248,73 @@ def start(self) -> None:
f"Command `{' '.join(proc.args)}` failed with exit code {proc.returncode}",
)
except subprocess.TimeoutExpired:
- pass
+ pass # Still running — good
def stop(self) -> None:
- # Start killing recording process
subprocess.run(["pkill", "-f", RECORDER], stdout=subprocess.DEVNULL)
- # Wait for recording to finish to avoid corrupted video file
- while self.proc_running():
- time.sleep(0.1)
+ # Wait up to STOP_TIMEOUT seconds for a clean exit
+ max_polls = int(STOP_TIMEOUT / STOP_POLL_INTERVAL)
+ for _ in range(max_polls):
+ if not self.proc_running():
+ break
+ time.sleep(STOP_POLL_INTERVAL)
- # Move to recordings folder
- new_path = recordings_dir / f"recording_{datetime.now().strftime('%Y%m%d_%H-%M-%S')}.mp4"
+ if not recording_path.exists():
+ print("Warning: no recording file found")
+ try:
+ close_notification(recording_notif_path.read_text())
+ except IOError:
+ pass
+ return
+
+ # Move to recordings folder with a timestamped name
+ timestamp = datetime.now().strftime("%Y%m%d_%H-%M-%S")
+ new_path = recordings_dir / f"recording_{timestamp}.mp4"
recordings_dir.mkdir(exist_ok=True, parents=True)
shutil.move(recording_path, new_path)
- # Close start notification
+ # Re-encode audio to AAC for compatibility with Premiere, WhatsApp, etc.
+ # gpu-screen-recorder outputs Opus audio, which many apps don't support.
+ # -c:v copy means video is never re-encoded, so this only takes ~10-30s
+ # even for multi-hour recordings.
+ if shutil.which("ffmpeg") is None:
+ print("Warning: ffmpeg not found — skipping audio re-encode. "
+ "Install ffmpeg for Premiere/WhatsApp compatibility.")
+ else:
+ fixed_path = recordings_dir / f"recording_{timestamp}_aac.mp4"
+ result = subprocess.run(
+ [
+ "ffmpeg", "-i", str(new_path),
+ "-c:v", "copy", # copy video stream — no quality loss
+ "-c:a", "aac", # re-encode audio to AAC
+ "-b:a", "192k",
+ "-movflags", "+faststart", # better compatibility for apps/web
+ str(fixed_path),
+ ],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ if result.returncode == 0:
+ new_path.unlink() # delete the original Opus file
+ new_path = fixed_path.rename(recordings_dir / f"recording_{timestamp}.mp4")
+ else:
+ print("Warning: ffmpeg audio re-encode failed, keeping original file")
+
+ # Dismiss the "recording started" notification
try:
close_notification(recording_notif_path.read_text())
except IOError:
pass
+ # Copy to clipboard if requested
+ if self.args.clipboard:
+ file_uri = Path(new_path).resolve().as_uri() + "\n"
+ subprocess.run(
+ ["wl-copy", "--type", "text/uri-list"], input=file_uri.encode()
+ )
+
+ # Show completion notification and handle user action
action = notify(
"--action=watch=Watch",
"--action=open=Open",
@@ -132,6 +339,8 @@ def stop(self) -> None:
]
)
if p.returncode != 0:
- subprocess.Popen(["app2unit", "-O", new_path.parent], start_new_session=True)
+ subprocess.Popen(
+ ["app2unit", "-O", new_path.parent], start_new_session=True
+ )
elif action == "delete":
- new_path.unlink()
+ new_path.unlink()
\ No newline at end of file
diff --git a/src/caelestia/subcommands/resizer.py b/src/caelestia/subcommands/resizer.py
index ece8a884..c9d8fc0d 100644
--- a/src/caelestia/subcommands/resizer.py
+++ b/src/caelestia/subcommands/resizer.py
@@ -29,8 +29,6 @@ def __init__(self, args: Namespace) -> None:
def _load_window_rules(self) -> list[WindowRule]:
default_rules = [
WindowRule("(Bitwarden", "titleContains", "20%", "54%", ["float", "center"]),
- WindowRule("Sign in - Google Accounts", "titleContains", "35%", "65%", ["float", "center"]),
- WindowRule("oauth", "titleContains", "30%", "60%", ["float", "center"]),
WindowRule("^[Pp]icture(-| )in(-| )[Pp]icture$", "titleRegex", "", "", ["pip"]),
]
@@ -140,9 +138,12 @@ def _apply_pip_action(self, window_id: str) -> None:
monitor_x = monitor.get("x")
monitor_y = monitor.get("y")
- if not all(isinstance(x, (int, float)) for x in [monitor_height, monitor_width, monitor_scale, monitor_x, monitor_y]):
+ if not all(
+ isinstance(x, (int, float))
+ for x in [monitor_height, monitor_width, monitor_scale, monitor_x, monitor_y]
+ ):
return
-
+
monitor_height = monitor_height / monitor_scale
monitor_width = monitor_width / monitor_scale
@@ -232,7 +233,7 @@ def _handle_title_event(self, event: str) -> None:
window_id = event.split(">>>")[1].split(",")[0]
else:
window_id = event.split(">>")[1].split(",")[0]
-
+
# Remove any leading > characters
window_id = window_id.lstrip(">")
@@ -268,9 +269,9 @@ def _handle_open_event(self, event: str) -> None:
data = event[13:] # Remove "openwindow>>>"
else:
data = event[12:] # Remove "openwindow>>"
-
+
window_id, workspace, window_class, title = data.split(",", 3)
-
+
# Remove any leading > characters
window_id = window_id.lstrip(">")
@@ -348,19 +349,19 @@ def _run_active_mode(self) -> None:
# Find all windows that match the pattern
matching_windows = self._find_matching_windows(temp_rule)
-
+
if not matching_windows:
print(f"No windows found matching pattern '{temp_rule.name}' with match type '{temp_rule.match_type}'")
return
print(f"Found {len(matching_windows)} matching window(s)")
-
+
# Apply rule to all matching windows
success_count = 0
for window in matching_windows:
window_id = window["address"][2:] # Remove "0x" prefix
window_title = window.get("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)
if success:
@@ -386,7 +387,7 @@ def _apply_to_active_window(self, temp_rule: WindowRule) -> None:
return
window_id = address[2:] # Remove "0x" prefix
-
+
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)
if success:
@@ -411,7 +412,7 @@ def _find_matching_windows(self, temp_rule: WindowRule) -> list:
window_title = window.get("title", "")
initial_title = window.get("initialTitle", "")
-
+
# Check if window matches the pattern
matches = False
if temp_rule.match_type == "initialTitle":
diff --git a/src/caelestia/subcommands/screenshot.py b/src/caelestia/subcommands/screenshot.py
index 6b4c00ad..873616b7 100644
--- a/src/caelestia/subcommands/screenshot.py
+++ b/src/caelestia/subcommands/screenshot.py
@@ -1,10 +1,19 @@
import subprocess
+import time
from argparse import Namespace
from datetime import datetime
-
+from pathlib import Path
from caelestia.utils.notify import notify
from caelestia.utils.paths import screenshots_cache_dir, screenshots_dir
+LOG_FILE = Path.home() / ".local" / "share" / "caelestia" / "screenshot.log"
+
+
+def log(msg: str) -> None:
+ LOG_FILE.parent.mkdir(exist_ok=True, parents=True)
+ with LOG_FILE.open("a") as f:
+ f.write(f"[{datetime.now().isoformat()}] {msg}\n")
+
class Command:
args: Namespace
@@ -13,27 +22,81 @@ def __init__(self, args: Namespace) -> None:
self.args = args
def run(self) -> None:
+ log(f"run() called — args.region={self.args.region!r}")
if self.args.region:
self.region()
else:
self.fullscreen()
def region(self) -> None:
+ log(f"region() called — region={self.args.region!r}")
if self.args.region == "slurp":
- subprocess.run(
- ["qs", "-c", "caelestia", "ipc", "call", "picker", "openFreeze" if self.args.freeze else "open"]
- )
+ cmd = ["qs", "-c", "caelestia", "ipc", "call", "picker", "openFreeze" if self.args.freeze else "open"]
+ log(f"Firing IPC: {cmd}")
+ result = subprocess.run(cmd, capture_output=True, text=True)
+ log(f"IPC returned: returncode={result.returncode} stdout={result.stdout!r} stderr={result.stderr!r}")
else:
- sc_data = subprocess.check_output(["grim", "-l", "0", "-g", self.args.region.strip(), "-"])
- swappy = subprocess.Popen(["swappy", "-f", "-"], stdin=subprocess.PIPE, start_new_session=True)
- swappy.stdin.write(sc_data)
- swappy.stdin.close()
+ self._capture_region(self.args.region)
+
+ def _capture_region(self, region: str) -> None:
+ log(f"_capture_region() called — region={region!r}")
+ try:
+ sc_data = subprocess.check_output(
+ ["grim", "-l", "0", "-g", region.strip(), "-"],
+ stderr=subprocess.PIPE,
+ )
+ log(f"grim succeeded — {len(sc_data)} bytes captured")
+ except subprocess.CalledProcessError as e:
+ msg = e.stderr.decode().strip()
+ log(f"grim CalledProcessError: {msg}")
+ notify("Screenshot failed", f"grim error: {msg}")
+ return
+ except FileNotFoundError:
+ log("grim not found")
+ notify("Screenshot failed", "grim not found")
+ return
+
+ try:
+ swappy = subprocess.Popen(
+ ["swappy", "-f", "-"],
+ stdin=subprocess.PIPE,
+ start_new_session=True,
+ stderr=subprocess.PIPE,
+ )
+ log(f"swappy launched — pid={swappy.pid}")
+ if swappy.stdin:
+ swappy.stdin.write(sc_data)
+ swappy.stdin.close()
+
+ time.sleep(0.2)
+ poll = swappy.poll()
+ if poll is not None:
+ _, err = swappy.communicate()
+ msg = err.decode().strip()
+ log(f"swappy exited early with code {poll}: {msg}")
+ notify("Screenshot failed", f"swappy exited early: {msg}")
+ else:
+ log("swappy running OK")
+ except FileNotFoundError:
+ log("swappy not found")
+ notify("Screenshot failed", "swappy not found")
def fullscreen(self) -> None:
- sc_data = subprocess.check_output(["grim", "-"])
+ log("fullscreen() called")
+ try:
+ sc_data = subprocess.check_output(["grim", "-"], stderr=subprocess.PIPE)
+ log(f"grim succeeded — {len(sc_data)} bytes")
+ except subprocess.CalledProcessError as e:
+ msg = e.stderr.decode().strip()
+ log(f"grim CalledProcessError: {msg}")
+ notify("Screenshot failed", f"grim error: {msg}")
+ return
+ except FileNotFoundError:
+ log("grim not found")
+ notify("Screenshot failed", "grim not found")
+ return
subprocess.run(["wl-copy"], input=sc_data)
-
dest = screenshots_cache_dir / datetime.now().strftime("%Y%m%d%H%M%S")
screenshots_cache_dir.mkdir(exist_ok=True, parents=True)
dest.write_bytes(sc_data)
@@ -48,6 +111,7 @@ def fullscreen(self) -> None:
"Screenshot taken",
f"Screenshot stored in {dest} and copied to clipboard",
)
+ log(f"notify action: {action!r}")
if action == "open":
subprocess.Popen(["swappy", "-f", dest], start_new_session=True)
diff --git a/src/caelestia/subcommands/shell.py b/src/caelestia/subcommands/shell.py
index b9e81edb..48a71c21 100644
--- a/src/caelestia/subcommands/shell.py
+++ b/src/caelestia/subcommands/shell.py
@@ -33,11 +33,14 @@ def run(self) -> None:
subprocess.run(args)
else:
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)
def filter_log(self, line: str) -> bool:
diff --git a/src/caelestia/subcommands/toggle.py b/src/caelestia/subcommands/toggle.py
index ba5a351f..56565f37 100644
--- a/src/caelestia/subcommands/toggle.py
+++ b/src/caelestia/subcommands/toggle.py
@@ -3,6 +3,7 @@
import shutil
from argparse import Namespace
from collections import ChainMap
+from typing import Any, Callable, cast
from caelestia.utils import hypr
from caelestia.utils.paths import user_config_path
@@ -52,8 +53,8 @@ def __repr__(self):
class Command:
args: Namespace
- cfg: dict[str, dict[str, dict[str, any]]] | DeepChainMap
- clients: list[dict[str, any]] = None
+ cfg: dict[str, dict[str, dict[str, Any]]] | DeepChainMap
+ clients: list[dict[str, Any]] | None = None
def __init__(self, args: Namespace) -> None:
self.args = args
@@ -120,27 +121,27 @@ def run(self) -> None:
if not spawned:
hypr.dispatch("togglespecialworkspace", self.args.workspace)
- def get_clients(self) -> list[dict[str, any]]:
+ def get_clients(self) -> list[dict[str, Any]]:
if self.clients is None:
- self.clients = hypr.message("clients")
-
+ self.clients = cast(list[dict[str, Any]], hypr.message("clients"))
return self.clients
- def move_client(self, selector: callable, workspace: str) -> None:
+ def move_client(self, selector: Callable, workspace: str) -> None:
for client in self.get_clients():
if selector(client) and client["workspace"]["name"] != f"special:{workspace}":
hypr.dispatch("movetoworkspacesilent", f"special:{workspace},address:{client['address']}")
- def spawn_client(self, selector: callable, spawn: list[str]) -> bool:
+ def spawn_client(self, selector: Callable, spawn: list[str]) -> bool:
if (spawn[0].endswith(".desktop") or shutil.which(spawn[0])) and not any(
selector(client) for client in self.get_clients()
):
hypr.dispatch("exec", f"[workspace special:{self.args.workspace}] app2unit -- {shlex.join(spawn)}")
return True
- return False
+ else:
+ return False
- def handle_client_config(self, client: dict[str, any]) -> bool:
- def selector(c: dict[str, any]) -> bool:
+ def handle_client_config(self, client: dict[str, Any]) -> bool:
+ def selector(c: dict[str, Any]) -> bool:
# Each match is or, inside matches is and
for match in client["match"]:
if is_subset(c, match):
@@ -156,5 +157,8 @@ def selector(c: dict[str, any]) -> bool:
return spawned
def specialws(self) -> None:
- special = next(m for m in hypr.message("monitors") if m["focused"])["specialWorkspace"]["name"]
- hypr.dispatch("togglespecialworkspace", special[8:] or "special")
+ monitors = cast(list[dict[str, Any]], hypr.message("monitors"))
+ target = next((m for m in monitors if m.get("focused")), None)
+ if target:
+ special = target.get("specialWorkspace", {}).get("name", "")[8:] or "special"
+ hypr.dispatch("togglespecialworkspace", special)
diff --git a/src/caelestia/utils/colourfulness.py b/src/caelestia/utils/colourfulness.py
index f76c1b63..5e06eb3d 100644
--- a/src/caelestia/utils/colourfulness.py
+++ b/src/caelestia/utils/colourfulness.py
@@ -11,8 +11,7 @@ def stddev(values: list[float], mean_val: float) -> float:
return math.sqrt(sum((x - mean_val) ** 2 for x in values) / len(values)) if values else 0
-def calc_colourfulness(image: Image) -> float:
- width, height = image.size
+def calc_colourfulness(image: Image.Image) -> float:
pixels = list(image.getdata()) # List of (R, G, B) tuples
rg_diffs = []
@@ -32,7 +31,7 @@ def calc_colourfulness(image: Image) -> float:
return math.sqrt(std_rg**2 + std_yb**2) + 0.3 * math.sqrt(mean_rg**2 + mean_yb**2)
-def get_variant(image: Image) -> str:
+def get_variant(image: Image.Image) -> str:
colourfulness = calc_colourfulness(image)
if colourfulness < 10:
diff --git a/src/caelestia/utils/hypr.py b/src/caelestia/utils/hypr.py
index 81bc1231..d251b987 100644
--- a/src/caelestia/utils/hypr.py
+++ b/src/caelestia/utils/hypr.py
@@ -1,17 +1,18 @@
-import json as j
+import json
import os
import socket
+from typing import Any
socket_base = f"{os.getenv('XDG_RUNTIME_DIR')}/hypr/{os.getenv('HYPRLAND_INSTANCE_SIGNATURE')}"
socket_path = f"{socket_base}/.socket.sock"
socket2_path = f"{socket_base}/.socket2.sock"
-def message(msg: str, json: bool = True) -> str | dict[str, any]:
+def message(msg: str, is_json: bool = True) -> str | dict[str, Any]:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.connect(socket_path)
- if json:
+ if is_json:
msg = f"j/{msg}"
sock.send(msg.encode())
@@ -22,14 +23,17 @@ def message(msg: str, json: bool = True) -> str | dict[str, any]:
break
resp += new_resp.decode()
- return j.loads(resp) if json else resp
+ return json.loads(resp) if is_json else resp
-def dispatch(dispatcher: str, *args: list[any]) -> bool:
- return message(f"dispatch {dispatcher} {' '.join(map(str, args))}".rstrip(), json=False) == "ok"
+def dispatch(dispatcher: str, *args: str) -> bool:
+ 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]:
- if json:
- msgs = (f"j/{m.strip()}" for m in msgs)
- return message(f"[[BATCH]]{';'.join(msgs)}", json=False)
+def batch(*msgs: str, is_json: bool = False) -> str | dict[str, Any]:
+ formatted_msgs = msgs
+
+ if is_json:
+ formatted_msgs = [f"j/{m.strip()}" for m in msgs]
+
+ return message(f"[[BATCH]]{';'.join(formatted_msgs)}", is_json=False)
diff --git a/src/caelestia/utils/logging.py b/src/caelestia/utils/logging.py
index 20964078..228936e5 100644
--- a/src/caelestia/utils/logging.py
+++ b/src/caelestia/utils/logging.py
@@ -12,9 +12,11 @@ def log_exception(func):
Used by the `apply_()` functions so that an exception, when applying
a theme, does not prevent the other themes from being applied.
"""
+
def wrapper(*args, **kwargs):
try:
func(*args, **kwargs)
except Exception as e:
log_message(f'Error during execution of "{func.__name__}()": {str(e)}')
+
return wrapper
diff --git a/src/caelestia/utils/material/__init__.py b/src/caelestia/utils/material/__init__.py
index 88e855fc..b9aabab8 100644
--- a/src/caelestia/utils/material/__init__.py
+++ b/src/caelestia/utils/material/__init__.py
@@ -31,7 +31,7 @@ def get_colours_for_image(image: Path | str = wallpaper_thumbnail_path, scheme=N
scheme = get_scheme()
cache_base = scheme_cache_dir / compute_hash(image)
- cache = (cache_base / scheme.variant / scheme.mode).with_suffix(".json")
+ cache = (cache_base / scheme.variant / scheme.flavour / scheme.mode).with_suffix(".json")
try:
with cache.open("r") as f:
diff --git a/src/caelestia/utils/material/generator.py b/src/caelestia/utils/material/generator.py
index 200fa7f1..cf241e99 100644
--- a/src/caelestia/utils/material/generator.py
+++ b/src/caelestia/utils/material/generator.py
@@ -1,8 +1,5 @@
from materialyoucolor.blend import Blend
-from materialyoucolor.dynamiccolor.material_dynamic_colors import (
- DynamicScheme,
- MaterialDynamicColors,
-)
+from materialyoucolor.dynamiccolor.material_dynamic_colors import MaterialDynamicColors
from materialyoucolor.hct import Hct
from materialyoucolor.scheme.scheme_content import SchemeContent
from materialyoucolor.scheme.scheme_expressive import SchemeExpressive
@@ -14,6 +11,19 @@
from materialyoucolor.scheme.scheme_tonal_spot import SchemeTonalSpot
from materialyoucolor.scheme.scheme_vibrant import SchemeVibrant
from materialyoucolor.utils.math_utils import difference_degrees, rotation_direction, sanitize_degrees_double
+from typing import Protocol, Any
+
+
+# The base DynamicScheme class requires a 'variant' argument, but the specific
+# subclasses in get_scheme() handle that internally. This Protocol tells the type
+# checker to expect our specific 3-argument setup instead of the base class signature.
+class SchemeConstructor(Protocol):
+ def __call__(self, source_color_hct: Any, is_dark: bool, contrast_level: float) -> DynamicScheme: ...
+
+try:
+ from materialyoucolor.dynamiccolor.dynamic_scheme import DynamicScheme
+except ImportError:
+ from materialyoucolor.scheme.dynamic_scheme import DynamicScheme
def hex_to_hct(hex: str) -> Hct:
@@ -142,10 +152,10 @@ def lighten(colour: Hct, amount: float) -> Hct:
def darken(colour: Hct, amount: float) -> Hct:
diff = colour.tone * amount
- 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":
return SchemeContent
if scheme == "expressive":
@@ -166,45 +176,62 @@ def get_scheme(scheme: str) -> DynamicScheme:
def gen_scheme(scheme, primary: Hct) -> dict[str, str]:
- light = scheme.mode == "light"
+ is_light = scheme.mode == "light"
colours = {}
# Material colours
- primary_scheme = get_scheme(scheme.variant)(primary, not light, 0)
- for colour in vars(MaterialDynamicColors).keys():
- colour_name = getattr(MaterialDynamicColors, colour)
- if hasattr(colour_name, "get_hct"):
- colours[colour] = colour_name.get_hct(primary_scheme)
+ primary_scheme = get_scheme(scheme.variant)(source_color_hct=primary, is_dark=not is_light, contrast_level=0.0)
+ if hasattr(MaterialDynamicColors, "all_colors"): # materialyoucolor-python >= 3.0.0
+ dyn_colours = MaterialDynamicColors()
+ for colour in dyn_colours.all_colors:
+ colours[colour.name] = colour.get_hct(primary_scheme)
+ else:
+ for colour in vars(MaterialDynamicColors).keys():
+ colour_name = getattr(MaterialDynamicColors, colour)
+ if hasattr(colour_name, "get_hct"):
+ colours[colour] = colour_name.get_hct(primary_scheme)
+
+ # Backwards compatibility with old colour names
+ if "primaryPaletteKeyColor" in colours: # materialyoucolor-python >= 3.0.0
+ for colour in "primary", "secondary", "tertiary", "neutral":
+ colours[f"{colour}_paletteKeyColor"] = colours[f"{colour}PaletteKeyColor"]
+ colours["neutral_variant_paletteKeyColor"] = colours["neutralVariantPaletteKeyColor"]
# Harmonize terminal colours
- for i, hct in enumerate(light_gruvbox if light else dark_gruvbox):
+ for i, hct in enumerate(light_gruvbox if is_light else dark_gruvbox):
if scheme.variant == "monochrome":
- colours[f"term{i}"] = grayscale(hct, light)
+ colours[f"term{i}"] = grayscale(hct, is_light)
else:
colours[f"term{i}"] = harmonize(
- hct, colours["primary_paletteKeyColor"], (0.35 if i < 8 else 0.2) * (-1 if light else 1)
+ hct, colours["primary_paletteKeyColor"], (0.35 if i < 8 else 0.2) * (-1 if is_light else 1)
)
# Harmonize named colours
- for i, hct in enumerate(light_catppuccin if light else dark_catppuccin):
+ for i, hct in enumerate(light_catppuccin if is_light else dark_catppuccin):
if scheme.variant == "monochrome":
- colours[colour_names[i]] = grayscale(hct, light)
+ colours[colour_names[i]] = grayscale(hct, is_light)
else:
- colours[colour_names[i]] = harmonize(hct, colours["primary_paletteKeyColor"], (-0.2 if light else 0.05))
+ colours[colour_names[i]] = harmonize(hct, colours["primary_paletteKeyColor"], (-0.2 if is_light else 0.05))
# KColours
for colour in kcolours:
colours[colour["name"]] = harmonize(colour["hct"], colours["primary"], 0.1)
colours[f"{colour['name']}Selection"] = harmonize(colour["hct"], colours["onPrimaryFixedVariant"], 0.1)
if scheme.variant == "monochrome":
- colours[colour["name"]] = grayscale(colours[colour["name"]], light)
- colours[f"{colour['name']}Selection"] = grayscale(colours[f"{colour['name']}Selection"], light)
+ colours[colour["name"]] = grayscale(colours[colour["name"]], is_light)
+ colours[f"{colour['name']}Selection"] = grayscale(colours[f"{colour['name']}Selection"], is_light)
if scheme.variant == "neutral":
for name, hct in colours.items():
colours[name].chroma -= 15
+ # Darken surfaces for hard flavour
+ if scheme.flavour == "hard":
+ for colour in "background", *(k for k in colours.keys() if k.startswith("surface")):
+ colours[colour] = lighten(colours[colour], 0.4) if is_light else darken(colours[colour], 0.8)
+ colours["term0"] = lighten(colours["term0"], 0.4) if is_light else darken(colours["term0"], 0.9)
+
# FIXME: deprecated stuff
colours["text"] = colours["onBackground"]
colours["subtext1"] = colours["onSurfaceVariant"]
@@ -219,13 +246,25 @@ def gen_scheme(scheme, primary: Hct) -> dict[str, str]:
colours["mantle"] = darken(colours["surface"], 0.03)
colours["crust"] = darken(colours["surface"], 0.05)
+ # More darkening if hard flavour
+ if scheme.flavour == "hard":
+ for colour in "base", "mantle", "crust":
+ colours[colour] = lighten(colours[colour], 0.4) if is_light else darken(colours[colour], 0.9)
+ for i in range(3):
+ colours[f"overlay{i}"] = (
+ lighten(colours[f"overlay{i}"], 0.4) if is_light else darken(colours[f"overlay{i}"], 0.8)
+ )
+ colours[f"surface{i}"] = (
+ lighten(colours[f"surface{i}"], 0.4) if is_light else darken(colours[f"surface{i}"], 0.8)
+ )
+
# For debugging
# print("\n".join(["{}: \x1b[48;2;{};{};{}m \x1b[0m".format(n, *c.to_rgba()[:3]) for n, c in colours.items()]))
colours = {k: hex(v.to_int())[4:] for k, v in colours.items()}
# Extended material
- if light:
+ if is_light:
colours["success"] = "4F6354"
colours["onSuccess"] = "FFFFFF"
colours["successContainer"] = "D1E8D5"
diff --git a/src/caelestia/utils/notify.py b/src/caelestia/utils/notify.py
index ee862369..c88dece6 100644
--- a/src/caelestia/utils/notify.py
+++ b/src/caelestia/utils/notify.py
@@ -1,7 +1,7 @@
import subprocess
-def notify(*args: list[str]) -> str:
+def notify(*args: str) -> str:
return subprocess.check_output(["notify-send", "-a", "caelestia-cli", *args], text=True).strip()
diff --git a/src/caelestia/utils/paths.py b/src/caelestia/utils/paths.py
index 14eb5505..3223b900 100644
--- a/src/caelestia/utils/paths.py
+++ b/src/caelestia/utils/paths.py
@@ -4,41 +4,42 @@
import shutil
import tempfile
from pathlib import Path
+from typing import Any
-config_dir = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config"))
-data_dir = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
-state_dir = Path(os.getenv("XDG_STATE_HOME", Path.home() / ".local/state"))
-cache_dir = Path(os.getenv("XDG_CACHE_HOME", Path.home() / ".cache"))
-pictures_dir = Path(os.getenv("XDG_PICTURES_DIR", Path.home() / "Pictures"))
-videos_dir = Path(os.getenv("XDG_VIDEOS_DIR", Path.home() / "Videos"))
+config_dir: Path = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config"))
+data_dir: Path = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
+state_dir: Path = Path(os.getenv("XDG_STATE_HOME", Path.home() / ".local/state"))
+cache_dir: Path = Path(os.getenv("XDG_CACHE_HOME", Path.home() / ".cache"))
+pictures_dir: Path = Path(os.getenv("XDG_PICTURES_DIR", Path.home() / "Pictures"))
+videos_dir: Path = Path(os.getenv("XDG_VIDEOS_DIR", Path.home() / "Videos"))
-c_config_dir = config_dir / "caelestia"
-c_data_dir = data_dir / "caelestia"
-c_state_dir = state_dir / "caelestia"
-c_cache_dir = cache_dir / "caelestia"
+c_config_dir: Path = config_dir / "caelestia"
+c_data_dir: Path = data_dir / "caelestia"
+c_state_dir: Path = state_dir / "caelestia"
+c_cache_dir: Path = cache_dir / "caelestia"
-user_config_path = c_config_dir / "cli.json"
-cli_data_dir = Path(__file__).parent.parent / "data"
-templates_dir = cli_data_dir / "templates"
-user_templates_dir = c_config_dir / "templates"
-theme_dir = c_state_dir / "theme"
+user_config_path: Path = c_config_dir / "cli.json"
+cli_data_dir: Path = Path(__file__).parent.parent / "data"
+templates_dir: Path = cli_data_dir / "templates"
+user_templates_dir: Path = c_config_dir / "templates"
+theme_dir: Path = c_state_dir / "theme"
-scheme_path = c_state_dir / "scheme.json"
-scheme_data_dir = cli_data_dir / "schemes"
-scheme_cache_dir = c_cache_dir / "schemes"
+scheme_path: Path = c_state_dir / "scheme.json"
+scheme_data_dir: Path = cli_data_dir / "schemes"
+scheme_cache_dir: Path = c_cache_dir / "schemes"
-wallpapers_dir = os.getenv("CAELESTIA_WALLPAPERS_DIR", pictures_dir / "Wallpapers")
-wallpaper_path_path = c_state_dir / "wallpaper/path.txt"
-wallpaper_link_path = c_state_dir / "wallpaper/current"
-wallpaper_thumbnail_path = c_state_dir / "wallpaper/thumbnail.jpg"
-wallpapers_cache_dir = c_cache_dir / "wallpapers"
+wallpapers_dir: Path = Path(os.getenv("CAELESTIA_WALLPAPERS_DIR", pictures_dir / "Wallpapers"))
+wallpaper_path_path: Path = c_state_dir / "wallpaper/path.txt"
+wallpaper_link_path: Path = c_state_dir / "wallpaper/current"
+wallpaper_thumbnail_path: Path = c_state_dir / "wallpaper/thumbnail.jpg"
+wallpapers_cache_dir: Path = c_cache_dir / "wallpapers"
-screenshots_dir = os.getenv("CAELESTIA_SCREENSHOTS_DIR", pictures_dir / "Screenshots")
-screenshots_cache_dir = c_cache_dir / "screenshots"
+screenshots_dir: Path = Path(os.getenv("CAELESTIA_SCREENSHOTS_DIR", pictures_dir / "Screenshots"))
+screenshots_cache_dir: Path = c_cache_dir / "screenshots"
-recordings_dir = os.getenv("CAELESTIA_RECORDINGS_DIR", videos_dir / "Recordings")
-recording_path = c_state_dir / "record/recording.mp4"
-recording_notif_path = c_state_dir / "record/notifid.txt"
+recordings_dir: Path = Path(os.getenv("CAELESTIA_RECORDINGS_DIR", videos_dir / "Recordings"))
+recording_path: Path = c_state_dir / "record/recording.mp4"
+recording_notif_path: Path = c_state_dir / "record/notifid.txt"
def compute_hash(path: Path | str) -> str:
@@ -51,7 +52,7 @@ def compute_hash(path: Path | str) -> str:
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:
json.dump(content, f)
f.flush()
diff --git a/src/caelestia/utils/scheme.py b/src/caelestia/utils/scheme.py
index 31fb77a4..e08d777e 100644
--- a/src/caelestia/utils/scheme.py
+++ b/src/caelestia/utils/scheme.py
@@ -1,6 +1,7 @@
import json
import random
from pathlib import Path
+from typing import Any
from caelestia.utils.notify import notify
from caelestia.utils.paths import atomic_dump, scheme_data_dir, scheme_path
@@ -14,19 +15,19 @@ class Scheme:
_colours: dict[str, str]
notify: bool
- def __init__(self, json: dict[str, any] | None) -> None:
- if json is None:
+ def __init__(self, scheme_json: dict[str, Any] | None) -> None:
+ if scheme_json is None:
self._name = "catppuccin"
self._flavour = "mocha"
self._mode = "dark"
self._variant = "tonalspot"
self._colours = read_colours_from_file(self.get_colours_path())
else:
- self._name = json["name"]
- self._flavour = json["flavour"]
- self._mode = json["mode"]
- self._variant = json["variant"]
- self._colours = json["colours"]
+ self._name = scheme_json["name"]
+ self._flavour = scheme_json["flavour"]
+ self._mode = scheme_json["mode"]
+ self._variant = scheme_json["variant"]
+ self._colours = scheme_json["colours"]
self.notify = False
@property
@@ -196,7 +197,7 @@ def __str__(self) -> str:
"content",
]
-scheme: Scheme = None
+scheme: Scheme | None = None
def read_colours_from_file(path: Path) -> dict[str, str]:
@@ -225,18 +226,20 @@ def get_scheme_names() -> list[str]:
return [*(f.name for f in scheme_data_dir.iterdir() if f.is_dir()), "dynamic"]
-def get_scheme_flavours(name: str = None) -> list[str]:
+def get_scheme_flavours(name: str | None = None) -> list[str]:
if name is None:
name = get_scheme().name
- return ["default"] if name == "dynamic" else [f.name for f in (scheme_data_dir / name).iterdir() if f.is_dir()]
+ return (
+ ["default", "hard"] if name == "dynamic" else [f.name for f in (scheme_data_dir / name).iterdir() if f.is_dir()]
+ )
-def get_scheme_modes(name: str = None, flavour: str = None) -> list[str]:
- if name is None:
+def get_scheme_modes(name: str | None = None, flavour: str | None = None) -> list[str]:
+ if name is None or flavour is None:
scheme = get_scheme()
- name = scheme.name
- flavour = scheme.flavour
+ name = name or scheme.name
+ flavour = flavour or scheme.flavour
if name == "dynamic":
return ["light", "dark"]
diff --git a/src/caelestia/utils/theme.py b/src/caelestia/utils/theme.py
index d8e648d7..0addb831 100644
--- a/src/caelestia/utils/theme.py
+++ b/src/caelestia/utils/theme.py
@@ -1,6 +1,9 @@
+import fcntl
import json
import re
+import shutil
import subprocess
+import tempfile
from pathlib import Path
from caelestia.utils.colour import get_dynamic_colours
@@ -31,13 +34,13 @@ def gen_scss(colours: dict[str, str]) -> str:
def gen_replace(colours: dict[str, str], template: Path, hash: bool = False) -> str:
- template = template.read_text()
+ new_template = template.read_text()
for name, colour in colours.items():
- template = template.replace(f"{{{{ ${name} }}}}", f"#{colour}" if hash else colour)
- return template
+ new_template = new_template.replace(f"{{{{ ${name} }}}}", f"#{colour}" if hash else colour)
+ return new_template
-def gen_replace_dynamic(colours: dict[str, str], template: Path) -> str:
+def gen_replace_dynamic(colours: dict[str, str], template: Path, mode: str) -> str:
def fill_colour(match: re.Match) -> str:
data = match.group(1).strip().split(".")
if len(data) != 2:
@@ -48,15 +51,21 @@ def fill_colour(match: re.Match) -> str:
return getattr(colours_dyn[col], form)
# match atomic {{ . }} pairs
- field = r"\{\{((?:(?!\{\{|\}\}).)*)\}\}"
+ dotField = r"\{\{((?:(?!\{\{|\}\}).)*)\}\}"
+
+ # match {{ mode }}
+ modeField = r"\{\{\s*mode\s*\}\}"
+
colours_dyn = get_dynamic_colours(colours)
template_content = template.read_text()
- template_filled = re.sub(field, fill_colour, template_content)
+
+ template_filled = re.sub(dotField, fill_colour, template_content)
+ template_filled = re.sub(modeField, mode, 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\\)"""
return f"\x1b]{';'.join(map(str, i))};rgb:{c[0:2]}/{c[2:4]}/{c[4:6]}\x1b\\"
@@ -73,35 +82,39 @@ def gen_sequences(colours: dict[str, str]) -> str:
16+: 256 colours
"""
return (
- c2s(colours["onSurface"], 10)
- + c2s(colours["surface"], 11)
- + c2s(colours["secondary"], 12)
- + c2s(colours["secondary"], 17)
- + c2s(colours["term0"], 4, 0)
- + c2s(colours["term1"], 4, 1)
- + c2s(colours["term2"], 4, 2)
- + c2s(colours["term3"], 4, 3)
- + c2s(colours["term4"], 4, 4)
- + c2s(colours["term5"], 4, 5)
- + c2s(colours["term6"], 4, 6)
- + c2s(colours["term7"], 4, 7)
- + c2s(colours["term8"], 4, 8)
- + c2s(colours["term9"], 4, 9)
- + c2s(colours["term10"], 4, 10)
- + c2s(colours["term11"], 4, 11)
- + c2s(colours["term12"], 4, 12)
- + c2s(colours["term13"], 4, 13)
- + c2s(colours["term14"], 4, 14)
- + c2s(colours["term15"], 4, 15)
- + c2s(colours["primary"], 4, 16)
- + c2s(colours["secondary"], 4, 17)
- + c2s(colours["tertiary"], 4, 18)
+ hex_to_ansi(colours["onSurface"], 10)
+ + hex_to_ansi(colours["surface"], 11)
+ + hex_to_ansi(colours["secondary"], 12)
+ + hex_to_ansi(colours["secondary"], 17)
+ + hex_to_ansi(colours["term0"], 4, 0)
+ + hex_to_ansi(colours["term1"], 4, 1)
+ + hex_to_ansi(colours["term2"], 4, 2)
+ + hex_to_ansi(colours["term3"], 4, 3)
+ + hex_to_ansi(colours["term4"], 4, 4)
+ + hex_to_ansi(colours["term5"], 4, 5)
+ + hex_to_ansi(colours["term6"], 4, 6)
+ + hex_to_ansi(colours["term7"], 4, 7)
+ + hex_to_ansi(colours["term8"], 4, 8)
+ + hex_to_ansi(colours["term9"], 4, 9)
+ + hex_to_ansi(colours["term10"], 4, 10)
+ + hex_to_ansi(colours["term11"], 4, 11)
+ + hex_to_ansi(colours["term12"], 4, 12)
+ + hex_to_ansi(colours["term13"], 4, 13)
+ + hex_to_ansi(colours["term14"], 4, 14)
+ + hex_to_ansi(colours["term15"], 4, 15)
+ + hex_to_ansi(colours["primary"], 4, 16)
+ + hex_to_ansi(colours["secondary"], 4, 17)
+ + hex_to_ansi(colours["tertiary"], 4, 18)
)
def write_file(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text(content)
+
+ with tempfile.NamedTemporaryFile("w") as f:
+ f.write(content)
+ f.flush()
+ shutil.move(f.name, path)
@log_exception
@@ -114,9 +127,16 @@ def apply_terms(sequences: str) -> None:
for pt in pts_path.iterdir():
if pt.name.isdigit():
try:
- with pt.open("a") as f:
- f.write(sequences)
- except PermissionError:
+ # Use non-blocking write with timeout to prevent hangs
+ import os
+
+ fd = os.open(str(pt), os.O_WRONLY | os.O_NONBLOCK | os.O_NOCTTY)
+ try:
+ os.write(fd, sequences.encode())
+ finally:
+ os.close(fd)
+ except (PermissionError, OSError, BlockingIOError):
+ # Skip terminals that are busy, closed, or inaccessible
pass
@@ -137,6 +157,13 @@ def apply_discord(scss: str) -> None:
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
def apply_spicetify(colours: dict[str, str], mode: str) -> None:
template = gen_replace(colours, templates_dir / f"spicetify-{mode}.ini")
@@ -169,42 +196,136 @@ def apply_htop(colours: dict[str, str]) -> None:
subprocess.run(["killall", "-USR2", "htop"], stderr=subprocess.DEVNULL)
+def sync_papirus_colors(hex_color: str) -> None:
+ """Sync Papirus folder icon colors using hue/saturation analysis"""
+ try:
+ result = subprocess.run(["which", "papirus-folders"], capture_output=True, check=False)
+ if result.returncode != 0:
+ return
+ except Exception:
+ return
+
+ papirus_paths = [
+ Path("/usr/share/icons/Papirus"),
+ Path("/usr/share/icons/Papirus-Dark"),
+ Path.home() / ".local/share/icons/Papirus",
+ Path.home() / ".icons/Papirus",
+ ]
+
+ if not any(p.exists() for p in papirus_paths):
+ return
+
+ r = int(hex_color[0:2], 16)
+ g = int(hex_color[2:4], 16)
+ b = int(hex_color[4:6], 16)
+
+ # Brightness and saturation
+ max_val = max(r, g, b)
+ min_val = min(r, g, b)
+ brightness = max_val
+ saturation = 0 if max_val == 0 else ((max_val - min_val) * 100) // max_val
+
+ # Low saturation = grayscale
+ if saturation < 20:
+ if brightness < 85:
+ color = "black"
+ elif brightness < 170:
+ color = "grey"
+ else:
+ color = "white"
+ # Medium-low saturation with high brightness = pale variants
+ elif saturation < 60 and brightness > 180:
+ use_pale = True
+ color = _determine_hue_color(r, g, b, brightness, use_pale)
+ else:
+ color = _determine_hue_color(r, g, b, brightness, False)
+
+ try:
+ subprocess.Popen(
+ ["sudo", "-n", "papirus-folders", "-C", color, "-u"],
+ stderr=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ start_new_session=True,
+ )
+ except Exception:
+ pass
+
+
+def _determine_hue_color(r: int, g: int, b: int, brightness: int, use_pale: bool) -> str:
+ if b > r and b > g:
+ # Blue dominant
+ r_ratio = (r * 100) // b if b > 0 else 0
+ g_ratio = (g * 100) // b if b > 0 else 0
+ rg_diff = abs(r - g)
+
+ if r_ratio > 70 and g_ratio > 70:
+ # Both R and G high relative to B = light blue/periwinkle
+ if rg_diff < 15:
+ return "blue"
+ elif r > g:
+ return "violet"
+ else:
+ return "cyan"
+ elif r_ratio > 60 and r > g:
+ return "violet"
+ elif g_ratio > 60 and g > r:
+ return "cyan"
+ else:
+ return "blue"
+ elif r > g and r > b:
+ # Red dominant
+ if g > b + 30:
+ # Orange/yellow-ish/brown
+ rg_ratio = (g * 100) // r if r > 0 else 0
+ if use_pale:
+ if rg_ratio > 70 and brightness < 220:
+ return "palebrown"
+ else:
+ return "paleorange"
+ else:
+ if rg_ratio > 70 and brightness < 180:
+ return "brown"
+ else:
+ return "orange"
+ elif b > g + 20:
+ return "pink"
+ else:
+ return "pink" if use_pale else "red"
+ elif g > r and g > b:
+ # Green dominant
+ if r > b + 30:
+ return "yellow"
+ else:
+ return "green"
+ else:
+ return "grey"
+
+
@log_exception
def apply_gtk(colours: dict[str, str], mode: str) -> None:
- template = gen_replace(colours, templates_dir / "gtk.css", hash=True)
- write_file(config_dir / "gtk-3.0/gtk.css", template)
- write_file(config_dir / "gtk-4.0/gtk.css", template)
+ gtk_template = gen_replace(colours, templates_dir / "gtk.css", hash=True)
+ thunar_template = gen_replace(colours, templates_dir / "thunar.css", hash=True)
+
+ for gtk_version in ["gtk-3.0", "gtk-4.0"]:
+ gtk_config_dir = config_dir / gtk_version
+ write_file(gtk_config_dir / "gtk.css", gtk_template)
+ write_file(gtk_config_dir / "thunar.css", thunar_template)
subprocess.run(["dconf", "write", "/org/gnome/desktop/interface/gtk-theme", "'adw-gtk3-dark'"])
subprocess.run(["dconf", "write", "/org/gnome/desktop/interface/color-scheme", f"'prefer-{mode}'"])
subprocess.run(["dconf", "write", "/org/gnome/desktop/interface/icon-theme", f"'Papirus-{mode.capitalize()}'"])
+ sync_papirus_colors(colours["primary"])
+
@log_exception
def apply_qt(colours: dict[str, str], mode: str) -> None:
- template = gen_replace(colours, templates_dir / f"qt{mode}.colors", hash=True)
- write_file(config_dir / "qt5ct/colors/caelestia.colors", template)
- write_file(config_dir / "qt6ct/colors/caelestia.colors", template)
-
- qtct = (templates_dir / "qtct.conf").read_text()
- qtct = qtct.replace("{{ $mode }}", mode.capitalize())
-
- for ver in 5, 6:
- conf = qtct.replace("{{ $config }}", str(config_dir / f"qt{ver}ct"))
-
- if ver == 5:
- conf += """
-[Fonts]
-fixed="Monospace,12,-1,5,50,0,0,0,0,0"
-general="Sans Serif,12,-1,5,50,0,0,0,0,0"
-"""
- else:
- conf += """
-[Fonts]
-fixed="Monospace,12,-1,5,400,0,0,0,0,0,0,0,0,0,0,1"
-general="Sans Serif,12,-1,5,400,0,0,0,0,0,0,0,0,0,0,1"
-"""
- write_file(config_dir / f"qt{ver}ct/qt{ver}ct.conf", conf)
+ colours = gen_replace(colours, templates_dir / f"qt{mode}.colors", hash=True)
+ write_file(config_dir / "qtengine/caelestia.colors", colours)
+
+ config = (templates_dir / "qtengine.json").read_text()
+ config = config.replace("{{ $mode }}", mode.capitalize())
+ write_file(config_dir / "qtengine/config.json", config)
@log_exception
@@ -216,6 +337,51 @@ def apply_warp(colours: dict[str, str], mode: str) -> None:
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
def apply_cava(colours: dict[str, str]) -> None:
template = gen_replace(colours, templates_dir / "cava.conf", hash=True)
@@ -224,47 +390,70 @@ def apply_cava(colours: dict[str, str]) -> None:
@log_exception
-def apply_user_templates(colours: dict[str, str]) -> None:
+def apply_user_templates(colours: dict[str, str], mode: str) -> None:
if not user_templates_dir.is_dir():
return
for file in user_templates_dir.iterdir():
if file.is_file():
- content = gen_replace_dynamic(colours, file)
+ content = gen_replace_dynamic(colours, file, mode)
write_file(theme_dir / file.name, content)
def apply_colours(colours: dict[str, str], mode: str) -> None:
+ # Use file-based lock to prevent concurrent theme changes
+ lock_file = c_state_dir / "theme.lock"
+ c_state_dir.mkdir(parents=True, exist_ok=True)
+
try:
- cfg = json.loads(user_config_path.read_text())["theme"]
- except (FileNotFoundError, json.JSONDecodeError, KeyError):
- cfg = {}
-
- def check(key: str) -> bool:
- return cfg[key] if key in cfg else True
-
- if check("enableTerm"):
- apply_terms(gen_sequences(colours))
- if check("enableHypr"):
- apply_hypr(gen_conf(colours))
- if check("enableDiscord"):
- apply_discord(gen_scss(colours))
- if check("enableSpicetify"):
- apply_spicetify(colours, mode)
- if check("enableFuzzel"):
- apply_fuzzel(colours)
- if check("enableBtop"):
- apply_btop(colours)
- if check("enableNvtop"):
- apply_nvtop(colours)
- if check("enableHtop"):
- apply_htop(colours)
- if check("enableGtk"):
- apply_gtk(colours, mode)
- if check("enableQt"):
- apply_qt(colours, mode)
- if check("enableWarp"):
- apply_warp(colours, mode)
- if check("enableCava"):
- apply_cava(colours)
- apply_user_templates(colours)
+ with open(lock_file, "w") as lock_fd:
+ try:
+ fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except BlockingIOError:
+ return
+
+ try:
+ cfg = json.loads(user_config_path.read_text())["theme"]
+ except (FileNotFoundError, json.JSONDecodeError, KeyError):
+ cfg = {}
+
+ def check(key: str) -> bool:
+ return cfg[key] if key in cfg else True
+
+ if check("enableTerm"):
+ apply_terms(gen_sequences(colours))
+ if check("enableHypr"):
+ apply_hypr(gen_conf(colours))
+ if check("enableDiscord"):
+ apply_discord(gen_scss(colours))
+ if check("enableSpicetify"):
+ apply_spicetify(colours, mode)
+ if check("enablePandora"):
+ apply_pandora(colours, mode)
+ if check("enableFuzzel"):
+ apply_fuzzel(colours)
+ if check("enableBtop"):
+ apply_btop(colours)
+ if check("enableNvtop"):
+ apply_nvtop(colours)
+ if check("enableHtop"):
+ apply_htop(colours)
+ if check("enableGtk"):
+ apply_gtk(colours, mode)
+ if check("enableQt"):
+ apply_qt(colours, mode)
+ if check("enableWarp"):
+ apply_warp(colours, mode)
+ if check("enableChromium"):
+ apply_chromium(colours)
+ if check("enableZed"):
+ apply_zed(colours, mode)
+ if check("enableCava"):
+ apply_cava(colours)
+ apply_user_templates(colours, mode)
+
+ finally:
+ try:
+ lock_file.unlink()
+ except FileNotFoundError:
+ pass
diff --git a/src/caelestia/utils/wallpaper.py b/src/caelestia/utils/wallpaper.py
index 327da5c4..02d3f4e8 100644
--- a/src/caelestia/utils/wallpaper.py
+++ b/src/caelestia/utils/wallpaper.py
@@ -2,8 +2,10 @@
import os
import random
import subprocess
+
from argparse import Namespace
from pathlib import Path
+from typing import cast
from materialyoucolor.hct import Hct
from materialyoucolor.utils.color_utils import argb_from_rgb
@@ -11,6 +13,7 @@
from caelestia.utils.hypr import message
from caelestia.utils.material import get_colours_for_image
+from caelestia.utils.colourfulness import get_variant
from caelestia.utils.paths import (
compute_hash,
user_config_path,
@@ -24,7 +27,7 @@
def is_valid_image(path: Path) -> bool:
- return path.is_file() and path.suffix in [".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff"]
+ return path.is_file() and path.suffix in [".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff", ".gif"]
def check_wall(wall: Path, filter_size: tuple[int, int], threshold: float) -> bool:
@@ -33,7 +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
-def get_wallpaper() -> str:
+def get_wallpaper() -> str | None:
try:
return wallpaper_path_path.read_text()
except IOError:
@@ -41,16 +44,16 @@ def get_wallpaper() -> str:
def get_wallpapers(args: Namespace) -> list[Path]:
- dir = Path(args.random)
- if not dir.is_dir():
+ directory = Path(args.random)
+ if not directory.is_dir():
return []
- walls = [f for f in dir.rglob("*") if is_valid_image(f)]
+ walls = [f for f in directory.rglob("*") if is_valid_image(f)]
if args.no_filter:
return walls
- monitors = message("monitors")
+ monitors = cast(list[dict[str, int]], message("monitors"))
filter_size = min(m["width"] for m in monitors), min(m["height"] for m in monitors)
return [f for f in walls if check_wall(f, filter_size, args.threshold)]
@@ -62,14 +65,14 @@ def get_thumb(wall: Path, cache: Path) -> Path:
if not thumb.exists():
with Image.open(wall) as img:
img = img.convert("RGB")
- img.thumbnail((128, 128), Image.NEAREST)
+ img.thumbnail((128, 128), Image.Resampling.NEAREST)
thumb.parent.mkdir(parents=True, exist_ok=True)
img.save(thumb, "JPEG")
return thumb
-def get_smart_opts(wall: Path, cache: Path) -> str:
+def get_smart_opts(wall: Path, cache: Path) -> dict:
opts_cache = cache / "smart.json"
try:
@@ -77,15 +80,16 @@ def get_smart_opts(wall: Path, cache: Path) -> str:
except (IOError, json.JSONDecodeError):
pass
- from caelestia.utils.colourfulness import get_variant
-
opts = {}
with Image.open(get_thumb(wall, cache)) as img:
opts["variant"] = get_variant(img)
+ img.thumbnail((1, 1), Image.Resampling.LANCZOS)
+
+ # Cast the pixel to a tuple of 3 integers to safely unpack it
+ pixel = cast(tuple[int, int, int], img.getpixel((0, 0)))
+ hct = Hct.from_int(argb_from_rgb(*pixel))
- img.thumbnail((1, 1), Image.LANCZOS)
- hct = Hct.from_int(argb_from_rgb(*img.getpixel((0, 0))))
opts["mode"] = "light" if hct.tone > 60 else "dark"
opts_cache.parent.mkdir(parents=True, exist_ok=True)
@@ -96,9 +100,13 @@ def get_smart_opts(wall: Path, cache: Path) -> str:
def get_colours_for_wall(wall: Path | str, no_smart: bool) -> None:
+ wall = Path(wall)
scheme = get_scheme()
cache = wallpapers_cache_dir / compute_hash(wall)
+ if wall.suffix.lower() == ".gif":
+ wall = convert_gif(wall)
+
name = "dynamic"
if not no_smart:
@@ -106,7 +114,7 @@ def get_colours_for_wall(wall: Path | str, no_smart: bool) -> None:
scheme = Scheme(
{
"name": name,
- "flavour": "default",
+ "flavour": scheme.flavour,
"mode": smart_opts["mode"],
"variant": smart_opts["variant"],
"colours": scheme.colours,
@@ -115,20 +123,41 @@ def get_colours_for_wall(wall: Path | str, no_smart: bool) -> None:
return {
"name": name,
- "flavour": "default",
+ "flavour": scheme.flavour,
"mode": scheme.mode,
"variant": scheme.variant,
"colours": get_colours_for_image(get_thumb(wall, cache), scheme),
}
-def set_wallpaper(wall: Path | str, no_smart: bool) -> None:
+def convert_gif(wall: Path) -> Path:
+ cache = wallpapers_cache_dir / compute_hash(wall)
+ output_path = cache / "first_frame.png"
+
+ if not output_path.exists():
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ with Image.open(wall) as img:
+ try:
+ img.seek(0)
+ except EOFError:
+ pass
+
+ img = img.convert("RGB")
+ img.save(output_path, "PNG")
+
+ return output_path
+
+
+def set_wallpaper(wall: Path, no_smart: bool) -> None:
# Make path absolute
wall = Path(wall).resolve()
if not is_valid_image(wall):
raise ValueError(f'"{wall}" is not a valid image')
+ # Use gif's 1st frame for thumb only
+ wall_cache = convert_gif(wall) if wall.suffix.lower() == ".gif" else wall
+
# Update files
wallpaper_path_path.parent.mkdir(parents=True, exist_ok=True)
wallpaper_path_path.write_text(str(wall))
@@ -136,10 +165,10 @@ def set_wallpaper(wall: Path | str, no_smart: bool) -> None:
wallpaper_link_path.unlink(missing_ok=True)
wallpaper_link_path.symlink_to(wall)
- cache = wallpapers_cache_dir / compute_hash(wall)
+ cache = wallpapers_cache_dir / compute_hash(wall_cache)
# Generate thumbnail or get from cache
- thumb = get_thumb(wall, cache)
+ thumb = get_thumb(wall_cache, cache)
wallpaper_thumbnail_path.parent.mkdir(parents=True, exist_ok=True)
wallpaper_thumbnail_path.unlink(missing_ok=True)
wallpaper_thumbnail_path.symlink_to(thumb)
@@ -148,7 +177,7 @@ def set_wallpaper(wall: Path | str, no_smart: bool) -> None:
# Change mode and variant based on wallpaper colour
if scheme.name == "dynamic" and not no_smart:
- smart_opts = get_smart_opts(wall, cache)
+ smart_opts = get_smart_opts(wall_cache, cache)
scheme.mode = smart_opts["mode"]
scheme.variant = smart_opts["variant"]