feat(screenshot): add annotations following system GTK theme
This commit is contained in:
parent
c9a2545656
commit
335bd62552
5 changed files with 456 additions and 4 deletions
75
docs/screenshot-annotation.md
Normal file
75
docs/screenshot-annotation.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# Capture annotée SwayFX V1
|
||||
|
||||
`Print` conserve la capture de zone rapide vers `wl-copy image/png`.
|
||||
`Shift+Print` conserve la capture plein écran rapide. `Ctrl+Print` lance
|
||||
`screenshot-region --annotate` : sélectionner une zone, puis annoter dans
|
||||
Swappy. Échap pendant la sélection annule avant `grim` ; fermer Swappy annule
|
||||
sans copie ni sauvegarde.
|
||||
Avant `slurp`, les panneaux QuickShell Clipboard, Applications et Overview
|
||||
ouverts sur la sortie focalisée sont fermés via leurs IPC, puis leur disparition
|
||||
est attendue. Aucun workspace ni fenêtre utilisateur n'est déplacé.
|
||||
|
||||
Swappy 1.8.0 fournit flèche, rectangle, texte, pinceau, annulation et
|
||||
rétablissement. Le panneau est ouvert au démarrage. `Ctrl+C` copie la version
|
||||
annotée en `image/png` avec l'action native de Swappy ; `Ctrl+S` enregistre.
|
||||
`early_exit=true` ferme l'éditeur après l'une de ces actions. Échap ou
|
||||
`Ctrl+W` ferment sans sauvegarde. Pour masquer une information sensible,
|
||||
utiliser un **rectangle plein opaque** (`f` pour remplir, transparence
|
||||
désactivée), puis vérifier le résultat. Le flou n'est pas une garantie
|
||||
d'effacement d'un secret.
|
||||
|
||||
La capture brute reste dans un répertoire privé `0700` propre à l'invocation
|
||||
sous `XDG_RUNTIME_DIR`. Aucun `wl-copy` ne reçoit l'image brute dans ce mode.
|
||||
Un verrou consultatif sur le répertoire runtime sérialise l'ouverture de
|
||||
Swappy, application GTK à instance unique, lorsque deux raccourcis arrivent
|
||||
presque ensemble ; il ne crée pas de fichier persistant.
|
||||
Swappy reçoit le PNG directement et son bouton Copier appelle `wl-copy` avec
|
||||
le type `image/png`. Les collecteurs cliphist restent inchangés ; une copie
|
||||
Wayland réussie ne garantit pas l'archivage d'un PNG de plus de 5 000 000
|
||||
d'octets dans la version installée de cliphist.
|
||||
|
||||
Le bouton Enregistrer écrit d'abord dans un dossier privé de cette invocation.
|
||||
Après fermeture, le script vérifie le PNG et le publie sous un nom exclusif
|
||||
`Screenshot-YYYYMMDD-HHMMSS-XXXXXXXX.png` dans `Screenshots` du répertoire
|
||||
Images XDG. La configuration actuelle pointe vers `~/Pictures/Screenshots`.
|
||||
Ce dossier n'est créé qu'après un enregistrement explicite. Le script nettoie
|
||||
ses seuls fichiers temporaires à la fermeture, à l'annulation, en erreur et
|
||||
sur interruption. `auto_save=false` et l'absence de `--output-file` sont
|
||||
essentiels : Swappy 1.8.0 écrit cette sortie même lors d'une fermeture.
|
||||
|
||||
Dépendances : Swappy 1.8.0 (`sudo pacman -S swappy` sur Arch), GTK3, Cairo,
|
||||
Pango, `wl-clipboard`, `grim`, `jq`, `flock` (util-linux), Python 3 et
|
||||
`python-pillow`. Le script
|
||||
utilise **`~/.local/libexec/labfy-slurp`** et non `/usr/bin/slurp` ; le
|
||||
construire avec `tools/build-slurp-keymap-guard` si nécessaire. La règle de
|
||||
fenêtre cible exclusivement `me.jtheoof.swappy`, identifiant déclaré dans le
|
||||
code de Swappy 1.8.0. La bordure et les coins viennent de SwayFX ; le thème
|
||||
GTK courant n'est pas modifié.
|
||||
|
||||
## Thème système
|
||||
|
||||
Le thème de Swappy suit la configuration GTK de la session, sans forcer
|
||||
un nom de thème ni une variante sombre. Le répertoire `XDG_CONFIG_HOME`
|
||||
temporaire reste propre à Swappy pour ses options d'annotation et de sauvegarde,
|
||||
mais ses sous-répertoires `dconf` et `gtk-3.0` sont des liens symboliques vers
|
||||
la configuration utilisateur originale (`$XDG_CONFIG_HOME`, sinon
|
||||
`$HOME/.config`). Le thème, les icônes, la police et le CSS GTK restent ainsi
|
||||
ceux du système ; ils ne sont pas copiés dans un instantané figé.
|
||||
|
||||
La suppression du répertoire temporaire retire uniquement ces liens, pas
|
||||
les réglages utilisateur. Les chemins XDG avec espaces et l'absence de
|
||||
réglages GTK explicites sont pris en charge. Aucune valeur `GTK_THEME`
|
||||
n'est ajoutée : une éventuelle valeur déjà fournie par la session est
|
||||
conservée, comme pour les autres applications GTK.
|
||||
|
||||
Le diagnostic a reproduit un retour à Adwaita clair sans ces liens, puis
|
||||
retrouvé le même Catppuccin Mocha, les mêmes icônes et la même police que GTK
|
||||
lancé normalement. Les tests isolés couvrent deux thèmes factices successifs,
|
||||
un répertoire XDG personnalisé et la conservation des fichiers originaux.
|
||||
|
||||
Le test isolé `python3 -B -m unittest tests/test_screenshot_annotation.py -v`
|
||||
utilise une image synthétique et de faux exécutables. Une vérification
|
||||
graphique après installation de Swappy reste nécessaire : flèche, cadre,
|
||||
texte, annuler/rétablir, collage de la copie après fermeture, enregistrement
|
||||
explicite et fermeture sans enregistrement. Le test du presse-papiers réel ne
|
||||
doit être lancé qu'avec l'accord de l'utilisateur, car il remplace son contenu.
|
||||
|
|
@ -235,6 +235,9 @@ bindsym Shift+Print exec grim - | wl-copy -t image/png
|
|||
# Print = zone -> presse-papier
|
||||
bindsym Print exec ~/.config/sway/scripts/screenshot-region
|
||||
|
||||
# Ctrl+Print = zone -> annotation ; l'éditeur décide Copier/Enregistrer.
|
||||
bindsym Ctrl+Print exec ~/.config/sway/scripts/screenshot-region --annotate
|
||||
|
||||
|
||||
###############################################
|
||||
#
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@ for_window [app_id="Proton Pass"] floating enable, resize set 40ppt 40ppt, move
|
|||
# la bordure serveur garantit la même politique que les autres flottantes.
|
||||
for_window [app_id="labfy-yazi-filechooser"] floating enable, border pixel 2, move position center
|
||||
|
||||
# WHY: l'identifiant GTK de Swappy 1.8.0 est me.jtheoof.swappy (source
|
||||
# application.c). La règle ne cible aucune autre fenêtre GTK.
|
||||
# CONTRACT: garder l'éditeur accessible sur la sortie active, redimensionnable,
|
||||
# avec la bordure serveur et les coins SwayFX déjà configurés globalement.
|
||||
for_window [app_id="me.jtheoof.swappy"] floating enable, resize set 70ppt 70ppt, move position center, border pixel 2
|
||||
|
||||
#=================================================
|
||||
# Lardon
|
||||
#=================================================
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CONTRACT: sans argument, le parcours Print historique reste inchangé.
|
||||
annotate=false
|
||||
if [[ $# -ne 0 ]]; then
|
||||
[[ $# -eq 1 && $1 == --annotate ]] || { echo "Usage: screenshot-region [--annotate]" >&2; exit 2; }
|
||||
annotate=true
|
||||
command -v swappy >/dev/null || { echo "Swappy absent : installer le paquet Arch swappy (sudo pacman -S swappy)." >&2; exit 1; }
|
||||
fi
|
||||
|
||||
# WHY: la surface plein écran du presse-papiers prend le focus exclusif et
|
||||
# peut faire échouer slurp sur SwayFX. La détruire avant de créer le sélecteur.
|
||||
output="$(swaymsg -t get_outputs -r | jq -r '.[] | select(.focused == true) | .name' | head -n 1)"
|
||||
|
|
@ -21,15 +29,59 @@ if [[ -n "$output" ]]; then
|
|||
fi
|
||||
fi
|
||||
|
||||
if $annotate && [[ -n $output ]]; then
|
||||
# WHY: Applications et Overview possèdent aussi un focus layer-shell
|
||||
# exclusif. Leurs IPC existants libèrent ce focus avant la sélection.
|
||||
# CONTRACT: ne toucher ni au workspace ni aux fenêtres utilisateur.
|
||||
for panel in applicationsUi overviewUi; do
|
||||
target="$panel-$output"
|
||||
state="$(quickshell ipc -c labfy-sway call "$target" state 2>/dev/null || true)"
|
||||
if [[ -n $state ]] && jq -e '.loaded == true or .requested == true' >/dev/null 2>&1 <<<"$state"; then
|
||||
quickshell ipc -c labfy-sway call "$target" toggle >/dev/null
|
||||
for ((attempt = 0; attempt < 40; attempt++)); do
|
||||
state="$(quickshell ipc -c labfy-sway call "$target" state 2>/dev/null || true)"
|
||||
if [[ -n $state ]] && jq -e '.loaded == false and (.requested != true)' >/dev/null 2>&1 <<<"$state"; then
|
||||
break
|
||||
fi
|
||||
sleep 0.05
|
||||
done
|
||||
[[ -n $state ]] && jq -e '.loaded == false and (.requested != true)' >/dev/null 2>&1 <<<"$state" || exit 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# INVARIANT: Échap ou une géométrie vide ne lance ni grim ni wl-copy.
|
||||
selector="$HOME/.local/libexec/labfy-slurp"
|
||||
[[ -x "$selector" ]] || { echo "Sélecteur slurp corrigé absent : exécuter tools/build-slurp-keymap-guard" >&2; exit 1; }
|
||||
geometry="$("$selector")" || exit 0
|
||||
[[ -n "$geometry" ]] || exit 0
|
||||
if $annotate; then
|
||||
# CONTRACT: seules des coordonnées et des dimensions positives atteignent grim.
|
||||
[[ $geometry =~ ^-?[0-9]+,-?[0-9]+[[:space:]][1-9][0-9]*x[1-9][0-9]*$ ]] || { echo "Géométrie slurp invalide" >&2; exit 1; }
|
||||
fi
|
||||
runtime="${XDG_RUNTIME_DIR:?}"
|
||||
image="$(mktemp "$runtime/labfy-screenshot-XXXXXXXX.png")"
|
||||
chmod 600 "$image"
|
||||
trap 'rm -f -- "$image"' EXIT
|
||||
if $annotate; then
|
||||
# INVARIANT: chaque invocation possède exclusivement son répertoire 0700.
|
||||
umask 077
|
||||
workdir="$(mktemp -d "$runtime/labfy-screenshot-annotate-XXXXXXXX")"
|
||||
editor_pid=""
|
||||
cleanup_annotation() {
|
||||
if [[ -n $editor_pid ]]; then
|
||||
kill "$editor_pid" 2>/dev/null || true
|
||||
wait "$editor_pid" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf -- "$workdir"
|
||||
}
|
||||
trap cleanup_annotation EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
trap 'exit 129' HUP
|
||||
image="$workdir/capture.png"
|
||||
else
|
||||
image="$(mktemp "$runtime/labfy-screenshot-XXXXXXXX.png")"
|
||||
chmod 600 "$image"
|
||||
trap 'rm -f -- "$image"' EXIT
|
||||
fi
|
||||
grim -g "$geometry" "$image"
|
||||
# CONTRACT: seul un PNG complet et non vide peut remplacer le presse-papiers.
|
||||
[[ -s "$image" ]] || exit 1
|
||||
|
|
@ -39,4 +91,89 @@ with open(sys.argv[1], 'rb') as image:
|
|||
if image.read(8) != b'\x89PNG\r\n\x1a\n':
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
wl-copy --type image/png < "$image"
|
||||
if ! $annotate; then
|
||||
wl-copy --type image/png < "$image"
|
||||
exit
|
||||
fi
|
||||
|
||||
# CONTRACT: une signature seule ne prouve pas qu'un PNG est décodable.
|
||||
# Ce contrôle supplémentaire appartient au mode annotation ; Print garde son
|
||||
# traitement historique. Pillow est déjà présent sur cette machine.
|
||||
python3 - "$image" <<'PY'
|
||||
import sys
|
||||
from PIL import Image
|
||||
with Image.open(sys.argv[1]) as image:
|
||||
if image.format != "PNG" or image.width < 1 or image.height < 1:
|
||||
raise SystemExit(1)
|
||||
image.verify()
|
||||
PY
|
||||
|
||||
mkdir -m 700 -- "$workdir/config" "$workdir/config/swappy" "$workdir/saved"
|
||||
# WHY: isoler XDG_CONFIG_HOME pour Swappy masquait aussi dconf et GTK,
|
||||
# qui revenaient aux valeurs Adwaita claires au lieu du thème système.
|
||||
# CONTRACT: partager les répertoires de réglages, jamais une copie figée ni
|
||||
# un GTK_THEME forcé. Les liens suivent les changements de configuration ;
|
||||
# leur suppression au nettoyage ne supprime pas les données utilisateur.
|
||||
user_config_home="${XDG_CONFIG_HOME:-$HOME/.config}"
|
||||
for shared_config in dconf gtk-3.0; do
|
||||
ln -s -- "$user_config_home/$shared_config" "$workdir/config/$shared_config"
|
||||
done
|
||||
# WHY: Swappy 1.8.0 écrit le dossier au démarrage et son nom horodaté peut
|
||||
# écraser un autre fichier. Le dossier privé isole le clic Enregistrer ; seul
|
||||
# un fichier issu de ce clic sera publié ensuite avec un nom exclusif.
|
||||
cat > "$workdir/config/swappy/config" <<'CONFIG'
|
||||
[Default]
|
||||
save_dir="$LABFY_SWAPPY_SAVE_DIR"
|
||||
save_filename_format=swappy-%Y%m%d-%H%M%S.png
|
||||
show_panel=true
|
||||
paint_mode=arrow
|
||||
early_exit=true
|
||||
auto_save=false
|
||||
fill_shape=false
|
||||
transparent=false
|
||||
CONFIG
|
||||
export LABFY_SWAPPY_SAVE_DIR="$workdir/saved"
|
||||
# WHY: Swappy est une GtkApplication à instance unique. Deux captures proches
|
||||
# doivent conserver chacune leur fichier et attendre leur propre édition.
|
||||
# Le verrou advisory porte sur le répertoire runtime privé, sans fichier
|
||||
# partagé à laisser ou à nettoyer. Le client ferme ce descripteur hérité.
|
||||
exec {editor_lock}< "$runtime"
|
||||
flock "$editor_lock"
|
||||
XDG_CONFIG_HOME="$workdir/config" swappy --file "$image" {editor_lock}<&- &
|
||||
editor_pid=$!
|
||||
wait "$editor_pid" || exit 1
|
||||
editor_pid=""
|
||||
exec {editor_lock}<&-
|
||||
|
||||
# CONTRACT: Copier est entièrement natif (wl-copy image/png dans Swappy).
|
||||
# Fermer ne crée aucun fichier enregistré. Un code de sortie 0 ne prouve
|
||||
# aucune action ; seul le fichier issu du bouton Enregistrer est publié.
|
||||
shopt -s nullglob
|
||||
saved=("$workdir/saved"/*)
|
||||
[[ ${#saved[@]} -eq 0 ]] && exit 0
|
||||
[[ ${#saved[@]} -eq 1 && -f ${saved[0]} && -s ${saved[0]} ]] || exit 1
|
||||
python3 - "${saved[0]}" <<'PY'
|
||||
import sys
|
||||
from PIL import Image
|
||||
with Image.open(sys.argv[1]) as image:
|
||||
if image.format != "PNG" or image.width < 1 or image.height < 1:
|
||||
raise SystemExit(1)
|
||||
image.verify()
|
||||
PY
|
||||
|
||||
# CONTRACT: XDG_PICTURES_DIR est lu depuis la configuration utilisateur ;
|
||||
# aucun dossier Desktop/Bureau n'est créé. Le chemin peut contenir des espaces.
|
||||
pictures_dir="${XDG_PICTURES_DIR:-}"
|
||||
if [[ -z $pictures_dir && -f ${XDG_CONFIG_HOME:-$HOME/.config}/user-dirs.dirs ]]; then
|
||||
source "${XDG_CONFIG_HOME:-$HOME/.config}/user-dirs.dirs"
|
||||
pictures_dir="${XDG_PICTURES_DIR:-}"
|
||||
fi
|
||||
pictures_dir="${pictures_dir:-$HOME/Pictures}"
|
||||
save_dir="$pictures_dir/Screenshots"
|
||||
mkdir -p -- "$save_dir"
|
||||
destination="$(mktemp "$save_dir/Screenshot-$(date +%Y%m%d-%H%M%S)-XXXXXXXX.png")"
|
||||
chmod 600 "$destination"
|
||||
if ! cp -- "${saved[0]}" "$destination"; then
|
||||
rm -f -- "$destination"
|
||||
exit 1
|
||||
fi
|
||||
|
|
|
|||
231
tests/test_screenshot_annotation.py
Normal file
231
tests/test_screenshot_annotation.py
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
"""Parcours annotation isolé : aucun vrai écran ni presse-papiers n'est lu."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "sway/.config/sway/scripts/screenshot-region"
|
||||
|
||||
|
||||
class ScreenshotAnnotationTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory(prefix="annotation test ")
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name)
|
||||
self.home = self.root / "home with spaces"
|
||||
self.bin = self.root / "fake tools"
|
||||
self.runtime = self.root / "runtime with spaces"
|
||||
self.bin.mkdir()
|
||||
self.runtime.mkdir()
|
||||
(self.home / ".local/libexec").mkdir(parents=True)
|
||||
(self.home / ".config").mkdir()
|
||||
self.pictures = self.root / "Images de test"
|
||||
(self.home / ".config/user-dirs.dirs").write_text(
|
||||
f'XDG_PICTURES_DIR="{self.pictures}"\n')
|
||||
self.png = self.root / "synthetic.png"
|
||||
Image.new("RGB", (4, 3), (7, 13, 29)).save(self.png)
|
||||
self.log = self.root / "calls.log"
|
||||
self.make_tool("swaymsg", "#!/bin/sh\necho '[{\"focused\":true,\"name\":\"eDP-1\"}]'\n")
|
||||
self.make_tool("quickshell", """#!/bin/sh
|
||||
if [ "${TEST_PANELS:-}" = yes ]; then
|
||||
marker="$TEST_PANEL_DIR/$5"
|
||||
if [ "$6" = toggle ]; then
|
||||
touch "$marker"
|
||||
echo true
|
||||
elif [ -e "$marker" ]; then
|
||||
echo '{"loaded":false,"requested":false}'
|
||||
else
|
||||
echo '{"loaded":true,"requested":true}'
|
||||
fi
|
||||
else
|
||||
echo '{"loaded":false}'
|
||||
fi
|
||||
""")
|
||||
self.make_tool("grim", "#!/bin/sh\necho \"$0\" >> \"$TEST_LOG\"\n[ \"$TEST_CASE\" = grim_fail ] && exit 1\n[ \"$TEST_CASE\" = bad_png ] && { printf 'not a png' > \"$3\"; exit 0; }\ncp \"$TEST_PNG\" \"$3\"\n")
|
||||
self.make_tool("wl-copy", "#!/bin/sh\necho \"$0\" >> \"$TEST_LOG\"\n")
|
||||
self.make_tool("swappy", """#!/usr/bin/python3
|
||||
import os, pathlib, sys
|
||||
p = pathlib.Path(os.environ['TEST_LOG'])
|
||||
with p.open('a') as out: out.write(sys.argv[0] + '\\n')
|
||||
assert sys.argv[1] == '--file'
|
||||
assert pathlib.Path(sys.argv[2]).read_bytes() == pathlib.Path(os.environ['TEST_PNG']).read_bytes()
|
||||
config_root = pathlib.Path(os.environ['XDG_CONFIG_HOME'])
|
||||
source_root = pathlib.Path(os.environ['TEST_SOURCE_CONFIG'])
|
||||
assert config_root != source_root
|
||||
for shared in ['dconf', 'gtk-3.0']:
|
||||
link = config_root / shared
|
||||
assert link.is_symlink()
|
||||
assert os.readlink(link) == str(source_root / shared)
|
||||
for source in (source_root / shared).rglob('*'):
|
||||
if source.is_file():
|
||||
assert (link / source.relative_to(source_root / shared)).read_bytes() == source.read_bytes()
|
||||
assert os.environ.get('GTK_THEME') == os.environ.get('TEST_EXPECT_GTK_THEME')
|
||||
config = config_root / 'swappy/config'
|
||||
content = config.read_text()
|
||||
assert 'show_panel=true' in content and 'early_exit=true' in content
|
||||
assert 'auto_save=false' in content and 'save_dir="$LABFY_SWAPPY_SAVE_DIR"' in content
|
||||
assert len(sys.argv) == 3
|
||||
if os.environ['TEST_CASE'] == 'save':
|
||||
dest = pathlib.Path(os.environ['LABFY_SWAPPY_SAVE_DIR']) / 'swappy-test.png'
|
||||
dest.write_bytes(pathlib.Path(os.environ['TEST_PNG']).read_bytes())
|
||||
if os.environ['TEST_CASE'] == 'save_slow':
|
||||
import time
|
||||
time.sleep(0.25)
|
||||
dest = pathlib.Path(os.environ['LABFY_SWAPPY_SAVE_DIR']) / 'swappy-test.png'
|
||||
dest.write_bytes(pathlib.Path(os.environ['TEST_PNG']).read_bytes())
|
||||
""")
|
||||
selector = self.home / ".local/libexec/labfy-slurp"
|
||||
selector.write_text("#!/bin/sh\necho \"$0\" >> \"$TEST_LOG\"\nif [ \"${TEST_PANELS:-}\" = yes ]; then\n [ -e \"$TEST_PANEL_DIR/clipboardUi-eDP-1\" ] && [ -e \"$TEST_PANEL_DIR/applicationsUi-eDP-1\" ] && [ -e \"$TEST_PANEL_DIR/overviewUi-eDP-1\" ] || exit 17\nfi\ncase \"$TEST_CASE\" in cancel) exit 1;; empty) exit 0;; invalid) echo bogus;; *) echo '10,20 4x3';; esac\n")
|
||||
selector.chmod(0o755)
|
||||
|
||||
def make_tool(self, name, body):
|
||||
path = self.bin / name
|
||||
path.write_text(body)
|
||||
path.chmod(0o755)
|
||||
|
||||
def case_env(self, name):
|
||||
env = dict(os.environ, HOME=str(self.home), XDG_CONFIG_HOME=str(self.home / ".config"),
|
||||
XDG_RUNTIME_DIR=str(self.runtime),
|
||||
PATH=f"{self.bin}:{os.environ['PATH']}", TEST_CASE=name,
|
||||
TEST_LOG=str(self.log), TEST_PNG=str(self.png),
|
||||
TEST_SOURCE_CONFIG=str(self.home / ".config"))
|
||||
env.pop("GTK_THEME", None)
|
||||
env.pop("TEST_EXPECT_GTK_THEME", None)
|
||||
env.pop("XDG_PICTURES_DIR", None)
|
||||
return env
|
||||
|
||||
def run_case(self, name, annotate=True):
|
||||
env = self.case_env(name)
|
||||
return subprocess.run([str(SCRIPT)] + (["--annotate"] if annotate else []), env=env,
|
||||
capture_output=True, text=True, timeout=10)
|
||||
|
||||
def calls(self):
|
||||
return self.log.read_text().splitlines() if self.log.exists() else []
|
||||
|
||||
def expected(self, *names):
|
||||
return [str(self.home / ".local/libexec/labfy-slurp") if name == "slurp"
|
||||
else str(self.bin / name) for name in names]
|
||||
|
||||
def assert_clean(self):
|
||||
self.assertEqual(list(self.runtime.iterdir()), [])
|
||||
|
||||
def test_cancel_and_invalid_geometry(self):
|
||||
for case, expected in (("cancel", 0), ("empty", 0), ("invalid", 1)):
|
||||
with self.subTest(case=case):
|
||||
self.log.unlink(missing_ok=True)
|
||||
self.assertEqual(self.run_case(case).returncode, expected)
|
||||
self.assertEqual(self.calls(), self.expected("slurp"))
|
||||
self.assert_clean()
|
||||
|
||||
def test_grim_failure(self):
|
||||
self.assertNotEqual(self.run_case("grim_fail").returncode, 0)
|
||||
self.assertEqual(self.calls(), self.expected("slurp", "grim"))
|
||||
self.assert_clean()
|
||||
|
||||
def test_invalid_png_never_opens_editor(self):
|
||||
self.assertNotEqual(self.run_case("bad_png").returncode, 0)
|
||||
self.assertEqual(self.calls(), self.expected("slurp", "grim"))
|
||||
self.assert_clean()
|
||||
|
||||
def test_exclusive_panels_close_before_selector(self):
|
||||
panel_dir = self.root / "panels"
|
||||
panel_dir.mkdir()
|
||||
env = self.case_env("close")
|
||||
env.update(TEST_PANELS="yes", TEST_PANEL_DIR=str(panel_dir))
|
||||
result = subprocess.run([str(SCRIPT), "--annotate"], env=env,
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(len(list(panel_dir.iterdir())), 3)
|
||||
self.assertEqual(self.calls(), self.expected("slurp", "grim", "swappy"))
|
||||
self.assert_clean()
|
||||
|
||||
def test_close_does_not_publish_or_copy(self):
|
||||
self.assertEqual(self.run_case("close").returncode, 0)
|
||||
self.assertEqual(self.calls(), self.expected("slurp", "grim", "swappy"))
|
||||
self.assertFalse(self.pictures.exists())
|
||||
self.assert_clean()
|
||||
|
||||
def test_explicit_save_and_two_invocations(self):
|
||||
for _ in range(2):
|
||||
self.assertEqual(self.run_case("save").returncode, 0)
|
||||
self.assert_clean()
|
||||
images = list((self.pictures / "Screenshots").glob("Screenshot-*.png"))
|
||||
self.assertEqual(len(images), 2)
|
||||
self.assertEqual(len({p.name for p in images}), 2)
|
||||
self.assertTrue(all(p.read_bytes() == self.png.read_bytes() for p in images))
|
||||
self.assertNotIn(str(self.bin / "wl-copy"), self.calls())
|
||||
|
||||
def test_overlapping_invocations_have_distinct_outputs(self):
|
||||
env = self.case_env("save_slow")
|
||||
processes = [subprocess.Popen([str(SCRIPT), "--annotate"], env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
for _ in range(2)]
|
||||
for process in processes:
|
||||
_, stderr = process.communicate(timeout=10)
|
||||
self.assertEqual(process.returncode, 0, stderr.decode())
|
||||
images = list((self.pictures / "Screenshots").glob("Screenshot-*.png"))
|
||||
self.assertEqual(len(images), 2)
|
||||
self.assertEqual(len({p.name for p in images}), 2)
|
||||
self.assert_clean()
|
||||
|
||||
def test_system_theme_changes_are_shared_without_copy_or_override(self):
|
||||
config = self.home / ".config"
|
||||
for shared in ("dconf", "gtk-3.0"):
|
||||
(config / shared).mkdir()
|
||||
for theme in ("Fixture-Dark", "Fixture-Light"):
|
||||
with self.subTest(theme=theme):
|
||||
# Données factices : aucune base dconf personnelle n'est lue.
|
||||
database = config / "dconf/user"
|
||||
settings = config / "gtk-3.0/settings.ini"
|
||||
css = config / "gtk-3.0/gtk.css"
|
||||
database.write_bytes(("fixture:" + theme).encode())
|
||||
settings.write_text("[Settings]\ngtk-theme-name=" + theme + "\n")
|
||||
css.write_text("/* CSS système factice " + theme + " */\n")
|
||||
before = {p: p.read_bytes() for p in (database, settings, css)}
|
||||
result = self.run_case("close")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual({p: p.read_bytes() for p in before}, before)
|
||||
self.assert_clean()
|
||||
|
||||
def test_custom_xdg_config_and_inherited_theme_are_preserved(self):
|
||||
config = self.root / "custom config with spaces"
|
||||
(config / "dconf").mkdir(parents=True)
|
||||
(config / "gtk-3.0").mkdir()
|
||||
settings = config / "gtk-3.0/settings.ini"
|
||||
settings.write_text("[Settings]\ngtk-theme-name=Fixture-User\n")
|
||||
env = self.case_env("close")
|
||||
env.update(XDG_CONFIG_HOME=str(config), TEST_SOURCE_CONFIG=str(config),
|
||||
GTK_THEME="Fixture-Session", TEST_EXPECT_GTK_THEME="Fixture-Session")
|
||||
result = subprocess.run([str(SCRIPT), "--annotate"], env=env,
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(settings.read_text(), "[Settings]\ngtk-theme-name=Fixture-User\n")
|
||||
self.assert_clean()
|
||||
|
||||
def test_unset_xdg_config_and_missing_gtk_settings_are_supported(self):
|
||||
env = self.case_env("close")
|
||||
env.pop("XDG_CONFIG_HOME")
|
||||
result = subprocess.run([str(SCRIPT), "--annotate"], env=env,
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse((self.home / ".config/gtk-3.0").exists())
|
||||
self.assertFalse((self.home / ".config/dconf").exists())
|
||||
self.assert_clean()
|
||||
|
||||
def test_print_default_and_shift_binding_remain(self):
|
||||
self.assertEqual(self.run_case("close", annotate=False).returncode, 0)
|
||||
self.assertEqual(self.calls(), self.expected("slurp", "grim", "wl-copy"))
|
||||
self.assert_clean()
|
||||
binds = (ROOT / "sway/.config/sway/bind").read_text()
|
||||
self.assertIn("bindsym Shift+Print exec grim - | wl-copy -t image/png", binds)
|
||||
self.assertIn("bindsym Print exec ~/.config/sway/scripts/screenshot-region\n", binds)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in a new issue