diff --git a/.gitignore b/.gitignore index 7e0dc1848..d441ac8b8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,11 @@ sway/.config/sway/generated/theme.conf.tmp sway/.config/sway/generated/wallpaper.conf sway/.config/sway/generated/wallpaper.conf.tmp +# 17G: palettes applicatives recréées depuis l'état Appearance effectif. +kitty/.config/kitty/generated-appearance.conf +qt5ct/.config/qt5ct/colors/labfy-appearance.conf +qt6ct/.config/qt6ct/colors/labfy-appearance.conf + # Local editor/application backups *.bak *.old diff --git a/bin/.local/bin/appearance-app-sync.py b/bin/.local/bin/appearance-app-sync.py new file mode 100755 index 000000000..0774167d7 --- /dev/null +++ b/bin/.local/bin/appearance-app-sync.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +"""Distribuer l'état effectif Appearance aux applications du bureau.""" +import argparse +import configparser +import fcntl +import json +import os +from pathlib import Path +import re +import signal +import subprocess +import sys +import tempfile +import time + +ROOT = Path(__file__).resolve().parents[3] +PALETTE = ROOT / 'quickshell/.config/quickshell/labfy-sway/theme/catppuccin.json' +CONFIG = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) +STATE_DIR = Path(os.environ.get('XDG_STATE_HOME', Path.home() / '.local/state')) / 'labfy-appearance' +STATE = STATE_DIR / 'effective.json' +RESULT = STATE_DIR / 'last-applied.json' +FLAVORS = ('latte', 'frappe', 'macchiato', 'mocha') +ACCENTS = ('rosewater', 'flamingo', 'pink', 'mauve', 'red', 'maroon', 'peach', + 'yellow', 'green', 'teal', 'sky', 'sapphire', 'blue', 'lavender') +COLOR_KEYS = set(ACCENTS) | {'text', 'subtext1', 'subtext0', 'overlay2', 'overlay1', + 'overlay0', 'surface2', 'surface1', 'surface0', + 'base', 'mantle', 'crust'} +HEX = re.compile(r'#[0-9a-fA-F]{6}\Z') + + +def atomic_write(path, data): + """CONTRACT: un ancien fichier valide survit à tout échec avant os.replace.""" + path.parent.mkdir(parents=True, exist_ok=True) + fd, name = tempfile.mkstemp(prefix=path.name + '.', suffix='.tmp', dir=path.parent) + try: + with os.fdopen(fd, 'wb') as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, path) + directory = os.open(path.parent, os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + Path(name).unlink(missing_ok=True) + + +def read_effective(path=STATE): + raw = json.loads(path.read_text(encoding='utf-8')) + if not isinstance(raw, dict): + raise ValueError('effective.json doit être un objet') + keys = ('effectiveFlavor', 'effectiveAccent', 'effectiveDark', + 'effectiveHighContrast', 'effectiveMode', 'revision') + value = {key: raw[key] for key in keys} + flavor, accent = value['effectiveFlavor'], value['effectiveAccent'] + if flavor not in FLAVORS or accent not in ACCENTS: + raise ValueError('flavor/accent invalide') + if type(value['effectiveDark']) is not bool or type(value['effectiveHighContrast']) is not bool: + raise ValueError('booléen effectif invalide') + if type(value['revision']) is not int or value['revision'] < 0: + raise ValueError('revision invalide') + mode = value['effectiveMode'] + if mode not in ('normal', 'sun-light', 'sun-dark'): + raise ValueError('mode invalide') + if value['effectiveDark'] != (flavor != 'latte'): + raise ValueError('dark/flavor incohérents') + if value['effectiveHighContrast'] != (mode != 'normal'): + raise ValueError('contrast/mode incohérents') + if mode == 'sun-light' and flavor != 'latte' or mode == 'sun-dark' and flavor == 'latte': + raise ValueError('flavor/mode incohérents') + return value + + +def read_palette(path=PALETTE): + raw = json.loads(path.read_text(encoding='utf-8')) + if not isinstance(raw, dict) or set(raw) != set(FLAVORS) or any( + not isinstance(p, dict) or set(p) != COLOR_KEYS or + not all(isinstance(c, str) and HEX.fullmatch(c) for c in p.values()) + for p in raw.values()): + raise ValueError('palette Catppuccin invalide') + return raw + + +def run(*args): + return subprocess.run(args, check=True, capture_output=True, text=True, timeout=5) + + +def gtk_adapter(state, colors): + flavor = state['effectiveFlavor'] + dark = state['effectiveDark'] + # WHY: seul Mocha est installé localement ; inventer les autres noms donne + # un fallback GTK imprévisible. Adwaita respecte au moins Light/Dark. + name = 'Catppuccin-Mocha' if flavor == 'mocha' else 'Adwaita' + if name == 'Catppuccin-Mocha' and not (Path.home() / '.themes/Catppuccin-Mocha/gtk-3.0').is_dir(): + name = 'Adwaita' + scheme = 'prefer-dark' if dark else 'prefer-light' + run('gsettings', 'set', 'org.gnome.desktop.interface', 'gtk-theme', name) + run('gsettings', 'set', 'org.gnome.desktop.interface', 'color-scheme', scheme) + return {'status': 'ok', 'theme': name, 'colorScheme': scheme, + 'accent': 'rosewater (installé)' if name == 'Catppuccin-Mocha' else 'default', + 'highContrast': 'unsupported' if state['effectiveHighContrast'] else 'off'} + + +def qt_palette(p, accent): + # CONTRACT: Qt ColorScheme contient 22 rôles QColor, en ordre documenté par + # les fichiers qt5ct/qt6ct existants ; alpha explicite, aucune struct native. + roles = ('text', 'surface1', 'surface2', 'surface0', 'crust', 'mantle', + 'text', 'text', 'text', 'base', 'mantle', 'crust', accent, + 'crust', 'blue', accent, 'mantle', 'text', 'base', 'text', + 'overlay0', accent) + active = ['#ff' + p[r][1:] for r in roles] + inactive = active.copy() + disabled = active.copy() + for index in (0, 6, 8, 13, 14, 15): + inactive[index] = '#ff' + p['subtext0'][1:] + disabled[index] = '#ff' + p['overlay0'][1:] + return ('[ColorScheme]\n' + + 'active_colors=' + ', '.join(active) + '\n' + + 'inactive_colors=' + ', '.join(inactive) + '\n' + + 'disabled_colors=' + ', '.join(disabled) + '\n').encode() + + +def qt_adapter(state, colors): + # WHY: les deux versions doivent pointer sur des palettes cohérentes. + # Les instances Qt déjà ouvertes peuvent garder la palette chargée. + for version in ('qt5ct', 'qt6ct'): + directory = CONFIG / version + settings = directory / (version + '.conf') + target = directory / 'colors/labfy-appearance.conf' + if not settings.is_file(): + raise FileNotFoundError(settings) + content = settings.read_text(encoding='utf-8') + updated, count = re.subn(r'(?m)^color_scheme_path=.*$', + 'color_scheme_path=' + str(target), content) + if count != 1: + raise ValueError('color_scheme_path manquant ou dupliqué: ' + str(settings)) + atomic_write(target, qt_palette(colors[state['effectiveFlavor']], state['effectiveAccent'])) + if updated != content: + atomic_write(settings, updated.encode()) + return {'status': 'ok', 'flavor': state['effectiveFlavor'], 'live': 'new-window'} + + +def kitty_theme(p, accent, opaque): + def line(key, value): + return key + ' ' + value + lines = ['# Généré depuis theme/catppuccin.json ; ne pas éditer.', + line('foreground', p['text']), line('background', p['base']), + line('selection_foreground', p['base']), + line('selection_background', p['rosewater']), + line('cursor', p['rosewater']), line('cursor_text_color', p['base']), + line('active_border_color', p[accent]), + line('inactive_border_color', p['overlay0']), + line('active_tab_foreground', p['crust']), + line('active_tab_background', p[accent]), + line('inactive_tab_foreground', p['text']), + line('inactive_tab_background', p['mantle']), + line('tab_bar_background', p['crust']), + line('background_opacity', '1.0' if opaque else '1.0')] + # CONTRACT: l'opacité normale était la valeur implicite Kitty 1.0. + ansi = ('surface1', 'red', 'green', 'yellow', 'blue', 'pink', 'teal', 'subtext1', + 'surface2', 'red', 'green', 'yellow', 'blue', 'pink', 'teal', 'text') + lines.extend(line('color' + str(i), p[name]) for i, name in enumerate(ansi)) + return ('\n'.join(lines) + '\n').encode() + + +def kitty_adapter(state, colors): + path = CONFIG / 'kitty/generated-appearance.conf' + config = CONFIG / 'kitty/kitty.conf' + if 'include generated-appearance.conf' not in config.read_text(encoding='utf-8'): + raise ValueError('include Kitty manquant') + atomic_write(path, kitty_theme(colors[state['effectiveFlavor']], state['effectiveAccent'], + state['effectiveHighContrast'])) + # WHY: l'instance actuelle surveille kitty.conf, mais pas nécessairement + # l'include créé après son lancement. Kitty 0.49.2 documente SIGUSR1 pour + # recharger la configuration sans exposer de socket remote-control. + signalled = 0 + for entry in os.scandir('/proc'): + if not entry.name.isdecimal(): + continue + try: + if entry.stat().st_uid == os.getuid() and os.readlink(entry.path + '/exe') == '/usr/bin/kitty': + os.kill(int(entry.name), signal.SIGUSR1) + signalled += 1 + except (FileNotFoundError, PermissionError, ProcessLookupError): + continue + return {'status': 'ok', 'reload': 'SIGUSR1', 'instances': signalled, 'opacity': '1.0'} + + +def nvim_adapter(state, colors): + # CONTRACT: Neovim lit effective.json directement au démarrage et via fs_event. + return {'status': 'ok', 'contract': 'effective.json', 'revision': state['revision']} + + +def firefox_adapter(state, colors): + # CONTRACT: lecture seule de deux préférences ; jamais de modification du + # profil Firefox ouvert, de prefs.js ou des bases de données. + root = Path.home() / '.mozilla/firefox' + profiles = configparser.ConfigParser() + if not profiles.read(root / 'profiles.ini'): + return {'status': 'unsupported', 'reason': 'profiles.ini absent'} + install = next((s for s in profiles.sections() if s.startswith('Install')), None) + if not install: + return {'status': 'unsupported', 'reason': 'profil actif indéterminé'} + profile = (root / profiles[install]['Default']).resolve() + if not profile.is_relative_to(root.resolve()): + raise ValueError('chemin de profil Firefox invalide') + prefs = profile / 'prefs.js' + if prefs.stat().st_size > 4_000_000: + raise ValueError('prefs.js trop volumineux pour audit borné') + content = prefs.read_text(encoding='utf-8') + theme_match = re.search(r'^user_pref\("extensions.activeThemeID", "([^"\\]+)"\);$', content, re.M) + override_match = re.search(r'^user_pref\("layout.css.prefers-color-scheme.content-override", (\d+)\);$', content, re.M) + theme = theme_match.group(1) if theme_match else 'indéterminé' + override = int(override_match.group(1)) if override_match else 2 + # WHY: Proton 1.1 fournit `theme` et `dark_theme` dans son manifeste ; + # le test Firefox ouvert a confirmé ses deux palettes avec le portail. + # CONTRACT: seuls les thèmes audités suivent le système ; l'override Auto + # reste obligatoire et cet adaptateur ne fait aucune écriture de profil. + if theme in ('default-theme@mozilla.org', 'proton-theme@mozilla.org') and override == 2: + return {'status': 'ok', 'integration': 'system', 'lightDark': 'supported', + 'profileWrite': False, 'theme': theme, + 'colorScheme': 'dark' if state['effectiveDark'] else 'light'} + return {'status': 'unsupported', 'theme': theme, 'contentOverride': override, + 'reason': 'migration Firefox vers thème System et contenu Auto nécessaire'} + + +ADAPTERS = {'gtk': gtk_adapter, 'qt': qt_adapter, 'kitty': kitty_adapter, + 'nvim': nvim_adapter, 'firefox': firefox_adapter} + + +def reconcile(state_path=STATE, result_path=RESULT, palette_path=PALETTE): + result_path.parent.mkdir(parents=True, exist_ok=True) + with open(result_path.parent / 'app-sync.lock', 'a+b') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + # INVARIANT: lecture sous verrou après tout prédécesseur ; une révision + # arrivée pendant l'application est relue avant de publier le résultat. + colors = read_palette(palette_path) + while True: + state = read_effective(state_path) + outcomes = {} + for name, adapter in ADAPTERS.items(): + try: + outcomes[name] = adapter(state, colors) + except (OSError, ValueError, KeyError, subprocess.CalledProcessError, + subprocess.TimeoutExpired) as exc: + outcomes[name] = {'status': 'error', 'reason': str(exc)[:300]} + if read_effective(state_path) != state: + continue + result = {'revision': state['revision'], 'timestamp': int(time.time()), + 'adapters': outcomes} + atomic_write(result_path, (json.dumps(result, ensure_ascii=False) + '\n').encode()) + if read_effective(state_path) == state: + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('action', choices=('reconcile',)) + parser.parse_args() + try: + result = reconcile() + print(json.dumps(result, ensure_ascii=False)) + return 1 if any(v['status'] == 'error' for v in result['adapters'].values()) else 0 + except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc: + print('Erreur Appearance : ' + str(exc), file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/docs/appearance-app-sync.md b/docs/appearance-app-sync.md new file mode 100644 index 000000000..b4bd68b97 --- /dev/null +++ b/docs/appearance-app-sync.md @@ -0,0 +1,150 @@ +# Synchronisation des thèmes applicatifs 17G + +## Contrat et architecture + +`$XDG_STATE_HOME/labfy-appearance/effective.json` est la seule source des +choix applicatifs. `appearance-app-sync.py reconcile` consomme uniquement +`effectiveFlavor`, `effectiveAccent`, `effectiveDark`, +`effectiveHighContrast`, `effectiveMode` et `revision`. Il rejette un schéma +absent, invalide ou incohérent avant toute modification. Il ne lit ni les +coordonnées, ni les lieux sauvegardés, ni l'analyse du wallpaper. + +Le synchroniseur prend un verrou `app-sync.lock`, relit l'état après chaque +passage des adaptateurs et ne publie `last-applied.json` qu'avec la dernière +révision observée. Le résultat contient un statut indépendant par adaptateur. +Les fichiers générés sont écrits par fichier temporaire, `fsync`, puis +`os.replace`. Les palettes Qt et Kitty viennent toutes de +`quickshell/.config/quickshell/labfy-sway/theme/catppuccin.json`. +Les trois fichiers de palettes générés sont ignorés par Git et recréés par +le service à la réconciliation ; seuls les points d'entrée sont versionnés. + +`labfy-appearance-app-sync.path` observe `effective.json` et déclenche le +service one-shot. Le service est aussi voulu par la cible de session UWSM, +ce qui réconcilie après login. Aucun polling ni daemon Python. Les limites +de fréquence systemd sont désactivées pour accepter les rafales de révisions ; +le verrou sérialise les exécutions. Une panne d'un adaptateur est enregistrée +sans bloquer les suivants. `last-applied.json` hors Git contient la révision, +les résultats et un timestamp ; il ne contient pas de lieu. + +## État de départ audité le 5 octobre 2026 + +| Écosystème | État réellement constaté | +| --- | --- | +| GTK | `gsettings` demandait `catppuccin-mocha-lavender-standard+default` et `prefer-dark`. Seul `~/.themes/Catppuccin-Mocha` était présent ; son `index.theme` et son CSS utilisent Rosewater. Le nom demandé ne correspondait pas au répertoire installé. | +| Qt | `qt5ct` et `qt6ct` installés, palettes Mocha fixes ; `QT_QPA_PLATFORMTHEME` absent de l'environnement systemd de session. | +| Kitty | 0.49.2, instance active, include Mocha fixe, opacité implicite 1.0. | +| Neovim | 0.12.5, plugin Catppuccin, `init.lua` et lualine fixés sur Mocha. | +| Firefox avant migration utilisateur | 157.0, profil actif identifié par `profiles.ini`, thème intégré Dark (`firefox-compact-dark@mozilla.org`), contenu forcé clair par `layout.css.prefers-color-scheme.content-override=1`. Aucun `user.js`, `userChrome.css` ou `userContent.css` trouvé. Aucun profil n'est modifié par 17G. | + +## Capacités et limites + +| Application | Light/Dark | Flavor exact | High Contrast | Live | +| --- | --- | --- | --- | --- | +| GTK classique | oui via `gsettings` | Mocha installé avec Rosewater ; autres flavors absents, fallback Adwaita | partiel : préférence et thème clair/sombre ; aucune variante Catppuccin HC installée | nouvelles fenêtres ; recharge des fenêtres existantes selon application | +| GTK4/libadwaita | préférence système Light/Dark | thème custom non garanti pour libadwaita | non démontré | selon application | +| Qt5/Qt6 | oui | quatre palettes générées Lavender | pas de palette HC distincte | nouvelles applications ; redémarrage possible des instances ouvertes | +| Kitty | oui | quatre flavors depuis la palette unique | fond opaque 1.0 ; déjà opaque en mode normal | SIGUSR1 documenté, envoyé aux processus Kitty du même utilisateur | +| Neovim | oui | quatre flavors Catppuccin | Visual/Search/StatusLine/séparateurs adaptés, sans dimming | `uv.fs_event`, testé sans redémarrage | +| Firefox actuel | oui : thème Proton 1.1 à deux palettes et apparence des sites Auto | aucun flavor Catppuccin | non | chrome sans redémarrage ; page locale actualisée lors du test | + +Pour GTK, `Catppuccin-Mocha` est le répertoire réellement présent. Latte, +Frappé et Macchiato Lavender n'étaient pas installés ; l'adaptateur choisit +Adwaita pour ne pas publier un nom de thème inexistant. Installer des variantes +auditées de [Catppuccin GTK](https://github.com/catppuccin/gtk) est un travail +distinct. Un thème GTK custom ne recolore pas automatiquement libadwaita. + +`uwsm/.config/uwsm/env` exporte `QT_QPA_PLATFORMTHEME=qt5ct`. Sur cette +machine, le plugin Qt6 expose aussi la clé `qt5ct` ; les deux versions ont été +observées ouvrant leurs plugins et leurs palettes générées avec `strace`. +Un nouveau login est nécessaire pour que tous les lanceurs et processus de +session héritent de cette variable. L'environnement systemd/DBus courant a +été mis à jour pour les nouvelles applications lancées par ces services. + +Kitty inclut `generated-appearance.conf` ; le synchroniseur envoie SIGUSR1 +après chaque publication. La documentation locale Kitty 0.49.2 confirme ce +signal de rechargement. Le synchroniseur n'ouvre aucun socket de contrôle. +L'opacité normale préexistante était 1.0 ; Sun Light et Sun Dark restent à 1.0. + +Neovim lit l'état avant de choisir son premier colorscheme, puis surveille +le répertoire de `effective.json` pour les renommages atomiques. Un état absent +ou invalide retombe sur Mocha sans bloquer l'éditeur. Le watcher est fermé à +`VimLeavePre`. `lazy-lock.json` préexistant reste hors chantier. + +Firefox consulte la préférence système/portail quand le thème actif prend +en charge les deux palettes et que le contenu est configuré sur Auto. Le portail existant +`xdg-desktop-portal-gtk` a renvoyé `1` en sombre et `2` en Sun Light. +La [source Mozilla](https://searchfox.org/firefox-main/source/modules/libpref/init/StaticPrefList.yaml) +définit l'override contenu `0=Dark`, `1=Light`, `2=Auto`. La migration doit +être faite dans l'interface Firefox : choisir un thème compatible avec les +deux palettes, dont le thème intégré **System** ou le Proton 1.1 audité, et +l'apparence des sites **Automatic**, puis vérifier localement +`matchMedia('(prefers-color-scheme: dark)').matches` en Mocha, Sun Light et +Sun Dark. Le chrome et le contenu doivent être contrôlés séparément. Aucune +écriture de `prefs.js`, base SQLite ou fichier d'extensions n'est permise +pendant que Firefox est ouvert. + +Avant la migration, une page HTML locale a été ouverte dans le profil actif et a affiché +`light` en Mocha, Sun Light **et** Sun Dark. Ce test confirme que le contenu +ne suivait alors pas la préférence système. La fenêtre de test a été fermée. + +## Validation de migration du 5 octobre 2026 + +Après les changements manuels dans Firefox, le profil actif découvert par +`profiles.ini` indique `proton-theme@mozilla.org`, et ne contient +plus de préférence `layout.css.prefers-color-scheme.content-override` : +la valeur par défaut Auto vaut 2. Le thème intégré **System** n'est donc pas +le thème actif, malgré le choix annoncé dans l'interface. Le manifeste local +du thème Proton 1.1 possède `theme` et `dark_theme`. Les essais réels ont +montré que son chrome suit la préférence du portail : sombre en Mocha normal, +clair en Sun Light, sombre en Sun Dark, sans redémarrage. La page HTML locale +a rendu `dark`, `light`, `dark` ; lors du premier passage en Sun Light, un +rechargement a accéléré l'observation de `light`. Lors du second passage, la +page déjà ouverte a basculé après un délai sans rechargement. Le portail +a rendu respectivement 1, 2, 1. L'adaptateur accepte donc ce thème précis +comme intégration Light/Dark système, tout en signalant son identifiant réel. +Il ne lit que `profiles.ini` et `prefs.js` et n'écrit aucun fichier Firefox. +Le thème Proton n'est pas littéralement le thème intégré **System** ; cette +nuance n'empêche pas la synchronisation Light/Dark observée. + +## Vérification et retour arrière + +Les transitions Mocha → Latte → Sun Light → Sun Dark → Frappé → Macchiato → +Mocha ont produit des résultats indépendants pour les cinq adaptateurs. +GTK a basculé `prefer-dark`/`prefer-light`, Qt et Kitty ont généré les bonnes +palettes, Neovim a basculé live vers Latte, et le portail a publié `1`/`2`. +Des captures de Thunar GTK3, qt5ct, qt6ct, Kitty, Neovim et Firefox en Mocha +et Sun Light ainsi que des vues globales sont conservées dans +`~/.local/state/labfy-appearance/captures/17g/` avec permissions privées. +Thunar et les fenêtres de configuration Qt montrent le basculement visuel +clair/sombre ; Kitty et Neovim montrent Latte. Une trace d'ouverture GTK4 +confirme que `~/.themes/Catppuccin-Mocha/gtk-4.0/gtk.css` est réellement lu +en Mocha. Avant la migration, Firefox conservait le chrome sombre en Sun Light ; +après la migration, il passe au chrome clair. La validation +de lisibilité physique en extérieur reste à faire par l'utilisateur ; une +capture ne la remplace pas. + +Validation automatisée finale : 63 tests Python, `py_compile`, `luac -p`, +démarrage Neovim headless, `qmllint` sur 52 QML, +`systemd-analyze --user verify` et `git diff --check`. +L'instance Neovim ouverte est passée à Latte via `fs_event`. Une rafale de +six changements a laissé le `.path` actif avec la dernière révision publiée. +Une réconciliation mesurée a duré environ 73 ms sur cette machine. + +Pour désactiver : + +```sh +systemctl --user disable --now labfy-appearance-app-sync.path labfy-appearance-app-sync.service +``` + +Réappliquer l'état courant après une installation ou un redémarrage : + +```sh +~/.local/bin/appearance-app-sync.py reconcile +``` + +Les configurations générées restent utilisables sans le service. Pour +retrouver les chemins initiaux, rétablir l'include Kitty +`kitty-themes/themes/mocha.conf`, les deux `color_scheme_path` vers +`catppuccin-mocha-lavender.conf`, supprimer l'export UWSM et choisir +explicitement Mocha dans Neovim. Réactiver le service se fait avec +`systemctl --user enable --now` pour les deux unités. diff --git a/kitty/.config/kitty/kitty.conf b/kitty/.config/kitty/kitty.conf index d747ca7ae..a5f347f60 100644 --- a/kitty/.config/kitty/kitty.conf +++ b/kitty/.config/kitty/kitty.conf @@ -29,6 +29,7 @@ cursor_stop_blinking_after 0 cursor_trail 0 shell_integration no-cursor -include kitty-themes/themes/mocha.conf +# CONTRACT: le synchroniseur publie ce fichier atomiquement depuis la palette commune. +include generated-appearance.conf map alt+c no_cp diff --git a/nvim/.config/nvim/init.lua b/nvim/.config/nvim/init.lua index db32fb39e..e48414fde 100644 --- a/nvim/.config/nvim/init.lua +++ b/nvim/.config/nvim/init.lua @@ -115,4 +115,5 @@ end, { desc = "Docker terminal", }) -vim.cmd.colorscheme "catppuccin-mocha" +-- CONTRACT: démarrage et instances ouvertes consomment le même état effectif. +require("config.appearance").start() diff --git a/nvim/.config/nvim/lua/config/appearance.lua b/nvim/.config/nvim/lua/config/appearance.lua new file mode 100644 index 000000000..a0cf0b197 --- /dev/null +++ b/nvim/.config/nvim/lua/config/appearance.lua @@ -0,0 +1,70 @@ +-- WHY: le thème de l'éditeur suit la révision Appearance, sans minuterie ni +-- dépendance envers le processus QuickShell. +local M = {} +local uv = vim.uv +local path = (vim.env.XDG_STATE_HOME or (vim.env.HOME .. "/.local/state")) + .. "/labfy-appearance/effective.json" +local revision = -1 +local watcher + +local function state() + local file = io.open(path, "r") + if not file then return nil end + local data = file:read("*a") + file:close() + local ok, value = pcall(vim.json.decode, data) + if not ok or type(value) ~= "table" then return nil end + local flavors = { latte = true, frappe = true, macchiato = true, mocha = true } + if not flavors[value.effectiveFlavor] or type(value.revision) ~= "number" + or value.revision < 0 or value.revision % 1 ~= 0 + or type(value.effectiveHighContrast) ~= "boolean" then return nil end + return value +end + +local function apply() + local current = state() + local flavor = current and current.effectiveFlavor or "mocha" + local current_revision = current and current.revision or -1 + if current_revision == revision and vim.g.colors_name == "catppuccin-" .. flavor then return end + -- CONTRACT: erreurs de l'état ou du plugin n'empêchent pas l'éditeur de démarrer. + local ok = pcall(function() + require("catppuccin").setup({ + flavour = flavor, + dim_inactive = { enabled = false }, + custom_highlights = function(colors) + if not (current and current.effectiveHighContrast) then return {} end + return { + Visual = { bg = colors.surface2, fg = colors.text }, + Search = { bg = colors.yellow, fg = colors.base }, + IncSearch = { bg = colors.peach, fg = colors.base }, + StatusLine = { bg = colors.surface2, fg = colors.text }, + WinSeparator = { fg = colors.overlay2 }, + } + end, + }) + vim.cmd.colorscheme("catppuccin-" .. flavor) + end) + if not ok then pcall(vim.cmd.colorscheme, "default") end + revision = current_revision +end + +function M.start() + apply() + -- fs_event sur le répertoire observe l'écriture tmp + rename atomique. + -- Un seul handle par instance ; VimLeavePre ferme explicitement sa ressource. + watcher = uv.new_fs_event() + if watcher then + local directory = vim.fn.fnamemodify(path, ":h") + if uv.fs_stat(directory) then + local ok = watcher:start(directory, {}, function(err, filename) + if not err and filename == "effective.json" then vim.schedule(apply) end + end) + if not ok then watcher:close(); watcher = nil end + else watcher:close(); watcher = nil end + end + vim.api.nvim_create_autocmd("VimLeavePre", { callback = function() + if watcher then watcher:stop(); watcher:close(); watcher = nil end + end }) +end + +return M diff --git a/nvim/.config/nvim/lua/plugins/ui.lua b/nvim/.config/nvim/lua/plugins/ui.lua index 410bce43e..8438b3e96 100644 --- a/nvim/.config/nvim/lua/plugins/ui.lua +++ b/nvim/.config/nvim/lua/plugins/ui.lua @@ -4,7 +4,7 @@ return { dependencies = { "nvim-tree/nvim-web-devicons" }, opts = { options = { - theme = "catppuccin-mocha", + theme = "auto", icons_enabled = true, section_separators = "", component_separators = "", @@ -20,4 +20,3 @@ return { }, }, } - diff --git a/qt5ct/.config/qt5ct/qt5ct.conf b/qt5ct/.config/qt5ct/qt5ct.conf index 58617ebba..c6a2a8a3f 100644 --- a/qt5ct/.config/qt5ct/qt5ct.conf +++ b/qt5ct/.config/qt5ct/qt5ct.conf @@ -1,5 +1,5 @@ [Appearance] -color_scheme_path=/home/fy59/.config/qt5ct/colors/catppuccin-mocha-lavender.conf +color_scheme_path=/home/fy59/.config/qt5ct/colors/labfy-appearance.conf custom_palette=true standard_dialogs=default style=Fusion diff --git a/qt6ct/.config/qt6ct/qt6ct.conf b/qt6ct/.config/qt6ct/qt6ct.conf index e2a70a873..a9756650a 100644 --- a/qt6ct/.config/qt6ct/qt6ct.conf +++ b/qt6ct/.config/qt6ct/qt6ct.conf @@ -1,5 +1,5 @@ [Appearance] -color_scheme_path=/home/fy59/.config/qt6ct/colors/catppuccin-mocha-lavender.conf +color_scheme_path=/home/fy59/.config/qt6ct/colors/labfy-appearance.conf custom_palette=true standard_dialogs=default style=Fusion diff --git a/systemd/.config/systemd/user/labfy-appearance-app-sync.path b/systemd/.config/systemd/user/labfy-appearance-app-sync.path new file mode 100644 index 000000000..2d0fedcdd --- /dev/null +++ b/systemd/.config/systemd/user/labfy-appearance-app-sync.path @@ -0,0 +1,10 @@ +[Unit] +Description=Observer les changements Appearance effectifs +StartLimitIntervalSec=0 + +[Path] +PathChanged=%h/.local/state/labfy-appearance/effective.json +Unit=labfy-appearance-app-sync.service + +[Install] +WantedBy=wayland-session@sway.desktop.target diff --git a/systemd/.config/systemd/user/labfy-appearance-app-sync.service b/systemd/.config/systemd/user/labfy-appearance-app-sync.service new file mode 100644 index 000000000..a558c240c --- /dev/null +++ b/systemd/.config/systemd/user/labfy-appearance-app-sync.service @@ -0,0 +1,12 @@ +[Unit] +Description=Synchroniser les applications avec l'état Appearance effectif +# WHY: plusieurs révisions légitimes peuvent arriver en une seconde ; le lock +# applicatif sérialise les exécutions, la limite systemd ne doit pas tuer le path. +StartLimitIntervalSec=0 + +[Service] +Type=oneshot +ExecStart=%h/.local/bin/appearance-app-sync.py reconcile + +[Install] +WantedBy=wayland-session@sway.desktop.target diff --git a/systemd/.config/systemd/user/wayland-session@sway.desktop.target.wants/labfy-appearance-app-sync.path b/systemd/.config/systemd/user/wayland-session@sway.desktop.target.wants/labfy-appearance-app-sync.path new file mode 120000 index 000000000..f1bc7c3cb --- /dev/null +++ b/systemd/.config/systemd/user/wayland-session@sway.desktop.target.wants/labfy-appearance-app-sync.path @@ -0,0 +1 @@ +../labfy-appearance-app-sync.path \ No newline at end of file diff --git a/systemd/.config/systemd/user/wayland-session@sway.desktop.target.wants/labfy-appearance-app-sync.service b/systemd/.config/systemd/user/wayland-session@sway.desktop.target.wants/labfy-appearance-app-sync.service new file mode 120000 index 000000000..4175297f3 --- /dev/null +++ b/systemd/.config/systemd/user/wayland-session@sway.desktop.target.wants/labfy-appearance-app-sync.service @@ -0,0 +1 @@ +../labfy-appearance-app-sync.service \ No newline at end of file diff --git a/tests/test_appearance_app_sync.py b/tests/test_appearance_app_sync.py new file mode 100644 index 000000000..d93fe466a --- /dev/null +++ b/tests/test_appearance_app_sync.py @@ -0,0 +1,164 @@ +"""Contrats de validation et publication du synchroniseur Appearance.""" +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +from unittest import mock + + +SOURCE = Path(__file__).resolve().parents[1] / 'bin/.local/bin/appearance-app-sync.py' +spec = importlib.util.spec_from_file_location('appearance_app_sync', SOURCE) +sync = importlib.util.module_from_spec(spec) +spec.loader.exec_module(sync) + + +class SyncTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.state = self.root / 'effective.json' + self.result = self.root / 'last-applied.json' + self.palette = sync.PALETTE + self.value = {'effectiveFlavor': 'mocha', 'effectiveAccent': 'lavender', + 'effectiveDark': True, 'effectiveHighContrast': False, + 'effectiveMode': 'normal', 'revision': 4, + 'latitude': 48.0, 'savedLocations': ['secret']} + self.write() + + def write(self): + self.state.write_text(json.dumps(self.value), encoding='utf-8') + + def test_projection_excludes_location(self): + state = sync.read_effective(self.state) + self.assertEqual(set(state), {'effectiveFlavor', 'effectiveAccent', + 'effectiveDark', 'effectiveHighContrast', + 'effectiveMode', 'revision'}) + + def test_bad_state_never_runs_adapter_or_publishes(self): + for mutation in ({'revision': None}, {'effectiveFlavor': 'invalid'}, + {'effectiveDark': False}, {'effectiveMode': 'sun-light'}): + with self.subTest(mutation=mutation): + self.value.update(mutation) + self.write() + with mock.patch.object(sync, 'ADAPTERS', {'gtk': mock.Mock()}) as adapters: + with self.assertRaises(ValueError): + sync.reconcile(self.state, self.result, self.palette) + adapters['gtk'].assert_not_called() + self.assertFalse(self.result.exists()) + self.value = {'effectiveFlavor': 'mocha', 'effectiveAccent': 'lavender', + 'effectiveDark': True, 'effectiveHighContrast': False, + 'effectiveMode': 'normal', 'revision': 4} + del self.value['revision'] + self.write() + with self.assertRaises(KeyError): + sync.reconcile(self.state, self.result, self.palette) + self.assertFalse(self.result.exists()) + + def test_partial_failure_and_privacy(self): + def failed(*args): + raise OSError('indisponible') + with mock.patch.object(sync, 'ADAPTERS', {'gtk': failed, + 'qt': lambda *_: {'status': 'ok'}}): + result = sync.reconcile(self.state, self.result, self.palette) + self.assertEqual(result['adapters']['gtk']['status'], 'error') + self.assertEqual(result['adapters']['qt']['status'], 'ok') + content = self.result.read_text() + self.assertNotIn('latitude', content) + self.assertNotIn('secret', content) + + def test_new_revision_wins_during_adapter_execution(self): + seen = [] + def mutate_once(state, colors): + seen.append(state['revision']) + if len(seen) == 1: + self.value['revision'] = 5 + self.write() + return {'status': 'ok'} + with mock.patch.object(sync, 'ADAPTERS', {'gtk': mutate_once}): + result = sync.reconcile(self.state, self.result, self.palette) + self.assertEqual(seen, [4, 5]) + self.assertEqual(result['revision'], 5) + + def test_missing_palette_does_not_replace_config(self): + with self.assertRaises(FileNotFoundError): + sync.reconcile(self.state, self.result, self.root / 'missing.json') + self.assertFalse(self.result.exists()) + + def test_qt_kitty_palette_is_shared(self): + p = sync.read_palette()['latte'] + self.assertIn(p['base'].encode(), sync.kitty_theme(p, 'lavender', True)) + self.assertIn(('#ff' + p['base'][1:]).encode(), sync.qt_palette(p, 'lavender')) + self.assertEqual(len(sync.qt_palette(p, 'lavender').splitlines()), 4) + + def test_gtk_adapter_uses_effective_dark(self): + self.value.update(effectiveFlavor='latte', effectiveDark=False) + with mock.patch.object(sync, 'run') as run: + result = sync.gtk_adapter(self.value, sync.read_palette()) + self.assertEqual(result['colorScheme'], 'prefer-light') + self.assertEqual(result['theme'], 'Adwaita') + self.assertEqual(run.call_count, 2) + + def test_qt_adapter_writes_both_versions(self): + for version in ('qt5ct', 'qt6ct'): + config = self.root / version / (version + '.conf') + config.parent.mkdir(parents=True) + config.write_text('[Appearance]\ncolor_scheme_path=/old\nstyle=Fusion\n') + with mock.patch.object(sync, 'CONFIG', self.root): + sync.qt_adapter(self.value, sync.read_palette()) + for version in ('qt5ct', 'qt6ct'): + self.assertTrue((self.root / version / 'colors/labfy-appearance.conf').is_file()) + content = (self.root / version / (version + '.conf')).read_text() + self.assertIn('style=Fusion', content) + self.assertIn('labfy-appearance.conf', content) + + def test_kitty_adapter_signals_only_verified_process(self): + kitty = self.root / 'kitty' + kitty.mkdir() + (kitty / 'kitty.conf').write_text('include generated-appearance.conf\n') + with mock.patch.object(sync, 'CONFIG', self.root), \ + mock.patch.object(sync.os, 'scandir', return_value=[]): + result = sync.kitty_adapter(self.value, sync.read_palette()) + self.assertEqual(result['instances'], 0) + self.assertIn('foreground', (kitty / 'generated-appearance.conf').read_text()) + + def test_nvim_contract_reports_revision(self): + self.assertEqual(sync.nvim_adapter(self.value, {})['revision'], 4) + + def test_firefox_profile_is_read_only_and_reports_migration(self): + firefox = self.root / '.mozilla/firefox' + firefox.mkdir(parents=True) + (firefox / 'profiles.ini').write_text('[InstallABCD]\nDefault=profile\n') + profile = firefox / 'profile' + profile.mkdir() + prefs = profile / 'prefs.js' + prefs.write_text('user_pref("extensions.activeThemeID", "firefox-compact-dark@mozilla.org");\n' + 'user_pref("layout.css.prefers-color-scheme.content-override", 1);\n') + before = prefs.read_bytes() + with mock.patch.object(sync.Path, 'home', return_value=self.root): + result = sync.firefox_adapter(self.value, {}) + self.assertEqual(result['status'], 'unsupported') + self.assertEqual(result['contentOverride'], 1) + self.assertEqual(prefs.read_bytes(), before) + + def test_firefox_proton_dual_palette_follows_system_without_profile_write(self): + firefox = self.root / '.mozilla/firefox' + firefox.mkdir(parents=True) + (firefox / 'profiles.ini').write_text('[InstallABCD]\nDefault=profile\n') + profile = firefox / 'profile' + profile.mkdir() + prefs = profile / 'prefs.js' + prefs.write_text('user_pref("extensions.activeThemeID", "proton-theme@mozilla.org");\n') + before = prefs.read_bytes() + with mock.patch.object(sync.Path, 'home', return_value=self.root): + result = sync.firefox_adapter(self.value, {}) + self.assertEqual(result['status'], 'ok') + self.assertEqual(result['integration'], 'system') + self.assertEqual(result['lightDark'], 'supported') + self.assertIs(result['profileWrite'], False) + self.assertEqual(prefs.read_bytes(), before) + + +if __name__ == '__main__': + unittest.main() diff --git a/uwsm/.config/uwsm/env b/uwsm/.config/uwsm/env new file mode 100644 index 000000000..db234dca7 --- /dev/null +++ b/uwsm/.config/uwsm/env @@ -0,0 +1,3 @@ +# CONTRACT: UWSM transmet ces variables au compositeur et aux applications +# lancées dans la session au prochain login. +export QT_QPA_PLATFORMTHEME=qt5ct