summaryrefslogtreecommitdiff
path: root/src/caelestia/utils
diff options
context:
space:
mode:
Diffstat (limited to 'src/caelestia/utils')
-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--src/caelestia/utils/material/score.py132
-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
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)