diff options
Diffstat (limited to 'src/caelestia/utils')
| -rw-r--r-- | src/caelestia/utils/hypr.py | 29 | ||||
| -rw-r--r-- | src/caelestia/utils/material/__init__.py | 52 | ||||
| -rw-r--r-- | src/caelestia/utils/material/generator.py | 194 | ||||
| -rw-r--r-- | src/caelestia/utils/material/score.py | 132 | ||||
| -rw-r--r-- | src/caelestia/utils/paths.py | 53 | ||||
| -rw-r--r-- | src/caelestia/utils/scheme.py | 224 | ||||
| -rw-r--r-- | src/caelestia/utils/theme.py | 122 | ||||
| -rw-r--r-- | src/caelestia/utils/wallpaper.py | 139 |
8 files changed, 945 insertions, 0 deletions
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/src/caelestia/utils/material/score.py b/src/caelestia/utils/material/score.py new file mode 100644 index 0000000..7765050 --- /dev/null +++ b/src/caelestia/utils/material/score.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python + +import sys + +from materialyoucolor.dislike.dislike_analyzer import DislikeAnalyzer +from materialyoucolor.hct import Hct +from materialyoucolor.quantize import ImageQuantizeCelebi +from materialyoucolor.utils.math_utils import difference_degrees, sanitize_degrees_int + + +class Score: + TARGET_CHROMA = 48.0 + WEIGHT_PROPORTION = 0.7 + WEIGHT_CHROMA_ABOVE = 0.3 + WEIGHT_CHROMA_BELOW = 0.1 + CUTOFF_CHROMA = 5.0 + CUTOFF_EXCITED_PROPORTION = 0.01 + + def __init__(self): + pass + + @staticmethod + def score(colors_to_population: dict, filter_enabled: bool = True) -> tuple[list[Hct], list[Hct]]: + desired = 14 + dislike_filter = True + + colors_hct = [] + hue_population = [0] * 360 + population_sum = 0 + + for rgb, population in colors_to_population.items(): + hct = Hct.from_int(rgb) + colors_hct.append(hct) + hue = int(hct.hue) + hue_population[hue] += population + population_sum += population + + hue_excited_proportions = [0.0] * 360 + + for hue in range(360): + proportion = hue_population[hue] / population_sum + for i in range(hue - 14, hue + 16): + neighbor_hue = int(sanitize_degrees_int(i)) + hue_excited_proportions[neighbor_hue] += proportion + + # Score colours + scored_hct = [] + for hct in colors_hct: + 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): + 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_score = (hct.chroma - Score.TARGET_CHROMA) * chroma_weight + score = proportion_score + chroma_score + scored_hct.append({"hct": hct, "score": score}) + + scored_hct.sort(key=lambda x: x["score"], reverse=True) + + # Choose distinct colours + chosen_colors = [] + for difference_degrees_ in range(90, 0, -1): + chosen_colors.clear() + 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 + ) + if not duplicate_hue: + chosen_colors.append(hct) + if len(chosen_colors) >= desired: + break + if len(chosen_colors) >= desired: + break + + # Get primary colour + primary = None + for cutoff in range(20, 0, -1): + for item in scored_hct: + if item["hct"].chroma > cutoff and item["hct"].tone > cutoff * 3: + primary = item["hct"] + break + if primary: + break + + # Choose distinct primaries + chosen_primaries = [primary] + for difference_degrees_ in range(90, 14, -1): + chosen_primaries = [primary] + 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 + ) + if not duplicate_hue: + chosen_primaries.append(hct) + if len(chosen_primaries) >= 3: + break + if len(chosen_primaries) >= 3: + break + + # Fix disliked colours + if dislike_filter: + for i, chosen_hct in enumerate(chosen_primaries): + chosen_primaries[i] = DislikeAnalyzer.fix_if_disliked(chosen_hct) + 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 t in colours: + if mode != "hex": + 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 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) |