summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
author2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>2025-06-14 22:50:55 +1000
committerGitHub <noreply@github.com>2025-06-14 22:50:55 +1000
commitee7291b7f64a359d17eb1d050086ef5357d79055 (patch)
treef1c200d8c8ba81030cbb2113a2be122db6508c8f /src
parentMerge pull request #5 from dalpax/patch-1 (diff)
parentMerge branch 'main' into python-rework (diff)
downloadcaelestia-cli-ee7291b7f64a359d17eb1d050086ef5357d79055.tar.gz
caelestia-cli-ee7291b7f64a359d17eb1d050086ef5357d79055.tar.bz2
caelestia-cli-ee7291b7f64a359d17eb1d050086ef5357d79055.zip
Merge pull request #6 from caelestia-dots/python-rework
feat: rewrite in python
Diffstat (limited to '')
-rw-r--r--src/caelestia/__init__.py13
-rw-r--r--src/caelestia/data/emojis.txt (renamed from data/emojis.txt)0
-rw-r--r--src/caelestia/data/schemes/catppuccin/frappe/dark.txt (renamed from data/schemes/catppuccin/frappe/dark.txt)0
-rw-r--r--src/caelestia/data/schemes/catppuccin/latte/light.txt (renamed from data/schemes/catppuccin/latte/light.txt)0
-rw-r--r--src/caelestia/data/schemes/catppuccin/macchiato/dark.txt (renamed from data/schemes/catppuccin/macchiato/dark.txt)0
-rw-r--r--src/caelestia/data/schemes/catppuccin/mocha/dark.txt (renamed from data/schemes/catppuccin/mocha/dark.txt)0
-rw-r--r--src/caelestia/data/schemes/dynamic/alt1/dark.txt81
-rw-r--r--src/caelestia/data/schemes/dynamic/alt1/light.txt81
-rw-r--r--src/caelestia/data/schemes/dynamic/alt2/dark.txt81
-rw-r--r--src/caelestia/data/schemes/dynamic/alt2/light.txt81
-rw-r--r--src/caelestia/data/schemes/dynamic/default/dark.txt81
-rw-r--r--src/caelestia/data/schemes/dynamic/default/light.txt81
-rw-r--r--src/caelestia/data/schemes/gruvbox/hard/dark.txt (renamed from data/schemes/gruvbox/hard/dark.txt)0
-rw-r--r--src/caelestia/data/schemes/gruvbox/hard/light.txt (renamed from data/schemes/gruvbox/hard/light.txt)0
-rw-r--r--src/caelestia/data/schemes/gruvbox/medium/dark.txt (renamed from data/schemes/gruvbox/medium/dark.txt)0
-rw-r--r--src/caelestia/data/schemes/gruvbox/medium/light.txt (renamed from data/schemes/gruvbox/medium/light.txt)0
-rw-r--r--src/caelestia/data/schemes/gruvbox/soft/dark.txt (renamed from data/schemes/gruvbox/soft/dark.txt)0
-rw-r--r--src/caelestia/data/schemes/gruvbox/soft/light.txt (renamed from data/schemes/gruvbox/soft/light.txt)0
-rw-r--r--src/caelestia/data/schemes/oldworld/default/dark.txt (renamed from data/schemes/oldworld/dark.txt)0
-rw-r--r--src/caelestia/data/schemes/onedark/default/dark.txt (renamed from data/schemes/onedark/dark.txt)0
-rw-r--r--src/caelestia/data/schemes/rosepine/dawn/light.txt (renamed from data/schemes/rosepine/dawn/light.txt)0
-rw-r--r--src/caelestia/data/schemes/rosepine/main/dark.txt (renamed from data/schemes/rosepine/main/dark.txt)0
-rw-r--r--src/caelestia/data/schemes/rosepine/moon/dark.txt (renamed from data/schemes/rosepine/moon/dark.txt)0
-rw-r--r--src/caelestia/data/schemes/shadotheme/default/dark.txt (renamed from data/schemes/shadotheme/dark.txt)0
-rw-r--r--src/caelestia/data/templates/btop.theme83
-rw-r--r--src/caelestia/data/templates/discord.scss174
-rw-r--r--src/caelestia/data/templates/fuzzel.ini41
-rw-r--r--src/caelestia/data/templates/spicetify-dark.ini19
-rw-r--r--src/caelestia/data/templates/spicetify-light.ini19
-rw-r--r--src/caelestia/parser.py112
-rw-r--r--src/caelestia/subcommands/clipboard.py25
-rw-r--r--src/caelestia/subcommands/emoji.py18
-rw-r--r--src/caelestia/subcommands/pip.py44
-rw-r--r--src/caelestia/subcommands/record.py122
-rw-r--r--src/caelestia/subcommands/scheme.py30
-rw-r--r--src/caelestia/subcommands/screenshot.py78
-rw-r--r--src/caelestia/subcommands/shell.py41
-rw-r--r--src/caelestia/subcommands/toggle.py75
-rw-r--r--src/caelestia/subcommands/wallpaper.py21
-rw-r--r--src/caelestia/subcommands/wsaction.py18
-rw-r--r--src/caelestia/utils/hypr.py29
-rw-r--r--src/caelestia/utils/material/__init__.py52
-rw-r--r--src/caelestia/utils/material/generator.py194
-rw-r--r--[-rwxr-xr-x]src/caelestia/utils/material/score.py (renamed from scheme/score.py)34
-rw-r--r--src/caelestia/utils/paths.py53
-rw-r--r--src/caelestia/utils/scheme.py224
-rw-r--r--src/caelestia/utils/theme.py122
-rw-r--r--src/caelestia/utils/wallpaper.py139
48 files changed, 2248 insertions, 18 deletions
diff --git a/src/caelestia/__init__.py b/src/caelestia/__init__.py
new file mode 100644
index 0000000..71c9b62
--- /dev/null
+++ b/src/caelestia/__init__.py
@@ -0,0 +1,13 @@
+from caelestia.parser import parse_args
+
+
+def main() -> None:
+ parser, args = parse_args()
+ if "cls" in args:
+ args.cls(args).run()
+ else:
+ parser.print_help()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/data/emojis.txt b/src/caelestia/data/emojis.txt
index 3d929c7..3d929c7 100644
--- a/data/emojis.txt
+++ b/src/caelestia/data/emojis.txt
diff --git a/data/schemes/catppuccin/frappe/dark.txt b/src/caelestia/data/schemes/catppuccin/frappe/dark.txt
index 1502230..1502230 100644
--- a/data/schemes/catppuccin/frappe/dark.txt
+++ b/src/caelestia/data/schemes/catppuccin/frappe/dark.txt
diff --git a/data/schemes/catppuccin/latte/light.txt b/src/caelestia/data/schemes/catppuccin/latte/light.txt
index 6cc0fce..6cc0fce 100644
--- a/data/schemes/catppuccin/latte/light.txt
+++ b/src/caelestia/data/schemes/catppuccin/latte/light.txt
diff --git a/data/schemes/catppuccin/macchiato/dark.txt b/src/caelestia/data/schemes/catppuccin/macchiato/dark.txt
index 6ffb12f..6ffb12f 100644
--- a/data/schemes/catppuccin/macchiato/dark.txt
+++ b/src/caelestia/data/schemes/catppuccin/macchiato/dark.txt
diff --git a/data/schemes/catppuccin/mocha/dark.txt b/src/caelestia/data/schemes/catppuccin/mocha/dark.txt
index 66497d0..66497d0 100644
--- a/data/schemes/catppuccin/mocha/dark.txt
+++ b/src/caelestia/data/schemes/catppuccin/mocha/dark.txt
diff --git a/src/caelestia/data/schemes/dynamic/alt1/dark.txt b/src/caelestia/data/schemes/dynamic/alt1/dark.txt
new file mode 100644
index 0000000..f3c70ea
--- /dev/null
+++ b/src/caelestia/data/schemes/dynamic/alt1/dark.txt
@@ -0,0 +1,81 @@
+primary_paletteKeyColor 5E8046
+secondary_paletteKeyColor 6E7B62
+tertiary_paletteKeyColor 517F7E
+neutral_paletteKeyColor 75786F
+neutral_variant_paletteKeyColor 74796D
+background 11140E
+onBackground E1E4D9
+surface 11140E
+surfaceDim 11140E
+surfaceBright 373A33
+surfaceContainerLowest 0C0F09
+surfaceContainerLow 191D16
+surfaceContainer 1D211A
+surfaceContainerHigh 282B24
+surfaceContainerHighest 33362F
+onSurface E1E4D9
+surfaceVariant 44483E
+onSurfaceVariant C4C8BB
+inverseSurface E1E4D9
+inverseOnSurface 2E312A
+outline 8E9286
+outlineVariant 44483E
+shadow 000000
+scrim 000000
+surfaceTint ACD28F
+primary ACD28F
+onPrimary 1A3705
+primaryContainer 304F1A
+onPrimaryContainer C7EEA9
+inversePrimary 476730
+secondary BDCBAF
+onSecondary 283420
+secondaryContainer 414D37
+onSecondaryContainer D9E7CA
+tertiary A0CFCE
+onTertiary 003737
+tertiaryContainer 6B9998
+onTertiaryContainer 000000
+error FFB4AB
+onError 690005
+errorContainer 93000A
+onErrorContainer FFDAD6
+primaryFixed C7EEA9
+primaryFixedDim ACD28F
+onPrimaryFixed 0A2000
+onPrimaryFixedVariant 304F1A
+secondaryFixed D9E7CA
+secondaryFixedDim BDCBAF
+onSecondaryFixed 141E0C
+onSecondaryFixedVariant 3F4A35
+tertiaryFixed BBECEA
+tertiaryFixedDim A0CFCE
+onTertiaryFixed 002020
+onTertiaryFixedVariant 1E4E4D
+text E1E4D9
+subtext1 C4C8BB
+subtext0 8E9286
+overlay2 7D8075
+overlay1 6A6D63
+overlay0 585C52
+surface2 474A42
+surface1 353931
+surface0 22261F
+base 11140E
+mantle 090B08
+crust 040503
+success ADE29A
+rosewater ACD28F
+flamingo 9BD4A0
+pink 8AD0EF
+mauve 91CEF5
+red 86D6BE
+maroon 81D4DA
+peach 90D6AE
+yellow A7D293
+green A3D398
+teal 82D5C7
+sky 80D5D3
+sapphire 86D2E8
+blue 9CCBFA
+lavender 81D3E2 \ No newline at end of file
diff --git a/src/caelestia/data/schemes/dynamic/alt1/light.txt b/src/caelestia/data/schemes/dynamic/alt1/light.txt
new file mode 100644
index 0000000..84b0e64
--- /dev/null
+++ b/src/caelestia/data/schemes/dynamic/alt1/light.txt
@@ -0,0 +1,81 @@
+primary_paletteKeyColor 5E8046
+secondary_paletteKeyColor 6E7B62
+tertiary_paletteKeyColor 517F7E
+neutral_paletteKeyColor 75786F
+neutral_variant_paletteKeyColor 74796D
+background F9FAF0
+onBackground 191D16
+surface F9FAF0
+surfaceDim D9DBD1
+surfaceBright F9FAF0
+surfaceContainerLowest FFFFFF
+surfaceContainerLow F3F5EA
+surfaceContainer EDEFE4
+surfaceContainerHigh E7E9DF
+surfaceContainerHighest E1E4D9
+onSurface 191D16
+surfaceVariant E0E4D6
+onSurfaceVariant 44483E
+inverseSurface 2E312A
+inverseOnSurface F0F2E7
+outline 71766B
+outlineVariant C4C8BB
+shadow 000000
+scrim 000000
+surfaceTint 476730
+primary 476730
+onPrimary FFFFFF
+primaryContainer C7EEA9
+onPrimaryContainer 304F1A
+inversePrimary ACD28F
+secondary 56624B
+onSecondary FFFFFF
+secondaryContainer D7E4C7
+onSecondaryContainer 3F4A35
+tertiary 4F7C7C
+onTertiary FFFFFF
+tertiaryContainer 4F7C7C
+onTertiaryContainer FFFFFF
+error BA1A1A
+onError FFFFFF
+errorContainer FFDAD6
+onErrorContainer 93000A
+primaryFixed C7EEA9
+primaryFixedDim ACD28F
+onPrimaryFixed 0A2000
+onPrimaryFixedVariant 304F1A
+secondaryFixed D9E7CA
+secondaryFixedDim BDCBAF
+onSecondaryFixed 141E0C
+onSecondaryFixedVariant 3F4A35
+tertiaryFixed BBECEA
+tertiaryFixedDim A0CFCE
+onTertiaryFixed 002020
+onTertiaryFixedVariant 1E4E4D
+text 191D16
+subtext1 44483E
+subtext0 71766B
+overlay2 84887E
+overlay1 989C92
+overlay0 ABAFA4
+surface2 BFC1B7
+surface1 D2D4C9
+surface0 E6E8DD
+base F9FAF0
+mantle F4F6E5
+crust F1F4DD
+success 4A9F23
+rosewater 3D6837
+flamingo 34693F
+pink 006968
+mauve 00696F
+red 156A59
+maroon 006876
+peach 256B4A
+yellow 426733
+green 476730
+teal 00677B
+sky 2E628B
+sapphire 206486
+blue 0F6681
+lavender 0D6A5F \ No newline at end of file
diff --git a/src/caelestia/data/schemes/dynamic/alt2/dark.txt b/src/caelestia/data/schemes/dynamic/alt2/dark.txt
new file mode 100644
index 0000000..9b36dee
--- /dev/null
+++ b/src/caelestia/data/schemes/dynamic/alt2/dark.txt
@@ -0,0 +1,81 @@
+primary_paletteKeyColor 5E76AB
+secondary_paletteKeyColor 70778B
+tertiary_paletteKeyColor 8B6D8C
+neutral_paletteKeyColor 76777D
+neutral_variant_paletteKeyColor 757780
+background 121318
+onBackground E2E2E9
+surface 121318
+surfaceDim 121318
+surfaceBright 37393E
+surfaceContainerLowest 0C0E13
+surfaceContainerLow 1A1B20
+surfaceContainer 1E1F25
+surfaceContainerHigh 282A2F
+surfaceContainerHighest 33353A
+onSurface E2E2E9
+surfaceVariant 44474F
+onSurfaceVariant C5C6D0
+inverseSurface E2E2E9
+inverseOnSurface 2F3036
+outline 8E9099
+outlineVariant 44474F
+shadow 000000
+scrim 000000
+surfaceTint AEC6FF
+primary AEC6FF
+onPrimary 122F60
+primaryContainer 2C4678
+onPrimaryContainer D8E2FF
+inversePrimary 455E91
+secondary BFC6DC
+onSecondary 293041
+secondaryContainer 3F4759
+onSecondaryContainer DBE2F9
+tertiary DFBBDE
+onTertiary 402843
+tertiaryContainer A786A7
+onTertiaryContainer 000000
+error FFB4AB
+onError 690005
+errorContainer 93000A
+onErrorContainer FFDAD6
+primaryFixed D8E2FF
+primaryFixedDim AEC6FF
+onPrimaryFixed 001A43
+onPrimaryFixedVariant 2C4678
+secondaryFixed DBE2F9
+secondaryFixedDim BFC6DC
+onSecondaryFixed 141B2C
+onSecondaryFixedVariant 3F4759
+tertiaryFixed FCD7FB
+tertiaryFixedDim DFBBDE
+onTertiaryFixed 2A132D
+onTertiaryFixedVariant 583E5A
+text E2E2E9
+subtext1 C5C6D0
+subtext0 8E9099
+overlay2 7D7E87
+overlay1 6A6C74
+overlay0 595A62
+surface2 47494F
+surface1 36373D
+surface0 23242A
+base 121318
+mantle 0B0C0F
+crust 070709
+success 93E5B6
+rosewater 9BD4A1
+flamingo 84D5C3
+pink A1CAFE
+mauve A5C8FF
+red 80D3DE
+maroon 8ECFF2
+peach 80D5D0
+yellow 93D5A9
+green 8DD5B3
+teal 84D2E5
+sky 89D0ED
+sapphire 9CCBFB
+blue ACC6FF
+lavender 94CDF7 \ No newline at end of file
diff --git a/src/caelestia/data/schemes/dynamic/alt2/light.txt b/src/caelestia/data/schemes/dynamic/alt2/light.txt
new file mode 100644
index 0000000..00483f0
--- /dev/null
+++ b/src/caelestia/data/schemes/dynamic/alt2/light.txt
@@ -0,0 +1,81 @@
+primary_paletteKeyColor 5E76AB
+secondary_paletteKeyColor 70778B
+tertiary_paletteKeyColor 8B6D8C
+neutral_paletteKeyColor 76777D
+neutral_variant_paletteKeyColor 757780
+background FAF9FF
+onBackground 1A1B20
+surface FAF9FF
+surfaceDim DAD9E0
+surfaceBright FAF9FF
+surfaceContainerLowest FFFFFF
+surfaceContainerLow F3F3FA
+surfaceContainer EEEDF4
+surfaceContainerHigh E8E7EF
+surfaceContainerHighest E2E2E9
+onSurface 1A1B20
+surfaceVariant E1E2EC
+onSurfaceVariant 44474F
+inverseSurface 2F3036
+inverseOnSurface F1F0F7
+outline 72747D
+outlineVariant C5C6D0
+shadow 000000
+scrim 000000
+surfaceTint 455E91
+primary 455E91
+onPrimary FFFFFF
+primaryContainer D8E2FF
+onPrimaryContainer 2C4678
+inversePrimary AEC6FF
+secondary 575E71
+onSecondary FFFFFF
+secondaryContainer DBE2F9
+onSecondaryContainer 3F4759
+tertiary 896B8A
+onTertiary FFFFFF
+tertiaryContainer 896B8A
+onTertiaryContainer FFFFFF
+error BA1A1A
+onError FFFFFF
+errorContainer FFDAD6
+onErrorContainer 93000A
+primaryFixed D8E2FF
+primaryFixedDim AEC6FF
+onPrimaryFixed 001A43
+onPrimaryFixedVariant 2C4678
+secondaryFixed DBE2F9
+secondaryFixedDim BFC6DC
+onSecondaryFixed 141B2C
+onSecondaryFixedVariant 3F4759
+tertiaryFixed FCD7FB
+tertiaryFixedDim DFBBDE
+onTertiaryFixed 2A132D
+onTertiaryFixedVariant 583E5A
+text 1A1B20
+subtext1 44474F
+subtext0 72747D
+overlay2 85878F
+overlay1 999BA3
+overlay0 ACADB5
+surface2 C0C0C7
+surface1 D3D2D9
+surface0 E7E6ED
+base FAF9FF
+mantle EDEAFF
+crust E5E0FF
+success 00A25A
+rosewater 1F6A4E
+flamingo 056A5C
+pink 15667E
+mauve 1B6685
+red 006972
+maroon 266389
+peach 006A67
+yellow 2B6A46
+green 35693F
+teal 30628C
+sky 435E91
+sapphire 3D5F8F
+blue 37608E
+lavender 0A6777 \ No newline at end of file
diff --git a/src/caelestia/data/schemes/dynamic/default/dark.txt b/src/caelestia/data/schemes/dynamic/default/dark.txt
new file mode 100644
index 0000000..001e000
--- /dev/null
+++ b/src/caelestia/data/schemes/dynamic/default/dark.txt
@@ -0,0 +1,81 @@
+primary_paletteKeyColor 2E8195
+secondary_paletteKeyColor 647B82
+tertiary_paletteKeyColor 707598
+neutral_paletteKeyColor 72787A
+neutral_variant_paletteKeyColor 70797C
+background 0F1416
+onBackground DEE3E6
+surface 0F1416
+surfaceDim 0F1416
+surfaceBright 343A3C
+surfaceContainerLowest 090F11
+surfaceContainerLow 171C1E
+surfaceContainer 1B2022
+surfaceContainerHigh 252B2D
+surfaceContainerHighest 303638
+onSurface DEE3E6
+surfaceVariant 3F484B
+onSurfaceVariant BFC8CB
+inverseSurface DEE3E6
+inverseOnSurface 2C3133
+outline 899295
+outlineVariant 3F484B
+shadow 000000
+scrim 000000
+surfaceTint 85D2E7
+primary 85D2E7
+onPrimary 003641
+primaryContainer 004E5D
+onPrimaryContainer AEECFF
+inversePrimary 00687B
+secondary B2CBD3
+onSecondary 1D343A
+secondaryContainer 364D53
+onSecondaryContainer CEE7EF
+tertiary BFC4EB
+onTertiary 292E4D
+tertiaryContainer 898FB3
+onTertiaryContainer 000000
+error FFB4AB
+onError 690005
+errorContainer 93000A
+onErrorContainer FFDAD6
+primaryFixed AEECFF
+primaryFixedDim 85D2E7
+onPrimaryFixed 001F26
+onPrimaryFixedVariant 004E5D
+secondaryFixed CEE7EF
+secondaryFixedDim B2CBD3
+onSecondaryFixed 061F25
+onSecondaryFixedVariant 344A51
+tertiaryFixed DEE1FF
+tertiaryFixedDim BFC4EB
+onTertiaryFixed 141937
+onTertiaryFixedVariant 3F4565
+text DEE3E6
+subtext1 BFC8CB
+subtext0 899295
+overlay2 788083
+overlay1 666D70
+overlay0 555C5E
+surface2 434A4D
+surface1 32393B
+surface0 202628
+base 0F1416
+mantle 090C0D
+crust 050607
+success 93E5B6
+rosewater 9BD4A1
+flamingo 84D5C3
+pink 8CD0F1
+mauve 91CEF5
+red 80D4DC
+maroon 85D2E7
+peach 80D5D0
+yellow 93D5A9
+green 8DD5B3
+teal 81D3E0
+sky 83D2E4
+sapphire 8AD1EE
+blue 9CCBFA
+lavender 86D1EB \ No newline at end of file
diff --git a/src/caelestia/data/schemes/dynamic/default/light.txt b/src/caelestia/data/schemes/dynamic/default/light.txt
new file mode 100644
index 0000000..09648cf
--- /dev/null
+++ b/src/caelestia/data/schemes/dynamic/default/light.txt
@@ -0,0 +1,81 @@
+primary_paletteKeyColor 2E8195
+secondary_paletteKeyColor 647B82
+tertiary_paletteKeyColor 707598
+neutral_paletteKeyColor 72787A
+neutral_variant_paletteKeyColor 70797C
+background F5FAFC
+onBackground 171C1E
+surface F5FAFC
+surfaceDim D5DBDD
+surfaceBright F5FAFC
+surfaceContainerLowest FFFFFF
+surfaceContainerLow EFF4F7
+surfaceContainer E9EFF1
+surfaceContainerHigh E4E9EB
+surfaceContainerHighest DEE3E6
+onSurface 171C1E
+surfaceVariant DBE4E7
+onSurfaceVariant 3F484B
+inverseSurface 2C3133
+inverseOnSurface ECF2F4
+outline 6D7679
+outlineVariant BFC8CB
+shadow 000000
+scrim 000000
+surfaceTint 00687B
+primary 00687B
+onPrimary FFFFFF
+primaryContainer AEECFF
+onPrimaryContainer 004E5D
+inversePrimary 85D2E7
+secondary 4B6269
+onSecondary FFFFFF
+secondaryContainer CEE7EF
+onSecondaryContainer 344A51
+tertiary 6D7395
+onTertiary FFFFFF
+tertiaryContainer 6D7395
+onTertiaryContainer FFFFFF
+error BA1A1A
+onError FFFFFF
+errorContainer FFDAD6
+onErrorContainer 93000A
+primaryFixed AEECFF
+primaryFixedDim 85D2E7
+onPrimaryFixed 001F26
+onPrimaryFixedVariant 004E5D
+secondaryFixed CEE7EF
+secondaryFixedDim B2CBD3
+onSecondaryFixed 061F25
+onSecondaryFixedVariant 344A51
+tertiaryFixed DEE1FF
+tertiaryFixedDim BFC4EB
+onTertiaryFixed 141937
+onTertiaryFixedVariant 3F4565
+text 171C1E
+subtext1 3F484B
+subtext0 6D7679
+overlay2 80888B
+overlay1 949C9F
+overlay0 A7AFB1
+surface2 BBC1C4
+surface1 CED4D6
+surface0 E2E8EA
+base F5FAFC
+mantle E9F4F8
+crust E1F0F6
+success 00A25A
+rosewater 1F6A4E
+flamingo 056A5C
+pink 046877
+mauve 00687B
+red 006970
+maroon 02677E
+peach 006A67
+yellow 2B6A46
+green 35693F
+teal 0D6680
+sky 2E628B
+sapphire 206486
+blue 156583
+lavender 036873 \ No newline at end of file
diff --git a/data/schemes/gruvbox/hard/dark.txt b/src/caelestia/data/schemes/gruvbox/hard/dark.txt
index 06bd012..06bd012 100644
--- a/data/schemes/gruvbox/hard/dark.txt
+++ b/src/caelestia/data/schemes/gruvbox/hard/dark.txt
diff --git a/data/schemes/gruvbox/hard/light.txt b/src/caelestia/data/schemes/gruvbox/hard/light.txt
index 89c65a8..89c65a8 100644
--- a/data/schemes/gruvbox/hard/light.txt
+++ b/src/caelestia/data/schemes/gruvbox/hard/light.txt
diff --git a/data/schemes/gruvbox/medium/dark.txt b/src/caelestia/data/schemes/gruvbox/medium/dark.txt
index 1ed9168..1ed9168 100644
--- a/data/schemes/gruvbox/medium/dark.txt
+++ b/src/caelestia/data/schemes/gruvbox/medium/dark.txt
diff --git a/data/schemes/gruvbox/medium/light.txt b/src/caelestia/data/schemes/gruvbox/medium/light.txt
index 0c484cf..0c484cf 100644
--- a/data/schemes/gruvbox/medium/light.txt
+++ b/src/caelestia/data/schemes/gruvbox/medium/light.txt
diff --git a/data/schemes/gruvbox/soft/dark.txt b/src/caelestia/data/schemes/gruvbox/soft/dark.txt
index 5a952e7..5a952e7 100644
--- a/data/schemes/gruvbox/soft/dark.txt
+++ b/src/caelestia/data/schemes/gruvbox/soft/dark.txt
diff --git a/data/schemes/gruvbox/soft/light.txt b/src/caelestia/data/schemes/gruvbox/soft/light.txt
index eae8b04..eae8b04 100644
--- a/data/schemes/gruvbox/soft/light.txt
+++ b/src/caelestia/data/schemes/gruvbox/soft/light.txt
diff --git a/data/schemes/oldworld/dark.txt b/src/caelestia/data/schemes/oldworld/default/dark.txt
index 846dc18..846dc18 100644
--- a/data/schemes/oldworld/dark.txt
+++ b/src/caelestia/data/schemes/oldworld/default/dark.txt
diff --git a/data/schemes/onedark/dark.txt b/src/caelestia/data/schemes/onedark/default/dark.txt
index 269096e..269096e 100644
--- a/data/schemes/onedark/dark.txt
+++ b/src/caelestia/data/schemes/onedark/default/dark.txt
diff --git a/data/schemes/rosepine/dawn/light.txt b/src/caelestia/data/schemes/rosepine/dawn/light.txt
index 90f4f73..90f4f73 100644
--- a/data/schemes/rosepine/dawn/light.txt
+++ b/src/caelestia/data/schemes/rosepine/dawn/light.txt
diff --git a/data/schemes/rosepine/main/dark.txt b/src/caelestia/data/schemes/rosepine/main/dark.txt
index 061454b..061454b 100644
--- a/data/schemes/rosepine/main/dark.txt
+++ b/src/caelestia/data/schemes/rosepine/main/dark.txt
diff --git a/data/schemes/rosepine/moon/dark.txt b/src/caelestia/data/schemes/rosepine/moon/dark.txt
index 37183ae..37183ae 100644
--- a/data/schemes/rosepine/moon/dark.txt
+++ b/src/caelestia/data/schemes/rosepine/moon/dark.txt
diff --git a/data/schemes/shadotheme/dark.txt b/src/caelestia/data/schemes/shadotheme/default/dark.txt
index e178804..e178804 100644
--- a/data/schemes/shadotheme/dark.txt
+++ b/src/caelestia/data/schemes/shadotheme/default/dark.txt
diff --git a/src/caelestia/data/templates/btop.theme b/src/caelestia/data/templates/btop.theme
new file mode 100644
index 0000000..9e63bce
--- /dev/null
+++ b/src/caelestia/data/templates/btop.theme
@@ -0,0 +1,83 @@
+# Main background, empty for terminal default, need to be empty if you want transparent background
+theme[main_bg]={{ $surface }}
+
+# Main text color
+theme[main_fg]={{ $onSurface }}
+
+# Title color for boxes
+theme[title]={{ $onSurface }}
+
+# Highlight color for keyboard shortcuts
+theme[hi_fg]={{ $primary }}
+
+# Background color of selected item in processes box
+theme[selected_bg]={{ $surfaceContainer }}
+
+# Foreground color of selected item in processes box
+theme[selected_fg]={{ $primary }}
+
+# Color of inactive/disabled text
+theme[inactive_fg]={{ $outline }}
+
+# Color of text appearing on top of graphs, i.e uptime and current network graph scaling
+theme[graph_text]={{ $tertiary }}
+
+# Background color of the percentage meters
+theme[meter_bg]={{ $outline }}
+
+# Misc colors for processes box including mini cpu graphs, details memory graph and details status text
+theme[proc_misc]={{ $tertiary }}
+
+# CPU, Memory, Network, Proc box outline colors
+theme[cpu_box]={{ $mauve }}
+theme[mem_box]={{ $green }}
+theme[net_box]={{ $maroon }}
+theme[proc_box]={{ $blue }}
+
+# Box divider line and small boxes line color
+theme[div_line]={{ $outlineVariant }}
+
+# Temperature graph color (Green -> Yellow -> Red)
+theme[temp_start]={{ $green }}
+theme[temp_mid]={{ $yellow }}
+theme[temp_end]={{ $red }}
+
+# CPU graph colors (Teal -> Sapphire -> Lavender)
+theme[cpu_start]={{ $teal }}
+theme[cpu_mid]={{ $sapphire }}
+theme[cpu_end]={{ $lavender }}
+
+# Mem/Disk free meter (Mauve -> Lavender -> Blue)
+theme[free_start]={{ $mauve }}
+theme[free_mid]={{ $lavender }}
+theme[free_end]={{ $blue }}
+
+# Mem/Disk cached meter (Sapphire -> Blue -> Lavender)
+theme[cached_start]={{ $sapphire }}
+theme[cached_mid]={{ $blue }}
+theme[cached_end]={{ $lavender }}
+
+# Mem/Disk available meter (Peach -> Maroon -> Red)
+theme[available_start]={{ $peach }}
+theme[available_mid]={{ $maroon }}
+theme[available_end]={{ $red }}
+
+# Mem/Disk used meter (Green -> Teal -> Sky)
+theme[used_start]={{ $green }}
+theme[used_mid]={{ $teal }}
+theme[used_end]={{ $sky }}
+
+# Download graph colors (Peach -> Maroon -> Red)
+theme[download_start]={{ $peach }}
+theme[download_mid]={{ $maroon }}
+theme[download_end]={{ $red }}
+
+# Upload graph colors (Green -> Teal -> Sky)
+theme[upload_start]={{ $green }}
+theme[upload_mid]={{ $teal }}
+theme[upload_end]={{ $sky }}
+
+# Process box color gradient for threads, mem and cpu usage (Sapphire -> Lavender -> Mauve)
+theme[process_start]={{ $sapphire }}
+theme[process_mid]={{ $lavender }}
+theme[process_end]={{ $mauve }}
diff --git a/src/caelestia/data/templates/discord.scss b/src/caelestia/data/templates/discord.scss
new file mode 100644
index 0000000..34220d5
--- /dev/null
+++ b/src/caelestia/data/templates/discord.scss
@@ -0,0 +1,174 @@
+/**
+ * @name Midnight (Caelestia)
+ * @description A dark, rounded discord theme. Caelestia scheme colours.
+ * @author refact0r, esme, anubis
+ * @version 1.6.2
+ * @invite nz87hXyvcy
+ * @website https://github.com/refact0r/midnight-discord
+ * @authorId 508863359777505290
+ * @authorLink https://www.refact0r.dev
+*/
+
+@use "sass:color";
+@use "colours" as c;
+
+@import url("https://refact0r.github.io/midnight-discord/build/midnight.css");
+
+body {
+ /* font, change to '' for default discord font */
+ --font: "figtree";
+
+ /* sizes */
+ --gap: 12px; /* spacing between panels */
+ --divider-thickness: 4px; /* thickness of unread messages divider and highlighted message borders */
+ --border-thickness: 1px; /* thickness of borders around main panels. DOES NOT AFFECT OTHER BORDERS */
+
+ /* animation/transition options */
+ --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 */
+
+ /* 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) */
+ --subtle-top-bar-title: off; /* off: default, on: hide the icon and use subtle text color (like old discord) */
+
+ /* window controls */
+ --custom-window-controls: on; /* turn off to use discord default window controls */
+ --window-control-size: 14px; /* size of custom window controls */
+
+ /* 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-size: 90%; /* size of the svg (css mask-size) */
+ --dms-icon-color-before: var(--icon-secondary); /* normal icon color */
+ --dms-icon-color-after: var(--white); /* icon color when button is hovered/selected */
+
+ /* dms button background options */
+ --custom-dms-background: off; /* off to disable, image to use a background image (must set url variable below), color to use a custom color/gradient */
+ --dms-background-image-url: url(""); /* url of the background image */
+ --dms-background-image-size: cover; /* size of the background image (css background-size) */
+ --dms-background-color: linear-gradient(
+ 70deg,
+ var(--blue-2),
+ var(--purple-2),
+ var(--red-2)
+ ); /* fixed color/gradient (css background) */
+
+ /* background image options */
+ --background-image: off; /* turn on to use a background image */
+ --background-image-url: url(""); /* url of the background image */
+
+ /* transparency/blur options */
+ /* NOTE: TO USE TRANSPARENCY/BLUR, YOU MUST HAVE TRANSPARENT BG COLORS. FOR EXAMPLE: --bg-4: hsla(220, 15%, 10%, 0.7); */
+ --transparency-tweaks: off; /* turn on to remove some elements for better transparency */
+ --remove-bg-layer: off; /* turn on to remove the base --bg-3 layer for use with window transparency (WILL OVERRIDE BACKGROUND IMAGE) */
+ --panel-blur: off; /* turn on to blur the background of panels */
+ --blur-amount: 12px; /* amount of blur */
+ --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. */
+
+ /* other options */
+ --small-user-panel: off; /* turn on to make the user panel smaller like in old discord */
+}
+
+/* color options */
+:root {
+ --colors: on; /* turn off to use discord default colors */
+
+ /* text colors */
+ --text-0: #{c.$onPrimary}; /* text on colored elements */
+ --text-1: #{color.scale(c.$onSurface, $lightness: 10%)}; /* bright text on colored elements */
+ --text-2: #{color.scale(c.$onSurface, $lightness: 5%)}; /* headings and important text */
+ --text-3: #{c.$onSurface}; /* normal text */
+ --text-4: #{c.$outline}; /* icon buttons and channels */
+ --text-5: #{c.$outline}; /* muted channels/chats and timestamps */
+
+ /* background and dark colors */
+ --bg-1: #{c.$surfaceContainerHighest}; /* dark buttons when clicked */
+ --bg-2: #{c.$surfaceContainerHigh}; /* dark buttons */
+ --bg-3: #{c.$surface}; /* spacing, secondary elements */
+ --bg-4: #{c.$surfaceContainer}; /* main background color */
+ --hover: #{color.change(c.$onSurface, $alpha: 0.08)}; /* channels and buttons when hovered */
+ --active: #{color.change(c.$onSurface, $alpha: 0.1)}; /* channels and buttons when clicked or selected */
+ --active-2: #{color.change(c.$onSurface, $alpha: 0.2)}; /* extra state for transparent buttons */
+ --message-hover: #{color.change(c.$onSurface, $alpha: 0.08)}; /* messages when hovered */
+
+ /* accent colors */
+ --accent-1: var(--blue-1); /* links and other accent text */
+ --accent-2: var(--blue-2); /* small accent elements */
+ --accent-3: var(--blue-3); /* accent buttons */
+ --accent-4: var(--blue-4); /* accent buttons when hovered */
+ --accent-5: var(--blue-5); /* accent buttons when clicked */
+ --accent-new: #{c.$error}; /* stuff that's normally red like mute/deafen buttons */
+ --mention: linear-gradient(
+ to right,
+ color-mix(in hsl, var(--blue-2), transparent 90%) 40%,
+ transparent
+ ); /* background of messages that mention you */
+ --mention-hover: linear-gradient(
+ to right,
+ color-mix(in hsl, var(--blue-2), transparent 95%) 40%,
+ transparent
+ ); /* background of messages that mention you when hovered */
+ --reply: linear-gradient(
+ to right,
+ color-mix(in hsl, var(--text-3), transparent 90%) 40%,
+ transparent
+ ); /* background of messages that reply to you */
+ --reply-hover: linear-gradient(
+ to right,
+ color-mix(in hsl, var(--text-3), transparent 95%) 40%,
+ transparent
+ ); /* background of messages that reply to you when hovered */
+
+ /* status indicator colors */
+ --online: var(--green-2); /* change to #43a25a for default */
+ --dnd: var(--red-2); /* change to #d83a42 for default */
+ --idle: var(--yellow-2); /* change to #ca9654 for default */
+ --streaming: var(--purple-2); /* change to #593695 for default */
+ --offline: var(--text-4); /* change to #83838b for default offline color */
+
+ /* border colors */
+ --border-light: #{color.change(c.$outline, $alpha: 0)}; /* light border color */
+ --border: #{color.change(c.$outline, $alpha: 0)}; /* normal border color */
+ --button-border: #{color.change(c.$outline, $alpha: 0)}; /* neutral border color of buttons */
+
+ /* base colors */
+ --red-1: #{c.$error};
+ --red-2: #{color.scale(c.$error, $lightness: -5%)};
+ --red-3: #{color.scale(c.$error, $lightness: -10%)};
+ --red-4: #{color.scale(c.$error, $lightness: -15%)};
+ --red-5: #{color.scale(c.$error, $lightness: -20%)};
+
+ --green-1: #{c.$green};
+ --green-2: #{color.scale(c.$green, $lightness: -5%)};
+ --green-3: #{color.scale(c.$green, $lightness: -10%)};
+ --green-4: #{color.scale(c.$green, $lightness: -15%)};
+ --green-5: #{color.scale(c.$green, $lightness: -20%)};
+
+ --blue-1: #{c.$primary};
+ --blue-2: #{color.scale(c.$primary, $lightness: -5%)};
+ --blue-3: #{color.scale(c.$primary, $lightness: -10%)};
+ --blue-4: #{color.scale(c.$primary, $lightness: -15%)};
+ --blue-5: #{color.scale(c.$primary, $lightness: -20%)};
+
+ --yellow-1: #{c.$yellow};
+ --yellow-2: #{color.scale(c.$yellow, $lightness: -5%)};
+ --yellow-3: #{color.scale(c.$yellow, $lightness: -10%)};
+ --yellow-4: #{color.scale(c.$yellow, $lightness: -15%)};
+ --yellow-5: #{color.scale(c.$yellow, $lightness: -20%)};
+
+ --purple-1: #{c.$mauve};
+ --purple-2: #{color.scale(c.$mauve, $lightness: -5%)};
+ --purple-3: #{color.scale(c.$mauve, $lightness: -10%)};
+ --purple-4: #{color.scale(c.$mauve, $lightness: -15%)};
+ --purple-5: #{color.scale(c.$mauve, $lightness: -20%)};
+}
diff --git a/src/caelestia/data/templates/fuzzel.ini b/src/caelestia/data/templates/fuzzel.ini
new file mode 100644
index 0000000..d61f208
--- /dev/null
+++ b/src/caelestia/data/templates/fuzzel.ini
@@ -0,0 +1,41 @@
+font=JetBrains Mono NF:size=17
+terminal=foot -e
+prompt="> "
+layer=overlay
+lines=15
+width=60
+dpi-aware=no
+inner-pad=10
+horizontal-pad=40
+vertical-pad=15
+match-counter=yes
+
+[colors]
+background=282c34dd
+text=abb2bfdd
+prompt=d19a66ff
+placeholder=666e7cff
+input=abb2bfff
+match=be5046ff
+selection=d19a6687
+selection-text=abb2bfff
+selection-match=be5046ff
+counter=666e7cff
+border=d19a6677
+
+[border]
+radius=10
+width=2
+
+[colors]
+background={{ $surface }}dd
+text={{ $onSurface }}dd
+prompt={{ $primary }}ff
+placeholder={{ $outline }}ff
+input={{ $onSurface }}ff
+match={{ $tertiary }}ff
+selection={{ $primary }}87
+selection-text={{ $onSurface }}ff
+selection-match={{ $tertiary }}ff
+counter={{ $outline }}ff
+border={{ $primary }}77
diff --git a/src/caelestia/data/templates/spicetify-dark.ini b/src/caelestia/data/templates/spicetify-dark.ini
new file mode 100644
index 0000000..4bf85eb
--- /dev/null
+++ b/src/caelestia/data/templates/spicetify-dark.ini
@@ -0,0 +1,19 @@
+[caelestia]
+text = {{ $onSurface }} ; Main text colour
+subtext = {{ $onSurfaceVariant }} ; Subtext colour
+main = {{ $surfaceContainer }} ; Panel backgrounds
+highlight = {{ $primary }} ; Doesn't seem to do anything
+misc = {{ $primary }} ; Doesn't seem to do anything
+notification = {{ $outline }} ; Notifications probably
+notification-error = {{ $error }} ; Error notifications probably
+shadow = {{ $shadow }} ; Shadow for covers, context menu, also affects playlist/artist banners
+card = {{ $surfaceContainerHigh }} ; Context menu and tooltips
+player = {{ $secondaryContainer }} ; Background for top result in search
+sidebar = {{ $surface }} ; Background
+main-elevated = {{ $surfaceContainerHigh }} ; Higher layers than main, e.g. search bar
+highlight-elevated = {{ $surfaceContainerHighest }} ; Home button and search bar accelerator
+selected-row = {{ $onSurface }} ; Selections, hover, other coloured text and slider background
+button = {{ $primary }} ; Slider and text buttons
+button-active = {{ $primary }} ; Background buttons
+button-disabled = {{ $outline }} ; Disabled buttons
+tab-active = {{ $surfaceContainerHigh }} ; Profile fallbacks in search
diff --git a/src/caelestia/data/templates/spicetify-light.ini b/src/caelestia/data/templates/spicetify-light.ini
new file mode 100644
index 0000000..a8b361b
--- /dev/null
+++ b/src/caelestia/data/templates/spicetify-light.ini
@@ -0,0 +1,19 @@
+[caelestia]
+text = {{ $onSurface }} ; Main text colour
+subtext = {{ $onSurfaceVariant }} ; Subtext colour
+main = {{ $surface }} ; Panel backgrounds
+highlight = {{ $primary }} ; Doesn't seem to do anything
+misc = {{ $primary }} ; Doesn't seem to do anything
+notification = {{ $outline }} ; Notifications probably
+notification-error = {{ $error }} ; Error notifications probably
+shadow = {{ $shadow }} ; Shadow for covers, context menu, also affects playlist/artist banners
+card = {{ $surfaceContainer }} ; Context menu and tooltips
+player = {{ $secondaryContainer }} ; Background for top result in search
+sidebar = {{ $surfaceContainer }} ; Background
+main-elevated = {{ $surfaceContainerHigh }} ; Higher layers than main, e.g. search bar
+highlight-elevated = {{ $surfaceContainerHighest }} ; Home button and search bar accelerator
+selected-row = {{ $onSurface }} ; Selections, hover, other coloured text and slider background
+button = {{ $primary }} ; Slider and text buttons
+button-active = {{ $primary }} ; Background buttons
+button-disabled = {{ $outline }} ; Disabled buttons
+tab-active = {{ $surfaceContainer }} ; Profile fallbacks in search
diff --git a/src/caelestia/parser.py b/src/caelestia/parser.py
new file mode 100644
index 0000000..6d0b552
--- /dev/null
+++ b/src/caelestia/parser.py
@@ -0,0 +1,112 @@
+import argparse
+
+from caelestia.subcommands import clipboard, emoji, pip, record, scheme, screenshot, shell, toggle, wallpaper, wsaction
+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")
+
+ # Add subcommand parsers
+ command_parser = parser.add_subparsers(
+ 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("-s", "--show", action="store_true", help="print all shell IPC commands")
+ shell_parser.add_argument(
+ "-l",
+ "--log",
+ nargs="?",
+ const="quickshell.dbus.properties.warning=false;quickshell.dbus.dbusmenu.warning=false;quickshell.service.notifications.warning=false;quickshell.service.sni.host.warning=false",
+ metavar="RULES",
+ help="print the shell log",
+ )
+
+ # Create parser for toggle opts
+ toggle_parser = command_parser.add_parser("toggle", help="toggle a special workspace")
+ toggle_parser.set_defaults(cls=toggle.Command)
+ toggle_parser.add_argument(
+ "workspace", choices=["communication", "music", "sysmon", "specialws", "todo"], help="the workspace to toggle"
+ )
+
+ # Create parser for workspace-action opts
+ ws_action_parser = command_parser.add_parser(
+ "workspace-action", help="execute a Hyprland workspace dispatcher in the current group"
+ )
+ ws_action_parser.set_defaults(cls=wsaction.Command)
+ ws_action_parser.add_argument(
+ "-g", "--group", action="store_true", help="whether to execute the dispatcher on a group"
+ )
+ ws_action_parser.add_argument(
+ "dispatcher", choices=["workspace", "movetoworkspace"], help="the dispatcher to execute"
+ )
+ ws_action_parser.add_argument("workspace", type=int, help="the workspace to pass to the dispatcher")
+
+ # Create parser for scheme opts
+ scheme_parser = command_parser.add_parser("scheme", help="manage the colour scheme")
+ scheme_parser.set_defaults(cls=scheme.Command)
+ scheme_parser.add_argument("-r", "--random", action="store_true", help="switch to a random scheme")
+ scheme_parser.add_argument("-n", "--name", choices=get_scheme_names(), help="the name of the scheme to switch to")
+ scheme_parser.add_argument("-f", "--flavour", help="the flavour to switch to")
+ scheme_parser.add_argument("-m", "--mode", choices=["dark", "light"], help="the mode to switch to")
+ scheme_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.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"
+ )
+
+ # 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")
+
+ # Create parser for clipboard opts
+ 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")
+
+ # Create parser for emoji-picker opts
+ emoji_parser = command_parser.add_parser("emoji-picker", help="toggle the emoji picker")
+ emoji_parser.set_defaults(cls=emoji.Command)
+
+ # Create parser for wallpaper opts
+ 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"
+ )
+ wallpaper_parser.add_argument(
+ "-r", "--random", nargs="?", const=wallpapers_dir, metavar="DIR", help="switch to a random wallpaper"
+ )
+ 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",
+ default=0.8,
+ help="the minimum percentage of the largest monitor size the image must be greater than to be selected",
+ )
+ wallpaper_parser.add_argument(
+ "-N",
+ "--no-smart",
+ action="store_true",
+ help="do not automatically change the scheme mode based on wallpaper colour",
+ )
+
+ # Create parser for pip opts
+ pip_parser = command_parser.add_parser("pip", help="picture in picture utilities")
+ pip_parser.set_defaults(cls=pip.Command)
+ pip_parser.add_argument("-d", "--daemon", action="store_true", help="start the daemon")
+
+ return parser, parser.parse_args()
diff --git a/src/caelestia/subcommands/clipboard.py b/src/caelestia/subcommands/clipboard.py
new file mode 100644
index 0000000..c0eddb5
--- /dev/null
+++ b/src/caelestia/subcommands/clipboard.py
@@ -0,0 +1,25 @@
+import subprocess
+from argparse import Namespace
+
+
+class Command:
+ args: Namespace
+
+ def __init__(self, args: Namespace) -> None:
+ self.args = args
+
+ def run(self) -> None:
+ clip = subprocess.check_output(["cliphist", "list"])
+
+ if self.args.delete:
+ args = ["--prompt=del > ", "--placeholder=Delete from clipboard"]
+ else:
+ args = ["--placeholder=Type to search clipboard"]
+
+ chosen = subprocess.check_output(["fuzzel", "--dmenu", *args], input=clip)
+
+ if self.args.delete:
+ subprocess.run(["cliphist", "delete"], input=chosen)
+ else:
+ decoded = subprocess.check_output(["cliphist", "decode"], input=chosen)
+ subprocess.run(["wl-copy"], input=decoded)
diff --git a/src/caelestia/subcommands/emoji.py b/src/caelestia/subcommands/emoji.py
new file mode 100644
index 0000000..f04b502
--- /dev/null
+++ b/src/caelestia/subcommands/emoji.py
@@ -0,0 +1,18 @@
+import subprocess
+from argparse import Namespace
+
+from caelestia.utils.paths import cli_data_dir
+
+
+class Command:
+ args: Namespace
+
+ def __init__(self, args: Namespace) -> None:
+ self.args = args
+
+ def run(self) -> None:
+ emojis = (cli_data_dir / "emojis.txt").read_text()
+ chosen = subprocess.check_output(
+ ["fuzzel", "--dmenu", "--placeholder=Type to search emojis"], input=emojis, text=True
+ )
+ subprocess.run(["wl-copy"], input=chosen.split()[0], text=True)
diff --git a/src/caelestia/subcommands/pip.py b/src/caelestia/subcommands/pip.py
new file mode 100644
index 0000000..5f1b5fa
--- /dev/null
+++ b/src/caelestia/subcommands/pip.py
@@ -0,0 +1,44 @@
+import re
+import socket
+from argparse import Namespace
+
+from caelestia.utils import hypr
+
+
+class Command:
+ args: Namespace
+
+ def __init__(self, args: Namespace) -> None:
+ self.args = args
+
+ def run(self) -> None:
+ if self.args.daemon:
+ self.daemon()
+ else:
+ win = hypr.message("activewindow")
+ if win["floating"]:
+ self.handle_window(win["address"], win["workspace"]["name"])
+
+ def handle_window(self, address: str, ws: str) -> None:
+ mon_id = next(w for w in hypr.message("workspaces") if w["name"] == ws)["monitorID"]
+ mon = next(m for m in hypr.message("monitors") if m["id"] == mon_id)
+ width, height = next(c for c in hypr.message("clients") if c["address"] == address)["size"]
+
+ scale_factor = mon["height"] / 4 / height
+ scaled_win_size = f"{int(width * scale_factor)} {int(height * scale_factor)}"
+ off = min(mon["width"], mon["height"]) * 0.03
+ move_to = f"{int(mon['width'] - off - width * scale_factor)} {int(mon['height'] - off - height * scale_factor)}"
+
+ hypr.dispatch("resizewindowpixel", "exact", f"{scaled_win_size},address:{address}")
+ hypr.dispatch("movewindowpixel", "exact", f"{move_to},address:{address}")
+
+ def daemon(self) -> None:
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
+ sock.connect(hypr.socket2_path)
+
+ while True:
+ data = sock.recv(4096).decode()
+ if data.startswith("openwindow>>"):
+ address, ws, cls, title = data[12:].split(",")
+ if re.match(r"^[Pp]icture(-| )in(-| )[Pp]icture$", title):
+ self.handle_window(f"0x{address}", ws)
diff --git a/src/caelestia/subcommands/record.py b/src/caelestia/subcommands/record.py
new file mode 100644
index 0000000..a4fa51d
--- /dev/null
+++ b/src/caelestia/subcommands/record.py
@@ -0,0 +1,122 @@
+import subprocess
+import time
+from argparse import Namespace
+from datetime import datetime
+
+from caelestia.utils.paths import recording_notif_path, recording_path, recordings_dir
+
+
+class Command:
+ args: Namespace
+
+ def __init__(self, args: Namespace) -> None:
+ self.args = args
+
+ def run(self) -> None:
+ proc = subprocess.run(["pidof", "wl-screenrec"])
+ if proc.returncode == 0:
+ self.stop()
+ else:
+ self.start()
+
+ def start(self) -> None:
+ args = []
+
+ if self.args.region:
+ if self.args.region == "slurp":
+ region = subprocess.check_output(["slurp"], text=True)
+ else:
+ region = self.args.region
+ args += ["-g", region.strip()]
+
+ if self.args.sound:
+ sources = subprocess.check_output(["pactl", "list", "short", "sources"], text=True).splitlines()
+ for source in sources:
+ if "RUNNING" in source:
+ args += ["--audio", "--audio-device", source.split()[1]]
+ break
+ else:
+ raise ValueError("No audio source found")
+
+ proc = subprocess.Popen(
+ ["wl-screenrec", *args, "--codec", "hevc", "-f", recording_path],
+ stderr=subprocess.PIPE,
+ text=True,
+ start_new_session=True,
+ )
+
+ # Send notif if proc hasn't ended after a small delay
+ time.sleep(0.1)
+ if proc.poll() is None:
+ notif = subprocess.check_output(
+ ["notify-send", "-p", "-a", "caelestia-cli", "Recording started", "Recording..."], text=True
+ ).strip()
+ recording_notif_path.write_text(notif)
+ else:
+ subprocess.run(
+ [
+ "notify-send",
+ "-a",
+ "caelestia-cli",
+ "Recording failed",
+ f"Recording failed to start: {proc.communicate()[1]}",
+ ]
+ )
+
+ def stop(self) -> None:
+ subprocess.run(["pkill", "wl-screenrec"])
+
+ # Move to recordings folder
+ new_path = recordings_dir / f"recording_{datetime.now().strftime('%Y%m%d_%H-%M-%S')}.mp4"
+ recording_path.rename(new_path)
+
+ # Close start notification
+ try:
+ notif = recording_notif_path.read_text()
+ subprocess.run(
+ [
+ "gdbus",
+ "call",
+ "--session",
+ "--dest=org.freedesktop.Notifications",
+ "--object-path=/org/freedesktop/Notifications",
+ "--method=org.freedesktop.Notifications.CloseNotification",
+ notif,
+ ]
+ )
+ except IOError:
+ pass
+
+ action = subprocess.check_output(
+ [
+ "notify-send",
+ "-a",
+ "caelestia-cli",
+ "--action=watch=Watch",
+ "--action=open=Open",
+ "--action=delete=Delete",
+ "Recording stopped",
+ f"Recording saved in {new_path}",
+ ],
+ text=True,
+ ).strip()
+
+ if action == "watch":
+ subprocess.Popen(["app2unit", "-O", new_path], start_new_session=True)
+ elif action == "open":
+ p = subprocess.run(
+ [
+ "dbus-send",
+ "--session",
+ "--dest=org.freedesktop.FileManager1",
+ "--type=method_call",
+ "/org/freedesktop/FileManager1",
+ "org.freedesktop.FileManager1.ShowItems",
+ f"array:string:file://{new_path}",
+ "string:",
+ ]
+ )
+ if p.returncode != 0:
+ subprocess.Popen(["app2unit", "-O", new_path.parent], start_new_session=True)
+ elif action == "delete":
+ new_path.unlink()
diff --git a/src/caelestia/subcommands/scheme.py b/src/caelestia/subcommands/scheme.py
new file mode 100644
index 0000000..c95df96
--- /dev/null
+++ b/src/caelestia/subcommands/scheme.py
@@ -0,0 +1,30 @@
+from argparse import Namespace
+
+from caelestia.utils.scheme import get_scheme
+from caelestia.utils.theme import apply_colours
+
+
+class Command:
+ args: Namespace
+
+ def __init__(self, args: Namespace) -> None:
+ self.args = args
+
+ def run(self) -> None:
+ scheme = get_scheme()
+
+ if self.args.random:
+ scheme.set_random()
+ apply_colours(scheme.colours, scheme.mode)
+ elif self.args.name or self.args.flavour or self.args.mode or self.args.variant:
+ if self.args.name:
+ scheme.name = self.args.name
+ if self.args.flavour:
+ scheme.flavour = self.args.flavour
+ if self.args.mode:
+ scheme.mode = self.args.mode
+ if self.args.variant:
+ scheme.variant = self.args.variant
+ apply_colours(scheme.colours, scheme.mode)
+ else:
+ print(scheme)
diff --git a/src/caelestia/subcommands/screenshot.py b/src/caelestia/subcommands/screenshot.py
new file mode 100644
index 0000000..73d65f7
--- /dev/null
+++ b/src/caelestia/subcommands/screenshot.py
@@ -0,0 +1,78 @@
+import subprocess
+from argparse import Namespace
+from datetime import datetime
+
+from caelestia.utils import hypr
+from caelestia.utils.paths import screenshots_cache_dir, screenshots_dir
+
+
+class Command:
+ args: Namespace
+
+ def __init__(self, args: Namespace) -> None:
+ self.args = args
+
+ def run(self) -> None:
+ if self.args.region:
+ self.region()
+ else:
+ self.fullscreen()
+
+ def region(self) -> None:
+ freeze_proc = None
+
+ if self.args.freeze:
+ freeze_proc = subprocess.Popen(["wayfreeze", "--hide-cursor"])
+
+ if self.args.region == "slurp":
+ ws = hypr.message("activeworkspace")["id"]
+ geoms = [
+ f"{','.join(map(str, c['at']))} {'x'.join(map(str, c['size']))}"
+ for c in hypr.message("clients")
+ if c["workspace"]["id"] == ws
+ ]
+ region = subprocess.check_output(["slurp"], input="\n".join(geoms), text=True)
+ else:
+ region = self.args.region
+
+ sc_data = subprocess.check_output(["grim", "-l", "0", "-g", region.strip(), "-"])
+ swappy = subprocess.Popen(["swappy", "-f", "-"], stdin=subprocess.PIPE, start_new_session=True)
+ swappy.stdin.write(sc_data)
+ swappy.stdin.close()
+
+ if freeze_proc:
+ freeze_proc.kill()
+
+ def fullscreen(self) -> None:
+ sc_data = subprocess.check_output(["grim", "-"])
+
+ 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)
+
+ action = subprocess.check_output(
+ [
+ "notify-send",
+ "-i",
+ "image-x-generic-symbolic",
+ "-h",
+ f"STRING:image-path:{dest}",
+ "-a",
+ "caelestia-cli",
+ "--action=open=Open",
+ "--action=save=Save",
+ "Screenshot taken",
+ f"Screenshot stored in {dest} and copied to clipboard",
+ ],
+ text=True,
+ ).strip()
+
+ if action == "open":
+ subprocess.Popen(["swappy", "-f", dest], start_new_session=True)
+ elif action == "save":
+ new_dest = (screenshots_dir / dest.name).with_suffix(".png")
+ new_dest.parent.mkdir(exist_ok=True, parents=True)
+ dest.rename(new_dest)
+ subprocess.run(["notify-send", "Screenshot saved", f"Saved to {new_dest}"])
diff --git a/src/caelestia/subcommands/shell.py b/src/caelestia/subcommands/shell.py
new file mode 100644
index 0000000..25a39d8
--- /dev/null
+++ b/src/caelestia/subcommands/shell.py
@@ -0,0 +1,41 @@
+import subprocess
+from argparse import Namespace
+
+from caelestia.utils import paths
+
+
+class Command:
+ args: Namespace
+
+ def __init__(self, args: Namespace) -> None:
+ self.args = args
+
+ def run(self) -> None:
+ if self.args.show:
+ # Print the ipc
+ self.print_ipc()
+ elif self.args.log:
+ # Print the log
+ self.print_log()
+ elif self.args.message:
+ # Send a message
+ self.message(*self.args.message)
+ else:
+ # Start the shell
+ self.shell()
+
+ def shell(self, *args: list[str]) -> str:
+ return subprocess.check_output(["qs", "-p", paths.c_data_dir / "shell", *args], text=True)
+
+ def print_ipc(self) -> None:
+ print(self.shell("ipc", "show"), end="")
+
+ def print_log(self) -> None:
+ log = self.shell("log")
+ # FIXME: remove when logging rules are added/warning is removed
+ for line in log.splitlines():
+ if "QProcess: Destroyed while process" not in line:
+ print(line)
+
+ def message(self, *args: list[str]) -> None:
+ print(self.shell("ipc", "call", *args), end="")
diff --git a/src/caelestia/subcommands/toggle.py b/src/caelestia/subcommands/toggle.py
new file mode 100644
index 0000000..b8ad11b
--- /dev/null
+++ b/src/caelestia/subcommands/toggle.py
@@ -0,0 +1,75 @@
+import subprocess
+from argparse import Namespace
+
+from caelestia.utils import hypr
+
+
+class Command:
+ args: Namespace
+ clients: list[dict[str, any]] = None
+
+ def __init__(self, args: Namespace) -> None:
+ self.args = args
+
+ def run(self) -> None:
+ getattr(self, self.args.workspace)()
+
+ def get_clients(self) -> list[dict[str, any]]:
+ if self.clients is None:
+ self.clients = hypr.message("clients")
+
+ return self.clients
+
+ def move_client(self, selector: callable, workspace: str) -> None:
+ for client in self.get_clients():
+ if selector(client):
+ hypr.dispatch("movetoworkspacesilent", f"special:{workspace},address:{client['address']}")
+
+ def spawn_client(self, selector: callable, spawn: list[str]) -> bool:
+ exists = any(selector(client) for client in self.get_clients())
+
+ if not exists:
+ subprocess.Popen(["app2unit", "--", *spawn], start_new_session=True)
+
+ return not exists
+
+ def spawn_or_move(self, selector: callable, spawn: list[str], workspace: str) -> None:
+ if not self.spawn_client(selector, spawn):
+ self.move_client(selector, workspace)
+
+ def communication(self) -> None:
+ self.spawn_or_move(lambda c: c["class"] == "discord", ["discord"], "communication")
+ self.move_client(lambda c: c["class"] == "whatsapp", "communication")
+ hypr.dispatch("togglespecialworkspace", "communication")
+
+ def music(self) -> None:
+ self.spawn_or_move(
+ lambda c: c["class"] == "Spotify" or c["initialTitle"] == "Spotify" or c["initialTitle"] == "Spotify Free",
+ ["spicetify", "watch", "-s"],
+ "music",
+ )
+ self.move_client(lambda c: c["class"] == "feishin", "music")
+ hypr.dispatch("togglespecialworkspace", "music")
+
+ def sysmon(self) -> None:
+ self.spawn_client(
+ lambda c: c["class"] == "btop" and c["title"] == "btop" and c["workspace"]["name"] == "special:sysmon",
+ ["foot", "-a", "btop", "-T", "btop", "--", "btop"],
+ )
+ hypr.dispatch("togglespecialworkspace", "sysmon")
+
+ def todo(self) -> None:
+ self.spawn_or_move(lambda c: c["class"] == "Todoist", ["todoist"], "todo")
+ hypr.dispatch("togglespecialworkspace", "todo")
+
+ def specialws(self) -> None:
+ workspaces = hypr.message("workspaces")
+ on_special_ws = any(ws["name"] == "special:special" for ws in workspaces)
+ toggle_ws = "special"
+
+ if not on_special_ws:
+ active_ws = hypr.message("activewindow")["workspace"]["name"]
+ if active_ws.startswith("special:"):
+ toggle_ws = active_ws[8:]
+
+ hypr.dispatch("togglespecialworkspace", toggle_ws)
diff --git a/src/caelestia/subcommands/wallpaper.py b/src/caelestia/subcommands/wallpaper.py
new file mode 100644
index 0000000..940dcb5
--- /dev/null
+++ b/src/caelestia/subcommands/wallpaper.py
@@ -0,0 +1,21 @@
+import json
+from argparse import Namespace
+
+from caelestia.utils.wallpaper import get_colours_for_wall, get_wallpaper, set_random, set_wallpaper
+
+
+class Command:
+ args: Namespace
+
+ def __init__(self, args: Namespace) -> None:
+ self.args = args
+
+ def run(self) -> None:
+ if self.args.print:
+ print(json.dumps(get_colours_for_wall(self.args.print, self.args.no_smart)))
+ elif self.args.file:
+ set_wallpaper(self.args.file, self.args.no_smart)
+ elif self.args.random:
+ set_random(self.args)
+ else:
+ print(get_wallpaper() or "No wallpaper set")
diff --git a/src/caelestia/subcommands/wsaction.py b/src/caelestia/subcommands/wsaction.py
new file mode 100644
index 0000000..d496381
--- /dev/null
+++ b/src/caelestia/subcommands/wsaction.py
@@ -0,0 +1,18 @@
+from argparse import Namespace
+
+from caelestia.utils import hypr
+
+
+class Command:
+ args: Namespace
+
+ def __init__(self, args: Namespace) -> None:
+ self.args = args
+
+ def run(self) -> None:
+ active_ws = hypr.message("activeworkspace")["id"]
+
+ if self.args.group:
+ hypr.dispatch(self.args.dispatcher, (self.args.workspace - 1) * 10 + active_ws % 10)
+ else:
+ hypr.dispatch(self.args.dispatcher, int((active_ws - 1) / 10) * 10 + self.args.workspace)
diff --git a/src/caelestia/utils/hypr.py b/src/caelestia/utils/hypr.py
new file mode 100644
index 0000000..f89cd98
--- /dev/null
+++ b/src/caelestia/utils/hypr.py
@@ -0,0 +1,29 @@
+import json as j
+import os
+import socket
+
+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]:
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
+ sock.connect(socket_path)
+
+ if json:
+ msg = f"j/{msg}"
+ sock.send(msg.encode())
+
+ resp = sock.recv(8192).decode()
+ while True:
+ new_resp = sock.recv(8192)
+ if not new_resp:
+ break
+ resp += new_resp.decode()
+
+ return j.loads(resp) if json else resp
+
+
+def dispatch(dispatcher: str, *args: list[any]) -> bool:
+ return message(f"dispatch {dispatcher} {' '.join(map(str, args))}".rstrip(), json=False) == "ok"
diff --git a/src/caelestia/utils/material/__init__.py b/src/caelestia/utils/material/__init__.py
new file mode 100644
index 0000000..8adab1f
--- /dev/null
+++ b/src/caelestia/utils/material/__init__.py
@@ -0,0 +1,52 @@
+import json
+from pathlib import Path
+
+from materialyoucolor.hct import Hct
+
+from caelestia.utils.material.generator import gen_scheme
+from caelestia.utils.material.score import score
+from caelestia.utils.paths import compute_hash, scheme_cache_dir, wallpaper_thumbnail_path
+
+
+def get_score_for_image(image: str, cache_base: Path) -> tuple[list[Hct], list[Hct]]:
+ cache = cache_base / "score.json"
+
+ try:
+ with cache.open("r") as f:
+ return [[Hct.from_int(c) for c in li] for li in json.load(f)]
+ except (IOError, json.JSONDecodeError):
+ pass
+
+ s = score(image)
+
+ cache.parent.mkdir(parents=True, exist_ok=True)
+ with cache.open("w") as f:
+ json.dump([[c.to_int() for c in li] for li in s], f)
+
+ return s
+
+
+def get_colours_for_image(image: str = str(wallpaper_thumbnail_path), scheme=None) -> dict[str, str]:
+ if scheme is None:
+ from caelestia.utils.scheme import get_scheme
+
+ scheme = get_scheme()
+
+ cache_base = scheme_cache_dir / compute_hash(image)
+ cache = (cache_base / scheme.variant / scheme.flavour / scheme.mode).with_suffix(".json")
+
+ try:
+ with cache.open("r") as f:
+ return json.load(f)
+ except (IOError, json.JSONDecodeError):
+ pass
+
+ primaries, colours = get_score_for_image(image, cache_base)
+ i = ["default", "alt1", "alt2"].index(scheme.flavour)
+ scheme = gen_scheme(scheme, primaries[i], colours)
+
+ cache.parent.mkdir(parents=True, exist_ok=True)
+ with cache.open("w") as f:
+ json.dump(scheme, f)
+
+ return scheme
diff --git a/src/caelestia/utils/material/generator.py b/src/caelestia/utils/material/generator.py
new file mode 100644
index 0000000..584d375
--- /dev/null
+++ b/src/caelestia/utils/material/generator.py
@@ -0,0 +1,194 @@
+from materialyoucolor.blend import Blend
+from materialyoucolor.dynamiccolor.material_dynamic_colors import (
+ DynamicScheme,
+ MaterialDynamicColors,
+)
+from materialyoucolor.hct import Hct
+from materialyoucolor.hct.cam16 import Cam16
+from materialyoucolor.scheme.scheme_content import SchemeContent
+from materialyoucolor.scheme.scheme_expressive import SchemeExpressive
+from materialyoucolor.scheme.scheme_fidelity import SchemeFidelity
+from materialyoucolor.scheme.scheme_fruit_salad import SchemeFruitSalad
+from materialyoucolor.scheme.scheme_monochrome import SchemeMonochrome
+from materialyoucolor.scheme.scheme_neutral import SchemeNeutral
+from materialyoucolor.scheme.scheme_rainbow import SchemeRainbow
+from materialyoucolor.scheme.scheme_tonal_spot import SchemeTonalSpot
+from materialyoucolor.scheme.scheme_vibrant import SchemeVibrant
+
+
+def hex_to_hct(hex: str) -> Hct:
+ return Hct.from_int(int(f"0xFF{hex}", 16))
+
+
+light_colours = [
+ hex_to_hct("dc8a78"),
+ hex_to_hct("dd7878"),
+ hex_to_hct("ea76cb"),
+ hex_to_hct("8839ef"),
+ hex_to_hct("d20f39"),
+ hex_to_hct("e64553"),
+ hex_to_hct("fe640b"),
+ hex_to_hct("df8e1d"),
+ hex_to_hct("40a02b"),
+ hex_to_hct("179299"),
+ hex_to_hct("04a5e5"),
+ hex_to_hct("209fb5"),
+ hex_to_hct("1e66f5"),
+ hex_to_hct("7287fd"),
+]
+
+dark_colours = [
+ hex_to_hct("f5e0dc"),
+ hex_to_hct("f2cdcd"),
+ hex_to_hct("f5c2e7"),
+ hex_to_hct("cba6f7"),
+ hex_to_hct("f38ba8"),
+ hex_to_hct("eba0ac"),
+ hex_to_hct("fab387"),
+ hex_to_hct("f9e2af"),
+ hex_to_hct("a6e3a1"),
+ hex_to_hct("94e2d5"),
+ hex_to_hct("89dceb"),
+ hex_to_hct("74c7ec"),
+ hex_to_hct("89b4fa"),
+ hex_to_hct("b4befe"),
+]
+
+colour_names = [
+ "rosewater",
+ "flamingo",
+ "pink",
+ "mauve",
+ "red",
+ "maroon",
+ "peach",
+ "yellow",
+ "green",
+ "teal",
+ "sky",
+ "sapphire",
+ "blue",
+ "lavender",
+ "success",
+ "error",
+]
+
+
+def grayscale(colour: Hct, light: bool) -> Hct:
+ colour = darken(colour, 0.35) if light else lighten(colour, 0.65)
+ colour.chroma = 0
+ return colour
+
+
+def mix(a: Hct, b: Hct, w: float) -> Hct:
+ return Hct.from_int(Blend.cam16_ucs(a.to_int(), b.to_int(), w))
+
+
+def harmonize(a: Hct, b: Hct) -> Hct:
+ return Hct.from_int(Blend.harmonize(a.to_int(), b.to_int()))
+
+
+def lighten(colour: Hct, amount: float) -> Hct:
+ diff = (100 - colour.tone) * amount
+ return Hct.from_hct(colour.hue, colour.chroma + diff / 5, colour.tone + diff)
+
+
+def darken(colour: Hct, amount: float) -> Hct:
+ diff = colour.tone * amount
+ return Hct.from_hct(colour.hue, colour.chroma + diff / 5, colour.tone - diff)
+
+
+def distance(colour: Cam16, base: Cam16) -> float:
+ return colour.distance(base)
+
+
+def smart_sort(colours: list[Hct], base: list[Hct]) -> dict[str, Hct]:
+ sorted_colours = [None] * len(colours)
+ distances = {}
+
+ cams = [(c, Cam16.from_int(c.to_int())) for c in colours]
+ base_cams = [Cam16.from_int(c.to_int()) for c in base]
+
+ for colour, cam in cams:
+ dist = [(i, distance(cam, b)) for i, b in enumerate(base_cams)]
+ dist.sort(key=lambda x: x[1])
+ distances[colour] = dist
+
+ for colour in colours:
+ while len(distances[colour]) > 0:
+ i, dist = distances[colour][0]
+
+ if sorted_colours[i] is None:
+ sorted_colours[i] = colour, dist
+ break
+ elif sorted_colours[i][1] > dist:
+ old = sorted_colours[i][0]
+ sorted_colours[i] = colour, dist
+ colour = old
+
+ distances[colour].pop(0)
+
+ return {colour_names[i]: c[0] for i, c in enumerate(sorted_colours)}
+
+
+def get_scheme(scheme: str) -> DynamicScheme:
+ if scheme == "content":
+ return SchemeContent
+ if scheme == "expressive":
+ return SchemeExpressive
+ if scheme == "fidelity":
+ return SchemeFidelity
+ if scheme == "fruitsalad":
+ return SchemeFruitSalad
+ if scheme == "monochrome":
+ return SchemeMonochrome
+ if scheme == "neutral":
+ return SchemeNeutral
+ if scheme == "rainbow":
+ return SchemeRainbow
+ if scheme == "tonalspot":
+ return SchemeTonalSpot
+ return SchemeVibrant
+
+
+def gen_scheme(scheme, primary: Hct, colours: list[Hct]) -> dict[str, str]:
+ light = scheme.mode == "light"
+ base = light_colours if light else dark_colours
+
+ # Sort colours and turn into dict
+ colours = smart_sort(colours, base)
+
+ # Harmonize colours
+ for name, hct in colours.items():
+ if scheme.variant == "monochrome":
+ colours[name] = grayscale(hct, light)
+ else:
+ harmonized = harmonize(hct, primary)
+ colours[name] = darken(harmonized, 0.35) if light else lighten(harmonized, 0.65)
+
+ # 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)
+
+ # FIXME: deprecated stuff
+ colours["text"] = colours["onBackground"]
+ colours["subtext1"] = colours["onSurfaceVariant"]
+ colours["subtext0"] = colours["outline"]
+ colours["overlay2"] = mix(colours["surface"], colours["outline"], 0.86)
+ colours["overlay1"] = mix(colours["surface"], colours["outline"], 0.71)
+ colours["overlay0"] = mix(colours["surface"], colours["outline"], 0.57)
+ colours["surface2"] = mix(colours["surface"], colours["outline"], 0.43)
+ colours["surface1"] = mix(colours["surface"], colours["outline"], 0.29)
+ colours["surface0"] = mix(colours["surface"], colours["outline"], 0.14)
+ colours["base"] = colours["surface"]
+ colours["mantle"] = darken(colours["surface"], 0.03)
+ colours["crust"] = darken(colours["surface"], 0.05)
+ colours["success"] = harmonize(base[8], primary)
+
+ # For debugging
+ # print("\n".join(["{}: \x1b[48;2;{};{};{}m \x1b[0m".format(n, *c.to_rgba()[:3]) for n, c in colours.items()]))
+
+ return {k: hex(v.to_int())[4:] for k, v in colours.items()}
diff --git a/scheme/score.py b/src/caelestia/utils/material/score.py
index 18ddc21..7765050 100755..100644
--- a/scheme/score.py
+++ b/src/caelestia/utils/material/score.py
@@ -20,9 +20,8 @@ class Score:
pass
@staticmethod
- def score(colors_to_population: dict) -> tuple[list[Hct], list[Hct]]:
+ def score(colors_to_population: dict, filter_enabled: bool = True) -> tuple[list[Hct], list[Hct]]:
desired = 14
- filter_enabled = False
dislike_filter = True
colors_hct = []
@@ -50,18 +49,11 @@ class Score:
hue = int(sanitize_degrees_int(round(hct.hue)))
proportion = hue_excited_proportions[hue]
- if filter_enabled and (
- hct.chroma < Score.CUTOFF_CHROMA
- or proportion <= Score.CUTOFF_EXCITED_PROPORTION
- ):
+ if filter_enabled and (hct.chroma < Score.CUTOFF_CHROMA or proportion <= Score.CUTOFF_EXCITED_PROPORTION):
continue
proportion_score = proportion * 100.0 * Score.WEIGHT_PROPORTION
- chroma_weight = (
- Score.WEIGHT_CHROMA_BELOW
- if hct.chroma < Score.TARGET_CHROMA
- else Score.WEIGHT_CHROMA_ABOVE
- )
+ chroma_weight = Score.WEIGHT_CHROMA_BELOW if hct.chroma < Score.TARGET_CHROMA else Score.WEIGHT_CHROMA_ABOVE
chroma_score = (hct.chroma - Score.TARGET_CHROMA) * chroma_weight
score = proportion_score + chroma_score
scored_hct.append({"hct": hct, "score": score})
@@ -75,8 +67,7 @@ class Score:
for item in scored_hct:
hct = item["hct"]
duplicate_hue = any(
- difference_degrees(hct.hue, chosen_hct.hue) < difference_degrees_
- for chosen_hct in chosen_colors
+ difference_degrees(hct.hue, chosen_hct.hue) < difference_degrees_ for chosen_hct in chosen_colors
)
if not duplicate_hue:
chosen_colors.append(hct)
@@ -102,8 +93,7 @@ class Score:
for item in scored_hct:
hct = item["hct"]
duplicate_hue = any(
- difference_degrees(hct.hue, chosen_hct.hue) < difference_degrees_
- for chosen_hct in chosen_primaries
+ difference_degrees(hct.hue, chosen_hct.hue) < difference_degrees_ for chosen_hct in chosen_primaries
)
if not duplicate_hue:
chosen_primaries.append(hct)
@@ -119,16 +109,24 @@ class Score:
for i, chosen_hct in enumerate(chosen_colors):
chosen_colors[i] = DislikeAnalyzer.fix_if_disliked(chosen_hct)
+ # Ensure enough colours
+ if len(chosen_colors) < desired:
+ return Score.score(colors_to_population, False)
+
return chosen_primaries, chosen_colors
+def score(image: str) -> tuple[list[Hct], list[Hct]]:
+ return Score.score(ImageQuantizeCelebi(image, 1, 128))
+
+
if __name__ == "__main__":
img = sys.argv[1]
mode = sys.argv[2] if len(sys.argv) > 2 else "hex"
colours = Score.score(ImageQuantizeCelebi(img, 1, 128))
- for l in colours:
+ for t in colours:
if mode != "hex":
- print("".join(["\x1b[48;2;{};{};{}m \x1b[0m".format(*c.to_rgba()[:3]) for c in l]))
+ print("".join(["\x1b[48;2;{};{};{}m \x1b[0m".format(*c.to_rgba()[:3]) for c in t]))
if mode != "swatch":
- print(" ".join(["{:02X}{:02X}{:02X}".format(*c.to_rgba()[:3]) for c in l]))
+ print(" ".join(["{:02X}{:02X}{:02X}".format(*c.to_rgba()[:3]) for c in t]))
diff --git a/src/caelestia/utils/paths.py b/src/caelestia/utils/paths.py
new file mode 100644
index 0000000..a4ef36f
--- /dev/null
+++ b/src/caelestia/utils/paths.py
@@ -0,0 +1,53 @@
+import hashlib
+import json
+import os
+import shutil
+import tempfile
+from pathlib import Path
+
+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"))
+
+c_config_dir = config_dir / "caelestia"
+c_data_dir = data_dir / "caelestia"
+c_state_dir = state_dir / "caelestia"
+c_cache_dir = cache_dir / "caelestia"
+
+cli_data_dir = Path(__file__).parent.parent / "data"
+templates_dir = cli_data_dir / "templates"
+
+scheme_path = c_state_dir / "scheme.json"
+scheme_data_dir = cli_data_dir / "schemes"
+scheme_cache_dir = c_cache_dir / "schemes"
+
+wallpapers_dir = Path.home() / "Pictures/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"
+
+screenshots_dir = Path.home() / "Pictures/Screenshots"
+screenshots_cache_dir = c_cache_dir / "screenshots"
+
+recordings_dir = Path.home() / "Videos/Recordings"
+recording_path = c_state_dir / "record/recording.mp4"
+recording_notif_path = c_state_dir / "record/notifid.txt"
+
+
+def compute_hash(path: Path | str) -> str:
+ sha = hashlib.sha256()
+
+ with open(path, "rb") as f:
+ while chunk := f.read(8192):
+ sha.update(chunk)
+
+ return sha.hexdigest()
+
+
+def atomic_dump(path: Path, content: dict[str, any]) -> None:
+ with tempfile.NamedTemporaryFile("w") as f:
+ json.dump(content, f)
+ f.flush()
+ shutil.move(f.name, path)
diff --git a/src/caelestia/utils/scheme.py b/src/caelestia/utils/scheme.py
new file mode 100644
index 0000000..0d6cfb5
--- /dev/null
+++ b/src/caelestia/utils/scheme.py
@@ -0,0 +1,224 @@
+import json
+import random
+from pathlib import Path
+
+from caelestia.utils.material import get_colours_for_image
+from caelestia.utils.paths import atomic_dump, scheme_data_dir, scheme_path
+
+
+class Scheme:
+ _name: str
+ _flavour: str
+ _mode: str
+ _variant: str
+ _colours: dict[str, str]
+
+ def __init__(self, json: dict[str, any] | None) -> None:
+ if 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"]
+
+ @property
+ def name(self) -> str:
+ return self._name
+
+ @name.setter
+ def name(self, name: str) -> None:
+ if name == self._name:
+ return
+
+ if name not in get_scheme_names():
+ raise ValueError(f"Invalid scheme name: {name}")
+
+ self._name = name
+ self._check_flavour()
+ self._check_mode()
+ self._update_colours()
+ self.save()
+
+ @property
+ def flavour(self) -> str:
+ return self._flavour
+
+ @flavour.setter
+ def flavour(self, flavour: str) -> None:
+ if flavour == self._flavour:
+ return
+
+ if flavour not in get_scheme_flavours():
+ raise ValueError(f'Invalid scheme flavour: "{flavour}". Valid flavours: {get_scheme_flavours()}')
+
+ self._flavour = flavour
+ self._check_mode()
+ self.update_colours()
+
+ @property
+ def mode(self) -> str:
+ return self._mode
+
+ @mode.setter
+ def mode(self, mode: str) -> None:
+ if mode == self._mode:
+ return
+
+ if mode not in get_scheme_modes():
+ raise ValueError(f'Invalid scheme mode: "{mode}". Valid modes: {get_scheme_modes()}')
+
+ self._mode = mode
+ self.update_colours()
+
+ @property
+ def variant(self) -> str:
+ return self._variant
+
+ @variant.setter
+ def variant(self, variant: str) -> None:
+ if variant == self._variant:
+ return
+
+ self._variant = variant
+ self.update_colours()
+
+ @property
+ def colours(self) -> dict[str, str]:
+ return self._colours
+
+ def get_colours_path(self) -> Path:
+ return (scheme_data_dir / self.name / self.flavour / self.mode).with_suffix(".txt")
+
+ def save(self) -> None:
+ scheme_path.parent.mkdir(parents=True, exist_ok=True)
+ atomic_dump(
+ scheme_path,
+ {
+ "name": self.name,
+ "flavour": self.flavour,
+ "mode": self.mode,
+ "variant": self.variant,
+ "colours": self.colours,
+ },
+ )
+
+ def set_random(self) -> None:
+ self._name = random.choice(get_scheme_names())
+ self._flavour = random.choice(get_scheme_flavours())
+ self._mode = random.choice(get_scheme_modes())
+ self.update_colours()
+
+ def update_colours(self) -> None:
+ self._update_colours()
+ self.save()
+
+ def _check_flavour(self) -> None:
+ global scheme_flavours
+ scheme_flavours = None
+ if self._flavour not in get_scheme_flavours():
+ self._flavour = get_scheme_flavours()[0]
+
+ def _check_mode(self) -> None:
+ global scheme_modes
+ scheme_modes = None
+ if self._mode not in get_scheme_modes():
+ self._mode = get_scheme_modes()[0]
+
+ def _update_colours(self) -> None:
+ if self.name == "dynamic":
+ self._colours = get_colours_for_image()
+ else:
+ self._colours = read_colours_from_file(self.get_colours_path())
+
+ def __str__(self) -> str:
+ return (
+ f"Current scheme:\n"
+ f" Name: {self.name}\n"
+ f" Flavour: {self.flavour}\n"
+ f" Mode: {self.mode}\n"
+ f" Variant: {self.variant}\n"
+ f" Colours:\n"
+ f" {'\n '.join(f'{n}: \x1b[38;2;{int(c[0:2], 16)};{int(c[2:4], 16)};{int(c[4:6], 16)}m{c}\x1b[0m' for n, c in self.colours.items())}"
+ )
+
+
+scheme_variants = [
+ "tonalspot",
+ "vibrant",
+ "expressive",
+ "fidelity",
+ "fruitsalad",
+ "monochrome",
+ "neutral",
+ "rainbow",
+ "content",
+]
+
+scheme_names: list[str] = None
+scheme_flavours: list[str] = None
+scheme_modes: list[str] = None
+
+scheme: Scheme = None
+
+
+def read_colours_from_file(path: Path) -> dict[str, str]:
+ return {k.strip(): v.strip() for k, v in (line.split(" ") for line in path.read_text().splitlines())}
+
+
+def get_scheme_path() -> Path:
+ return get_scheme().get_colours_path()
+
+
+def get_scheme() -> Scheme:
+ global scheme
+
+ if scheme is None:
+ try:
+ scheme_json = json.loads(scheme_path.read_text())
+ scheme = Scheme(scheme_json)
+ except (IOError, json.JSONDecodeError):
+ scheme = Scheme(None)
+
+ return scheme
+
+
+def get_scheme_names() -> list[str]:
+ global scheme_names
+
+ if scheme_names is None:
+ scheme_names = [f.name for f in scheme_data_dir.iterdir() if f.is_dir()]
+ scheme_names.append("dynamic")
+
+ return scheme_names
+
+
+def get_scheme_flavours() -> list[str]:
+ global scheme_flavours
+
+ if scheme_flavours is None:
+ name = get_scheme().name
+ if name == "dynamic":
+ scheme_flavours = ["default", "alt1", "alt2"]
+ else:
+ scheme_flavours = [f.name for f in (scheme_data_dir / name).iterdir() if f.is_dir()]
+
+ return scheme_flavours
+
+
+def get_scheme_modes() -> list[str]:
+ global scheme_modes
+
+ if scheme_modes is None:
+ scheme = get_scheme()
+ if scheme.name == "dynamic":
+ scheme_modes = ["light", "dark"]
+ else:
+ scheme_modes = [f.stem for f in (scheme_data_dir / scheme.name / scheme.flavour).iterdir() if f.is_file()]
+
+ return scheme_modes
diff --git a/src/caelestia/utils/theme.py b/src/caelestia/utils/theme.py
new file mode 100644
index 0000000..d205fb1
--- /dev/null
+++ b/src/caelestia/utils/theme.py
@@ -0,0 +1,122 @@
+import subprocess
+import tempfile
+from pathlib import Path
+
+from caelestia.utils.paths import config_dir, templates_dir
+
+
+def gen_conf(colours: dict[str, str]) -> str:
+ conf = ""
+ for name, colour in colours.items():
+ conf += f"${name} = {colour}\n"
+ return conf
+
+
+def gen_scss(colours: dict[str, str]) -> str:
+ scss = ""
+ for name, colour in colours.items():
+ scss += f"${name}: #{colour};\n"
+ return scss
+
+
+def gen_replace(colours: dict[str, str], template: Path, hash: bool = False) -> str:
+ template = template.read_text()
+ for name, colour in colours.items():
+ template = template.replace(f"{{{{ ${name} }}}}", f"#{colour}" if hash else colour)
+ return template
+
+
+def c2s(c: str, *i: list[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\\"
+
+
+def gen_sequences(colours: dict[str, str]) -> str:
+ """
+ 10: foreground
+ 11: background
+ 12: cursor
+ 17: selection
+ 4:
+ 0 - 7: normal colours
+ 8 - 15: bright colours
+ 16+: 256 colours
+ """
+ return (
+ c2s(colours["onSurface"], 10)
+ + c2s(colours["surface"], 11)
+ + c2s(colours["secondary"], 12)
+ + c2s(colours["secondary"], 17)
+ + c2s(colours["surfaceContainer"], 4, 0)
+ + c2s(colours["red"], 4, 1)
+ + c2s(colours["green"], 4, 2)
+ + c2s(colours["yellow"], 4, 3)
+ + c2s(colours["blue"], 4, 4)
+ + c2s(colours["pink"], 4, 5)
+ + c2s(colours["teal"], 4, 6)
+ + c2s(colours["onSurfaceVariant"], 4, 7)
+ + c2s(colours["surfaceContainer"], 4, 8)
+ + c2s(colours["red"], 4, 9)
+ + c2s(colours["green"], 4, 10)
+ + c2s(colours["yellow"], 4, 11)
+ + c2s(colours["blue"], 4, 12)
+ + c2s(colours["pink"], 4, 13)
+ + c2s(colours["teal"], 4, 14)
+ + c2s(colours["onSurfaceVariant"], 4, 15)
+ + c2s(colours["primary"], 4, 16)
+ + c2s(colours["secondary"], 4, 17)
+ + c2s(colours["tertiary"], 4, 18)
+ )
+
+
+def try_write(path: Path, content: str) -> None:
+ try:
+ path.write_text(content)
+ except FileNotFoundError:
+ pass
+
+
+def apply_terms(sequences: str) -> None:
+ pts_path = Path("/dev/pts")
+ for pt in pts_path.iterdir():
+ if pt.name.isdigit():
+ with pt.open("a") as f:
+ f.write(sequences)
+
+
+def apply_hypr(conf: str) -> None:
+ try_write(config_dir / "hypr/scheme/current.conf", conf)
+
+
+def apply_discord(scss: str) -> None:
+ with tempfile.TemporaryDirectory("w") as tmp_dir:
+ (Path(tmp_dir) / "_colours.scss").write_text(scss)
+ conf = subprocess.check_output(["sass", "-I", tmp_dir, templates_dir / "discord.scss"], text=True)
+
+ for client in "Equicord", "Vencord", "BetterDiscord", "equicord", "vesktop", "legcord":
+ try_write(config_dir / client / "themes/caelestia.theme.css", conf)
+
+
+def apply_spicetify(colours: dict[str, str], mode: str) -> None:
+ template = gen_replace(colours, templates_dir / f"spicetify-{mode}.ini")
+ try_write(config_dir / "spicetify/Themes/caelestia/color.ini", template)
+
+
+def apply_fuzzel(colours: dict[str, str]) -> None:
+ template = gen_replace(colours, templates_dir / "fuzzel.ini")
+ try_write(config_dir / "fuzzel/fuzzel.ini", template)
+
+
+def apply_btop(colours: dict[str, str]) -> None:
+ template = gen_replace(colours, templates_dir / "btop.theme", hash=True)
+ try_write(config_dir / "btop/themes/caelestia.theme", template)
+ subprocess.run(["killall", "-USR2", "btop"])
+
+
+def apply_colours(colours: dict[str, str], mode: str) -> None:
+ apply_terms(gen_sequences(colours))
+ apply_hypr(gen_conf(colours)) # FIXME: LAGGY
+ apply_discord(gen_scss(colours))
+ apply_spicetify(colours, mode)
+ apply_fuzzel(colours)
+ apply_btop(colours)
diff --git a/src/caelestia/utils/wallpaper.py b/src/caelestia/utils/wallpaper.py
new file mode 100644
index 0000000..0a666be
--- /dev/null
+++ b/src/caelestia/utils/wallpaper.py
@@ -0,0 +1,139 @@
+import random
+from argparse import Namespace
+from pathlib import Path
+
+from materialyoucolor.hct import Hct
+from materialyoucolor.utils.color_utils import argb_from_rgb
+from PIL import Image
+
+from caelestia.utils.hypr import message
+from caelestia.utils.material import get_colours_for_image
+from caelestia.utils.paths import (
+ compute_hash,
+ wallpaper_link_path,
+ wallpaper_path_path,
+ wallpaper_thumbnail_path,
+ wallpapers_cache_dir,
+)
+from caelestia.utils.scheme import Scheme, get_scheme
+from caelestia.utils.theme import apply_colours
+
+
+def is_valid_image(path: Path | str) -> bool:
+ path = Path(path)
+ return path.is_file() and path.suffix in [".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff"]
+
+
+def check_wall(wall: Path, filter_size: tuple[int, int], threshold: float) -> bool:
+ with Image.open(wall) as img:
+ width, height = img.size
+ return width >= filter_size[0] * threshold and height >= filter_size[1] * threshold
+
+
+def get_wallpaper() -> str:
+ try:
+ return wallpaper_path_path.read_text()
+ except IOError:
+ return None
+
+
+def get_wallpapers(args: Namespace) -> list[Path]:
+ dir = Path(args.random)
+ if not dir.is_dir():
+ return []
+
+ walls = [f for f in dir.rglob("*") if is_valid_image(f)]
+
+ if args.no_filter:
+ return walls
+
+ monitors = message("monitors")
+ filter_size = monitors[0]["width"], monitors[0]["height"]
+ for monitor in monitors[1:]:
+ if filter_size[0] > monitor["width"]:
+ filter_size[0] = monitor["width"]
+ if filter_size[1] > monitor["height"]:
+ filter_size[1] = monitor["height"]
+
+ return [f for f in walls if check_wall(f, filter_size, args.threshold)]
+
+
+def get_thumb(wall: Path, cache: Path) -> Path:
+ thumb = cache / "thumbnail.jpg"
+
+ if not thumb.exists():
+ with Image.open(wall) as img:
+ img = img.convert("RGB")
+ img.thumbnail((128, 128), Image.NEAREST)
+ thumb.parent.mkdir(parents=True, exist_ok=True)
+ img.save(thumb, "JPEG")
+
+ return thumb
+
+
+def get_smart_mode(wall: Path, cache: Path) -> str:
+ mode_cache = cache / "mode.txt"
+
+ try:
+ return mode_cache.read_text()
+ except IOError:
+ with Image.open(get_thumb(wall, cache)) as img:
+ img.thumbnail((1, 1), Image.LANCZOS)
+ mode = "light" if Hct.from_int(argb_from_rgb(*img.getpixel((0, 0)))).tone > 60 else "dark"
+
+ mode_cache.parent.mkdir(parents=True, exist_ok=True)
+ mode_cache.write_text(mode)
+
+ return mode
+
+
+def get_colours_for_wall(wall: Path | str, no_smart: bool) -> None:
+ scheme = get_scheme()
+ cache = wallpapers_cache_dir / compute_hash(wall)
+
+ if not no_smart:
+ scheme = Scheme(
+ {
+ "name": scheme.name,
+ "flavour": scheme.flavour,
+ "mode": get_smart_mode(wall, cache),
+ "variant": scheme.variant,
+ "colours": scheme.colours,
+ }
+ )
+
+ return get_colours_for_image(get_thumb(wall, cache), scheme)
+
+
+def set_wallpaper(wall: Path | str, no_smart: bool) -> None:
+ if not is_valid_image(wall):
+ raise ValueError(f'"{wall}" is not a valid image')
+
+ # Update files
+ wallpaper_path_path.parent.mkdir(parents=True, exist_ok=True)
+ wallpaper_path_path.write_text(str(wall))
+ wallpaper_link_path.parent.mkdir(parents=True, exist_ok=True)
+ wallpaper_link_path.unlink(missing_ok=True)
+ wallpaper_link_path.symlink_to(wall)
+
+ cache = wallpapers_cache_dir / compute_hash(wall)
+
+ # Generate thumbnail or get from cache
+ thumb = get_thumb(wall, cache)
+ wallpaper_thumbnail_path.parent.mkdir(parents=True, exist_ok=True)
+ wallpaper_thumbnail_path.unlink(missing_ok=True)
+ wallpaper_thumbnail_path.symlink_to(thumb)
+
+ scheme = get_scheme()
+
+ # Change mode based on wallpaper colour
+ if not no_smart:
+ scheme.mode = get_smart_mode(wall, cache)
+
+ # Update colours
+ scheme.update_colours()
+ apply_colours(scheme.colours, scheme.mode)
+
+
+def set_random(args: Namespace) -> None:
+ set_wallpaper(random.choice(get_wallpapers(args)), args.no_smart)