Files

172 lines
5.3 KiB
Python

#!/usr/bin/env python3
"""qs-shot — screenshot capture with a QuickShell toast preview.
Subcommands:
pick show a wofi chooser (select area / full screen) then capture
area interactively select a region (grimblast)
screen capture the current output (grimblast)
open <path> open a saved screenshot in the default viewer (toast action)
copy <path> re-push a saved screenshot to the clipboard (toast action)
The capture is copied to the clipboard AND saved to a temp file. A
notification toast is posted with the image preview and Open / Copy
actions; the shell intercepts those in calNotifAction (identifier
payload carries the saved file path).
All binary paths are injected via $QS_* env vars by the .local/bin/qs-shot
wrapper so execution works regardless of PATH. When no DISPLAY/WAYLAND_DISPLAY
is present, wofi can't open, so the picker exits silently with code 1.
"""
import os
import subprocess
import sys
import time
TARGETS = {
"area": "Select area",
"screen": "Full screen",
}
def run(command, **kwargs):
return subprocess.run(command, capture_output=True, text=True, **kwargs)
def wofi(entries, prompt):
cmd = [os.environ.get("QS_WOFI", "wofi"), "--dmenu", "--prompt", prompt]
result = run(cmd, input=entries)
if result.returncode != 0:
return None
return result.stdout.rstrip("\n")
def _output_path():
return os.path.join(
os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache")),
"qs-shot",
"shot-%s.png" % time.strftime("%Y%m%d-%H%M%S"),
)
def _grab(target, out):
"""grimblast save <target> <out> — file only, no clipboard."""
grimblast = os.environ.get("QS_GRIMBLAST", "grimblast")
return subprocess.run(
[grimblast, "save", target, out],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode == 0
def _copy_to_clip(out):
"""wl-copy < out — fire-and-forget, mirrors the `copy` subcommand."""
wl_copy = os.environ.get("QS_WLCOPY", "wl-copy")
subprocess.Popen(["sh", "-c",
'exec "$1" --type image/png < "$2"', "--",
wl_copy, out],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def _notify(out, target):
summary = "Screenshot" if target == "screen" else "Screenshot (area)"
body = "\u2702 + \U0001f4be " + out
notify = os.environ.get("QS_NOTIFY", "notify-send")
# fire-and-forget: notify-send with --action stays alive as the D-Bus action
# sender waiting for ActionInvoked; quickshell handles the shot-* actions by
# identifier in QML instead, so the sender can exit immediately.
subprocess.Popen([
notify,
"-a", "Screenshot",
"-t", "8000",
"--hint=string:image-path:" + out,
"--action=shot-open:%s=OPEN" % out,
"--action=shot-copy:%s=COPY" % out,
summary,
body,
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def pick_mode():
if "WAYLAND_DISPLAY" not in os.environ and "DISPLAY" not in os.environ:
return 1
out = _output_path()
os.makedirs(os.path.dirname(out), exist_ok=True)
_prune_old_shots(os.path.dirname(out), keep=40)
# Capture BEFORE the chooser opens. wofi's layer surface lingers as a
# composited zombie after it exits (Hyprland keeps it in hyprctl layers even
# with a dead pid), so any post-exit wait races teardown and can bake the
# menu into the shot. A pre-capture makes that impossible for full screen.
if not _grab("screen", out):
return 1
label = wofi("\n".join(TARGETS.values()), "\U0001f4f7 Screenshot")
if not label:
try:
os.unlink(out)
except OSError:
pass
return 0
target = None
for t, entry in TARGETS.items():
if label == entry:
target = t
break
if target is None:
try:
os.unlink(out)
except OSError:
pass
return 0
if target == "area":
if not _grab("area", out):
return 1
_copy_to_clip(out)
_notify(out, target)
return 0
def _prune_old_shots(directory, keep=40):
try:
files = sorted(
os.path.join(directory, f) for f in os.listdir(directory)
if f.startswith("shot-") and f.endswith(".png")
)
for old in files[:-keep]:
os.unlink(old)
except OSError:
pass
def capture(target):
out = _output_path()
os.makedirs(os.path.dirname(out), exist_ok=True)
_prune_old_shots(os.path.dirname(out), keep=40)
if not _grab(target, out):
return 1
_copy_to_clip(out)
_notify(out, target)
return 0
def main():
sub = sys.argv[1] if len(sys.argv) > 1 else "pick"
if sub == "area" or sub == "screen":
return capture(sub)
if sub == "pick":
return pick_mode()
if sub == "open" and len(sys.argv) > 2:
xdg_open = os.environ.get("QS_XDGO", "xdg-open")
subprocess.Popen([xdg_open, sys.argv[2]])
return 0
if sub == "copy" and len(sys.argv) > 2:
wl_copy = os.environ.get("QS_WLCOPY", "wl-copy")
subprocess.Popen(["sh", "-c",
'exec "$1" --type image/png < "$2"', "--",
wl_copy, sys.argv[2]])
return 0
print(__doc__, file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())