#!/usr/bin/env python3
"""frame-snip: screenshot a rectangle of the window you're using, then copy it, save it, copy
its text (OCR), or read its text aloud (Narrate).

  frame-snip            capture the window the Steam session has focused (the desktop, or a
                        window you floated) and open the selection window
  frame-snip FILE       select from an image file instead (for testing)
  frame-snip --narrate IMAGE TEXT   (internal) the narration panel: reads TEXT aloud beside IMAGE

Drag a rectangle with the laser (or mouse), then pick an action. Esc or right-click cancels.
The selection window opens on the Steam session's display, so it shows up as a VR window.
Tools: Tesseract (OCR, Homebrew), Piper (speech, offline), wl-copy (clipboard; the Shared
clipboard tweak carries it to VR windows).
"""
import ctypes
import ctypes.util
import json
import os
import re
import shutil
import signal
import subprocess
import sys
import tempfile
import time
from pathlib import Path

sys.path.insert(0, os.environ.get("FRAME_TWEAKS_PYLIB") or str(Path.home() / ".local/share/frame-tweaks/i18n"))   # (lib/common.sh install_i18n)
try:
    import i18n
    from i18n import _, N_, ngettext  # noqa: F401
except ImportError:                       # (not installed by Frame Tweaks: English)
    i18n = None

    def _(text):
        return text

    def ngettext(singular, plural, n):
        return singular if n == 1 else plural
    N_ = _

NARRATE = len(sys.argv) > 1 and sys.argv[1] == "--narrate"
XCLIP = len(sys.argv) > 1 and sys.argv[1] == "--x-clipboard"
if NARRATE or XCLIP:      # the narration panel draws itself into its own SteamVR overlay (like Arrow Pad)
    os.environ["QT_QPA_PLATFORM"] = "offscreen"
else:
    os.environ["QT_QPA_PLATFORM"] = "xcb"
    os.environ["DISPLAY"] = ":0"                # the Steam session: every VR window lives here

import ftconf  # noqa: E402  (settings files: lib/ftconf.py, installed beside i18n.py)
import vrkit  # noqa: E402  (SteamVR poses, grips and haptics: lib/vrkit.py, the same)
import needs  # noqa: E402  ("this needs a tweak that isn't installed": lib/needs.py, the same)
from PyQt6.QtCore import (QEvent, QObject, QPoint, QPointF, QProcess, QProcessEnvironment, QRect, Qt, QTimer,
                          pyqtSignal)  # noqa: E402
from PyQt6.QtNetwork import QLocalServer, QLocalSocket                            # noqa: E402
from PyQt6.QtCore import QRectF, QSize                                            # noqa: E402
from PyQt6.QtGui import QColor, QIcon, QImage, QMouseEvent, QPainter, QPen, QPixmap  # noqa: E402
from PyQt6.QtWidgets import (QApplication, QGridLayout, QHBoxLayout, QLabel, QPushButton,  # noqa: E402
                             QVBoxLayout, QWidget)

HOME = Path.home()
RUN = Path(f"/run/user/{os.getuid()}")
DATA = HOME / ".local/share/frame-tweaks/speech"     # the Speech engine helper (speech/)
PIPER = DATA / "piper/piper"
VOICE = DATA / "voice.onnx"
ESPEAK = DATA / "piper/espeak-ng"                      # eSpeak NG, as it comes with Piper


def narrate_conf():
    """The Speech engine's settings for Narrate (speech/install.sh writes narrate.conf)."""
    try:
        return dict(l.split("=", 1) for l in (DATA / "narrate.conf").read_text().splitlines() if "=" in l)
    except OSError:
        return {}
SAVE_DIR = HOME / "Pictures/Screenshots"
TESSERACT = shutil.which("tesseract") or "/home/linuxbrew/.linuxbrew/bin/tesseract"
PANEL_SOCKET = f"frame-snip-narration-{os.getuid()}"   # the open narration panel takes new snips here
MIN_SIZE = 8                                    # px: smaller drags are taps, not selections


# ---- capture ------------------------------------------------------------------------------
class XImage(ctypes.Structure):
    _fields_ = [("width", ctypes.c_int), ("height", ctypes.c_int), ("xoffset", ctypes.c_int),
                ("format", ctypes.c_int), ("data", ctypes.c_void_p), ("byte_order", ctypes.c_int),
                ("bitmap_unit", ctypes.c_int), ("bitmap_bit_order", ctypes.c_int), ("bitmap_pad", ctypes.c_int),
                ("depth", ctypes.c_int), ("bytes_per_line", ctypes.c_int), ("bits_per_pixel", ctypes.c_int)]


class Attrs(ctypes.Structure):
    _fields_ = [("x", ctypes.c_int), ("y", ctypes.c_int), ("width", ctypes.c_int), ("height", ctypes.c_int),
                ("rest", ctypes.c_char * 256)]


def capture_focused():
    """The focused window of the Steam session (gamescope's GAMESCOPE_FOCUSED_WINDOW) as a QImage."""
    x = ctypes.cdll.LoadLibrary(ctypes.util.find_library("X11") or "libX11.so.6")
    x.XOpenDisplay.restype = ctypes.c_void_p
    x.XGetImage.restype = ctypes.c_void_p
    x.XInternAtom.restype = ctypes.c_ulong
    x.XDefaultRootWindow.restype = ctypes.c_ulong
    d = ctypes.c_void_p(x.XOpenDisplay(b":0"))
    if not d.value:
        raise RuntimeError(_("can't open the Steam session's display"))
    root = ctypes.c_ulong(x.XDefaultRootWindow(d))
    atom = x.XInternAtom(d, b"GAMESCOPE_FOCUSED_WINDOW", 1)
    rtype, fmt, n, after = ctypes.c_ulong(), ctypes.c_int(), ctypes.c_ulong(), ctypes.c_ulong()
    prop = ctypes.c_void_p()
    if not atom or x.XGetWindowProperty(d, root, ctypes.c_ulong(atom), 0, 1, 0, ctypes.c_ulong(6),   # XA_CARDINAL
                                        ctypes.byref(rtype), ctypes.byref(fmt), ctypes.byref(n),
                                        ctypes.byref(after), ctypes.byref(prop)) != 0 or not n.value:
        raise RuntimeError(_("no focused window"))
    win = ctypes.c_ulong(ctypes.cast(prop, ctypes.POINTER(ctypes.c_ulong))[0])
    x.XFree(prop)
    a = Attrs()
    x.XGetWindowAttributes(d, win, ctypes.byref(a))
    img = x.XGetImage(d, win, 0, 0, a.width, a.height, ctypes.c_ulong(0xFFFFFFFF), 2)        # ZPixmap
    if not img:
        raise RuntimeError(_("couldn't read the window's picture"))
    xi = XImage.from_address(img)
    data = ctypes.string_at(xi.data, xi.bytes_per_line * xi.height)
    q = QImage(data, xi.width, xi.height, xi.bytes_per_line, QImage.Format.Format_RGB32).copy()
    return q                                   # (the X image is freed when this short-lived tool exits)


# ---- actions ------------------------------------------------------------------------------
def nested_wayland():
    """The desktop's Wayland socket (wl-copy talks to it from any session)."""
    for s in [RUN / "nested_plasma/wayland-0", *RUN.glob("*/wayland-0"), RUN / "wayland-0"]:
        if s.is_socket():
            return str(s)
    return None


def wl_tool(name):
    """wl-copy / wl-paste: on PATH, or Homebrew's (not always on a launcher's PATH)."""
    return shutil.which(name) or next((p for p in ("/home/linuxbrew/.linuxbrew/bin/" + name,) if os.path.exists(p)), None)


def to_clipboard(data, mime):
    sock = nested_wayland()
    if not sock or not wl_tool("wl-copy"):
        return False
    env = dict(os.environ, WAYLAND_DISPLAY=sock)
    p = subprocess.Popen([wl_tool("wl-copy"), "--type", mime], stdin=subprocess.PIPE, env=env, start_new_session=True)
    p.communicate(data)
    return p.returncode == 0


def icon_button(b, icon, tip):
    """An icon-only button (Breeze), named by its tooltip (English, N_; shown translated)."""
    b.setText("")
    b.setIcon(breeze(icon))
    b.setIconSize(QSize(30, 30))
    tr_tip(b, tip)


def tr_tip(w, tip):
    """A tooltip (and accessible name) in the user's language, kept in English too so it can
    follow a change of language (retranslate)."""
    w.setProperty("tr_tip", tip)
    w.setToolTip(_(tip))
    w.setAccessibleName(_(tip))


def tr_text(w, text, suffix="", **values):
    """A label's or button's text in the user's language, kept in English too (with its values)
    so it can follow a change of language (retranslate). Returns w."""
    w.setProperty("tr_text", text)
    w.setProperty("tr_values", json.dumps(values))
    w.setProperty("tr_suffix", suffix)
    w.setText(_(text).format(**values) + suffix if values else _(text) + suffix)
    return w


def retranslate_tips(root):
    """Every tr_tip / tr_text under root, again in the current language."""
    from PyQt6.QtWidgets import QWidget
    for w in [root, *root.findChildren(QWidget)]:
        tip = w.property("tr_tip")
        if tip:
            w.setToolTip(_(tip))
            w.setAccessibleName(_(tip))
        text = w.property("tr_text")
        if text:
            values = json.loads(w.property("tr_values") or "{}")
            w.setText((_(text).format(**values) if values else _(text)) + (w.property("tr_suffix") or ""))


# A plain gear (Breeze's "configure" is sliders): the same shape as Scroll strips' gear in VR.
GEAR_PATH = "M19.4 13a7.5 7.5 0 0 0 0-2l2.1-1.6-2-3.5-2.5 1a7.6 7.6 0 0 0-1.7-1L15 3h-4l-.4 2.9a7.6 7.6 0 0 0-1.7 1l-2.5-1-2 3.5L6.6 11a7.5 7.5 0 0 0 0 2l-2.1 1.6 2 3.5 2.5-1a7.6 7.6 0 0 0 1.7 1L11 21h4l.4-2.9a7.6 7.6 0 0 0 1.7-1l2.5 1 2-3.5zM13 15.5a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7z"


