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"]