From 335bd62552d8cdb06587b470ddf63867db50d273 Mon Sep 17 00:00:00 2001 From: fy59 Date: Fri, 9 Oct 2026 05:14:44 +0200 Subject: [PATCH] feat(screenshot): add annotations following system GTK theme --- docs/screenshot-annotation.md | 75 +++++++ sway/.config/sway/bind | 3 + sway/.config/sway/rules | 6 + sway/.config/sway/scripts/screenshot-region | 145 +++++++++++- tests/test_screenshot_annotation.py | 231 ++++++++++++++++++++ 5 files changed, 456 insertions(+), 4 deletions(-) create mode 100644 docs/screenshot-annotation.md create mode 100644 tests/test_screenshot_annotation.py diff --git a/docs/screenshot-annotation.md b/docs/screenshot-annotation.md new file mode 100644 index 000000000..4b3d5f905 --- /dev/null +++ b/docs/screenshot-annotation.md @@ -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. diff --git a/sway/.config/sway/bind b/sway/.config/sway/bind index 63340289c..871092155 100644 --- a/sway/.config/sway/bind +++ b/sway/.config/sway/bind @@ -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 + ############################################### # diff --git a/sway/.config/sway/rules b/sway/.config/sway/rules index 5ee11d2f7..af15cc3e1 100644 --- a/sway/.config/sway/rules +++ b/sway/.config/sway/rules @@ -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 #================================================= diff --git a/sway/.config/sway/scripts/screenshot-region b/sway/.config/sway/scripts/screenshot-region index 38a3c9084..ad0bb9567 100755 --- a/sway/.config/sway/scripts/screenshot-region +++ b/sway/.config/sway/scripts/screenshot-region @@ -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 diff --git a/tests/test_screenshot_annotation.py b/tests/test_screenshot_annotation.py new file mode 100644 index 000000000..fb30500f0 --- /dev/null +++ b/tests/test_screenshot_annotation.py @@ -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()