def gear_icon():
    from PyQt6.QtSvg import QSvgRenderer
    svg = ('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 26 24"><path fill="#e8ecf0" '
           f'fill-rule="evenodd" d="{GEAR_PATH}"/></svg>').encode()
    pm = QPixmap(64, 64)
    pm.fill(Qt.GlobalColor.transparent)
    p = QPainter(pm)
    QSvgRenderer(svg).render(p)
    p.end()
    return QIcon(pm)


def start_snip():
    """A new snip, as Arrow Pad's Snip key starts one (its own process and session)."""
    subprocess.Popen([os.path.abspath(sys.argv[0])], start_new_session=True)


def breeze(name):
    """A Breeze icon (largest SVG), or an empty icon."""
    hits = sorted(Path("/usr/share/icons/breeze-dark").glob(f"*/*/{name}.svg"))
    return QIcon(str(hits[-1])) if hits else QIcon()


def x_clipboard_owner(folder):
    """frame-snip --x-clipboard DIR: own the desktop's X clipboard (:2) and offer the files in DIR
    (clip.png, clip.bmp, clip.html) as image/png, image/bmp and text/html, until something else is
    copied. Some apps (OnlyOffice) don't take an image offered as PNG alone, which is all wl-copy
    can do; KWin passes the X clipboard on to Wayland apps too. No focus needed, unlike Wayland."""
    folder = Path(folder)
    files = {"image/png": "clip.png", "image/bmp": "clip.bmp", "text/html": "clip.html"}
    data = {k: (folder / v).read_bytes() for k, v in files.items() if (folder / v).exists()}
    shutil.rmtree(folder, ignore_errors=True)
    auth = next(iter(sorted(RUN.glob("nested_plasma/xauth_*"))), None)
    if auth:
        os.environ["XAUTHORITY"] = str(auth)
    x = ctypes.cdll.LoadLibrary(ctypes.util.find_library("X11") or "libX11.so.6")
    x.XOpenDisplay.restype = ctypes.c_void_p
    x.XInternAtom.restype = ctypes.c_ulong
    x.XCreateSimpleWindow.restype = ctypes.c_ulong
    x.XDefaultRootWindow.restype = ctypes.c_ulong
    x.XGetSelectionOwner.restype = ctypes.c_ulong
    d = ctypes.c_void_p(x.XOpenDisplay(b":2"))
    if not d.value:
        return 1
    atom = lambda n: x.XInternAtom(d, n.encode(), 0)
    win = x.XCreateSimpleWindow(d, ctypes.c_ulong(x.XDefaultRootWindow(d)), 0, 0, 1, 1, 0, 0, 0)
    CLIP, TARGETS, TIMESTAMP, XA_ATOM, XA_INTEGER = atom("CLIPBOARD"), atom("TARGETS"), atom("TIMESTAMP"), 4, 19
    offers = {atom(k): v for k, v in data.items()}
    x.XSetSelectionOwner(d, ctypes.c_ulong(CLIP), ctypes.c_ulong(win), ctypes.c_ulong(0))
    if x.XGetSelectionOwner(d, ctypes.c_ulong(CLIP)) != win:
        return 1

    class Req(ctypes.Structure):                      # XSelectionRequestEvent
        _fields_ = [("type", ctypes.c_int), ("serial", ctypes.c_ulong), ("send_event", ctypes.c_int),
                    ("display", ctypes.c_void_p), ("owner", ctypes.c_ulong), ("requestor", ctypes.c_ulong),
                    ("selection", ctypes.c_ulong), ("target", ctypes.c_ulong), ("property", ctypes.c_ulong),
                    ("time", ctypes.c_ulong)]

    class Note(ctypes.Structure):                     # XSelectionEvent
        _fields_ = [("type", ctypes.c_int), ("serial", ctypes.c_ulong), ("send_event", ctypes.c_int),
                    ("display", ctypes.c_void_p), ("requestor", ctypes.c_ulong), ("selection", ctypes.c_ulong),
                    ("target", ctypes.c_ulong), ("property", ctypes.c_ulong), ("time", ctypes.c_ulong),
                    ("pad", ctypes.c_char * 64)]

    ev = (ctypes.c_char * 256)()
    while True:
        x.XNextEvent(d, ev)
        etype = ctypes.c_int.from_buffer(ev).value
        if etype == 29:                               # SelectionClear: something else was copied
            return 0
        if etype != 30:                               # SelectionRequest
            continue
        r = Req.from_buffer(ev)
        prop = r.property or r.target
        if r.target == TARGETS:
            atoms = (ctypes.c_ulong * (len(offers) + 2))(TARGETS, TIMESTAMP, *offers)
            x.XChangeProperty(d, ctypes.c_ulong(r.requestor), ctypes.c_ulong(prop), ctypes.c_ulong(XA_ATOM),
                              32, 0, atoms, len(atoms))
        elif r.target == TIMESTAMP:
            t = (ctypes.c_ulong * 1)(0)
            x.XChangeProperty(d, ctypes.c_ulong(r.requestor), ctypes.c_ulong(prop), ctypes.c_ulong(XA_INTEGER), 32, 0, t, 1)
        elif r.target in offers:
            blob = offers[r.target]
            x.XChangeProperty(d, ctypes.c_ulong(r.requestor), ctypes.c_ulong(prop), ctypes.c_ulong(r.target),
                              8, 0, ctypes.c_char_p(blob), len(blob))
        else:
            prop = 0                                  # can't give that
        note = Note(type=31, send_event=1, display=d, requestor=r.requestor, selection=r.selection,
                    target=r.target, property=prop, time=r.time)
        x.XSendEvent(d, ctypes.c_ulong(r.requestor), 0, 0, ctypes.byref(note))
        x.XFlush(d)


def copy_image_everywhere(image):
    """Put an image on the clipboard in the formats apps look for (PNG, BMP, an HTML <img>), via an
    X clipboard owner on the desktop; plain wl-copy (PNG only) if that can't start."""
    from PyQt6.QtCore import QBuffer, QByteArray, QIODevice
    import base64
    def encoded(fmt):
        buf = QByteArray()
        b = QBuffer(buf)
        b.open(QIODevice.OpenModeFlag.WriteOnly)
        image.save(b, fmt)
        return bytes(buf)
    png = encoded("PNG")
    if not list(RUN.glob("nested_plasma/xauth_*")):
        return to_clipboard(png, "image/png")
    d = Path(tempfile.mkdtemp(prefix="snip-clip-"))
    (d / "clip.png").write_bytes(png)
    (d / "clip.bmp").write_bytes(encoded("BMP"))
    (d / "clip.html").write_bytes(f'<img src="data:image/png;base64,{base64.b64encode(png).decode()}">'.encode())
    subprocess.Popen([sys.executable, os.path.abspath(sys.argv[0]), "--x-clipboard", str(d)], start_new_session=True,
                     stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    return True


def from_clipboard():
    """The desktop clipboard's text (wl-paste), or None."""
    sock = nested_wayland()
    if not sock or not wl_tool("wl-paste"):
        return None
    try:
        r = subprocess.run([wl_tool("wl-paste"), "--no-newline", "--type", "text"], capture_output=True,
                           env=dict(os.environ, WAYLAND_DISPLAY=sock), timeout=5)
    except (OSError, subprocess.TimeoutExpired):      # (a clipboard owner that doesn't answer: no text)
        return None
    text = r.stdout.decode(errors="replace").strip() if r.returncode == 0 else ""
    return text or None


def notify(title, body=""):
    subprocess.run(["notify-send", "-a", _("Screen snip + Narrate"), title, body[:300]], stderr=subprocess.DEVNULL)


def tesseract_missing():
    """Copy text and Narrate read the snip with Tesseract, which Screen snip installs (Homebrew)."""
    needs.offer("snip", _("Tesseract is missing"),
                _("Copy text and Narrate read the text in a snip with Tesseract, which Screen snip installs "
                  "through Homebrew. It's missing now (removed from Homebrew?)."),
                question=_("Open Frame Tweaks to reinstall Screen snip (its ↻ button)?"))


def ocr(img):
    """Text in the image (Tesseract), or None if Tesseract isn't installed."""
    if not Path(TESSERACT).exists():
        return None
    with tempfile.TemporaryDirectory() as t:
        path = Path(t) / "snip.png"
        # Small screen text reads better at 2x.
        img.scaled(img.width() * 2, img.height() * 2, Qt.AspectRatioMode.KeepAspectRatio,
                   Qt.TransformationMode.SmoothTransformation).save(str(path))
        r = subprocess.run([TESSERACT, str(path), "-", "--psm", "3"], capture_output=True, text=True)
    text = re.sub(r"[ \t]+\n", "\n", r.stdout).strip()
    return re.sub(r"\n{3,}", "\n\n", text)


# ---- narration ------------------------------------------------------------------------------
SETTINGS = HOME / ".config/frame-tweaks/snip.json"
SPEEDS = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0]


def load_speed():
    try:
        v = float(ftconf.read_json(SETTINGS).get("speed", 1.0))
    except (TypeError, ValueError):
        v = 1.0
    return min(SPEEDS, key=lambda x: abs(x - v))


def save_speed(v):
    ftconf.update_json(SETTINGS, {"speed": v})


def sentences(text):
    """Speakable chunks: sentences, long ones split at commas, short ones joined."""
    parts = [p.strip() for p in re.split(r"(?<=[.!?:;])\s+|\n{2,}", text.replace("\r", "")) if p.strip()]
    out = []
    for part in parts:
        part = re.sub(r"\s+", " ", part)
        while len(part) > 300:                              # very long: break at a comma or space
            cut = max(part.rfind(", ", 0, 300), part.rfind(" ", 0, 300))
            cut = cut if cut > 100 else 300
            out.append(part[:cut + 1].strip())
            part = part[cut + 1:].strip()
        if out and len(out[-1]) < 40:
            out[-1] = f"{out[-1]} {part}"
        else:
            out.append(part)
    return out


