Files
dotfiles/config/scripts/.local/scripts/screenshot-script

183 lines
6.2 KiB
Python
Executable File

#!/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"
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.",
)
args = parser.parse_args()
# 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)
# 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(
"Screenshot Taken",
# Mako doesn't have action buttons displayed with notification cards,
"Click to edit",
)
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()