From dc855e1b01e9b4526927b8bd69daca733afd97c2 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Wed, 11 Jun 2025 17:37:04 +1000 Subject: internal: refactor scheme Also use a single file to store scheme data --- src/caelestia/utils/paths.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/caelestia/utils/paths.py (limited to 'src/caelestia/utils/paths.py') diff --git a/src/caelestia/utils/paths.py b/src/caelestia/utils/paths.py new file mode 100644 index 0000000..aff6ea0 --- /dev/null +++ b/src/caelestia/utils/paths.py @@ -0,0 +1,13 @@ +import os +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")) + +c_config_dir = config_dir / "caelestia" +c_data_dir = data_dir / "caelestia" +c_state_dir = state_dir / "caelestia" + +scheme_path = c_state_dir / "scheme.json" +scheme_data_path = Path(__file__).parent.parent / "data/schemes" -- cgit v1.2.3-freya From 6f7beecdc6de14cf1fd6be9038a86528d2ba52f0 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Wed, 11 Jun 2025 21:42:13 +1000 Subject: feat: theme discord --- src/caelestia/data/config.json | 51 --------- src/caelestia/data/templates/discord.scss | 174 ++++++++++++++++++++++++++++++ src/caelestia/utils/paths.py | 5 +- src/caelestia/utils/theme.py | 15 ++- 4 files changed, 191 insertions(+), 54 deletions(-) delete mode 100644 src/caelestia/data/config.json create mode 100644 src/caelestia/data/templates/discord.scss (limited to 'src/caelestia/utils/paths.py') diff --git a/src/caelestia/data/config.json b/src/caelestia/data/config.json deleted file mode 100644 index 47f61e5..0000000 --- a/src/caelestia/data/config.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "toggles": { - "communication": { - "apps": [ - { - "selector": ".class == \"discord\"", - "spawn": "discord", - "action": "spawn move" - }, - { - "selector": ".class == \"whatsapp\"", - "spawn": "firefox --name whatsapp -P whatsapp 'https://web.whatsapp.com'", - "action": "move", - "extraCond": "grep -q 'Name=whatsapp' ~/.mozilla/firefox/profiles.ini" - } - ] - }, - "music": { - "apps": [ - { - "selector": ".class == \"Spotify\" or .initialTitle == \"Spotify\" or .initialTitle == \"Spotify Free\"", - "spawn": "spicetify watch -s", - "action": "spawn move" - }, - { - "selector": ".class == \"feishin\"", - "spawn": "feishin", - "action": "move" - } - ] - }, - "sysmon": { - "apps": [ - { - "selector": ".class == \"btop\" and .title == \"btop\" and .workspace.name == \"special:sysmon\"", - "spawn": "foot -a 'btop' -T 'btop' -- btop", - "action": "spawn" - } - ] - }, - "todo": { - "apps": [ - { - "selector": ".class == \"Todoist\"", - "spawn": "todoist", - "action": "spawn move" - } - ] - } - } -} 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/utils/paths.py b/src/caelestia/utils/paths.py index aff6ea0..dfb57d9 100644 --- a/src/caelestia/utils/paths.py +++ b/src/caelestia/utils/paths.py @@ -9,5 +9,8 @@ c_config_dir = config_dir / "caelestia" c_data_dir = data_dir / "caelestia" c_state_dir = state_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_path = Path(__file__).parent.parent / "data/schemes" +scheme_data_path = cli_data_dir / "schemes" diff --git a/src/caelestia/utils/theme.py b/src/caelestia/utils/theme.py index 7774472..7940547 100644 --- a/src/caelestia/utils/theme.py +++ b/src/caelestia/utils/theme.py @@ -1,8 +1,9 @@ import json import subprocess +import tempfile from pathlib import Path -from caelestia.utils.paths import config_dir +from caelestia.utils.paths import config_dir, templates_dir def gen_conf(colours: dict[str, str]) -> str: @@ -15,7 +16,7 @@ def gen_conf(colours: dict[str, str]) -> str: def gen_scss(colours: dict[str, str]) -> str: scss = "" for name, colour in colours.items(): - scss += f"${name}: {colour};\n" + scss += f"${name}: #{colour};\n" return scss @@ -81,6 +82,16 @@ 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_colours(colours: dict[str, str]) -> None: apply_terms(gen_sequences(colours)) apply_hypr(gen_conf(colours)) + apply_discord(gen_scss(colours)) -- cgit v1.2.3-freya From 194826efaa95480bfe1799f889ca5c02571b3e36 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Thu, 12 Jun 2025 15:48:02 +1000 Subject: feat: generate dynamic schemes --- src/caelestia/utils/material/__init__.py | 52 ++++++++ src/caelestia/utils/material/generator.py | 192 ++++++++++++++++++++++++++++++ src/caelestia/utils/material/score.py | 129 ++++++++++++++++++++ src/caelestia/utils/paths.py | 19 ++- src/caelestia/utils/scheme.py | 19 ++- src/caelestia/utils/theme.py | 2 +- 6 files changed, 405 insertions(+), 8 deletions(-) create mode 100644 src/caelestia/utils/material/__init__.py create mode 100755 src/caelestia/utils/material/generator.py create mode 100755 src/caelestia/utils/material/score.py (limited to 'src/caelestia/utils/paths.py') 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 100755 index 0000000..33ff0e8 --- /dev/null +++ b/src/caelestia/utils/material/generator.py @@ -0,0 +1,192 @@ +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) -> None: + colour.chroma = 0 + + +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 / 2, colour.tone + diff) + + +def darken(colour: Hct, amount: float) -> Hct: + diff = colour.tone * amount + return Hct.from_hct(colour.hue, colour.chroma + diff / 2, 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": + 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 100755 index 0000000..da8b062 --- /dev/null +++ b/src/caelestia/utils/material/score.py @@ -0,0 +1,129 @@ +#!/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) -> tuple[list[Hct], list[Hct]]: + desired = 14 + filter_enabled = True + 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) + + 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 index dfb57d9..6a6e0a8 100644 --- a/src/caelestia/utils/paths.py +++ b/src/caelestia/utils/paths.py @@ -1,16 +1,33 @@ +import hashlib import os 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_path = cli_data_dir / "schemes" +scheme_data_dir = cli_data_dir / "schemes" +scheme_cache_dir = c_cache_dir / "schemes" + +last_wallpaper_path = c_state_dir / "wallpaper/last.txt" +wallpaper_thumbnail_path = c_state_dir / "wallpaper/thumbnail.jpg" + + +def compute_hash(path: str) -> str: + sha = hashlib.sha256() + + with open(path, "rb") as f: + while chunk := f.read(8192): + sha.update(chunk) + + return sha.hexdigest() diff --git a/src/caelestia/utils/scheme.py b/src/caelestia/utils/scheme.py index c26d2f8..66cd697 100644 --- a/src/caelestia/utils/scheme.py +++ b/src/caelestia/utils/scheme.py @@ -1,7 +1,8 @@ import json from pathlib import Path -from caelestia.utils.paths import scheme_data_path, scheme_path +from caelestia.utils.material import get_colours_for_image +from caelestia.utils.paths import scheme_data_dir, scheme_path class Scheme: @@ -89,7 +90,7 @@ class Scheme: return self._colours def get_colours_path(self) -> Path: - return (scheme_data_path / self.name / self.flavour / self.mode).with_suffix(".txt") + 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) @@ -118,7 +119,10 @@ class Scheme: self._mode = get_scheme_modes()[0] def _update_colours(self) -> None: - self._colours = read_colours_from_file(self.get_colours_path()) + 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"Scheme(name={self.name}, flavour={self.flavour}, mode={self.mode}, variant={self.variant})" @@ -168,7 +172,7 @@ def get_scheme_names() -> list[str]: global scheme_names if scheme_names is None: - scheme_names = [f.name for f in scheme_data_path.iterdir() if f.is_dir()] + scheme_names = [f.name for f in scheme_data_dir.iterdir() if f.is_dir()] scheme_names.append("dynamic") return scheme_names @@ -182,7 +186,7 @@ def get_scheme_flavours() -> list[str]: if name == "dynamic": scheme_flavours = ["default", "alt1", "alt2"] else: - scheme_flavours = [f.name for f in (scheme_data_path / name).iterdir() if f.is_dir()] + scheme_flavours = [f.name for f in (scheme_data_dir / name).iterdir() if f.is_dir()] return scheme_flavours @@ -192,6 +196,9 @@ def get_scheme_modes() -> list[str]: if scheme_modes is None: scheme = get_scheme() - scheme_modes = [f.stem for f in (scheme_data_path / scheme.name / scheme.flavour).iterdir() if f.is_file()] + 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 index 3250ea3..d205fb1 100644 --- a/src/caelestia/utils/theme.py +++ b/src/caelestia/utils/theme.py @@ -115,7 +115,7 @@ def apply_btop(colours: dict[str, str]) -> None: def apply_colours(colours: dict[str, str], mode: str) -> None: apply_terms(gen_sequences(colours)) - apply_hypr(gen_conf(colours)) + apply_hypr(gen_conf(colours)) # FIXME: LAGGY apply_discord(gen_scss(colours)) apply_spicetify(colours, mode) apply_fuzzel(colours) -- cgit v1.2.3-freya From a53a2568ec6e4e53d32a48443f50eee9d9fb8fcd Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Thu, 12 Jun 2025 16:49:01 +1000 Subject: scheme: fix not saving atomically Causes programs which rely on the save file (e.g. the shell) to fail occasionally as they try to read while the cli is writing --- src/caelestia/utils/paths.py | 10 ++++++++++ src/caelestia/utils/scheme.py | 23 +++++++++++------------ 2 files changed, 21 insertions(+), 12 deletions(-) (limited to 'src/caelestia/utils/paths.py') diff --git a/src/caelestia/utils/paths.py b/src/caelestia/utils/paths.py index 6a6e0a8..3b5a7a6 100644 --- a/src/caelestia/utils/paths.py +++ b/src/caelestia/utils/paths.py @@ -1,5 +1,8 @@ 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")) @@ -31,3 +34,10 @@ def compute_hash(path: str) -> str: 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 index c978231..9027589 100644 --- a/src/caelestia/utils/scheme.py +++ b/src/caelestia/utils/scheme.py @@ -3,7 +3,7 @@ import random from pathlib import Path from caelestia.utils.material import get_colours_for_image -from caelestia.utils.paths import scheme_data_dir, scheme_path +from caelestia.utils.paths import atomic_dump, scheme_data_dir, scheme_path class Scheme: @@ -100,17 +100,16 @@ class Scheme: def save(self) -> None: scheme_path.parent.mkdir(parents=True, exist_ok=True) - with scheme_path.open("w") as f: - json.dump( - { - "name": self.name, - "flavour": self.flavour, - "mode": self.mode, - "variant": self.variant, - "colours": self.colours, - }, - f, - ) + 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()) -- cgit v1.2.3-freya From c043a14ca24f70e81b69133350a1174d2e6572fc Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Thu, 12 Jun 2025 21:35:05 +1000 Subject: feat: impl wallpaper --- src/caelestia/parser.py | 12 +++- src/caelestia/subcommands/wallpaper.py | 12 +++- src/caelestia/utils/paths.py | 7 +- src/caelestia/utils/scheme.py | 12 ++-- src/caelestia/utils/wallpaper.py | 123 +++++++++++++++++++++++++++++++++ 5 files changed, 154 insertions(+), 12 deletions(-) create mode 100644 src/caelestia/utils/wallpaper.py (limited to 'src/caelestia/utils/paths.py') diff --git a/src/caelestia/parser.py b/src/caelestia/parser.py index 3f6f506..f8c7bac 100644 --- a/src/caelestia/parser.py +++ b/src/caelestia/parser.py @@ -1,7 +1,9 @@ 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): @@ -81,14 +83,18 @@ def parse_args() -> (argparse.ArgumentParser, argparse.Namespace): # 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("-g", "--get", action="store_true", help="print the current wallpaper") - wallpaper_parser.add_argument("-r", "--random", action="store_true", help="switch to a random wallpaper") + 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=80, + 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( diff --git a/src/caelestia/subcommands/wallpaper.py b/src/caelestia/subcommands/wallpaper.py index 37f9a2b..1440484 100644 --- a/src/caelestia/subcommands/wallpaper.py +++ b/src/caelestia/subcommands/wallpaper.py @@ -1,5 +1,8 @@ +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 @@ -8,4 +11,11 @@ class Command: self.args = args def run(self) -> None: - pass + 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()) diff --git a/src/caelestia/utils/paths.py b/src/caelestia/utils/paths.py index 3b5a7a6..f81b996 100644 --- a/src/caelestia/utils/paths.py +++ b/src/caelestia/utils/paths.py @@ -22,11 +22,14 @@ scheme_path = c_state_dir / "scheme.json" scheme_data_dir = cli_data_dir / "schemes" scheme_cache_dir = c_cache_dir / "schemes" -last_wallpaper_path = c_state_dir / "wallpaper/last.txt" +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" +thumbnail_cache_dir = c_cache_dir / "thumbnails" -def compute_hash(path: str) -> str: +def compute_hash(path: Path | str) -> str: sha = hashlib.sha256() with open(path, "rb") as f: diff --git a/src/caelestia/utils/scheme.py b/src/caelestia/utils/scheme.py index 9027589..0d6cfb5 100644 --- a/src/caelestia/utils/scheme.py +++ b/src/caelestia/utils/scheme.py @@ -59,8 +59,7 @@ class Scheme: self._flavour = flavour self._check_mode() - self._update_colours() - self.save() + self.update_colours() @property def mode(self) -> str: @@ -75,8 +74,7 @@ class Scheme: raise ValueError(f'Invalid scheme mode: "{mode}". Valid modes: {get_scheme_modes()}') self._mode = mode - self._update_colours() - self.save() + self.update_colours() @property def variant(self) -> str: @@ -88,8 +86,7 @@ class Scheme: return self._variant = variant - self._update_colours() - self.save() + self.update_colours() @property def colours(self) -> dict[str, str]: @@ -115,6 +112,9 @@ class Scheme: 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() diff --git a/src/caelestia/utils/wallpaper.py b/src/caelestia/utils/wallpaper.py new file mode 100644 index 0000000..c8b3a72 --- /dev/null +++ b/src/caelestia/utils/wallpaper.py @@ -0,0 +1,123 @@ +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, + thumbnail_cache_dir, + wallpaper_link_path, + wallpaper_path_path, + wallpaper_thumbnail_path, +) +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: + return wallpaper_path_path.read_text() + + +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) -> Path: + thumb = (thumbnail_cache_dir / compute_hash(wall)).with_suffix(".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) -> str: + with Image.open(get_thumb(wall)) as img: + img.thumbnail((1, 1), Image.LANCZOS) + tone = Hct.from_int(argb_from_rgb(*img.getpixel((0, 0)))).tone + return "light" if tone > 60 else "dark" + + +def get_colours_for_wall(wall: Path | str, no_smart: bool) -> None: + scheme = get_scheme() + + if not no_smart: + scheme = Scheme( + { + "name": scheme.name, + "flavour": scheme.flavour, + "mode": get_smart_mode(wall), + "variant": scheme.variant, + "colours": scheme.colours, + } + ) + + return get_colours_for_image(get_thumb(wall), 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) + + # Generate thumbnail or get from cache + thumb = get_thumb(wall) + 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 + scheme.mode = get_smart_mode(wall) + + # 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) -- cgit v1.2.3-freya From a97de9d430b8a0f900928f5cad33385deec3f659 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Thu, 12 Jun 2025 21:51:59 +1000 Subject: wallpaper: cache smart mode --- src/caelestia/utils/paths.py | 2 +- src/caelestia/utils/wallpaper.py | 35 ++++++++++++++++++++++++----------- 2 files changed, 25 insertions(+), 12 deletions(-) (limited to 'src/caelestia/utils/paths.py') diff --git a/src/caelestia/utils/paths.py b/src/caelestia/utils/paths.py index f81b996..37aeeef 100644 --- a/src/caelestia/utils/paths.py +++ b/src/caelestia/utils/paths.py @@ -26,7 +26,7 @@ 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" -thumbnail_cache_dir = c_cache_dir / "thumbnails" +wallpapers_cache_dir = c_cache_dir / "wallpapers" def compute_hash(path: Path | str) -> str: diff --git a/src/caelestia/utils/wallpaper.py b/src/caelestia/utils/wallpaper.py index c8b3a72..1146c73 100644 --- a/src/caelestia/utils/wallpaper.py +++ b/src/caelestia/utils/wallpaper.py @@ -10,10 +10,10 @@ from caelestia.utils.hypr import message from caelestia.utils.material import get_colours_for_image from caelestia.utils.paths import ( compute_hash, - thumbnail_cache_dir, 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 @@ -55,8 +55,8 @@ def get_wallpapers(args: Namespace) -> list[Path]: return [f for f in walls if check_wall(f, filter_size, args.threshold)] -def get_thumb(wall: Path) -> Path: - thumb = (thumbnail_cache_dir / compute_hash(wall)).with_suffix(".jpg") +def get_thumb(wall: Path, cache: Path) -> Path: + thumb = cache / "thumbnail.jpg" if not thumb.exists(): with Image.open(wall) as img: @@ -68,28 +68,38 @@ def get_thumb(wall: Path) -> Path: return thumb -def get_smart_mode(wall: Path) -> str: - with Image.open(get_thumb(wall)) as img: +def get_smart_mode(wall: Path, cache: Path) -> str: + mode_cache = cache / "mode.txt" + + if mode_cache.exists(): + return mode_cache.read_text() + + with Image.open(get_thumb(wall, cache)) as img: img.thumbnail((1, 1), Image.LANCZOS) - tone = Hct.from_int(argb_from_rgb(*img.getpixel((0, 0)))).tone - return "light" if tone > 60 else "dark" + 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), + "mode": get_smart_mode(wall, cache), "variant": scheme.variant, "colours": scheme.colours, } ) - return get_colours_for_image(get_thumb(wall), scheme) + return get_colours_for_image(get_thumb(wall, cache), scheme) def set_wallpaper(wall: Path | str, no_smart: bool) -> None: @@ -103,8 +113,10 @@ def set_wallpaper(wall: Path | str, no_smart: bool) -> None: wallpaper_link_path.unlink(missing_ok=True) wallpaper_link_path.symlink_to(wall) + cache = wallpapers_cache_dir / compute_hash(wall) + # Generate thumbnail or get from cache - thumb = get_thumb(wall) + 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) @@ -112,7 +124,8 @@ def set_wallpaper(wall: Path | str, no_smart: bool) -> None: scheme = get_scheme() # Change mode based on wallpaper colour - scheme.mode = get_smart_mode(wall) + if not no_smart: + scheme.mode = get_smart_mode(wall, cache) # Update colours scheme.update_colours() -- cgit v1.2.3-freya From 796d538b168855ebd2afd62278800151816adcab Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Fri, 13 Jun 2025 00:42:46 +1000 Subject: feat: impl screenshot command --- src/caelestia/parser.py | 2 +- src/caelestia/subcommands/screenshot.py | 69 ++++++++++++++++++++++++++++++++- src/caelestia/subcommands/toggle.py | 14 +------ src/caelestia/utils/paths.py | 3 ++ 4 files changed, 74 insertions(+), 14 deletions(-) (limited to 'src/caelestia/utils/paths.py') diff --git a/src/caelestia/parser.py b/src/caelestia/parser.py index f8c7bac..02ac3b9 100644 --- a/src/caelestia/parser.py +++ b/src/caelestia/parser.py @@ -60,7 +60,7 @@ def parse_args() -> (argparse.ArgumentParser, argparse.Namespace): # 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", help="take a screenshot of a region") + 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" ) diff --git a/src/caelestia/subcommands/screenshot.py b/src/caelestia/subcommands/screenshot.py index 37f9a2b..73d65f7 100644 --- a/src/caelestia/subcommands/screenshot.py +++ b/src/caelestia/subcommands/screenshot.py @@ -1,4 +1,9 @@ +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: @@ -8,4 +13,66 @@ class Command: self.args = args def run(self) -> None: - pass + 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/toggle.py b/src/caelestia/subcommands/toggle.py index fd49c30..b8ad11b 100644 --- a/src/caelestia/subcommands/toggle.py +++ b/src/caelestia/subcommands/toggle.py @@ -1,3 +1,4 @@ +import subprocess from argparse import Namespace from caelestia.utils import hypr @@ -6,7 +7,6 @@ from caelestia.utils import hypr class Command: args: Namespace clients: list[dict[str, any]] = None - app2unit: str = None def __init__(self, args: Namespace) -> None: self.args = args @@ -20,14 +20,6 @@ class Command: return self.clients - def get_app2unit(self) -> str: - if self.app2unit is None: - import shutil - - self.app2unit = shutil.which("app2unit") - - return self.app2unit - def move_client(self, selector: callable, workspace: str) -> None: for client in self.get_clients(): if selector(client): @@ -37,9 +29,7 @@ class Command: exists = any(selector(client) for client in self.get_clients()) if not exists: - import subprocess - - subprocess.Popen([self.get_app2unit(), "--", *spawn], start_new_session=True) + subprocess.Popen(["app2unit", "--", *spawn], start_new_session=True) return not exists diff --git a/src/caelestia/utils/paths.py b/src/caelestia/utils/paths.py index 37aeeef..6a98dae 100644 --- a/src/caelestia/utils/paths.py +++ b/src/caelestia/utils/paths.py @@ -28,6 +28,9 @@ 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" + def compute_hash(path: Path | str) -> str: sha = hashlib.sha256() -- cgit v1.2.3-freya From b805f8d67725352817d248562683cb90bf314401 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Sat, 14 Jun 2025 02:11:10 +1000 Subject: feat: impl recording subcommand --- src/caelestia/parser.py | 2 +- src/caelestia/subcommands/record.py | 113 +++++++++++++++++++++++++++++++++++- src/caelestia/utils/paths.py | 4 ++ 3 files changed, 117 insertions(+), 2 deletions(-) (limited to 'src/caelestia/utils/paths.py') diff --git a/src/caelestia/parser.py b/src/caelestia/parser.py index 02ac3b9..6d0b552 100644 --- a/src/caelestia/parser.py +++ b/src/caelestia/parser.py @@ -68,7 +68,7 @@ def parse_args() -> (argparse.ArgumentParser, argparse.Namespace): # 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", action="store_true", help="record a region") + 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 diff --git a/src/caelestia/subcommands/record.py b/src/caelestia/subcommands/record.py index 37f9a2b..a4fa51d 100644 --- a/src/caelestia/subcommands/record.py +++ b/src/caelestia/subcommands/record.py @@ -1,4 +1,9 @@ +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: @@ -8,4 +13,110 @@ class Command: self.args = args def run(self) -> None: - pass + 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/utils/paths.py b/src/caelestia/utils/paths.py index 6a98dae..a4ef36f 100644 --- a/src/caelestia/utils/paths.py +++ b/src/caelestia/utils/paths.py @@ -31,6 +31,10 @@ 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() -- cgit v1.2.3-freya