class Narrator(QObject):
    """Reads text aloud with Piper, a sentence at a time (the next one is prepared while one plays),
    so it starts quickly and a speed change applies at once (the current sentence restarts).
    Previous / Next (skip) move a whole part, playing or paused. A part the Speech engine fails
    to make (it doesn't start, or ends without the sound) is tried once more; then it stops there
    and says why (failed), rather than trying again and again."""
    progress = pyqtSignal(int, int)          # sentence index, count (also while paused there)
    finished = pyqtSignal()
    failed = pyqtSignal(str, int)            # why (English, N_), the part's index: stopped there
    TRIES = 2

    def __init__(self, text, speed):
        super().__init__()
        self.parts, self.speed = sentences(text), speed
        self.dir = Path(tempfile.mkdtemp(prefix="snip-narrate-"))
        self.wavs = {}                       # index -> (speed, path) synthesized
        self.synth = None                    # (index, speed, QProcess) being synthesized
        self.failures = {}                   # index -> times the Speech engine failed to make it
        self.why = None                      # what went wrong the last time
        self.player = None
        self.i = 0
        self.stopped = False
        self.paused = False

    def start(self):
        self._synth(self.i)

    def _synth(self, i):
        if (self.stopped or i >= len(self.parts) or self.synth or self.failures.get(i, 0) >= self.TRIES
                or (i in self.wavs and self.wavs[i][0] == self.speed)):
            return
        out = self.dir / f"{i}-{self.speed}.wav"
        proc = QProcess(self)
        speed = self.speed
        self.synth = (i, speed, proc)        # (before it starts: one that can't start says so)
        proc.finished.connect(lambda *_: self._synthesized(i, speed, out, proc))
        # One that can't start (Piper gone, say) never finishes: that's a failure too.
        proc.errorOccurred.connect(lambda e: e == QProcess.ProcessError.FailedToStart
                                   and self._synthesized(i, speed, out, proc, started=False))
        conf = narrate_conf()
        if conf.get("engine") == "espeak-ng" and ESPEAK.exists():
            env = QProcessEnvironment.systemEnvironment()
            env.insert("LD_LIBRARY_PATH", str(ESPEAK.parent))
            env.insert("ESPEAK_DATA_PATH", str(ESPEAK.parent))
            proc.setProcessEnvironment(env)
            proc.start(str(ESPEAK), ["-v", conf.get("espeak_voice") or "en-us", "-s", str(round(175 * speed)),
                                     "-w", str(out), "--stdin"])
        else:
            proc.start(str(PIPER), ["--model", str(VOICE), "--config", f"{VOICE}.json", "--output_file", str(out),
                                    "--length_scale", f"{1 / speed:.3f}"])
        proc.write(self.parts[i].encode())
        proc.closeWriteChannel()

    def _synthesized(self, i, speed, out, proc, started=True):
        if self.synth is None or self.synth[2] is not proc:
            return                           # (already done with)
        self.synth = None
        proc.deleteLater()
        if self.stopped:
            return
        if not (started and proc.exitStatus() == QProcess.ExitStatus.NormalExit and proc.exitCode() == 0
                and out.is_file() and out.stat().st_size):
            self.failures[i] = self.failures.get(i, 0) + 1       # (tried again, up to TRIES: _play)
            self.why = (N_("The Speech engine failed to read part {part}.") if started
                        else N_("The Speech engine didn't start: reinstall it in Frame Tweaks."))
        elif speed == self.speed:
            self.wavs[i] = (speed, out)
        if self.player is None:
            self._play()
        self._synth(self.i + 1 if self.player is not None else self.i)

    def _play(self):
        if self.stopped:
            return
        if self.i >= len(self.parts):        # read to the end (before Pause: one pressed as the last
            self.paused = False              # part ended isn't left standing, Stop then reading it again)
            self.finished.emit()
            return
        self.progress.emit(self.i, len(self.parts))    # where it is, even before it plays (Previous / Next count from it)
        if self.paused:
            return
        w = self.wavs.get(self.i)
        if not w or w[0] != self.speed:
            if self.failures.get(self.i, 0) >= self.TRIES:
                self.stop()                  # the Speech engine can't make this part: stop here, saying why
                self.failed.emit(self.why, self.i)
            else:
                self._synth(self.i)          # not ready yet: plays when it is
            return
        self.player = QProcess(self)
        self.player.finished.connect(self._played)
        self.player.start("pw-play", [str(w[1])])
        self._synth(self.i + 1)

    def _played(self, *_):
        if self.player:
            self.player.deleteLater()
        self.player = None
        if self.stopped:
            return
        self.i += 1
        self._play()

    def _drop_player(self):
        """Stop the sound playing, without moving on (_played isn't called)."""
        if self.player:
            p, self.player = self.player, None
            if self.paused and p.processId():
                os.kill(p.processId(), signal.SIGCONT)            # a stopped process can't be killed cleanly
            p.finished.disconnect()
            p.kill()
            p.waitForFinished(500)
            p.deleteLater()

    def set_speed(self, speed):
        """New speed: the current sentence starts again at it."""
        self.speed = speed
        self._drop_player()
        self._play()

    def skip(self, d):
        """Previous (-1) / Next (1): the start of that part, at once. Paused, it stays paused there
        (that part is prepared, and Resume starts it). Nothing before the first or after the last."""
        i = self.i + d
        if self.stopped or not 0 <= i < len(self.parts):
            return
        self.i = i
        self._drop_player()
        self._play()
        if self.paused:
            self._synth(i)

    def again(self):
        self.stopped, self.paused, self.i = False, False, 0
        self.failures.clear()                # (a part that failed is tried again)
        self._play()

    def pause(self):
        """Freeze the sound where it is (the player is suspended, so Resume carries on mid-word)."""
        self.paused = True
        if self.player and self.player.processId():
            os.kill(self.player.processId(), signal.SIGSTOP)

    def resume(self):
        self.paused = False
        if self.player and self.player.processId():
            os.kill(self.player.processId(), signal.SIGCONT)
        elif self.player is None:
            self._play()

    def stop(self):
        self.stopped = True
        if self.paused and self.player and self.player.processId():
            os.kill(self.player.processId(), signal.SIGCONT)      # a stopped process can't be killed cleanly
        self.paused = False
        for p in (self.player, self.synth[2] if self.synth else None):
            if p is not None:
                p.kill()
                p.waitForFinished(500)

    def cleanup(self):
        self.stop()
        shutil.rmtree(self.dir, ignore_errors=True)


SPEECH_CONF = HOME / ".config/frame-tweaks/speech.conf"


def speech_settings():
    """(engine, language) from speech.conf, as the Speech engine reads them."""
    conf = {"SPEECH_ENGINE": "piper", "SPEECH_LANGUAGE": "en_US", **ftconf.read_shell(SPEECH_CONF)}
    return conf["SPEECH_ENGINE"], conf["SPEECH_LANGUAGE"]


def save_speech_setting(key, value):
    """One KEY=value line in speech.conf (other lines kept)."""
    ftconf.set_shell(SPEECH_CONF, {key: value})


class SpeechConfig(QWidget):
    """Narrate's ⚙ view: engine and language (the Speech engine's settings, as in Frame Tweaks),
    and Apply, which downloads a new language's voice and switches (DATA/apply)."""

    def __init__(self, back):
        super().__init__()
        lay = QVBoxLayout(self)
        lay.setContentsMargins(18, 16, 18, 16)
        head = QHBoxLayout()
        b = tr_text(QPushButton(), N_("← Back"))
        b.clicked.connect(back)
        head.addWidget(b)
        title = tr_text(QLabel(), N_("Narrate's voice"))
        title.setStyleSheet("font-size: 22px; font-weight: 600;")
        head.addWidget(title, 1)
        lay.addLayout(head)
        self.buttons = {}
        lay.addWidget(self.heading(N_("Narrate with")))
        row = QHBoxLayout()
        for value, label in (("piper", N_("Piper: natural")), ("espeak-ng", N_("eSpeak NG: fast"))):
            row.addWidget(tr_text(self.choice("SPEECH_ENGINE", value, label), label))
        row.addStretch(1)
        lay.addLayout(row)
        lay.addWidget(self.heading(N_("Language")))
        grid = QGridLayout()
        try:
            rows = [l.split("\t") for l in (DATA / "voices.tsv").read_text().splitlines() if l and not l.startswith("#")]
        except OSError:
            rows = [["en_US", "English (US)"]]
        for n, r in enumerate(rows):
            grid.addWidget(self.choice("SPEECH_LANGUAGE", r[0], r[1]), n // 4, n % 4)
        lay.addLayout(grid)
        foot = QHBoxLayout()
        self.apply = tr_text(QPushButton(), N_("Apply"))
        tr_tip(self.apply, N_("Download the chosen language's voice if needed, and switch"))
        self.apply.clicked.connect(self.run_apply)
        foot.addWidget(self.apply)
        self.state = QLabel()
        self.state.setStyleSheet("color: #9aa6b2;")
        self.state.setWordWrap(True)
        foot.addWidget(self.state, 1)
        lay.addLayout(foot)
        for w in self.findChildren(QPushButton):
            w.setStyleSheet("QPushButton { padding: 12px 14px; font-size: 20px; }"
                            " QPushButton:checked { background: #355b7a; font-weight: 700; }")

    @staticmethod
    def heading(text):
        h = tr_text(QLabel(), text)
        h.setStyleSheet("color: #9aa6b2; font-size: 18px; margin-top: 10px;")
        return h

    def choice(self, key, value, label):
        b = QPushButton(label)
        b.setCheckable(True)
        b.clicked.connect(lambda: self.pick(key, value))
        self.buttons[(key, value)] = b
        return b

    def refresh(self):
        engine, lang = speech_settings()
        for (key, value), b in self.buttons.items():
            b.setChecked(value == (engine if key == "SPEECH_ENGINE" else lang))
        tr_text(self.state, N_("Changes apply when you press Apply."))

    def pick(self, key, value):
        save_speech_setting(key, value)
        self.refresh()

    def run_apply(self):
        apply_cmd = DATA / "apply"
        if not apply_cmd.exists():
            tr_text(self.state, N_("Reinstall the Speech engine in Frame Tweaks first."))
            return
        self.apply.setEnabled(False)
        tr_text(self.state, N_("Applying… (a new language downloads its voice, about 60 MB)"))
        proc = QProcess(self)
        proc.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)

        def done(*_args):
            out = [re.sub(r"\x1b\[[0-9;]*m", "", l).strip() for l in bytes(proc.readAll()).decode(errors="replace").splitlines()]
            out = [l for l in out if l and "#" not in l[:3]]
            ok = proc.exitStatus() == QProcess.ExitStatus.NormalExit and proc.exitCode() == 0
            msg = out[-1].removeprefix("==> ") if out else _("Done.")
            self.state.setText(msg if ok else _("Didn't work: %s") % msg)
            self.apply.setEnabled(True)
        proc.finished.connect(done)
        proc.start(str(apply_cmd), [])
        self.proc = proc


