183 lines
6.2 KiB
Python
Executable File
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()
|