Nix-Vibe public snapshot (squashed history)
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user