Files
dotfiles/.scripts/change-colortheme

275 lines
9.1 KiB
Python
Executable File

#!/usr/bin/env python3
'''
refer to `find $dotfiles_path -type f -iname "apply-color"` for implementations.
- kvantum: kvantummanager --set catppuccin-mocha-"$flavor"
(a kvantum window will pop out if such theme is not installed)
- nwg-look: edit $HOME/.local/share/nwg-look/gsettings
nwg-look -a
nwg-look -> confirm (not implemented yet)
- eww: edit $HOME/.config/eww/eww.scss
eww reload
- hypr: edit $HOME/.config/hypr/hyprland/colors.conf
hyprctl reload
- rofi: edit $HOME/.config/rofi/config.rasi
- waybar: edit $HOME/.config/waybar/style.css
waybar-toggle restart
- oh-my-posh: edit $HOME/.config/posh_theme.omp.json
- fastfetch: edit $HOME/.config/fish/post.d/fetch.fish
- mako: edit $HOME/.config/mako/config
makoctl reload
- yazi: cp -f "$path"/../.yazi-themes/catppuccin-mocha-"$flavor".toml ~/.config/yazi/theme.toml
- wlogout: edit $HOME/.config/wlogout/style.css
edit $HOME/.config/wlogout/icons/*.svg
- fuzzel: edit $HOME/.config/fuzzel/fuzzel.ini
- niri: edit $HOME/.config/niri/config.kdl
- quickshell qs ipc call colors setPrimary $hex
'''
import os
import sys
from pathlib import Path
import shutil
import argparse
from typing import Callable
SCRIPT_NAME = "apply-color"
PALETTES = {
"catppuccin-mocha": {
"rosewater": "f5e0dc",
"flamingo": "f2cdcd",
"pink": "f5c2e7",
"mauve": "cba6f7",
"red": "f38ba8",
"maroon": "eba0ac",
"peach": "fab387",
"yellow": "f9e2af",
"green": "a6e3a1",
"teal": "94e2d5",
"sky": "89dceb",
"sapphire": "74c7ec",
"blue": "89b4fa",
"lavender": "b4befe",
},
}
CONFIG_DIR = Path(__file__).resolve().parent.resolve().parent.resolve()
def get_script_list() -> list[Path]:
scripts = []
# find -type f -iname "apply-color" $configDir
for item in CONFIG_DIR.rglob(SCRIPT_NAME):
if item.is_file() and os.access(item, os.X_OK):
scripts.append(item.resolve())
print(f"Found {len(scripts)} scripts to apply themes")
return scripts
def hex2rgb(hex_color: str) -> tuple[int, int, int]:
return tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4)) # type: ignore
def extract_color(image_path: str) -> str:
from colorthief import ColorThief
return "#{:02x}{:02x}{:02x}".format(*ColorThief(image_path).get_color(quality=10))
def match_color(color: str, palette: dict[str, str]) -> str:
""" Matches a given color (rrggbb hex) to the closest color in the palette."""
# HUE distance of the given and returned color must no<t exceed this value
HUE_THRESHOLD = 60.0 # degrees
color = color.lower().strip().removeprefix('#')
# weigh by CCIR 601 luminosity
fr, fg, fb = 0.299 / 255 / 255, 0.587 / 255 / 255, 0.114 / 255 / 255
lfr, lfg, lfb = 0.299 / 255, 0.587 / 255, 0.114 / 255
def color_distance(c1: str, c2: str) -> float:
r1, g1, b1 = hex2rgb(c1)
r2, g2, b2 = hex2rgb(c2)
diff_l = (lfr * (r1 - r2) + lfg * (g1 - g2) + lfb * (b1 - b2))
diff_r = fr * (r1 - r2) ** 2
diff_g = fg * (g1 - g2) ** 2
diff_b = fb * (b1 - b2) ** 2
return (diff_r + diff_g + diff_b) * 0.75 + diff_l ** 2
def color_distance_hue(c1: str, c2: str) -> float:
def rgb2hue(r, g, b) -> float:
r, g, b = r / 255.0, g / 255.0, b / 255.0
mx = max(r, g, b)
mn = min(r, g, b)
diff = mx - mn
if diff == 0:
return 0.0
if mx == r:
hue = (g - b) / diff + (6 if g < b else 0)
elif mx == g:
hue = (b - r) / diff + 2
else:
hue = (r - g) / diff + 4
return hue * 60
r1, g1, b1 = hex2rgb(c1)
r2, g2, b2 = hex2rgb(c2)
return abs(rgb2hue(r1, g1, b1) - rgb2hue(r2, g2, b2))
closest_color = min(palette.keys(), key=lambda k: color_distance(color, palette[k]))
print(f"Matched color {color} to {closest_color}")
# if the hue distance is too large, rematch
if color_distance_hue(color, palette[closest_color]) > HUE_THRESHOLD:
print(f"Color {color} is too far from {closest_color}, rematching'")
else:
return closest_color
closest_color = min(palette.keys(), key=lambda k: color_distance_hue(color, palette[k]))
print(f"Rematched color {color} to {closest_color}")
return closest_color
def pick_flavor(palette: dict[str, str]) -> str:
def is_interactive() -> bool:
return sys.stdin.isatty() and sys.stdout.isatty()
def is_truecolor() -> bool:
colorterm = os.environ.get('COLORTERM', '')
term = os.environ.get('TERM', '')
return (
'truecolor' in colorterm or
'24bit' in colorterm or
term.endswith('-256color')
)
if is_interactive():
isTruecolor = is_truecolor()
print("Available flavors:")
for i, flavor in enumerate(palette.keys(), 1):
r, g, b = hex2rgb(palette[flavor])
if isTruecolor:
print(f"\033[38;2;{r};{g};{b}m█ {i}. {flavor}: #{palette[flavor]}\033[0m")
else:
print(f"{i}. {flavor}")
while True:
choice = input("Pick a flavor by number: ")
if choice.isdigit() and 1 <= int(choice) <= len(palette):
return list(palette.keys())[int(choice) - 1]
print("Invalid choice. Try again.")
else:
print("No flavor specified.")
sys.exit(1)
def main():
parser = argparse.ArgumentParser(description="Change color theme for various applications.")
parser.add_argument('-i', '--image', type=str, help="Path to the image")
parser.add_argument('-f', '--flavor', type=str, help="Flavor to apply")
parser.add_argument('-c', '--color', type=str, help="Color to match from the palette")
parser.add_argument('arguments', nargs='*',
help="Any number of 'pattern' and '!pattern'. "
f"Initially all scripts with name {SCRIPT_NAME} under {CONFIG_DIR} (recursively) will be considered. "
f"If at least one pattern is given, only scripts that has any of the patterns in their path will be executed. "
"If a pattern is prefixed with '!', scripts that have that pattern in their path will be excluded.")
arguments = parser.parse_args()
# for future use, probably
def parse_palette_name() -> str:
return "catppuccin-mocha"
def parse_flavor(palette: dict[str, str]) -> str:
if arguments.flavor:
if arguments.flavor not in palette:
print(f"Unknown flavor: {arguments.flavor}. Available flavors: {', '.join(palette.keys())}")
sys.exit(1)
flavor = arguments.flavor
elif arguments.color:
flavor = match_color(arguments.color, palette)
print(f"Matched color: {flavor}")
elif arguments.image:
if not Path(arguments.image).exists():
print(f"Image file {arguments.image} does not exist.")
sys.exit(1)
color = extract_color(arguments.image)
print(f"Extracted color {color} from image {arguments.image}")
flavor = match_color(color, palette)
print(f"Extracted color: {flavor}")
else:
flavor = pick_flavor(palette)
return flavor
def parse_apps() -> tuple[set[str], set[str]]:
includes = set()
excludes = set()
for arg in arguments.arguments:
if arg.startswith('!'):
excludes.add(arg[1:])
else:
includes.add(arg)
return includes, excludes
palette_name = parse_palette_name()
palette = PALETTES[palette_name]
flavor = parse_flavor(palette)
includes, excludes = parse_apps()
scripts = get_script_list()
filteredScripts = []
if includes:
print(f"Including only: {', '.join(includes)}")
# for script in scripts:
# for include in includes:
# if include in str(script):
# filteredScripts.append(script)
# break
filteredScripts = [
script for script in scripts
if any(include in str(script) for include in includes)
]
else:
filteredScripts = scripts
if excludes:
print(f"Excluding: {', '.join(excludes)}")
filteredScripts = [
script for script in filteredScripts
if not any(exclude in str(script) for exclude in excludes)
]
print(f"Applying flavor '{flavor}' using {len(filteredScripts)} scripts")
for script in filteredScripts:
print(f"Running script: {script}")
os.system(f'"{script}" {palette_name} {flavor} {palette[flavor]}')
print("")
os.system(
f'notify-send -a "change-colortheme" "Colortheme Changed" "Palette: {palette_name};\nFlavor: {flavor};\nApplied to {len(filteredScripts)} applications."')
if __name__ == "__main__":
main()