class NarrationWindow(QWidget):
    """The snip, popped out, with the narration's controls; the rest of VR stays usable. As a VR
    panel it can collapse to just an Expand button, and take a new snip in place (load)."""
    STYLE = ("QWidget { background: #0b1016; color: #e8ecf0; font-size: 30px; }"
             "QPushButton { background: #232a32; font-weight: 600; padding: 16px 28px; border-radius: 12px;"
             " min-width: 70px; }"
             "QPushButton:hover { background: #355b7a; } QPushButton:disabled { color: #3a434d; }")
    # (the VR panel: its own background is a rounded rectangle, paintEvent, like Arrow Pad's)
    PANEL_STYLE = ("QWidget { background: transparent; color: #e8ecf0; font-size: 22px; }"
                   "QPushButton { background: #232a32; font-weight: 600; padding: 12px 20px; border-radius: 10px;"
                   " min-width: 50px; }"
                   "QPushButton:disabled { color: #3a434d; }")
    MINI_MARGIN = 14                              # around Expand: something to grab it by
    PANEL_BG, PANEL_RADIUS = QColor("#0b1016"), 20    # Arrow Pad's surface colour and corner radius

    def __init__(self, image, text, speed, panel=False):
        super().__init__()
        self.panel, self.speed, self.narrator, self.collapsed = panel, speed, None, False
        self.toggled = False                     # the last resize was Collapse/Expand (see pivot)
        self.setWindowTitle(_("Screen snip: narrating"))
        if i18n is not None:          # (the installer window announces a new language: no polling)
            i18n.follow_announcements(self, self.retranslate)
        self.setStyleSheet(self.PANEL_STYLE if panel else self.STYLE)
        if panel:     # rounded corners: the panel's picture is transparent outside them, as Arrow Pad's
            self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
        outer = QVBoxLayout(self)
        outer.setContentsMargins(0, 0, 0, 0)
        self.full = QWidget()
        outer.addWidget(self.full)
        lay = QVBoxLayout(self.full)
        if panel:
            # Its own VR panel (NarrationOverlay): compact. Moved by the grip, or a trigger-drag on
            # an empty part (the margins).
            lay.setContentsMargins(18, 16, 18, 16)
        else:
            # The Steam session shows every window as a 1920x1080 panel, so fill it: the snip as
            # large as fits above the controls.
            lay.setContentsMargins(40, 30, 40, 30)
        self.pic = QLabel()                       # the snip (or, after Paste, the pasted text)
        self.pic.setAlignment(Qt.AlignmentFlag.AlignCenter)
        lay.addWidget(self.pic, 1)
        self.state = tr_text(QLabel(), N_("Preparing…"))
        self.state.setStyleSheet("color: #9aa6b2;")
        lay.addWidget(self.state)
        row = QHBoxLayout()
        self.toggle = QPushButton()
        icon_button(self.toggle, "media-playback-stop", N_("Stop"))
        self.toggle.clicked.connect(self.stop_or_again)
        row.addWidget(self.toggle)
        # Previous part, Pause / Resume, Next part (enabled where there's a part to go to: progress)
        self.previous_button, self.pause_button, self.next_button = QPushButton(), QPushButton(), QPushButton()
        icon_button(self.previous_button, "media-skip-backward", N_("Previous part"))
        icon_button(self.pause_button, "media-playback-pause", N_("Pause"))
        icon_button(self.next_button, "media-skip-forward", N_("Next part"))
        self.previous_button.clicked.connect(lambda: self.narrator.skip(-1))
        self.pause_button.clicked.connect(self.pause_or_resume)
        self.next_button.clicked.connect(lambda: self.narrator.skip(1))
        for b in (self.previous_button, self.pause_button, self.next_button):
            row.addWidget(b)
        self.view_button = tr_text(QPushButton(), N_("Show text"))
        tr_tip(self.view_button, N_("Switch between the snip's image and the text read from it"))
        self.view_button.clicked.connect(self.toggle_view)
        row.addWidget(self.view_button)
        row.addStretch(1)
        self.slower, self.faster = QPushButton("−"), QPushButton("+")
        self.speed_label = QLabel()
        self.speed_label.setMinimumWidth(110 if not panel else 70)
        self.speed_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.slower.clicked.connect(lambda: self.step(-1))
        self.faster.clicked.connect(lambda: self.step(1))
        for w in (tr_text(QLabel(), N_("Speed")), self.slower, self.speed_label, self.faster):
            row.addWidget(w)
        lay.addLayout(row)
        # Second row: the text itself, and the panel.
        row2 = QHBoxLayout()
        self.copy_image_button = tr_text(QPushButton(), N_("Copy image"))
        tr_tip(self.copy_image_button, N_("Put the snip's image on the clipboard"))
        self.copy_image_button.clicked.connect(self.copy_image)
        copy = tr_text(QPushButton(), N_("Copy text"))
        tr_tip(copy, N_("Put the text being read on the clipboard"))
        copy.clicked.connect(self.copy_text)
        paste = QPushButton()
        icon_button(paste, "edit-paste", N_("Paste: read the clipboard's text instead"))
        paste.clicked.connect(self.paste_text)
        # Collapse at both ends (icon only on the left), so it can be collapsed toward either side;
        # the copy buttons centred between equal-width ends.
        left, right = QWidget(), QWidget()
        left_lay, right_lay = QHBoxLayout(left), QHBoxLayout(right)
        for l in (left_lay, right_lay):
            l.setContentsMargins(0, 0, 0, 0)
        close = QPushButton()
        icon_button(close, "window-close", N_("Close"))
        snip = QPushButton()                      # a new snip; it's read here (see load)
        icon_button(snip, "select-rectangular", N_("Snip something new: it's read here"))
        snip.clicked.connect(start_snip)
        gear = QPushButton()
        icon_button(gear, "configure", N_("Settings: Narrate's voice and language"))
        gear.setIcon(gear_icon())
        gear.clicked.connect(self.open_config)
        close.clicked.connect(self.close)
        right_lay.addWidget(close)
        self.collapse_buttons = []
        if panel:
            collapse_left = QPushButton()
            collapse_left.setIcon(breeze("expand"))      # (Breeze's "expand" is the down chevron)
            collapse_left.setIconSize(QSize(30, 30))
            tr_tip(collapse_left, N_("Collapse"))
            collapse_left.setStyleSheet("min-width: 0px; padding: 11px 12px;")
            collapse_left.clicked.connect(lambda: self.collapse(collapse_left))
            left_lay.addWidget(collapse_left)
            left_lay.addStretch(1)
            collapse = tr_text(QPushButton(), N_("Collapse"), " ")
            collapse.setIcon(breeze("expand"))
            collapse.setIconSize(QSize(30, 30))
            collapse.setLayoutDirection(Qt.LayoutDirection.RightToLeft)      # icon on the right
            collapse.clicked.connect(lambda: self.collapse(collapse))
            right_lay.addWidget(collapse)
            self.collapse_buttons = [collapse_left, collapse]
        self.collapse_button = self.collapse_buttons[-1] if panel else None    # the one last used
        row2.addWidget(left)
        row2.addStretch(1)
        for b in (gear, snip, self.copy_image_button, copy, paste):
            row2.addWidget(b)
        row2.addStretch(1)
        row2.addWidget(right)
        for b in (gear, snip, self.copy_image_button, copy, paste, close) + ((collapse,) if panel else ()):
            b.setStyleSheet("padding: 12px 14px; font-size: 20px;")        # (a narrower row)
        self.row1, self.row2, self.left_end, self.right_end = row, row2, left, right
        self.ensurePolished()
        lay.addLayout(row2)
        # Collapsed: just Expand, with a margin to grab the panel by.
        self.mini = QWidget()
        mini = QHBoxLayout(self.mini)
        mini.setContentsMargins(*[self.MINI_MARGIN] * 4)
        expand = QPushButton()
        expand.setIcon(breeze("collapse"))                # (and its "collapse" the up chevron)
        expand.setIconSize(QSize(36, 36))
        tr_tip(expand, N_("Expand"))
        expand.setStyleSheet("min-width: 0px; padding: 10px;")
        expand.clicked.connect(self.expand)
        self.expand_button = expand
        mini.addWidget(expand)
        self.mini.hide()
        outer.addWidget(self.mini)
        self.config = None                        # the ⚙ settings view (built when opened)
        self.outer = outer
        self.show_speed()
        self.load(image, text)

    def load(self, image, text):
        """Read a (new) snip: stops what was being read, keeps the panel and its place. image None:
        pasted text (shown as text)."""
        if self.narrator is not None:
            self.narrator.cleanup()
        self.image, self.text = image, text
        self.view = "image" if image is not None else "text"
        self.view_button.setEnabled(image is not None)
        self.copy_image_button.setEnabled(image is not None)
        self.show_view()
        tr_text(self.state, N_("Preparing…"))
        icon_button(self.toggle, "media-playback-stop", N_("Stop"))
        icon_button(self.pause_button, "media-playback-pause", N_("Pause"))
        self.pause_button.setEnabled(True)
        self.enable_skips(False)
        self.narrator = Narrator(text, self.speed)
        self.narrator.progress.connect(self.show_progress)
        self.narrator.finished.connect(self.done)
        self.narrator.failed.connect(lambda why, i: self.show_stopped(why, part=i + 1))
        self.narrator.start()
        self.expand() if self.collapsed else self.fit()

    def paintEvent(self, ev):
        if not self.panel:
            return super().paintEvent(ev)
        p = QPainter(self)
        p.setRenderHint(QPainter.RenderHint.Antialiasing)
        p.setPen(Qt.PenStyle.NoPen)
        p.setBrush(self.PANEL_BG)
        p.drawRoundedRect(QRectF(self.rect()), self.PANEL_RADIUS, self.PANEL_RADIUS)

    def retranslate(self):
        """The installer window switched language: the tooltips and labels here follow (a state
        line changes with the next step)."""
        self.setWindowTitle(_("Screen snip: narrating"))
        retranslate_tips(self)
        self.view_button.setText(_("Show image") if self.view == "text" else _("Show text"))

    def show_view(self):
        """The snip's image, or the text (OCR'd or pasted)."""
        if self.view == "text" or self.image is None:
            shown = self.text if len(self.text) <= 600 else self.text[:600].rstrip() + " …"
            self.pic.setPixmap(QPixmap())
            self.pic.setWordWrap(True)
            self.pic.setText(shown)
            self.pic.setStyleSheet("background: #161c23; border-radius: 8px; padding: 14px;"
                                   " font-size: 20px; color: #e8ecf0;")
            self.pic.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
            self.pic_width = 820 if self.panel else 1800
            self.pic.setFixedWidth(self.pic_width)
        else:
            box = (860, 440) if self.panel else (1840, 800)
            pm = QPixmap.fromImage(self.image)
            if pm.width() > box[0] or pm.height() > box[1] or not self.panel:
                pm = pm.scaled(*box, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
            self.pic.setText("")
            # (a word-wrapping label measures its height from its text alone: 0 for an image)
            self.pic.setWordWrap(False)
            self.pic.setStyleSheet("")
            self.pic.setAlignment(Qt.AlignmentFlag.AlignCenter)
            self.pic.setMinimumWidth(0)
            self.pic.setMaximumWidth(16777215)
            self.pic.setPixmap(pm)
            self.pic_width = pm.width()
        self.view_button.setText(_("Show image") if self.view == "text" else _("Show text"))

    def toggle_view(self):
        self.view = "text" if self.view == "image" else "image"
        self.show_view()
        self.fit()

    def copy_image(self):
        if self.image is None:
            return
        ok = copy_image_everywhere(self.image)
        tr_text(self.state, N_("Image copied to the clipboard.") if ok else N_("Couldn't copy (is desktop mode running?)"))

    def copy_text(self):
        ok = to_clipboard(self.text.encode(), "text/plain;charset=utf-8")
        tr_text(self.state, N_("Text copied to the clipboard.") if ok else N_("Couldn't copy (is desktop mode running?)"))

    def paste_text(self):
        text = from_clipboard()
        if not text:
            tr_text(self.state, N_("The clipboard has no text."))
            return
        self.load(None, text)

    def fit(self):
        if not self.panel:
            self.resize(1920, 1080)
            return
        self.setMinimumSize(0, 0)
        self.setMaximumSize(16777215, 16777215)
        if self.collapsed:
            self.setFixedSize(self.mini.sizeHint())
        elif self.config is not None and self.config.isVisible():
            w = max(780, self.full.width())
            self.config.setFixedWidth(w)
            self.setFixedSize(w, self.config.sizeHint().height())
        else:
            # (styled sizes are known once shown: the left end as wide as the right centres the middle)
            self.left_end.setMinimumWidth(self.right_end.sizeHint().width())
            w = max(780, min(900, self.pic_width + 36),
                    *(r.sizeHint().width() + 36 for r in (self.row1, self.row2)))
            self.full.setFixedWidth(w)
            self.setFixedSize(w, self.full.sizeHint().height())

    # ---- ⚙: the Speech engine's settings (the same as in Frame Tweaks: speech.conf) ----------
    def open_config(self):
        if self.config is None:
            self.config = SpeechConfig(self.close_config)
            self.outer.addWidget(self.config)
        self.config.refresh()
        self.full.hide()
        self.config.show()
        self.fit()

    def close_config(self):
        self.config.hide()
        self.full.show()
        self.fit()

    def pivot(self, b=None):
        """Where the Collapse / Expand icon is (widget pixels): the overlay keeps that point still
        when the panel collapses or expands, so the icon stays under the laser. b: that button
        (default: Expand when collapsed, else the Collapse last used)."""
        b = b or (self.expand_button if self.collapsed else self.collapse_button)
        if b is None:
            return None
        if b is self.collapse_buttons[-1]:              # its icon, at the right end
            p = b.mapTo(self, QPoint(b.width() - 14 - 15, b.height() // 2))       # (padding, half the icon)
        else:
            p = b.mapTo(self, QPoint(b.width() // 2, b.height() // 2))
        return p

    def pivots(self):
        """{button: its icon's point} for each Collapse / Expand button now showing: which one is
        pressed next is only known then, so the overlay remembers them all."""
        shown = [self.expand_button] if self.collapsed else self.collapse_buttons
        return {b: self.pivot(b) for b in shown if b is not None}

    def collapse(self, button=None):
        """button: the Collapse used (left or right); Expand comes back to it, its icon in place."""
        self.collapse_button = button or self.collapse_button
        self.toggled = True
        self.collapsed = True
        self.full.hide()
        self.mini.show()
        self.fit()

    def expand(self):
        self.toggled = True
        self.collapsed = False
        self.mini.hide()
        self.full.show()
        self.fit()

    def show_speed(self):
        self.speed_label.setText(f"{self.speed:g}×")
        self.slower.setEnabled(self.speed > SPEEDS[0])
        self.faster.setEnabled(self.speed < SPEEDS[-1])

    def step(self, d):
        i = min(max(0, SPEEDS.index(self.speed) + d), len(SPEEDS) - 1)
        self.speed = SPEEDS[i]
        self.show_speed()
        save_speed(self.speed)
        if not self.narrator.stopped:
            self.narrator.set_speed(self.speed)
            if self.narrator.paused:                     # a new speed carries on reading
                self.narrator.resume()
                icon_button(self.pause_button, "media-playback-pause", N_("Pause"))
                tr_text(self.state, N_("Reading aloud…"))

    def show_progress(self, i, n):
        """The part being read (or paused at), and Previous / Next where there's a part to go to."""
        self.previous_button.setEnabled(i > 0)
        self.next_button.setEnabled(i < n - 1)
        tr_text(self.state, N_("Paused: part {part} of {count}") if self.narrator.paused
                else N_("Reading aloud: part {part} of {count}"), part=i + 1, count=n)

    def enable_skips(self, on):
        self.previous_button.setEnabled(on)
        self.next_button.setEnabled(on)

    def done(self):
        self.show_stopped(N_("Finished."))

    def show_stopped(self, text, **values):
        """Read to the end, stopped, or stopped by a failure (text says which): Read it again."""
        tr_text(self.state, text, **values)
        icon_button(self.toggle, "media-playlist-repeat", N_("Read it again"))
        icon_button(self.pause_button, "media-playback-pause", N_("Pause"))
        self.pause_button.setEnabled(False)
        self.enable_skips(False)

    def pause_or_resume(self):
        if self.narrator.paused:
            icon_button(self.pause_button, "media-playback-pause", N_("Pause"))
            tr_text(self.state, N_("Reading aloud…"))
            self.narrator.resume()          # (after: a part it starts again says which, show_progress)
        else:
            self.narrator.pause()
            icon_button(self.pause_button, "media-playback-start", N_("Resume"))
            tr_text(self.state, N_("Paused."))

    def stop_or_again(self):
        if self.narrator.stopped or self.narrator.i >= len(self.narrator.parts):     # stopped, or read to the end
            self.narrator.speed = self.speed
            icon_button(self.toggle, "media-playback-stop", N_("Stop"))
            tr_text(self.state, N_("Reading aloud…"))
            icon_button(self.pause_button, "media-playback-pause", N_("Pause"))
            self.pause_button.setEnabled(True)
            self.narrator.again()
        else:
            self.narrator.stop()
            self.show_stopped(N_("Stopped."))

    def closeEvent(self, ev):
        self.narrator.cleanup()
        super().closeEvent(ev)


class NarrationOverlay(QObject):
    """Shows a widget as its own SteamVR overlay, the way Arrow Pad does: laser clicks reach its
    buttons; a press elsewhere (its top bar, say) carries it with the controller until released.
    It redraws only when the widget changes, behind a cover overlay so a reload doesn't blink.
    If SteamVR fails it (restarting under it, say), it closes the widget (lost)."""
    WIDTH_M = 0.6
    M_PER_PX = 0.6 / 880                         # constant scale: a collapsed panel is small too

    def __init__(self, widget):
        super().__init__()
        import openvr
        self.vr, self.w = openvr, widget
        self.gone = False                           # closed, or SteamVR failed (lost): no more timers
        widget.destroyed.connect(self.stop)
        openvr.init(openvr.VRApplication_Overlay)
        self.ov, self.system = openvr.VROverlay(), openvr.VRSystem()
        key = f"frame-tweaks.snip.narration.{os.getpid()}"
        self.h = self.ov.createOverlay(key, "Screen snip")
        self.cover = self.ov.createOverlay(key + ".cover", "Screen snip")
        self.ov.setOverlayInputMethod(self.h, openvr.VROverlayInputMethod_Mouse)
        self.ov.setOverlaySortOrder(self.h, 10)
        self.ov.setOverlaySortOrder(self.cover, 11)
        self.width_px = 0
        self.size_m()
        self.drag = None
        self.hover_button = None                    # the button under the laser (for its tick)
        self.grip_drag = False                      # the current carry is a grip squeeze
        self.hovered = set()                        # controllers whose laser is on the panel
        try:
            self.grips = vrkit.GripReader(openvr)
        except Exception:
            self.grips = None
        self.offset = self.load_offset()            # the panel relative to the dashboard bar
        if not (self.offset and self.follow()):
            self.place_default()
        QTimer(self, interval=100, timeout=self.guarded(self.follow)).start()
        self.tex_dir = Path(tempfile.mkdtemp(prefix="snip-panel-"))
        self.n, self.last_digest, self.cover_path, self.pressed, self.drag = 0, None, None, None, None
        self.render()
        self.ov.showOverlay(self.h)
        # Events 45x a second while a laser is on the panel (or it's carried or pressed), 10x
        # otherwise (only to notice one arriving; nothing is lost meanwhile: events queue)
        self.poll_timer = QTimer(self, interval=22, timeout=self.guarded(self.poll))
        self.poll_timer.start()
        QTimer(self, interval=200, timeout=self.guarded(self.render)).start()

    def guarded(self, work):
        """A timer's work (it calls SteamVR): an error in it closes the panel (lost). Leaving the
        timer, it would end the program at once (PyQt6 aborts on one), leaving its files behind."""
        def run():
            if self.gone:
                return
            try:
                work()
            except Exception as e:
                self.lost(e)
        return run

    def lost(self, error):
        """SteamVR failed (restarting, say), and the panel is gone from VR with it: close it as
        Close does (the narration stops; a new snip opens a new panel), rather than read on unseen."""
        self.stop()
        print(f"frame-snip: SteamVR failed ({error!r}); closing the narration panel", file=sys.stderr)
        self.w.close()

    def stop(self):
        """No more of the timers' work: the panel closed (its widget gone), or SteamVR failed."""
        self.gone = True
        for t in self.findChildren(QTimer):
            t.stop()

    def size_m(self):
        """Overlay width from the widget's (constant metres per pixel); True if it changed."""
        if self.w.width() == self.width_px:
            return False
        self.width_px = self.w.width()
        # (the cover keeps the old size and place until the new picture is up: see render)
        self.ov.setOverlayWidthInMeters(self.h, self.width_px * self.M_PER_PX)
        return True

    def height_m(self):
        return self.w.height() * self.M_PER_PX

    # The panel is placed by its top edge (top centre), so it keeps its place when it changes
    # size (collapse / expand, a new snip); the overlay itself is positioned by its centre.
    def centre_from_top(self, top):
        h = self.height_m() / 2
        return [[top[i][0], top[i][1], top[i][2], top[i][3] - top[i][1] * h] for i in range(3)]

    def top_from_centre(self, c):
        h = self.height_m() / 2
        return [[c[i][0], c[i][1], c[i][2], c[i][3] + c[i][1] * h] for i in range(3)]

    def device_pose(self, index):
        poses = self.system.getDeviceToAbsoluteTrackingPose(self.vr.TrackingUniverseStanding, 0, index + 1)
        if index >= len(poses) or not poses[index].bPoseIsValid:
            return None
        m = poses[index].mDeviceToAbsoluteTracking.m
        return [[m[r][c] for c in range(4)] for r in range(3)]

    BAR = "valve.steam.gamepadui.bar"
    # Default spot: beside Arrow Pad's (which is centred under the dashboard bar), to its left,
    # at its height, 24 cm toward you, leaning back 30° for reading.
    LEFT, BELOW, TOWARD, PITCH = 0.62, 0.06, 0.24, 30

    def anchor(self):
        """The dashboard bar's bottom centre, scale removed (as Arrow Pad uses it), or None."""
        try:
            h = self.ov.findOverlay(self.BAR)
            scale = self.ov.getOverlayMouseScale(h)
            at = self.vr.HmdVector2_t()
            at.v[0], at.v[1] = scale.v[0] / 2, 0.0
            r = self.ov.getTransformForOverlayCoordinates(h, self.vr.TrackingUniverseStanding, at)
            m = r.m if hasattr(r, "m") else r[-1].m
            rows = [[m[i][j] for j in range(4)] for i in range(3)]
            for c in range(3):
                n = sum(rows[i][c] ** 2 for i in range(3)) ** 0.5 or 1.0
                for i in range(3):
                    rows[i][c] /= n
            return rows
        except Exception:
            return None

    def follow(self):
        """Pose = dashboard anchor x the remembered offset (follows the dashboard when it moves)."""
        if self.drag is not None or not getattr(self, "offset", None):
            return False
        if getattr(self, "height_px", 0) and (self.w.width(), self.w.height()) != (self.width_px, self.height_px):
            return True                       # a resize is pending: render() places it (pivot / top edge)
        a = self.anchor()
        if a is None:
            return False
        pose = self.centre_from_top(vrkit.rigid_mul(a, self.offset))
        if not getattr(self, "pose", None) or max(abs(x - y) for r1, r2 in zip(pose, self.pose) for x, y in zip(r1, r2)) > 0.0015:
            self.pose = pose
            self.apply_pose()
        return True

    def load_offset(self):
        off = ftconf.read_json(SETTINGS).get("panel_top")
        return off if isinstance(off, list) and len(off) == 3 else None

    def save_offset(self):
        a = self.anchor()
        if a is None:
            return
        self.offset = vrkit.relative(a, self.top_from_centre(self.pose))
        # (older versions kept the centre, as panel_offset)
        ftconf.update_json(SETTINGS, {"panel_top": self.offset}, drop=("panel_offset",))

    def place_default(self):
        import math
        a = self.anchor()
        if a is None:
            return self.place_in_front()
        ax = [a[0][0], 0.0, a[2][0]]                        # the bar's left-right, made level
        n = math.hypot(ax[0], ax[2]) or 1.0
        ax = [ax[0] / n, 0.0, ax[2] / n]
        ay, az = [0.0, 1.0, 0.0], [-ax[2], 0.0, ax[0]]
        if az[0] * a[0][2] + az[2] * a[2][2] < 0:           # "toward you"
            az, ax = [-az[0], 0.0, -az[2]], [-ax[0], 0.0, -ax[2]]
        t = math.radians(self.PITCH)
        y = [ay[i] * math.cos(t) - az[i] * math.sin(t) for i in range(3)]
        z = [ay[i] * math.sin(t) + az[i] * math.cos(t) for i in range(3)]
        h_m = self.WIDTH_M * self.w.height() / max(1, self.w.width())
        org = [a[i][3] for i in range(3)]
        centre = [org[i] - ax[i] * self.LEFT - ay[i] * self.BELOW - y[i] * (h_m / 2) + az[i] * self.TOWARD
                  for i in range(3)]
        self.pose = [[ax[i], y[i], z[i], centre[i]] for i in range(3)]
        self.apply_pose()
        self.save_offset()

    def place_in_front(self):
        """Without a dashboard: 0.65 m ahead of the headset, a little below eye level, facing you."""
        head = self.device_pose(self.vr.k_unTrackedDeviceIndex_Hmd)
        if head is None:
            fx, fz, pos = 0.0, -1.0, [0.0, 1.4, 0.0]
        else:
            fx, fz = -head[0][2], -head[2][2]
            n = (fx * fx + fz * fz) ** 0.5 or 1.0
            fx, fz, pos = fx / n, fz / n, [head[r][3] for r in range(3)]
        x_axis, z_axis = [-fz, 0.0, fx], [-fx, 0.0, -fz]
        centre = [pos[0] + fx * 0.65, pos[1] - 0.15, pos[2] + fz * 0.65]
        self.pose = [[x_axis[i], (0.0, 1.0, 0.0)[i], z_axis[i], centre[i]] for i in range(3)]
        self.apply_pose()

    def apply_pose(self, cover=True):
        m = vrkit.hmd34(self.vr, self.pose)
        for o in (self.h, self.cover) if cover else (self.h,):
            self.ov.setOverlayTransformAbsolute(o, self.vr.TrackingUniverseStanding, m)

    def render(self):
        resized = self.w.width() != self.width_px or self.w.height() != getattr(self, "height_px", 0)
        if resized and self.cover_path is not None:
            # A new size (Collapse / Expand, a new snip): the old picture stretched to the new size
            # flickered like static. Show the old one (cover: old size and place) and hide the
            # panel until its new picture has loaded.
            self.ov.showOverlay(self.cover)
            self.ov.hideOverlay(self.h)
        if resized:
            k = self.M_PER_PX
            old_w, old_h = self.width_px, getattr(self, "height_px", 0)
            new_w, new_h = self.w.width(), self.w.height()
            moved = False
            if getattr(self, "pose", None) and old_h:
                # The point of the button just pressed, before (remembered) and now.
                pressed = self.w.collapse_button if self.w.collapsed else self.w.expand_button
                pv, old_pv = self.w.pivot(), getattr(self, "last_pivots", {}).get(pressed)
                if getattr(self.w, "toggled", False) and pv is not None and old_pv is not None:
                    # Collapse / Expand: the icon's point stays where it was.
                    a = (old_pv.x() - old_w / 2 - (pv.x() - new_w / 2)) * k     # along the panel's x
                    b = (old_pv.y() - old_h / 2 - (pv.y() - new_h / 2)) * k     # pixels down = -y
                    self.pose = [[c[0], c[1], c[2], c[3] + c[0] * a - c[1] * b] for c in self.pose]
                else:                                       # otherwise the top edge stays
                    self.pose = [[c[0], c[1], c[2], c[3] + c[1] * (old_h - new_h) * k / 2] for c in self.pose]
                moved = True
            self.w.toggled = False
            self.size_m()
            self.height_px = new_h
            if moved:
                self.apply_pose(cover=False)
                if self.offset:                             # (follow() works from the offset)
                    self.save_offset()
        self.last_pivots = self.w.pivots() if hasattr(self.w, "pivots") else {}
        img = self.w.grab().toImage()
        digest = hash(bytes(img.constBits().asstring(img.sizeInBytes())))
        if digest == self.last_digest and not resized:
            return
        self.last_digest = digest
        self.n += 1
        path = str(self.tex_dir / f"panel-{self.n}.png")      # new name each time (SteamVR caches by name)
        img.save(path, "PNG")
        covered = self.cover_path is not None
        if covered:
            self.ov.showOverlay(self.cover)                   # the old picture on top while it reloads
        self.ov.setOverlayFromFile(self.h, path)
        scale = self.vr.HmdVector2_t()
        scale.v[0], scale.v[1] = img.width(), img.height()
        self.ov.setOverlayMouseScale(self.h, scale)
        QTimer.singleShot(120 if covered else 0, self.guarded(lambda: self.uncover(path)))

    def uncover(self, path):
        self.ov.showOverlay(self.h)
        self.ov.hideOverlay(self.cover)
        self.ov.setOverlayFromFile(self.cover, path)
        self.ov.setOverlayWidthInMeters(self.cover, self.width_px * self.M_PER_PX)
        self.apply_pose()
        old, self.cover_path = self.cover_path, path
        if old and old != path:
            Path(old).unlink(missing_ok=True)

    def to_widget(self, mouse):
        return QPoint(int(mouse.x), int(self.w.height() - mouse.y))

    def send(self, target, etype, pos, buttons):
        local = target.mapFrom(self.w, pos) if target is not self.w else pos
        QApplication.sendEvent(target, QMouseEvent(etype, QPointF(local), QPointF(pos), Qt.MouseButton.LeftButton,
                                                   buttons, Qt.KeyboardModifier.NoModifier))

    def button_at(self, pos):
        t = self.w.childAt(pos)
        while t is not None and not isinstance(t, QPushButton):
            t = t.parentWidget()
        return t

    def poll(self):
        vr, ev = self.vr, self.vr.VREvent_t()
        while self.ov.pollNextOverlayEvent(self.h, ev)[0]:
            t, dev = ev.eventType, ev.trackedDeviceIndex
            if t == vr.VREvent_FocusLeave:
                self.hover_button = None
                if dev == vr.k_unTrackedDeviceIndexInvalid:
                    self.hovered.clear()
                else:
                    self.hovered.discard(dev)
                continue
            if t == vr.VREvent_MouseMove:
                self.hovered.add(dev)
                b = self.button_at(self.to_widget(ev.data.mouse))
                if b is not self.hover_button:
                    self.hover_button = b
                    if b is not None and b.isEnabled():
                        self.haptic(dev, "hover")
            if t == vr.VREvent_MouseButtonDown and self.drag is not None:
                continue                                       # carrying it by the grip: no clicks
            if t == vr.VREvent_MouseButtonDown:
                pos = self.to_widget(ev.data.mouse)
                b = self.button_at(pos)
                if b is not None:
                    self.haptic(dev, "press")
                    self.pressed = b
                    self.send(b, QEvent.Type.MouseButtonPress, pos, Qt.MouseButton.LeftButton)
                else:                                          # elsewhere: carry the panel
                    ctrl = self.device_pose(dev)
                    if ctrl is not None:
                        self.drag = (dev, vrkit.relative(ctrl, self.pose))
            elif t == vr.VREvent_MouseButtonUp:
                pos = self.to_widget(ev.data.mouse)
                if self.pressed is not None:
                    self.send(self.pressed, QEvent.Type.MouseButtonRelease, pos, Qt.MouseButton.NoButton)
                    self.pressed = None
                if self.drag is not None and not self.grip_drag:
                    self.drag = None
                    self.save_offset()                     # remembered relative to the dashboard
        # Grip to carry it, as on Arrow Pad and Full Keyboard: squeeze while that controller's
        # laser is on the panel (buttons included), let go to put it down. (Read only then: each
        # read asks SteamVR for controller properties, which other programs wait on too.)
        grips = []
        if self.grips and (self.hovered or self.grip_drag):
            grips = self.grips.poll()
        elif self.grips:
            self.grips.pause()                  # (a grip squeezed elsewhere doesn't grab the panel)
        held = {d for d, h, _ in grips if h}
        for d, _, pressed in grips:
            if pressed and d in self.hovered and self.drag is None:
                ctrl = self.device_pose(d)
                if ctrl is not None:
                    if self.pressed is not None:           # a squeeze cancels a press in progress
                        self.send(self.pressed, QEvent.Type.MouseButtonRelease, QPoint(-1, -1), Qt.MouseButton.NoButton)
                        self.pressed = None
                    self.drag, self.grip_drag = (d, vrkit.relative(ctrl, self.pose)), True
        if self.drag is not None and self.grip_drag and self.drag[0] not in held:
            self.drag, self.grip_drag = None, False
            self.save_offset()
        if self.drag is not None:
            ctrl = self.device_pose(self.drag[0])
            if ctrl is not None:
                self.pose = vrkit.rigid_mul(ctrl, self.drag[1])
                self.apply_pose()
        busy = self.hovered or self.drag is not None or self.pressed is not None
        timer = getattr(self, "poll_timer", None)
        if timer is not None and timer.interval() != (22 if busy else 100):
            timer.setInterval(22 if busy else 100)

    def haptic(self, dev, kind):
        """A tick on the controller pointing at a button, a firmer one on a press (Arrow Pad's and
        Full Keyboard's feel). A direct pulse: SteamVR Input haptics need input focus."""
        try:
            vrkit.direct_pulse(self.vr, self.system, dev, vrkit.TICK[kind])
        except Exception:
            pass

    def close(self):
        try:
            for o in (self.h, self.cover):
                self.ov.destroyOverlay(o)
            self.vr.shutdown()
        except Exception:
            pass
        shutil.rmtree(self.tex_dir, ignore_errors=True)


def vr_panel_available():
    try:
        import openvr  # noqa: F401
        return True
    except Exception:
        return False


# ---- selection window -----------------------------------------------------------------------
class Snip(QWidget):
    def __init__(self, image):
        super().__init__()
        self.setWindowTitle(_("Screen snip"))
        self.image, self.pix = image, QPixmap.fromImage(image)
        self.start = self.end = None              # a new rectangle being drawn
        self.sel = None                           # the selection, once drawn (adjustable)
        self.drag = None
        self.setCursor(Qt.CursorShape.CrossCursor)
        self.hint = QLabel(_("Drag a rectangle to snip, then adjust it with its handles  ·  Esc or right-click cancels"), self)
        self.hint.setStyleSheet("background: rgba(11,16,22,220); color: #e8ecf0; font-size: 22px;"
                                "padding: 10px 18px; border-radius: 10px;")
        self.hint.adjustSize()
        self.bar = QWidget(self)
        self.bar.setStyleSheet(
            "QWidget { background: rgba(11,16,22,235); border-radius: 12px; }"
            "QPushButton { background: #232a32; color: #e8ecf0; font-size: 22px; font-weight: 600;"
            " padding: 14px 22px; border-radius: 10px; min-width: 120px; }"
            "QPushButton:hover { background: #355b7a; }")
        lay = QHBoxLayout(self.bar)
        lay.setContentsMargins(10, 10, 10, 10)
        self.speed = load_speed()
        for label, fn in ((_("Copy"), self.copy_image), (_("Save"), self.save), (_("Copy text"), self.copy_text),
                          (_("Narrate"), self.narrate), (_("Cancel"), self.close)):
            b = QPushButton(label)
            b.clicked.connect(fn)
            if fn != self.narrate:
                lay.addWidget(b, 0, Qt.AlignmentFlag.AlignTop)
                continue
            # Narrate, with its reading speed underneath
            cell = QVBoxLayout()
            cell.setSpacing(6)
            cell.addWidget(b)
            speeds = QHBoxLayout()
            self.speed_down, self.speed_up = QPushButton("−"), QPushButton("+")
            self.speed_text = QLabel()
            self.speed_text.setAlignment(Qt.AlignmentFlag.AlignCenter)
            self.speed_text.setStyleSheet("background: transparent; color: #e8ecf0; font-size: 20px;")
            self.speed_text.setMinimumWidth(70)
            for w, step in ((self.speed_down, -1), (self.speed_up, 1)):
                w.setStyleSheet("min-width: 44px; max-width: 44px; padding: 6px 0px; font-size: 22px;")
                w.clicked.connect(lambda _=False, st=step: self.step_speed(st))
            speeds.addWidget(self.speed_down)
            speeds.addWidget(self.speed_text, 1)
            speeds.addWidget(self.speed_up)
            cell.addLayout(speeds)
            lay.addLayout(cell)
            self.narrate_button = b
        self.show_speed()
        self.bar.adjustSize()
        self.bar.hide()
        self.resize(self.pix.size())
        self.hint.move((self.width() - self.hint.width()) // 2, 24)

    # selection: draw a rectangle, then adjust it with the handles (or drag it from inside)
    HANDLE = 30                                   # px: handle size, big enough for the laser

    def selection(self):
        if self.sel is not None:
            return self.sel
        if not self.start or not self.end:
            return QRect()
        r = QRect(self.start, self.end).normalized()
        return r if r.width() >= MIN_SIZE and r.height() >= MIN_SIZE else QRect()

    def handles(self):
        """name -> square: corners and edge midpoints of the selection ("tl", "tc", ... "br")."""
        r = self.sel
        if r is None:
            return {}
        xs = {"l": r.left(), "c": r.center().x(), "r": r.right()}
        ys = {"t": r.top(), "m": r.center().y(), "b": r.bottom()}
        h = self.HANDLE
        return {v + u: QRect(xs[u] - h // 2, ys[v] - h // 2, h, h)
                for v in "tmb" for u in "lcr" if v + u != "mc"}

    def scale_to_image(self):
        return self.image.width() / max(1, self.width()), self.image.height() / max(1, self.height())

    def snipped(self):
        r = self.selection()
        sx, sy = self.scale_to_image()
        return self.image.copy(round(r.x() * sx), round(r.y() * sy), round(r.width() * sx), round(r.height() * sy))

    def place_bar(self):
        r = self.sel
        x = min(max(0, r.center().x() - self.bar.width() // 2), self.width() - self.bar.width())
        below = r.bottom() + self.HANDLE
        y = below if below + self.bar.height() <= self.height() else max(0, r.top() - self.HANDLE - self.bar.height())
        self.bar.move(x, y)
        self.bar.show()
        self.bar.raise_()

    def mousePressEvent(self, ev):
        if ev.button() == Qt.MouseButton.RightButton:
            return self.close()
        p = ev.position().toPoint()
        self.drag = None
        if self.sel is not None:
            for name, sq in self.handles().items():
                if sq.adjusted(-6, -6, 6, 6).contains(p):
                    self.drag = ("resize", name, QRect(self.sel))
                    break
            else:
                if self.sel.contains(p):
                    self.drag = ("move", p, QRect(self.sel))
        if self.drag is None:                     # outside: start a new selection
            self.sel = None
            self.start = self.end = p
            self.drag = ("new",)
        self.bar.hide()
        self.update()

    def mouseMoveEvent(self, ev):
        if not self.drag:
            return
        p = ev.position().toPoint()
        bounds = self.rect()
        if self.drag[0] == "new":
            self.end = p
        elif self.drag[0] == "move":
            r = QRect(self.drag[2])
            r.translate(p - self.drag[1])
            r.moveLeft(min(max(0, r.left()), bounds.width() - r.width()))
            r.moveTop(min(max(0, r.top()), bounds.height() - r.height()))
            self.sel = r
        else:
            name, r = self.drag[1], QRect(self.drag[2])
            x, y = min(max(0, p.x()), bounds.width() - 1), min(max(0, p.y()), bounds.height() - 1)
            if name[1] == "l":
                r.setLeft(min(x, r.right() - MIN_SIZE))
            if name[1] == "r":
                r.setRight(max(x, r.left() + MIN_SIZE))
            if name[0] == "t":
                r.setTop(min(y, r.bottom() - MIN_SIZE))
            if name[0] == "b":
                r.setBottom(max(y, r.top() + MIN_SIZE))
            self.sel = r
        self.update()

    def mouseReleaseEvent(self, ev):
        if not self.drag:
            return
        if self.drag[0] == "new":
            self.end = ev.position().toPoint()
            r = self.selection()
            self.sel = None if r.isNull() else r
            self.start = self.end = None
        self.drag = None
        if self.sel is not None:
            self.place_bar()
        self.update()

    def keyPressEvent(self, ev):
        if ev.key() == Qt.Key.Key_Escape:
            self.close()

    def paintEvent(self, ev):
        p = QPainter(self)
        p.drawPixmap(self.rect(), self.pix)
        p.fillRect(self.rect(), QColor(0, 0, 0, 110))            # dim everything...
        r = self.selection()
        if not r.isNull():
            sx, sy = self.scale_to_image()
            p.drawPixmap(r, self.pix, QRect(round(r.x() * sx), round(r.y() * sy),
                                            round(r.width() * sx), round(r.height() * sy)))   # ...but the snip
            p.setPen(QPen(QColor("#7fb2e5"), 3))
            p.drawRect(r)
            p.setPen(QPen(QColor("#0b1016"), 2))
            p.setBrush(QColor("#7fb2e5"))
            for sq in self.handles().values():
                p.drawRoundedRect(sq, 6, 6)
        self.hint.setVisible(r.isNull())

    # actions
    def copy_image(self):
        ok = copy_image_everywhere(self.snipped())
        notify(_("Snip copied") if ok else _("Couldn't copy the snip"), "" if ok else _("the desktop isn't available"))
        self.close()

    def save(self):
        SAVE_DIR.mkdir(parents=True, exist_ok=True)
        path = SAVE_DIR / time.strftime("Snip_%Y-%m-%d_%H-%M-%S.png")
        ok = self.snipped().save(str(path))
        notify(_("Snip saved") if ok else _("Couldn't save the snip"), str(path))
        self.close()

    def copy_text(self):
        text = ocr(self.snipped())
        if text is None:
            tesseract_missing()
        elif not text:
            notify(_("No text found in the snip"))
        else:
            ok = to_clipboard(text.encode(), "text/plain;charset=utf-8")
            notify(_("Text copied") if ok else _("Couldn't copy the text"), text)
        self.close()

    def show_speed(self):
        self.speed_text.setText(f"{self.speed:g}×")
        self.speed_down.setEnabled(self.speed > SPEEDS[0])
        self.speed_up.setEnabled(self.speed < SPEEDS[-1])

    def step_speed(self, d):
        self.speed = SPEEDS[min(max(0, SPEEDS.index(self.speed) + d), len(SPEEDS) - 1)]
        save_speed(self.speed)
        self.show_speed()

    def narrate(self):
        snip = self.snipped()
        text = ocr(snip)
        if not text:
            if text == "":
                notify(_("No text found in the snip"), "")
            else:
                tesseract_missing()
            return self.close()
        if not PIPER.exists() or not VOICE.exists():
            needs.offer("speech", _("Narrate needs the Speech engine"),
                        _("Narrate reads the snip aloud with the Speech engine (Piper, on the Frame itself), "
                          "which isn't installed, or is missing its voice."))
            return self.close()
        # Pop the snip out and give the rest of VR back: the full-screen selection window closes.
        # In VR it's a panel of its own (a separate process, drawn like Arrow Pad); else a window.
        if vr_panel_available():
            d = Path(tempfile.mkdtemp(prefix="snip-narrate-in-"))
            snip.save(str(d / "snip.png"))
            (d / "text.txt").write_text(text)
            # A panel already open reads this one instead, where it is; otherwise start one.
            sock = QLocalSocket()
            sock.connectToServer(PANEL_SOCKET)
            if sock.waitForConnected(500):
                sock.write(json.dumps({"image": str(d / "snip.png"), "text": str(d / "text.txt")}).encode())
                sock.flush()
                sock.waitForBytesWritten(1000)
                sock.disconnectFromServer()
            else:
                subprocess.Popen([sys.executable, os.path.abspath(sys.argv[0]), "--narrate",
                                  str(d / "snip.png"), str(d / "text.txt")], start_new_session=True)
        else:
            self.popout = NarrationWindow(snip, text, self.speed)
            self.popout.show()
        self.close()


def narrate_panel(image_path, text_path):
    """The narration panel process (frame-snip --narrate): its own SteamVR overlay."""
    app = QApplication(sys.argv[:1])
    image, text = QImage(image_path), Path(text_path).read_text()
    shutil.rmtree(Path(image_path).parent, ignore_errors=True)
    w = NarrationWindow(image, text, load_speed(), panel=True)
    w.show()
    overlay = NarrationOverlay(w)
    # Later snips come here (one panel, in its place) instead of opening another.
    QLocalServer.removeServer(PANEL_SOCKET)
    server = QLocalServer()
    server.listen(PANEL_SOCKET)

    def take_snip():
        conn = server.nextPendingConnection()
        if conn is None:
            return
        def read():
            if not conn.waitForReadyRead(1000) and not conn.bytesAvailable():
                return
            try:
                req = json.loads(bytes(conn.readAll()).decode())
                img, txt = QImage(req["image"]), Path(req["text"]).read_text()
                shutil.rmtree(Path(req["image"]).parent, ignore_errors=True)
                w.speed = load_speed()      # the speed set under Narrate (saved there), as a new panel reads
                w.show_speed()
                w.load(img, txt)
            except (OSError, ValueError, KeyError):
                pass
            conn.disconnectFromServer()
        QTimer.singleShot(0, read)
    server.newConnection.connect(take_snip)
    w.destroyed.connect(app.quit)
    w.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
    code = app.exec()
    server.close()
    overlay.close()
    return code


def started_by_steam():
    """Started from Steam's "+" menu: Steam runs it as a game, with its overlay hooked in, and in
    VR the selection window then took no laser input. True if so (and it started itself again
    as a plain program, as Arrow Pad's Snip key does)."""
    if not (os.environ.get("SteamGameId") or os.environ.get("SteamAppId")) or os.environ.get("SFT_SNIP_PLAIN"):
        return False
    env = {k: v for k, v in os.environ.items()
           if not k.startswith(("Steam", "STEAM_", "SteamOverlay", "ENABLE_VK_LAYER"))}
    pre = [p for p in os.environ.get("LD_PRELOAD", "").replace(":", " ").split() if "gameoverlay" not in p]
    env.pop("LD_PRELOAD", None)
    if pre:
        env["LD_PRELOAD"] = ":".join(pre)
    env["SFT_SNIP_PLAIN"] = "1"
    subprocess.Popen([sys.executable, os.path.abspath(sys.argv[0]), *sys.argv[1:]], env=env,
                     start_new_session=True, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
                     stderr=subprocess.DEVNULL)
    return True


def main():
    if started_by_steam():
        return 0
    if XCLIP:
        return x_clipboard_owner(sys.argv[2])
    if NARRATE:
        return narrate_panel(sys.argv[2], sys.argv[3])
    app = QApplication(sys.argv[:1])
    try:
        image = QImage(sys.argv[1]) if len(sys.argv) > 1 else capture_focused()
    except RuntimeError as e:
        notify(_("Couldn't take a snip"), str(e))
        return 1
    if image.isNull():
        notify(_("Couldn't take a snip"), _("empty picture"))
        return 1
    w = Snip(image)
    w.showFullScreen()
    w.activateWindow()
    return app.exec()


if __name__ == "__main__":
    sys.exit(main())
