#!/usr/bin/env python3 # Description: # A screenshot utility script for Hyprland and Niri desktop environments. # Takes screenshots (full, area, window) and provides an option to edit them immediately # # Requirements: # - hyprshot (for Hyprland) # - grim + slurp (for Niri) # - gradia (for editing) # - glib bindings for python import argparse import subprocess import time import fcntl from os import environ from datetime import datetime from enum import Enum from pathlib import Path from shutil import copy2 # autopep8: off import gi gi.require_version("Notify", "0.7") from gi.repository import Notify, GLib # autopep8: on class ScreenshotType(Enum): FULL = "full" AREA = "area" WINDOW = "window" EDIT = "edit" SCREENSHOT_DIR = Path.home() / "Pictures" / "Screenshots" def wait_until_file_exists(filepath: Path, timeout: int = 1): """Wait until a file exists or timeout.""" start_time = time.time() while not filepath.exists(): if time.time() - start_time > timeout: return False time.sleep(0.05) return True def take_screenshot(filepath: Path, typeStr: str): lockFD = open("/tmp/screenshot-script.lock", "w") try: fcntl.flock(lockFD, fcntl.LOCK_EX | fcntl.LOCK_NB) except IOError: lockFD.close() raise RuntimeError("Another screenshot is currently being taken.") try: type = ScreenshotType(typeStr) currentDesktop = environ.get("XDG_CURRENT_DESKTOP", "") if "Hyprland" in currentDesktop: cmd = { # since I only have one monitor ScreenshotType.FULL: f"hyprshot -z -m output -m active -o {SCREENSHOT_DIR} -f ", ScreenshotType.AREA: f"hyprshot -z -m region -o {SCREENSHOT_DIR} -f ", ScreenshotType.WINDOW: f"hyprshot -z -m window -o {SCREENSHOT_DIR} -f ", } process = subprocess.run(f"{cmd[type]}{filepath.name}", shell=True) if process.returncode != 0: raise RuntimeError("Failed to take screenshot: hyprshot command failed.") if not wait_until_file_exists(filepath): raise RuntimeError("Failed to take screenshot: output file not found after hyprshot command.") elif "niri" in currentDesktop: niriScreenshotPath = SCREENSHOT_DIR / ".niri_screenshot.png" cmd = { # niri's built-in screenshot commands are asynchronous, which does not wait for the user to select the area. # and the selection ui is drawn inside of niri without its state exposed to external programs. # so we use grim + slurp for area mode and niri's built-in commands for others. ScreenshotType.FULL: "niri msg action screenshot-screen", ScreenshotType.AREA: f" grim -g \"$(slurp)\" -t png {niriScreenshotPath} && cat {niriScreenshotPath} | wl-copy", ScreenshotType.WINDOW: "niri msg action screenshot-window", } if niriScreenshotPath.exists(): niriScreenshotPath.unlink() process = subprocess.run(cmd[type], shell=True) if process.returncode != 0: print(process.returncode) raise RuntimeError("Failed to take screenshot: niri screenshot command failed.") if wait_until_file_exists(niriScreenshotPath): # niriScreenshotPath.rename(filepath) copy2(niriScreenshotPath, filepath) else: raise RuntimeError("Failed to take screenshot: output file not found after niri command.") if not wait_until_file_exists(filepath): raise RuntimeError("Failed to take screenshot: output file not found after copying.") else: # print("Unsupported desktop environment.") raise RuntimeError("Unsupported desktop environment.") finally: fcntl.flock(lockFD, fcntl.LOCK_UN) lockFD.close() def edit_screenshot(filepath: Path): subprocess.run(f"gradia {filepath}", shell=True) # subprocess.run(f"spectacle -l --edit-existing {filepath}", shell=True) def gen_file_name(prefix="screenshot", ext=".png"): timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") return f"{prefix}_{timestamp}{ext}" if __name__ == "__main__": Notify.init("Screenshot Utility") try: # raise RuntimeError("Never gonna give you up, never gonna let you down.") parser = argparse.ArgumentParser(description="Take screenshots with hyprshot.") parser.add_argument( "type", choices=[t.value for t in ScreenshotType], help="Type of screenshot to take.", ) parser.add_argument( "path", nargs="?", default="", help="Path of the given screenshot file (for edit type only).", ) args = parser.parse_args() filepath: Path = Path() if not args.type == ScreenshotType.EDIT.value: # file path SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True) filename = gen_file_name() filepath = SCREENSHOT_DIR / filename # take screenshot take_screenshot(filepath, args.type) else: if not args.path: raise RuntimeError("Path argument is required for edit type.") filepath = Path(args.path).expanduser() if not filepath.exists(): raise RuntimeError(f"File does not exist: {filepath}") # create loop instance loop = GLib.MainLoop() editing = False # callback on default action (edit) def edit_callback(n, action, user_data): try: global editing editing = True edit_screenshot(filepath) finally: n.close() loop.quit() # callback on close def close_callback(n): global editing if not editing: loop.quit() n = Notify.Notification.new( # Mako doesn't have action buttons displayed with notification cards, "Click to edit", str(filepath), ) n.add_action( # so default action is used, which will be triggered on simply clicking the notification card "default", # But for my (or more accurate, Noctalia's) quickshell config, buttons will be displayed with label, even for the default action "Open in Editor", edit_callback, None, ) # set timeout for close_callback GLib.timeout_add_seconds(10, close_callback, n) n.show() loop.run() except Exception as e: n = Notify.Notification.new( "Screenshot Error", str(e), ) n.show()