diff --git a/dot_config/awww-manager/config.toml.tmpl b/dot_config/awww-manager/config.toml.tmpl new file mode 100644 index 0000000..8d55c95 --- /dev/null +++ b/dot_config/awww-manager/config.toml.tmpl @@ -0,0 +1,30 @@ +[general] +backend = "swww" +# backend_cmd = "/usr/bin/swww" +# resize = "crop" +# filter = "Lanczos3" +# transition_type = "simple" +# transition_step = 90 +# transition_fps = 30 +# transition_duration = 3 +# fill_color = "000000ff" + +[[output]] +name = "DP-1" +mode = "cycle" +dir = "~/.config/wallpaper/dp-1" +extensions = ["png", "jpg", "jpeg", "gif", "webp"] +# Optional per-output overrides: +# resize = "crop" +# filter = "Lanczos3" +# transition_type = "simple" + +[[output]] +name = "DP-2" +mode = "cycle" +dir = "~/.config/wallpaper/dp-2" +extensions = ["png", "jpg", "jpeg", "gif", "webp"] +# Optional per-output overrides: +# resize = "crop" +# filter = "Lanczos3" +# transition_type = "simple" diff --git a/dot_config/hypr/hyprlock.conf.tmpl b/dot_config/hypr/hyprlock.conf.tmpl index 97c08c6..d989488 100644 --- a/dot_config/hypr/hyprlock.conf.tmpl +++ b/dot_config/hypr/hyprlock.conf.tmpl @@ -1,13 +1,6 @@ -{{- $lock_bg := "" -}} -{{- with (index .chezmoi.config.data "lockscreen-wallpaper") -}} - {{- $lock_bg = . -}} -{{- end -}} -{{- if eq $lock_bg "" -}} - {{- range .monitors -}} - {{- if index . "primary" -}} - {{- $lock_bg = .wallpaper -}} - {{- end -}} - {{- end -}} +{{- $lock_bg := (index .chezmoi.config.data "lockscreen-wallpaper") -}} +{{- if not $lock_bg -}} + {{- $lock_bg = "~/.config/wallpaper/lockscreen/current" -}} {{- end -}} general { hide_cursor = true diff --git a/dot_config/hypr/hyprpaper.conf.tmpl b/dot_config/hypr/hyprpaper.conf.tmpl index 2e0630c..063ab81 100644 --- a/dot_config/hypr/hyprpaper.conf.tmpl +++ b/dot_config/hypr/hyprpaper.conf.tmpl @@ -3,10 +3,8 @@ splash_offset = 2.0 ipc = true {{- range .monitors }} - {{- if .wallpaper }} wallpaper { monitor = {{ .name }} - path = {{ .wallpaper }} + path = ~/.config/wallpaper/{{ .name | lower }}/current } - {{- end }} {{- end }} diff --git a/dot_config/systemd/user/awww-manager-random.service b/dot_config/systemd/user/awww-manager-random.service new file mode 100644 index 0000000..5ab9c4e --- /dev/null +++ b/dot_config/systemd/user/awww-manager-random.service @@ -0,0 +1,8 @@ +[Unit] +Description=Randomize wallpapers via awww-manager +After=graphical-session.target swww.service +Wants=swww.service + +[Service] +Type=oneshot +ExecStart=%h/.local/bin/awww-manager.py random diff --git a/dot_config/systemd/user/awww-manager-random.timer b/dot_config/systemd/user/awww-manager-random.timer new file mode 100644 index 0000000..41e3611 --- /dev/null +++ b/dot_config/systemd/user/awww-manager-random.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Randomize wallpapers every 60 minutes + +[Timer] +OnBootSec=5min +OnUnitActiveSec=60min +AccuracySec=1min +Persistent=true +Unit=awww-manager-random.service + +[Install] +WantedBy=timers.target diff --git a/dot_config/systemd/user/swww.service b/dot_config/systemd/user/swww.service index 198c105..728c802 100644 --- a/dot_config/systemd/user/swww.service +++ b/dot_config/systemd/user/swww.service @@ -6,7 +6,7 @@ After=graphical-session.target [Service] Type=simple ExecStart=/usr/bin/swww-daemon -ExecStartPost=%h/.local/bin/set-wallpapers +ExecStartPost=%h/.local/bin/awww-manager.py apply Restart=on-failure RestartSec=1 diff --git a/dot_config/wallpaper/dp-1/Generated image 1.png b/dot_config/wallpaper/dp-1/Generated image 1.png new file mode 100644 index 0000000..68d76be Binary files /dev/null and b/dot_config/wallpaper/dp-1/Generated image 1.png differ diff --git a/dot_config/wallpaper/dp-1/girl_apex-neon.jpeg b/dot_config/wallpaper/dp-1/girl_apex-neon.jpeg new file mode 100644 index 0000000..a7bfb45 Binary files /dev/null and b/dot_config/wallpaper/dp-1/girl_apex-neon.jpeg differ diff --git a/dot_config/wallpaper/dp-1/symlink_current b/dot_config/wallpaper/dp-1/symlink_current new file mode 100644 index 0000000..6fb1f68 --- /dev/null +++ b/dot_config/wallpaper/dp-1/symlink_current @@ -0,0 +1 @@ +girl_apex-neon.jpeg diff --git a/dot_config/wallpaper/dp-2/dark_sorc_upscaled.jpg b/dot_config/wallpaper/dp-2/dark_sorc_upscaled.jpg new file mode 100644 index 0000000..4e541f1 Binary files /dev/null and b/dot_config/wallpaper/dp-2/dark_sorc_upscaled.jpg differ diff --git a/dot_config/wallpaper/dp-2/neon-rooftop.png b/dot_config/wallpaper/dp-2/neon-rooftop.png new file mode 100644 index 0000000..f830810 Binary files /dev/null and b/dot_config/wallpaper/dp-2/neon-rooftop.png differ diff --git a/dot_config/wallpaper/dp-2/neon-window-skyline.png b/dot_config/wallpaper/dp-2/neon-window-skyline.png new file mode 100644 index 0000000..8f89de0 Binary files /dev/null and b/dot_config/wallpaper/dp-2/neon-window-skyline.png differ diff --git a/dot_config/wallpaper/dp-2/symlink_current b/dot_config/wallpaper/dp-2/symlink_current new file mode 100644 index 0000000..aaa1fb7 --- /dev/null +++ b/dot_config/wallpaper/dp-2/symlink_current @@ -0,0 +1 @@ +neon-rooftop.png diff --git a/dot_config/wallpaper/lockscreen/dark_cyber_sorc.jpeg b/dot_config/wallpaper/lockscreen/dark_cyber_sorc.jpeg new file mode 100644 index 0000000..e9a4047 Binary files /dev/null and b/dot_config/wallpaper/lockscreen/dark_cyber_sorc.jpeg differ diff --git a/dot_config/wallpaper/lockscreen/symlink_current b/dot_config/wallpaper/lockscreen/symlink_current new file mode 100644 index 0000000..0ec1c4b --- /dev/null +++ b/dot_config/wallpaper/lockscreen/symlink_current @@ -0,0 +1 @@ +dark_cyber_sorc.jpeg diff --git a/dot_local/bin/executable_awww-manager.py b/dot_local/bin/executable_awww-manager.py new file mode 100644 index 0000000..fb01294 --- /dev/null +++ b/dot_local/bin/executable_awww-manager.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +import random +import shlex +import shutil +import subprocess +import sys +import time + +__version__ = "0.1.0" + + +def _load_toml(path): + try: + import toml + except ImportError: + print("toml not available; install python-toml", file=sys.stderr) + raise SystemExit(2) + + try: + with open(path, "r", encoding="utf-8") as handle: + return toml.load(handle) + except FileNotFoundError: + print(f"config not found: {path}", file=sys.stderr) + raise SystemExit(2) + + +def _config_path(explicit): + if explicit: + return explicit + if "AWWW_MANAGER_CONFIG" in os.environ: + return os.environ["AWWW_MANAGER_CONFIG"] + config_home = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) + return os.path.join(config_home, "awww-manager", "config.toml") + + +def _state_path(): + state_home = os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")) + return os.path.join(state_home, "awww-manager", "state.json") + + +def _load_state(): + path = _state_path() + try: + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + except FileNotFoundError: + return {} + except json.JSONDecodeError: + print(f"state file is invalid JSON: {path}", file=sys.stderr) + return {} + + +def _save_state(state): + path = _state_path() + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as handle: + json.dump(state, handle) + + +def _expand_path(value): + if not isinstance(value, str): + return value + return os.path.expandvars(os.path.expanduser(value)) + + +def _merged_settings(general, output): + merged = dict(general) + merged.update(output) + return merged + + +def _quote_cmd(cmd): + return " ".join(shlex.quote(part) for part in cmd) + + +def _run(cmd, dry_run): + if dry_run: + print(_quote_cmd(cmd)) + return + subprocess.run(cmd, check=True) + + +def _wait_for_backend(cmd, timeout, interval): + deadline = time.time() + timeout + while time.time() < deadline: + result = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if result.returncode == 0: + return True + time.sleep(interval) + return False + + +def _build_img_command(base_cmd, settings, wallpaper): + cmd = [base_cmd, "img"] + + output_name = settings.get("name") + if output_name: + cmd.extend(["-o", str(output_name)]) + + resize = settings.get("resize") + if resize: + cmd.extend(["--resize", str(resize)]) + + image_filter = settings.get("filter") + if image_filter: + cmd.extend(["--filter", str(image_filter)]) + + transition_type = settings.get("transition_type") + if transition_type: + cmd.extend(["--transition-type", str(transition_type)]) + + transition_step = settings.get("transition_step") + if transition_step is not None: + cmd.extend(["--transition-step", str(transition_step)]) + + transition_fps = settings.get("transition_fps") + if transition_fps is not None: + cmd.extend(["--transition-fps", str(transition_fps)]) + + transition_duration = settings.get("transition_duration") + if transition_duration is not None: + cmd.extend(["--transition-duration", str(transition_duration)]) + + transition_angle = settings.get("transition_angle") + if transition_angle is not None: + cmd.extend(["--transition-angle", str(transition_angle)]) + + transition_pos = settings.get("transition_pos") + if transition_pos: + cmd.extend(["--transition-pos", str(transition_pos)]) + + fill_color = settings.get("fill_color") + if fill_color: + cmd.extend(["--fill-color", str(fill_color)]) + + cmd.append(_expand_path(wallpaper)) + return cmd + + +def _ensure_backend_available(base_cmd): + if not shutil.which(base_cmd): + print(f"backend not found in PATH: {base_cmd}", file=sys.stderr) + raise SystemExit(2) + + +def _output_mode(output): + mode = output.get("mode") + if mode: + return str(mode).lower() + if output.get("dir"): + return "cycle" + return "static" + + +def _output_key(output, index): + return output.get("name") or f"output-{index}" + + +def _collect_images(output): + dir_path = _expand_path(output.get("dir", "")) + if not dir_path: + return [] + if not os.path.isdir(dir_path): + print(f"wallpaper dir not found: {dir_path}", file=sys.stderr) + return [] + + extensions = output.get("extensions") or ["png", "jpg", "jpeg", "gif", "webp"] + ext_set = {str(ext).lower().lstrip(".") for ext in extensions} + recursive = bool(output.get("recursive", False)) + + images = [] + if recursive: + for root, _, files in os.walk(dir_path): + for filename in files: + _, ext = os.path.splitext(filename) + if ext.lower().lstrip(".") in ext_set: + images.append(os.path.join(root, filename)) + else: + for filename in os.listdir(dir_path): + _, ext = os.path.splitext(filename) + if ext.lower().lstrip(".") in ext_set: + path = os.path.join(dir_path, filename) + if os.path.isfile(path): + images.append(path) + + images.sort() + return images + + +def _choose_random(images, last_path): + if not images: + return None + if len(images) == 1: + return images[0] + choice = random.choice(images) + if last_path and choice == last_path: + choice = random.choice([path for path in images if path != last_path]) + return choice + + +def _resolve_wallpaper(output, key, command, state): + mode = _output_mode(output) + if mode == "static": + return output.get("wallpaper"), False + + images = _collect_images(output) + if not images: + return None, False + + entry = state.get(key, {}) + last_path = entry.get("path") + index = entry.get("index", 0) + + if command == "random" or mode == "random": + selection = _choose_random(images, last_path) + state[key] = {"index": images.index(selection), "path": selection} + return selection, True + + if command == "next": + index = (index + 1) % len(images) + elif command == "prev": + index = (index - 1) % len(images) + + index = min(max(index, 0), len(images) - 1) + selection = images[index] + state[key] = {"index": index, "path": selection} + return selection, True + + +def cmd_apply_like(config, dry_run, wait_seconds, command): + general = config.get("general", {}) + base_cmd = general.get("backend_cmd", general.get("backend", "swww")) + outputs = config.get("output", []) + + if not outputs: + print("config has no [[output]] entries", file=sys.stderr) + raise SystemExit(2) + + _ensure_backend_available(base_cmd) + + if wait_seconds > 0: + ok = _wait_for_backend([base_cmd, "query"], wait_seconds, 0.1) + if not ok: + print(f"{base_cmd} backend not responding to query", file=sys.stderr) + raise SystemExit(2) + + state = _load_state() + updated = False + + for index, output in enumerate(outputs): + key = _output_key(output, index) + wallpaper, changed = _resolve_wallpaper(output, key, command, state) + if not wallpaper: + print(f"skipping output without wallpaper: {output.get('name', '')}", file=sys.stderr) + continue + settings = _merged_settings(general, output) + cmd = _build_img_command(base_cmd, settings, wallpaper) + _run(cmd, dry_run) + updated = updated or changed + + if updated and not dry_run: + _save_state(state) + + +def cmd_list(config): + outputs = config.get("output", []) + if not outputs: + print("config has no [[output]] entries", file=sys.stderr) + raise SystemExit(2) + state = _load_state() + for index, output in enumerate(outputs): + name = output.get("name", "") + mode = _output_mode(output) + if mode == "static": + wallpaper = output.get("wallpaper", "") + current = _expand_path(wallpaper) + else: + images = _collect_images(output) + key = _output_key(output, index) + current = state.get(key, {}).get("path") + if not current and images: + current = images[0] + current = _expand_path(current or "") + print(f"{name}\t{mode}\t{current}") + + +def main(): + parser = argparse.ArgumentParser(description="Apply wallpapers via swww/awww using a TOML config.") + parser.add_argument("--config", help="Path to config.toml (default: XDG config)") + parser.add_argument("--dry-run", action="store_true", help="Print commands without executing them") + parser.add_argument("--print-config", action="store_true", help="Print the config file and exit") + parser.add_argument("--version", action="version", version=f"awww-manager {__version__}") + parser.add_argument( + "--wait", + type=float, + default=2.0, + help="Seconds to wait for backend query to succeed (default: 2.0)", + ) + + subparsers = parser.add_subparsers(dest="command") + subparsers.add_parser("apply", help="Apply wallpapers from config") + subparsers.add_parser("list", help="List outputs and wallpapers from config") + subparsers.add_parser("next", help="Advance to the next wallpaper for cycle outputs") + subparsers.add_parser("prev", help="Go to the previous wallpaper for cycle outputs") + subparsers.add_parser("random", help="Randomize wallpapers for non-static outputs") + + args = parser.parse_args() + + config_path = _config_path(args.config) + + if args.print_config: + try: + with open(config_path, "r", encoding="utf-8") as handle: + print(handle.read(), end="") + except FileNotFoundError: + print(f"config not found: {config_path}", file=sys.stderr) + raise SystemExit(2) + return + + if not args.command: + parser.error("the following arguments are required: command") + + config = _load_toml(config_path) + + if args.command == "apply": + cmd_apply_like(config, args.dry_run, args.wait, "apply") + elif args.command == "next": + cmd_apply_like(config, args.dry_run, args.wait, "next") + elif args.command == "prev": + cmd_apply_like(config, args.dry_run, args.wait, "prev") + elif args.command == "random": + cmd_apply_like(config, args.dry_run, args.wait, "random") + elif args.command == "list": + cmd_list(config) + + +if __name__ == "__main__": + main() diff --git a/dot_local/bin/executable_set-wallpapers.tmpl b/dot_local/bin/executable_set-wallpapers.tmpl deleted file mode 100644 index ea45d0c..0000000 --- a/dot_local/bin/executable_set-wallpapers.tmpl +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if ! command -v swww >/dev/null 2>&1; then - echo "swww not found in PATH" >&2 - exit 1 -fi - -for _ in {1..50}; do - if swww query >/dev/null 2>&1; then - break - fi - sleep 0.1 -done - -if ! swww query >/dev/null 2>&1; then - echo "swww-daemon not responding" >&2 - exit 1 -fi - -{{- range .monitors }} - {{- if .wallpaper }} -swww img -o {{ .name }} "{{ .wallpaper }}" - {{- end }} -{{- end }}