diff --git a/.gitignore b/.gitignore index d6a8a01..0bcd554 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ for_config/* home-manager/modules/custom/* nixos/configuration.nix +nixos/hardware-configuration.nix my.nix \ No newline at end of file diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..aa2ffaf --- /dev/null +++ b/flake.nix @@ -0,0 +1,18 @@ +{ + description = "NixOS configuration for my personal setup"; + + inputs = { }; + + outputs = { self, nixpkgs, ... }: + let systems = [ "x86_64-linux" ]; + in { + nixosConfigurations = lib.genAttrs systems (system: + let pkgs = nixpkgs.legacyPackages.${system}; + in nixpkgs.lib.nixosSystem { + inherit system; + modules = [ ./nixos/configuration.nix ]; + specialArgs = { inherit pkgs; }; + }); + }; + +} diff --git a/home-manager/modules/hypr/hypr.nix b/home-manager/modules/hypr/hypr.nix index 432994f..848daf7 100644 --- a/home-manager/modules/hypr/hypr.nix +++ b/home-manager/modules/hypr/hypr.nix @@ -17,12 +17,17 @@ ./hypridle.nix # done ]; home.packages = with pkgs; [ - (writeShellScriptBin "next_wallpaper" - (builtins.readFile ./scripts/wallpaper/next_wallpaper.sh)) - (writeShellScriptBin "move_wallpaper" - (builtins.readFile ./scripts/wallpaper/move_wallpaper.sh)) - (writeShellScriptBin "fetch_wallpaper" - (builtins.readFile ./scripts/wallpaper/fetch_wallpaper.sh)) + # (writeShellScriptBin "next_wallpaper" + # (builtins.readFile ./scripts/wallpaper/next_wallpaper.sh)) + # (writeShellScriptBin "move_wallpaper" + # (builtins.readFile ./scripts/wallpaper/move_wallpaper.sh)) + # (writeShellScriptBin "fetch_wallpaper" + # (builtins.readFile ./scripts/wallpaper/fetch_wallpaper.sh)) + # (writeShellScriptBin "wallpaper" + # (builtins.readFile ./scripts/wallpaper/wallpaper.py)) + (pkgs.writeScriptBin "wallpaper" ('' + #!${pkgs.python311}/bin/python3 + '' + (builtins.readFile ./scripts/wallpaper/wallpaper.py))) hyprland brightnessctl playerctl @@ -66,8 +71,11 @@ submap = "global"; debug = { disable_logs = false; }; - monitor = - [ "eDP-1,preferred,auto,auto" "HDMI-A-1,preferred,auto-left,auto" ]; + monitor = [ + "eDP-1,preferred,auto,auto" + "HDMI-A-1,preferred,auto-left,auto" + "HDMI-A-2,preferred,auto-left,auto" + ]; input = { kb_layout = "us,ru"; diff --git a/home-manager/modules/hypr/hyprland/keybinds.nix b/home-manager/modules/hypr/hyprland/keybinds.nix index 01ae3fa..4e15413 100644 --- a/home-manager/modules/hypr/hyprland/keybinds.nix +++ b/home-manager/modules/hypr/hyprland/keybinds.nix @@ -55,12 +55,24 @@ "${super}, L, exec, loginctl lock-session" + # '' + # ${super}+Shift, D, exec, ~/.config/hypr/scripts/wallpaper/fetch_wallpaper.sh + # '' + '' - ${super}+Shift, D, exec, ~/.config/hypr/scripts/wallpaper/fetch_wallpaper.sh + ${super}+Shift, D, exec, ~/.config/hypr/scripts/wallpaper/wallpaper.py fetch '' '' - ${super}+Shift, C, exec, ~/.config/hypr/scripts/wallpaper/next_wallpaper.sh -f $(zenity --title "Pick a wallpaper" --file-selection --file-filter \*.png) + ${super}, N, exec, ~/.config/hypr/scripts/wallpaper/wallpaper.py next -r + '' + + # '' + # ${super}+Shift, C, exec, ~/.config/hypr/scripts/wallpaper/next_wallpaper.sh -f $(zenity --title "Pick a wallpaper" --file-selection --file-filter \*.png) + # '' + + '' + ${super}+Shift, C, exec, ~/.config/hypr/scripts/wallpaper/wallpaper.py next -i '' '' diff --git a/home-manager/modules/hypr/scripts/wallpaper/wallpaper.py b/home-manager/modules/hypr/scripts/wallpaper/wallpaper.py new file mode 100755 index 0000000..ec140b6 --- /dev/null +++ b/home-manager/modules/hypr/scripts/wallpaper/wallpaper.py @@ -0,0 +1,637 @@ +#!/usr/bin/env python3 +import argparse +import datetime +import json +import os +import pathlib +import random as _random +import re +import shutil +import subprocess +import urllib.parse +import urllib.request +import uuid + +WALLPAPERS_DIR = str( + pathlib.Path( + os.environ.get("WALLPAPERS_DIR") + or pathlib.Path.home() / "Pictures" / "Wallpapers" + ).expanduser() +) +SYMLINK_PATH = str(pathlib.Path("~/Pictures/wallpaper.png").expanduser()) + +# old_print = print + + +# def new_print(*args, **kwargs): +# old_print(*args, **kwargs) +# old_print("\r", end="") + + +# print = new_print + + +def check_setup() -> None: + # Check required programs are available + for prog in ["magick", "curl"]: + try: + subprocess.run([prog, "--version"], capture_output=True) + except FileNotFoundError: + print(f"Warning: Required program '{prog}' not found in PATH.") + exit(1) + + for prog in ["zenity", "yad"]: + try: + subprocess.run([prog, "--version"], capture_output=True) + break + except FileNotFoundError: + continue + else: + print("Warning: No GUI file chooser (zenity or yad) found in PATH.") + exit(1) + + for prog in ["swww", "hyprpanel", "wal"]: + try: + subprocess.run([prog, "--version"], capture_output=True) + except FileNotFoundError: + print(f"Note: Optional program '{prog}' not found in PATH.") + + wallpaper_dir = pathlib.Path(WALLPAPERS_DIR) + if not wallpaper_dir.is_dir(): + print(f"Creating wallpapers directory: {WALLPAPERS_DIR}") + wallpaper_dir.mkdir(parents=True, exist_ok=True) + + return + + +def get_path(file: bool = False, multiple: bool = False) -> list[str]: + # Try common Linux GUI choosers (zenity, yad). + # Returns a list of selected paths (absolute). + + def _normalize_output(raw: str) -> list[str]: + if not raw: + return [] + # zenity/yad often use a separator, kdialog/newlines, some use '|' or NUL + for sep in ("\x00", "::", "|"): + raw = raw.replace(sep, "\n") + parts = [p.strip() for p in raw.splitlines() if p.strip()] + return [os.path.abspath(os.path.expanduser(p)) for p in parts] + + chooser_commands = [] + + if file: + if multiple: + chooser_commands = [ + ["zenity", "--file-selection", "--multiple", "--separator=::"], + ["yad", "--file", "--multiple", "--separator=::"], + ] + else: + chooser_commands = [ + ["zenity", "--file-selection"], + ["yad", "--file"], + ] + else: + if multiple: + chooser_commands = [ + [ + "zenity", + "--file-selection", + "--directory", + "--multiple", + "--separator=::", + ], + ["yad", "--file", "--directory", "--multiple", "--separator=::"], + ] + else: + chooser_commands = [ + ["zenity", "--file-selection", "--directory"], + ["yad", "--file", "--directory"], + ] + + for cmd in chooser_commands: + try: + p = subprocess.run(cmd, capture_output=True, text=True) + except FileNotFoundError: + continue + + out = p.stdout.strip() + if p.returncode == 0 and out: + return _normalize_output(out) if multiple else _normalize_output(out)[:1] + + return [] + + +def add_wallpaper(path: list[str], remove_original: bool = False) -> None: + for pic in path: + pic_path = pathlib.Path(pic) + print(f"Adding wallpaper: {pic}") + filename = f"wp{datetime.datetime.now().strftime('%Y%m%d%H%M%S')}_{pic_path.name[:10]}.png" + # ensure target directory exists + target_dir = pathlib.Path(WALLPAPERS_DIR) + target_dir.mkdir(parents=True, exist_ok=True) + + dest = target_dir / filename + + try: + # convert/source -> PNG using ImageMagick + # TODO format to res? + subprocess.run(["magick", str(pic_path), str(dest)], check=True) + print(f"Saved converted wallpaper to: {dest}") + except FileNotFoundError: + print( + "ImageMagick 'magick' not found. Please install ImageMagick or ensure it's in PATH." + ) + except subprocess.CalledProcessError as e: + print(f"ImageMagick conversion failed: {e}") + + if remove_original: + print(f"Removing original file: {pic}") + try: + pic_path.unlink() + except Exception as e: + print(f"Failed to remove original file: {e}") + + return + + +def fetch_wallpaper(urls_or_ids: list[str] | None = None) -> None: + + TEMP_DIR = "/tmp/wallhaven_downloads" + TARGET_RESOLUTION = os.environ.get("WALLPAPER_TARGET_RESOLUTION", "2560x1600") + + pathlib.Path(TEMP_DIR).mkdir(parents=True, exist_ok=True) + + def _get_api_key() -> str: + key = os.environ.get("WALLHAVEN_API_KEY") or os.environ.get("WALLPAPER_API_KEY") + if key: + return key.strip() + try: + p = subprocess.run( + ["pass", "api/wallhaven"], capture_output=True, text=True + ) + if p.returncode == 0: + return p.stdout.strip() + except FileNotFoundError: + pass + + return "" + + api_key = _get_api_key() + + items = urls_or_ids or [] + if not items: + try: + p = subprocess.run( + ["wl-paste", "--no-newline"], capture_output=True, text=True + ) + clip = p.stdout.strip() if p.returncode == 0 else "" + except FileNotFoundError: + clip = "" + if not clip: + print("No URL/id in clipboard") + return + items = [clip] + + for item in items: + # Extract ID (last path segment, strip trailing slash and query) + parsed = urllib.parse.urlparse(item) + path = parsed.path or item + wallpaper_id = re.sub(r".*/", "", path) + wallpaper_id = re.split(r"[/?]", wallpaper_id)[0].strip() + + if not wallpaper_id: + print(f"Could not extract id from URL: {item}") + continue + + if not re.match(r"^[A-Za-z0-9]{4,8}$", wallpaper_id): + print(f"Invalid wallhaven ID format: {wallpaper_id}") + continue + + api_url = f"https://wallhaven.cc/api/v1/w/{wallpaper_id}" + if api_key: + api_url += f"?apikey={api_key}" + + print(f"Fetching wallpaper info for ID: {wallpaper_id}") + try: + p = subprocess.run( + ["curl", "-s", "-f", api_url], capture_output=True, text=True + ) + if p.returncode != 0: + print( + f"Failed to fetch data from wallhaven API (curl exit: {p.returncode})" + ) + continue + response = p.stdout + except FileNotFoundError: + print("curl not found; cannot fetch wallpaper metadata") + return + + try: + resp_json = json.loads(response) + except json.JSONDecodeError: + print("Failed to parse API response as JSON") + continue + + if resp_json.get("error"): + print(f"API Error: {resp_json.get('error')}") + continue + + data = resp_json.get("data") + if not data: + print("No wallpaper data found in API response") + continue + + image_url = data.get("path") + resolution = data.get("resolution") + file_type = data.get("file_type") + file_size = data.get("file_size") + purity = data.get("purity") + + if not image_url: + print("Could not extract image URL from API response") + continue + + print("Wallpaper details:") + print(f" Resolution: {resolution}") + print(f" File type: {file_type}") + print(f" File size: {((file_size or 0) // 1024)} KB") + print(f" Purity: {purity}") + print(f" URL: {image_url}") + + if resolution and resolution != TARGET_RESOLUTION: + print( + f"Warning: Wallpaper resolution is {resolution} (target: {TARGET_RESOLUTION})" + ) + + filename = f"{wallpaper_id}" + full_path = pathlib.Path(TEMP_DIR) / filename + + try: + with ( + urllib.request.urlopen(image_url) as r, + open(full_path, "wb") as out_f, + ): + shutil.copyfileobj(r, out_f) + except Exception as e: + print(f"Error: Failed to download the wallpaper from: {image_url} ({e})") + continue + + if not full_path.is_file(): + print("Error: Downloaded file is missing") + continue + + try: + size = full_path.stat().st_size + except OSError as e: + print(f"Error: Could not stat downloaded file: {e}") + continue + + if size == 0: + print("Error: Downloaded file is empty") + continue + + print(f"Download successful: {full_path} ({size // 1024} KB)") + + add_wallpaper([str(full_path)], remove_original=True) + + try: + subprocess.run( + ["zenity", "--notification", "--text", "Wallpaper downloaded!"] + ) + except FileNotFoundError: + pass + + return + + +def next_wallpaper( + path: str | None = None, random: bool = False, interactive: bool = False +) -> None: + + symlink = pathlib.Path(SYMLINK_PATH) + wallpapers_dir = pathlib.Path(WALLPAPERS_DIR) # use module-level constant + + def _atomic_symlink(target: str, link_path: pathlib.Path) -> None: + tmp_name = link_path.parent / f".{link_path.name}.tmp.{uuid.uuid4().hex}" + try: + os.symlink(target, tmp_name) + os.replace(tmp_name, link_path) + finally: + try: + if tmp_name.exists() or tmp_name.is_symlink(): + tmp_name.unlink() + except Exception: + pass + + def _apply_wallpaper(target: str) -> None: + print(f"Setting wallpaper to: {target}") + _atomic_symlink(target, symlink) + + # trigger external tools; ignore missing commands + try: + subprocess.run( + ["swww", "img", target, "--transition-type", "fade"], check=False + ) + except FileNotFoundError: + pass + try: + subprocess.run(["hyprpanel", "-q"], check=False) + # restart in background + subprocess.Popen( + ["hyprpanel"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + except FileNotFoundError: + pass + try: + subprocess.run(["wal", "-c"], check=False) + subprocess.run(["wal", "-n", "-i", target], check=False) + pass + except FileNotFoundError: + pass + + # If interactive requested, ask user for a file + if not path and interactive: + selected = get_path(file=True, multiple=False) + if not selected: + print("No file selected interactively.") + return + path = selected[0] + + # If a file path was provided (force), validate and apply immediately + if path: + p = pathlib.Path(path).expanduser() + if not p.is_file(): + print(f"Specified wallpaper not found: {p}") + return + + target = str(p.resolve()) + _apply_wallpaper(target) + return + + # No explicit path: choose from wallpapers dir + if not wallpapers_dir.is_dir(): + print(f"Wallpapers directory not found: {wallpapers_dir}") + return + + # Resolve current wallpaper target (if any) + try: + current = symlink.resolve(strict=False) + current_str = str(current) if current.exists() else "" + except Exception: + current_str = "" + + def _collect_png_files(wallpapers_dir: pathlib.Path, files: list[pathlib.Path]): + # If directory contains a ".off" file → skip entire directory + # for item in wallpapers_dir.iterdir(): + # print(f"Checking item: {item}") + if any(item.name == ".off" for item in wallpapers_dir.iterdir()): + print(f"Skipping folder (contains .off): {wallpapers_dir}") + return + + for item in wallpapers_dir.iterdir(): + if item.is_file(): + if item.suffix.lower() == ".png": + # print(f"Found wallpaper file: {item}") + files.append(item) + + elif item.is_dir(): + _collect_png_files(item, files) + + files = [] + _collect_png_files(wallpapers_dir, files) + files.sort(key=lambda p: p.name.lower()) + files = [str(f) for f in files] + + if not files: + print(f"No png wallpapers found in {wallpapers_dir}") + return + + def choose_random_excluding_current() -> str: + candidates = [ + f for f in files if os.path.realpath(f) != os.path.realpath(current_str) + ] + if not candidates: + print("No other wallpaper to choose (only current exists).") + raise SystemExit(1) + return _random.choice(candidates) + + def choose_next_in_list() -> str: + n = len(files) + if n == 1: + if os.path.realpath(files[0]) == os.path.realpath(current_str): + print("No other wallpaper to choose (only current exists).") + raise SystemExit(1) + return files[0] + # find current index + idx = -1 + for i, f in enumerate(files): + try: + if os.path.realpath(f) == os.path.realpath(current_str): + idx = i + break + except Exception: + continue + if idx == -1: + return files[0] + next_idx = (idx + 1) % n + return files[next_idx] + + try: + new = choose_random_excluding_current() if random else choose_next_in_list() + except SystemExit: + return + + new = str(pathlib.Path(new).resolve()) + print(f"Chosen wallpaper: {new}") + + _apply_wallpaper(new) + + return + + +def switch_folders(folders: list[str], on: bool = False, off: bool = False) -> None: + if on and off: + print("Cannot specify both --on and --off.") + return + + for folder_name in folders: + folder_path = pathlib.Path(WALLPAPERS_DIR) / folder_name + if not folder_path.is_dir(): + print(f"Folder not found: {folder_path}") + continue + + off_file = folder_path / ".off" + if on: + if off_file.is_file(): + off_file.unlink() + print(f"Turned ON folder: {folder_name}") + else: + print(f"Folder already ON: {folder_name}") + elif off: + if not off_file.is_file(): + off_file.touch() + print(f"Turned OFF folder: {folder_name}") + else: + print(f"Folder already OFF: {folder_name}") + else: + # Toggle state + if off_file.is_file(): + off_file.unlink() + print(f"Turned ON folder: {folder_name}") + else: + off_file.touch() + print(f"Turned OFF folder: {folder_name}") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="Wallpaper Setter", + description="This script do basic actions with wallpaper for Hyprland", + epilog="Designed by Kr0sH_512", + ) + + coparser = parser.add_subparsers( + required=True, dest="command", title="commands", metavar="COMMAND" + ) + # --- Add pic to wallpaper folder or run interactive mode --- + add_coparser = coparser.add_parser( + "add", aliases=["a", "move", "m"], help="Add wallpaper" + ) + add_coparser.add_argument( + "-r", + "--remove", + action="store_true", + default=False, + help="Remove original file(s) after adding", + ) + add_coparser.add_argument( + "-f", + "--file", + type=str, + help="Path to wallpaper to add (interactive if not specified)", + required=False, + nargs="*", + ) + # --- Download wallpaper from Wallhaven by URL or ID --- + down_coparser = coparser.add_parser( + "download", aliases=["d", "fetch", "f"], help="Download wallpaper" + ) + down_coparser.add_argument( + "-u", + "--urls_or_ids", + type=str, + help="Wallhaven wallpaper URL(s) or ID(s) to download (clipboard if not specified)", + required=False, + nargs="*", + ) + # --- Select wallpaper to set (interactive, by file or by order) --- + next_coparser = coparser.add_parser("next", aliases=["n"], help="Next wallpaper") + next_coparser.add_argument( + "-r", + "--random", + action="store_true", + default=False, + help="Set a random wallpaper (if no file is specified)", + ) + # TODO: if file is path to directory, pick from there + next_coparser.add_argument( + "-f", + "--file", + type=str, + help="Set wallpaper by file path", + required=False, + ) + next_coparser.add_argument( + "-i", + "--interactive", + action="store_true", + default=False, + help="Set wallpaper interactively", + ) + # --- List wallpapers in directory (and info) --- + list_coparser = coparser.add_parser( + "list", aliases=["l"], help="List and count wallpapers" + ) + list_coparser.add_argument( + "-f", + "--file", + type=str, + help="Path to list wallpapers from (defaults to wallpapers directory)", + required=False, + ) + list_coparser.add_argument( + "-v", + "--verbose", + action="store_true", + default=False, + help="Show detailed information about wallpapers", + ) + # --- Switch on/off wallpaper folders --- + switch_coparser = coparser.add_parser( + "switch", aliases=["sw"], help="Turn on/off folders in $WALLPAPERS_DIR" + ) + switch_coparser.add_argument( + "-0", + "--off", + action="store_true", + default=False, + help="Turn off specified folder(s) (switch state if not specified)", + ) + switch_coparser.add_argument( + "-1", + "--on", + action="store_true", + default=False, + help="Turn on specified folder(s) (switch state if not specified)", + ) + switch_coparser.add_argument( + "folders", + help="Folder(s) name to switch (interactive if not specified)", + nargs="+", + ) + # --- Get info about current wallpaper and folders --- + info_coparser = coparser.add_parser( + "info", aliases=["i"], help="Get info about current wallpaper and folders" + ) + info_coparser.add_argument( + "-v", + "--verbose", + action="store_true", + default=False, + help="Show detailed information", + ) + + args = parser.parse_args() + + return args + + +def main(): + args = parse_args() + # print(args) + + check_setup() + # return + + match args.command: + case "add" | "a" | "move" | "m": + path = args.file if args.file else get_path(file=True, multiple=True) + add_wallpaper(path=path, remove_original=args.remove) + case "download" | "d" | "fetch" | "f": + fetch_wallpaper(urls_or_ids=args.urls_or_ids) + case "next" | "n": + next_wallpaper( + path=args.file, random=args.random, interactive=args.interactive + ) + case "list" | "l": + pass + case "switch" | "sw": + switch_folders(args.folders, on=args.on, off=args.off) + case "info" | "i": + pass + + # print(args) + + +if __name__ == "__main__": + main() diff --git a/home-manager/modules/kitty.nix b/home-manager/modules/kitty.nix index b31fa47..5d32959 100644 --- a/home-manager/modules/kitty.nix +++ b/home-manager/modules/kitty.nix @@ -6,6 +6,7 @@ package = pkgs.kitty; settings = { + allow_remote_control = true; font_family = "JetBrainsMono Nerd Font"; cursor_trail = 10;