diff --git a/bin/.local/bin/generate-appearance.py b/bin/.local/bin/generate-appearance.py index 2ab505165..b97264e8f 100755 --- a/bin/.local/bin/generate-appearance.py +++ b/bin/.local/bin/generate-appearance.py @@ -81,53 +81,17 @@ def main(): if args.apply and args.output != SWAY / 'generated/theme.conf': parser.error('--apply exige la sortie runtime par défaut') try: + # CONTRACT: les applications 17D empruntent le verrou et la publication + # uniques du gestionnaire Appearance ; ce CLI garde le rendu hors ligne. + if args.apply: + if args.accent != 'lavender' or args.profile != 'default': + raise ValueError('17D applique uniquement Lavender avec le profil default') + checked([sys.executable, str(Path(__file__).with_name('wallpaper-manager.py')), + 'set-manual-flavor', args.flavor]) + print(f'{args.flavor}/lavender: {args.output}') + return 0 content = render(args.flavor, args.accent, args.profile).encode('utf-8') - old = args.output.read_bytes() if args.output.exists() else None - old_state = STATE.read_bytes() if args.apply and STATE.exists() else None - previous_theme = None - if args.apply: - previous_theme = checked(['qs', '-c', 'labfy-sway', 'ipc', 'call', - 'appearance', 'currentTheme']).stdout.strip() atomic_write(args.output, content) - if args.apply: - try: - checked(['sway', '--validate', '-c', str(SWAY / 'config')]) - checked(['swaymsg', 'reload']) - previous = json.loads(old_state) if old_state else {} - changed = (previous.get('effectiveFlavor') != args.flavor - or previous.get('effectiveAccent') != args.accent - or previous.get('effectiveMode') != 'normal') - revision = max(0, int(previous.get('revision', 0))) + int(changed) - # CONTRACT: le moteur de thème conserve les champs wallpaper - # du schéma 2 ; changer une palette n'efface pas l'état 17C. - state = dict(previous) - state.update(version=max(1, int(previous.get('version', 1))), - effectiveFlavor=args.flavor, effectiveMode='normal', - effectiveDark=args.flavor != 'latte', - effectiveHighContrast=False, effectiveAccent=args.accent, - revision=revision) - # CONTRACT: le backend persiste l'état après validation Sway ; - # le contrôleur l'expose aux consommateurs et le lit au restart. - atomic_write(STATE, (json.dumps(state, separators=(',', ':')) + '\n').encode('utf-8')) - result = checked(['qs', '-c', 'labfy-sway', 'ipc', 'call', 'appearance', - 'setTheme', args.flavor, args.accent]) - if result.stdout.strip() != 'true': - raise RuntimeError('QuickShell a refusé le thème') - except Exception: - if old is None: - args.output.unlink(missing_ok=True) - else: - atomic_write(args.output, old) - if old_state is None: - STATE.unlink(missing_ok=True) - else: - atomic_write(STATE, old_state) - checked(['swaymsg', 'reload']) - if previous_theme and '/' in previous_theme: - old_flavor, old_accent = previous_theme.split('/', 1) - checked(['qs', '-c', 'labfy-sway', 'ipc', 'call', 'appearance', - 'setTheme', old_flavor, old_accent]) - raise print(f'{args.flavor}/{args.accent}: {args.output}') except (OSError, ValueError, subprocess.CalledProcessError, RuntimeError) as exc: print(f'Erreur : {exc}', file=sys.stderr) diff --git a/bin/.local/bin/wallpaper-manager.py b/bin/.local/bin/wallpaper-manager.py index f1cae0698..f09f687d0 100755 --- a/bin/.local/bin/wallpaper-manager.py +++ b/bin/.local/bin/wallpaper-manager.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 -"""Galerie locale et publication transactionnelle du wallpaper Sway (schéma 2).""" +"""Galerie locale et publication compensable wallpaper/thème Sway (schéma 3).""" import argparse import fcntl import hashlib +import importlib.util import json import os from pathlib import Path @@ -15,6 +16,7 @@ import warnings from urllib.parse import unquote, urlsplit from PIL import Image, ImageOps, UnidentifiedImageError +from wallpaper_analysis import ALGORITHM_VERSION, analyze as analyze_wallpaper # WHY: une image piégée ne doit pas faire exploser la mémoire du processus # de scan ; la limite s'applique à un fichier, pas à la taille du dossier. @@ -31,7 +33,11 @@ PREFERENCES = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / STATE = Path(os.environ.get('XDG_STATE_HOME', Path.home() / '.local/state')) / 'labfy-appearance/effective.json' CACHE = Path(os.environ.get('XDG_CACHE_HOME', Path.home() / '.cache')) / 'labfy-appearance/wallpapers' GENERATED = SWAY / 'generated/wallpaper.conf' +GENERATED_THEME = SWAY / 'generated/theme.conf' ALIASES = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'labfy-appearance/wallpapers' +FLAVORS = ('latte', 'frappe', 'macchiato', 'mocha') +STATE_VERSION = 3 +PREF_VERSION = 2 def atomic_write(path, data): @@ -241,61 +247,172 @@ def current(): 'revision': state.get('revision', 0)} -def apply(path): - # INVARIANT: lock couvre config, reload et état ; deux appels ne s'entremêlent pas. +def preferences(state=None): + """Migration non destructive des préférences 17C et de l'état 17B/17C.""" + state = state if state is not None else read_json(STATE) + value = read_json(PREFERENCES) + mode = value.get('themeMode', state.get('themeMode', 'manual')) + manual = value.get('manualFlavor', state.get('manualFlavor', state.get('effectiveFlavor', 'mocha'))) + if mode not in ('manual', 'wallpaper'): + mode = 'manual' + if manual not in FLAVORS: + manual = 'mocha' + return {**value, 'version': PREF_VERSION, 'themeMode': mode, + 'manualFlavor': manual, 'accent': 'lavender', 'wallpaperMode': 'fill', + 'wallpaperDirectory': initial_directory()} + + +def rendered_theme(flavor): + """Réutilise le générateur 17B : aucune seconde palette ni formule Sway.""" + source = Path(__file__).with_name('generate-appearance.py') + spec = importlib.util.spec_from_file_location('generate_appearance', source) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.render(flavor, 'lavender').encode('utf-8') + + +def publish_live(state, pref): + # CONTRACT: IPC seulement après Sway et les écritures durables ; le QML + # reprend exactement la révision persistée, sans incrément local supplémentaire. + payload = json.dumps({'state': state, 'preferences': pref}, ensure_ascii=False, + separators=(',', ':')) + result = command('qs', '-c', 'labfy-sway', 'ipc', 'call', 'appearance', 'publishState', payload) + if result.stdout.strip() != 'true': + raise RuntimeError('QuickShell a refusé le nouvel état Appearance') + + +def restore_file(path, previous): + if previous is None: + path.unlink(missing_ok=True) + else: + atomic_write(path, previous) + + +def state_response(state, pref, analysis=None): + result = dict(state) + result.update(themeMode=pref['themeMode'], manualFlavor=pref['manualFlavor'], + wallpaperDirectory=pref['wallpaperDirectory']) + if analysis is not None: + result['analysis'] = analysis + return result + + +def transaction(wallpaper=None, mode=None, manual_flavor=None): + """Applique un état compensable sous un verrou commun thème/wallpaper.""" STATE.parent.mkdir(parents=True, exist_ok=True) with open(STATE.parent / 'wallpaper.lock', 'a+b') as lock: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - meta = validate_image(path) - target = meta['path'] - old_config = GENERATED.read_bytes() if GENERATED.exists() else None + old_wallpaper = GENERATED.read_bytes() if GENERATED.exists() else None + old_theme = GENERATED_THEME.read_bytes() if GENERATED_THEME.exists() else None old_state = STATE.read_bytes() if STATE.exists() else None + old_preferences = PREFERENCES.read_bytes() if PREFERENCES.exists() else None old = read_json(STATE) + previous_pref = preferences(old) + pref = dict(previous_pref) + if mode is not None: + if mode not in ('manual', 'wallpaper'): + raise ValueError('Mode de thème invalide') + pref['themeMode'] = mode + if manual_flavor is not None: + if manual_flavor not in FLAVORS: + raise ValueError('Flavor manuel invalide') + if pref['themeMode'] != 'manual': + raise ValueError('Revenir en mode Manuel avant de choisir un flavor') + pref['manualFlavor'] = manual_flavor + meta = validate_image(wallpaper) if wallpaper is not None else None + target = meta['path'] if meta else old.get('effectiveWallpaper') or active_wallpaper() + if not Path(target).is_file(): + raise ValueError('Wallpaper courant introuvable') + analysis = None + if pref['themeMode'] == 'wallpaper': + try: + analysis = analyze_wallpaper(meta or validate_image(target)) + except Exception as exc: + # L'analyse précède toute écriture ; un échec ne laisse aucun + # état hybride et ne retire jamais un wallpaper déjà appliqué. + raise ValueError('Analyse Auto Theme : ' + str(exc)) from exc + flavor = analysis['flavor'] + else: + flavor = pref['manualFlavor'] if mode is not None or manual_flavor is not None \ + else old.get('effectiveFlavor', pref['manualFlavor']) + if flavor not in FLAVORS: + raise ValueError('Flavor effectif invalide') + previous_wallpaper = old.get('effectiveWallpaper') or active_wallpaper() + wallpaper_changed = meta is not None and (target != previous_wallpaper + or old.get('wallpaperMtime') != meta['mtime']) + flavor_changed = flavor != old.get('effectiveFlavor', 'mocha') + mode_changed = pref['themeMode'] != previous_pref['themeMode'] + state = dict(old) + state.update(version=STATE_VERSION, effectiveFlavor=flavor, + effectiveMode='normal', effectiveDark=flavor != 'latte', + effectiveHighContrast=False, effectiveAccent='lavender', + effectiveWallpaper=target, wallpaperMode='fill', + themeMode=pref['themeMode'], manualFlavor=pref['manualFlavor'], + revision=max(0, int(old.get('revision', 0))) + + int(wallpaper_changed or flavor_changed or mode_changed)) + if meta is not None: + state['wallpaperMtime'] = meta['mtime'] + if analysis is not None: + state['wallpaperAnalysis'] = analysis + elif pref['themeMode'] == 'manual': + state.pop('wallpaperAnalysis', None) + if not (wallpaper_changed or flavor_changed or mode_changed + or pref != previous_pref or old.get('version') != STATE_VERSION): + return state_response(state, pref, analysis) + alias = None + wrote_config = False try: - alias = safe_alias(meta) - if old.get('effectiveWallpaper', active_wallpaper()) == target and swaybg_matches(alias): - return current() - atomic_write(GENERATED, render_wallpaper(alias)) - command('sway', '--validate', '-c', str(SWAY / 'config')) - command('swaymsg', 'reload') - deadline = time.monotonic() + 4 - while time.monotonic() < deadline and not swaybg_matches(alias): - time.sleep(0.1) - if not swaybg_matches(alias): - raise RuntimeError('Sway n’a pas appliqué le fond demandé') - # WHY: préserver tous les champs 17B et inconnus pour la migration - # et les futurs consommateurs ; seul le wallpaper et sa révision changent. - new_state = dict(old) - new_state.setdefault('effectiveFlavor', 'mocha') - new_state.setdefault('effectiveAccent', 'lavender') - new_state.setdefault('effectiveMode', 'normal') - new_state.setdefault('effectiveDark', new_state['effectiveFlavor'] != 'latte') - new_state.setdefault('effectiveHighContrast', False) - new_state.update(version=2, effectiveWallpaper=target, wallpaperMode='fill', - revision=max(0, int(old.get('revision', 0))) + 1) - write_json(STATE, new_state) + if wallpaper_changed: + alias = safe_alias(meta) + atomic_write(GENERATED, render_wallpaper(alias)) + wrote_config = True + if flavor_changed: + atomic_write(GENERATED_THEME, rendered_theme(flavor)) + wrote_config = True + if wrote_config: + command('sway', '--validate', '-c', str(SWAY / 'config')) + command('swaymsg', 'reload') + if wallpaper_changed: + deadline = time.monotonic() + 4 + while time.monotonic() < deadline and not swaybg_matches(alias): + time.sleep(0.1) + if not swaybg_matches(alias): + raise RuntimeError('Sway n’a pas appliqué le fond demandé') + write_json(STATE, state) + write_json(PREFERENCES, pref) + publish_live(state, pref) # Seul l'alias actif est requis au prochain login ; les anciens # liens créés par ce backend ne doivent pas s'accumuler. - for previous_alias in ALIASES.glob('image-*'): - if str(previous_alias) != alias and previous_alias.is_symlink(): - try: - previous_alias.unlink() - except OSError: - pass - return current() + if alias: + for previous_alias in ALIASES.glob('image-*'): + if str(previous_alias) != alias and previous_alias.is_symlink(): + try: + previous_alias.unlink() + except OSError: + pass + return state_response(state, pref, analysis) except Exception: - if old_config is None: - GENERATED.unlink(missing_ok=True) - else: - atomic_write(GENERATED, old_config) - if old_state is None: - STATE.unlink(missing_ok=True) - else: - atomic_write(STATE, old_state) - command('swaymsg', 'reload') + restore_file(GENERATED, old_wallpaper) + restore_file(GENERATED_THEME, old_theme) + restore_file(STATE, old_state) + restore_file(PREFERENCES, old_preferences) + if wrote_config: + command('swaymsg', 'reload') raise +def apply(path): + return transaction(wallpaper=path) + + +def set_theme_mode(mode): + return transaction(mode=mode) + + +def set_manual_flavor(flavor): + return transaction(manual_flavor=flavor) + + def set_directory(path): if path.startswith('file:'): uri = urlsplit(path) @@ -305,10 +422,15 @@ def set_directory(path): directory = Path(path).expanduser().resolve(strict=True) if not directory.is_dir(): raise ValueError('Dossier introuvable') - pref = read_json(PREFERENCES) - pref.update(version=1, wallpaperDirectory=str(directory), wallpaperMode='fill') - write_json(PREFERENCES, pref) - return {'version': 1, 'wallpaperDirectory': str(directory), 'wallpaperMode': 'fill'} + STATE.parent.mkdir(parents=True, exist_ok=True) + # INVARIANT: le picker ne doit pas perdre un changement de dossier si une + # application thème/wallpaper publie simultanément les préférences. + with open(STATE.parent / 'wallpaper.lock', 'a+b') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + pref = preferences() + pref.update(wallpaperDirectory=str(directory)) + write_json(PREFERENCES, pref) + return {'version': PREF_VERSION, 'wallpaperDirectory': str(directory), 'wallpaperMode': 'fill'} def reconcile(): @@ -322,20 +444,100 @@ def reconcile(): return result +def reconcile_state(): + """Au restart, effective.json tranche toute publication interrompue.""" + STATE.parent.mkdir(parents=True, exist_ok=True) + with open(STATE.parent / 'wallpaper.lock', 'a+b') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + state = read_json(STATE) + pref = preferences(state) + if state.get('version') == STATE_VERSION: + # Une préférence écrite avant un crash n'est pas une application. + applied_mode = state.get('themeMode') + applied_manual = state.get('manualFlavor') + pref['themeMode'] = applied_mode if applied_mode in ('manual', 'wallpaper') else pref['themeMode'] + pref['manualFlavor'] = applied_manual if applied_manual in FLAVORS else pref['manualFlavor'] + state.update(themeMode=pref['themeMode'], manualFlavor=pref['manualFlavor']) + else: + state.update(version=STATE_VERSION, themeMode=pref['themeMode'], + manualFlavor=pref['manualFlavor']) + flavor = state.get('effectiveFlavor', 'mocha') + if flavor not in FLAVORS: + raise ValueError('État effectif invalide') + state.update(effectiveFlavor=flavor, effectiveAccent='lavender', + effectiveDark=flavor != 'latte', effectiveHighContrast=False, + effectiveMode='normal', wallpaperMode='fill', + effectiveWallpaper=state.get('effectiveWallpaper') or active_wallpaper(), + revision=max(0, int(state.get('revision', 0)))) + if not Path(state['effectiveWallpaper']).is_file(): + # Le fond déjà rendu peut survivre à la suppression ; l'UI reçoit + # le signal de manque et un futur apply/reconcile utilise le repli. + return state_response(state, pref) + expected_theme = rendered_theme(flavor) + meta = validate_image(state['effectiveWallpaper']) + if pref['themeMode'] == 'wallpaper': + previous_analysis = state.get('wallpaperAnalysis') + stale = (not isinstance(previous_analysis, dict) + or previous_analysis.get('algorithmVersion') != ALGORITHM_VERSION + or previous_analysis.get('path') != state['effectiveWallpaper'] + or state.get('wallpaperMtime') != meta['mtime']) + if stale: + # CONTRACT: rétablir les mesures manquantes sans inventer une + # nouvelle application. Le flavor publié reste celui du dernier + # état validé ; un fichier modifié doit être appliqué explicitement. + refreshed = analyze_wallpaper(meta) + if (refreshed['flavor'] == flavor + and state.get('wallpaperMtime') == meta['mtime']): + state['wallpaperAnalysis'] = refreshed + alias = safe_alias(meta) + expected_wallpaper = render_wallpaper(alias) + previous_theme = GENERATED_THEME.read_bytes() if GENERATED_THEME.exists() else None + previous_wallpaper = GENERATED.read_bytes() if GENERATED.exists() else None + repair_theme = previous_theme != expected_theme + repair_wallpaper = previous_wallpaper != expected_wallpaper + try: + if repair_theme: + atomic_write(GENERATED_THEME, expected_theme) + if repair_wallpaper: + atomic_write(GENERATED, expected_wallpaper) + if repair_theme or repair_wallpaper: + command('sway', '--validate', '-c', str(SWAY / 'config')) + command('swaymsg', 'reload') + if STATE.exists(): + if read_json(STATE) != state: + write_json(STATE, state) + else: + write_json(STATE, state) + if read_json(PREFERENCES) != pref: + write_json(PREFERENCES, pref) + return state_response(state, pref) + except Exception: + restore_file(GENERATED_THEME, previous_theme) + restore_file(GENERATED, previous_wallpaper) + if repair_theme or repair_wallpaper: + command('swaymsg', 'reload') + raise + + def main(): parser = argparse.ArgumentParser(description=__doc__) sub = parser.add_subparsers(dest='action', required=True) sub.add_parser('current') sub.add_parser('reconcile') + sub.add_parser('status') + sub.add_parser('preferences') scan_parser = sub.add_parser('scan') scan_parser.add_argument('directory') scan_parser.add_argument('--page', type=int, default=0) - for action in ('validate', 'thumbnail', 'apply', 'set-directory'): + for action in ('validate', 'thumbnail', 'analyze', 'apply', 'set-directory', + 'set-theme-mode', 'set-manual-flavor'): sub.add_parser(action).add_argument('path') args = parser.parse_args() try: if args.action == 'current': result = current() elif args.action == 'reconcile': result = reconcile() + elif args.action == 'status': result = reconcile_state() + elif args.action == 'preferences': result = preferences() elif args.action == 'scan': if args.page < 0: raise ValueError('Page invalide') result = scan(args.directory, args.page) @@ -343,7 +545,10 @@ def main(): elif args.action == 'thumbnail': result = validate_image(args.path) result['thumbnail'] = thumbnail(result) + elif args.action == 'analyze': result = analyze_wallpaper(validate_image(args.path)) elif args.action == 'apply': result = apply(args.path) + elif args.action == 'set-theme-mode': result = set_theme_mode(args.path) + elif args.action == 'set-manual-flavor': result = set_manual_flavor(args.path) else: result = set_directory(args.path) print(json.dumps(result, ensure_ascii=False, separators=(',', ':'))) return 0 diff --git a/bin/.local/bin/wallpaper_analysis.py b/bin/.local/bin/wallpaper_analysis.py new file mode 100644 index 000000000..d66a53ff1 --- /dev/null +++ b/bin/.local/bin/wallpaper_analysis.py @@ -0,0 +1,111 @@ +"""Analyse déterministe de la luminance d'un fond d'écran local.""" +import hashlib +import json +import math +import os +from pathlib import Path +import tempfile + +from PIL import Image, ImageOps + + +ALGORITHM_VERSION = 1 +SAMPLE_SIDE = 128 +# Calibrés entre les groupes de gris 80/128, gradient/Sway bleu et 192/224. +THRESHOLDS = (0.15, 0.30, 0.64) +FLAVORS = ('mocha', 'macchiato', 'frappe', 'latte') +CACHE = Path(os.environ.get('XDG_CACHE_HOME', Path.home() / '.cache')) / 'labfy-appearance/analyses' + + +def linear_channel(value): + """sRGB normalisé vers lumière linéaire, conformément à IEC 61966-2-1.""" + return value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4 + + +def quantile(sorted_values, fraction): + position = (len(sorted_values) - 1) * fraction + low, high = math.floor(position), math.ceil(position) + if low == high: + return sorted_values[low] + return sorted_values[low] * (high - position) + sorted_values[high] * (position - low) + + +def classify(score): + if not math.isfinite(score) or not 0 <= score <= 1: + raise ValueError('Score de luminance invalide') + return FLAVORS[sum(score >= threshold for threshold in THRESHOLDS)] + + +def cache_key(meta): + # CONTRACT: changer l'algorithme ou le fichier invalide l'analyse cachée. + payload = json.dumps([ALGORITHM_VERSION, meta['canonicalPath'], meta['size'], meta['mtime']], + ensure_ascii=False, separators=(',', ':')) + return hashlib.sha256(payload.encode('utf-8')).hexdigest() + + +def sample_luminance(meta): + with Image.open(meta['canonicalPath']) as source: + sample = ImageOps.exif_transpose(source) + # WHY: les métriques travaillent sur <= 16 384 pixels, même pour une + # image 4K ; le noir explicite représente les zones transparentes. + sample.thumbnail((SAMPLE_SIDE, SAMPLE_SIDE), Image.Resampling.LANCZOS) + if 'A' in sample.getbands() or 'transparency' in sample.info: + rgba = sample.convert('RGBA') + rgb = Image.new('RGB', rgba.size, (0, 0, 0)) + rgb.paste(rgba, mask=rgba.getchannel('A')) + else: + rgb = sample.convert('RGB') + values = [] + for red, green, blue in rgb.get_flattened_data(): + values.append(0.2126 * linear_channel(red / 255) + + 0.7152 * linear_channel(green / 255) + + 0.0722 * linear_channel(blue / 255)) + return sorted(values) + + +def compute(meta): + values = sample_luminance(meta) + if not values: + raise ValueError('Image vide') + mean = sum(values) / len(values) + median = quantile(values, 0.50) + p10, p25 = quantile(values, 0.10), quantile(values, 0.25) + p75, p90 = quantile(values, 0.75), quantile(values, 0.90) + # La médiane évite qu'une petite zone opposée domine ; les quartiles + # séparent les compositions mixtes des fonds uniformes de même moyenne. + score = 0.50 * median + 0.30 * mean + 0.10 * p25 + 0.10 * p75 + return {'path': meta['path'], 'mean': mean, 'median': median, + 'p10': p10, 'p25': p25, 'p75': p75, 'p90': p90, + 'score': score, 'flavor': classify(score), + 'algorithmVersion': ALGORITHM_VERSION} + + +def analyze(meta): + CACHE.mkdir(parents=True, exist_ok=True) + target = CACHE / (cache_key(meta) + '.json') + try: + cached = json.loads(target.read_text(encoding='utf-8')) + if cached.get('algorithmVersion') == ALGORITHM_VERSION \ + and cached.get('path') == meta['path'] \ + and cached.get('flavor') == classify(cached.get('score')): + return cached + except (OSError, ValueError, TypeError, AttributeError): + pass + result = compute(meta) + fd, temporary = tempfile.mkstemp(prefix='.analysis-', suffix='.json', dir=CACHE) + try: + with os.fdopen(fd, 'w', encoding='utf-8') as stream: + json.dump(result, stream, ensure_ascii=False, separators=(',', ':')) + stream.write('\n') + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, target) + finally: + Path(temporary).unlink(missing_ok=True) + # Nettoyage local et non périodique : aucune base de données ni daemon. + entries = list(CACHE.glob('*.json')) + if len(entries) > 256: + for old in sorted(entries, key=lambda item: item.stat().st_mtime_ns)[:len(entries) - 224]: + if old != target: + old.unlink(missing_ok=True) + return result diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/AppearancePage.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/AppearancePage.qml index 656cf169f..774445c44 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/AppearancePage.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/AppearancePage.qml @@ -6,6 +6,8 @@ Item { id: page signal backRequested() signal wallpaperRequested() + signal themeRequested() + readonly property var flavorNames: ({ latte: "Latte", frappe: "Frappé", macchiato: "Macchiato", mocha: "Mocha" }) Column { anchors.fill: parent @@ -23,17 +25,24 @@ Item { anchors.left: parent.left; anchors.leftMargin: 47; anchors.verticalCenter: parent.verticalCenter spacing: 4 Text { text: "Fond d'écran"; color: Theme.foreground; font.pixelSize: 14; font.bold: true } - Text { text: "Galerie locale et choix explicite"; color: Theme.secondaryForeground; font.pixelSize: 11 } + Text { text: AppearanceController.effectiveWallpaper.split("/").pop(); color: Theme.secondaryForeground; font.pixelSize: 11; width: 290; elide: Text.ElideMiddle } } MouseArea { id: pointer; anchors.fill: parent; hoverEnabled: true; onClicked: page.wallpaperRequested() } } Rectangle { - width: parent.width; height: 58; radius: 4; color: Theme.buttonBackground - Text { - anchors.left: parent.left; anchors.leftMargin: 12; anchors.verticalCenter: parent.verticalCenter - text: "Thème • " + AppearanceController.effectiveFlavor + " / " + AppearanceController.effectiveAccent - color: Theme.foreground; font.pixelSize: 12 + width: parent.width; height: 72; radius: 4 + color: themePointer.containsMouse ? Theme.buttonHover : Theme.buttonBackground + NerdIcon { anchors.left: parent.left; anchors.leftMargin: 12; anchors.verticalCenter: parent.verticalCenter; text: "󰏘"; color: Theme.accent; font.pixelSize: 22 } + Column { + anchors.left: parent.left; anchors.leftMargin: 47; anchors.verticalCenter: parent.verticalCenter; spacing: 4 + Text { text: "Thème"; color: Theme.foreground; font.pixelSize: 14; font.bold: true } + Text { + text: (page.flavorNames[AppearanceController.effectiveFlavor] || "Mocha") + + " · " + (AppearanceController.themeMode === "wallpaper" ? "Selon le fond" : "Manuel") + color: Theme.secondaryForeground; font.pixelSize: 11 + } } + MouseArea { id: themePointer; anchors.fill: parent; hoverEnabled: true; onClicked: page.themeRequested() } } } } diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml index d3136bdb8..d1a3bea9b 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml @@ -14,7 +14,7 @@ PopupWindow { // CONTRACT: indices externes historiques conservés ; tout nouveau routage // passe par ces noms afin de ne pas multiplier les indices StackLayout. readonly property var pages: ({ main: 0, wifi: 1, bluetooth: 2, session: 3, - confirmation: 4, battery: 5, appearance: 6, wallpaper: 7 }) + confirmation: 4, battery: 5, appearance: 6, wallpaper: 7, theme: 8 }) property int currentPage: pages.main function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; } signal thresholdApplied() @@ -78,7 +78,7 @@ PopupWindow { : currentPage === 3 ? sessionPage.implicitHeight + 32 : currentPage === 4 ? confirmationPage.implicitHeight + 32 : currentPage === 5 ? batteryPage.implicitHeight + 32 - : currentPage === 7 ? 550 : 440 + : currentPage === pages.wallpaper ? 550 : 440 visible: false // CONTRACT: PopupWindow n'applique un changement de grabFocus qu'après // fermeture/réouverture ; le prendre dès MAIN garde le clavier disponible @@ -207,12 +207,17 @@ PopupWindow { AppearancePage { onBackRequested: popup.openPage("main") onWallpaperRequested: popup.openPage("wallpaper") + onThemeRequested: popup.openPage("theme") } WallpaperPage { activePage: popup.visible && popup.currentPage === popup.pages.wallpaper onBackRequested: popup.openPage("appearance") } + + ThemePage { + onBackRequested: popup.openPage("appearance") + } } } } diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/ThemePage.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/ThemePage.qml new file mode 100644 index 000000000..518fd7041 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/ThemePage.qml @@ -0,0 +1,91 @@ +import QtQuick +import "../theme" + +Item { + id: page + signal backRequested() + readonly property var options: [ + { key: "latte", name: "Latte", description: "Clair" }, + { key: "frappe", name: "Frappé", description: "Sombre doux" }, + { key: "macchiato", name: "Macchiato", description: "Sombre" }, + { key: "mocha", name: "Mocha", description: "Sombre profond" } + ] + readonly property var names: ({ latte: "Latte", frappe: "Frappé", macchiato: "Macchiato", mocha: "Mocha" }) + readonly property bool busy: AppearanceController.appearanceBusy + + Column { + anchors.fill: parent + spacing: 10 + Row { + spacing: 8; height: 30 + ActionButton { label: ""; onClicked: page.backRequested() } + Text { text: "Thème"; color: Theme.foreground; font.pixelSize: 16; font.bold: true; height: 30; verticalAlignment: Text.AlignVCenter } + } + Text { text: "MODE"; color: Theme.accentForeground; font.pixelSize: 11; font.bold: true } + Row { + spacing: 8 + Repeater { + model: [ + { key: "manual", name: "Manuel" }, + { key: "wallpaper", name: "Selon le fond" } + ] + delegate: Rectangle { + required property var modelData + readonly property bool active: AppearanceController.themeMode === modelData.key + width: (page.width - 8) / 2; height: 44; radius: 4 + color: active ? Theme.selectedBackground : Theme.buttonBackground + border.width: active ? 2 : 1 + border.color: active ? Theme.accent : Theme.border + opacity: page.busy ? 0.6 : 1 + Text { anchors.centerIn: parent; text: modelData.name; color: parent.active ? Theme.selectedForeground : Theme.foreground; font.pixelSize: 12; font.bold: parent.active } + MouseArea { anchors.fill: parent; enabled: !page.busy && !parent.active; onClicked: AppearanceController.setThemeMode(modelData.key) } + } + } + } + Text { + width: parent.width + text: "Thème effectif : " + (page.names[AppearanceController.effectiveFlavor] || "Mocha") + color: Theme.foreground; font.pixelSize: 12 + } + Text { + visible: AppearanceController.themeMode === "wallpaper" + width: parent.width; wrapMode: Text.Wrap + text: "Le thème suit le fond appliqué. Choix manuel conservé : " + + (page.names[AppearanceController.manualFlavor] || "Mocha") + "." + color: Theme.secondaryForeground; font.pixelSize: 11 + } + Text { + visible: AppearanceController.themeMode === "manual" + text: "FLAVOR MANUEL"; color: Theme.accentForeground; font.pixelSize: 11; font.bold: true + } + Grid { + visible: AppearanceController.themeMode === "manual" + columns: 2; spacing: 8 + Repeater { + model: page.options + delegate: Rectangle { + required property var modelData + readonly property bool active: AppearanceController.manualFlavor === modelData.key + width: (page.width - 8) / 2; height: 58; radius: 4 + color: active ? Theme.buttonHover : Theme.buttonBackground + border.width: active ? 2 : 1 + border.color: active ? Theme.accent : Theme.border + opacity: page.busy ? 0.6 : 1 + Column { + anchors.centerIn: parent; spacing: 3 + Text { text: modelData.name + (parent.parent.active ? " ✓" : ""); color: Theme.foreground; font.pixelSize: 13; font.bold: true } + Text { text: modelData.description; color: Theme.secondaryForeground; font.pixelSize: 10 } + } + MouseArea { anchors.fill: parent; enabled: !page.busy && !parent.active; onClicked: AppearanceController.setManualFlavor(modelData.key) } + } + } + } + Text { + visible: page.busy || !!AppearanceController.appearanceError + width: parent.width; wrapMode: Text.Wrap + text: page.busy ? "Application…" : AppearanceController.appearanceError + color: AppearanceController.appearanceError ? Theme.warningForeground : Theme.secondaryForeground + font.pixelSize: 11 + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/WALLPAPER.md b/quickshell/.config/quickshell/labfy-sway/controlcenter/WALLPAPER.md index 84b3b77af..ea73d94ba 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/WALLPAPER.md +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/WALLPAPER.md @@ -50,3 +50,9 @@ interrompre le rendu déjà présent. `reconcile` restaure le repli versionné a prochain appel explicite ; un nouvel `apply` valide peut aussi la remplacer. Le thème, Night Light et Sun Mode ne sont jamais calculés depuis l'image en 17C. L'état final validé de ce lot reste Mocha/Lavender et le fond Sway bleu. + +Depuis 17D, l'application en mode `themeMode=wallpaper` calcule le flavor +avant les écritures et publie thème et fond dans une transaction compensable. +Le schéma effectif est 3 ; l'accent reste Lavender. Voir +[`AUTO_THEME.md`](../theme/AUTO_THEME.md) pour l'algorithme et le comportement +de reprise. Le mode Manuel conserve l'isolation du fond et du thème de 17C. diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/WallpaperPage.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/WallpaperPage.qml index 85e54d038..2c6397716 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/WallpaperPage.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/WallpaperPage.qml @@ -18,6 +18,7 @@ Item { property bool scanning: false property int nextPage: 0 readonly property string backend: AppearanceController.wallpaperBackend + readonly property var flavorNames: ({ latte: "Latte", frappe: "Frappé", macchiato: "Macchiato", mocha: "Mocha" }) readonly property var previewMeta: selected || currentMeta onSelectedChanged: { selectionValid = false; @@ -155,6 +156,11 @@ Item { Text { text: "Fond d'écran"; height: 30; verticalAlignment: Text.AlignVCenter; color: Theme.foreground; font.pixelSize: 16; font.bold: true } } Text { text: page.selected ? "APERÇU DE LA SÉLECTION" : "APERÇU ACTUEL"; color: Theme.accentForeground; font.bold: true; font.pixelSize: 11 } + Text { + visible: AppearanceController.themeMode === "wallpaper" + text: "Thème selon le fond : " + page.flavorNames[AppearanceController.effectiveFlavor] + color: Theme.secondaryForeground; font.pixelSize: 10 + } Rectangle { width: parent.width; height: 110; radius: 4; color: Theme.buttonBackground Image { @@ -218,15 +224,15 @@ Item { ActionButton { label: "Suivant"; enabled: page.items.length > 0; onClicked: page.move(1) } ActionButton { label: AppearanceController.wallpaperApplying ? "Application…" : "Appliquer" - enabled: !!page.selected && page.selectionValid && !AppearanceController.wallpaperApplying + enabled: !!page.selected && page.selectionValid && !AppearanceController.appearanceBusy && page.selected.path !== AppearanceController.effectiveWallpaper onClicked: { page.feedback = ""; AppearanceController.applyWallpaper(page.selected.path); } } } Text { - width: parent.width; visible: !!page.feedback || !!AppearanceController.wallpaperError - text: AppearanceController.wallpaperError || page.feedback - color: AppearanceController.wallpaperError ? Theme.warningForeground : Theme.successForeground + width: parent.width; visible: !!page.feedback || !!AppearanceController.wallpaperError || !!AppearanceController.appearanceError + text: AppearanceController.wallpaperError || AppearanceController.appearanceError || page.feedback + color: AppearanceController.wallpaperError || AppearanceController.appearanceError ? Theme.warningForeground : Theme.successForeground wrapMode: Text.Wrap; font.pixelSize: 11 } } diff --git a/quickshell/.config/quickshell/labfy-sway/shell.qml b/quickshell/.config/quickshell/labfy-sway/shell.qml index d99a70e88..6b8357b0b 100644 --- a/quickshell/.config/quickshell/labfy-sway/shell.qml +++ b/quickshell/.config/quickshell/labfy-sway/shell.qml @@ -18,6 +18,9 @@ ShellRoot { function effectiveState(): string { return AppearanceController.effectiveState(); } + function publishState(payload: string): bool { + return AppearanceController.acceptPublished(payload); + } } // Une barre par écran, y compris si les sorties changent pendant la session. // CONTRACT: un seul serveur D-Bus pour toutes les sorties. diff --git a/quickshell/.config/quickshell/labfy-sway/theme/AUTO_THEME.md b/quickshell/.config/quickshell/labfy-sway/theme/AUTO_THEME.md new file mode 100644 index 000000000..f64625ee5 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/theme/AUTO_THEME.md @@ -0,0 +1,92 @@ +# Thème selon le fond d'écran — 17D + +`themeMode` dans `$XDG_CONFIG_HOME/labfy-appearance/preferences.json` vaut +`manual` ou `wallpaper`. `manualFlavor` conserve le dernier choix manuel pendant +Auto ; revenir à Manuel le restaure immédiatement. L'accent reste `lavender`. +`effectiveMode` décrit le rendu (`normal` en 17D), tandis que `themeMode` est la +stratégie utilisateur. `effectiveDark` vaut `false` uniquement pour Latte et +`effectiveHighContrast` reste `false`. Sun Mode aura priorité sur cette +stratégie dans un lot ultérieur, sans modifier les préférences manuelles. + +Le backend `wallpaper-manager.py` expose `analyze FILE`, `set-theme-mode +manual|wallpaper`, `set-manual-flavor latte|frappe|macchiato|mocha` et `status`. +Chaque succès écrit un seul objet JSON sur stdout ; les erreurs vont sur +stderr avec un code non nul. `analyze` ne modifie aucun état effectif. La page +Thème appelle `AppearanceController`, qui sérialise les opérations. Le CLI +`generate-appearance.py --apply` délègue au même backend en mode Manuel. + +## Algorithme 1 + +Pillow applique l'orientation EXIF et réduit l'image à **128 × 128 au plus** +avec LANCZOS. Les pixels transparents sont composités sur noir, ce qui rend +le résultat déterministe. Chaque canal sRGB normalisé `c` devient une valeur +linéaire `c/12.92` si `c <= 0.04045`, sinon +`((c+0.055)/1.055)^2.4`. La luminance est +`Y = 0.2126 R + 0.7152 G + 0.0722 B`. + +Le backend calcule `mean`, `median`, `p10`, `p25`, `p75` et `p90` de Y dans +`[0,1]`. Le score est +`0.50 × median + 0.30 × mean + 0.10 × p25 + 0.10 × p75`. +La médiane limite l'effet d'une petite zone opposée ; les quartiles +distinguent les compositions mixtes d'une couleur uniforme de même moyenne. +Les seuils constants de l'algorithme 1 sont : + +| Score | Flavor | +| --- | --- | +| `[0, 0.15)` | Mocha | +| `[0.15, 0.30)` | Macchiato | +| `[0.30, 0.64)` | Frappé | +| `[0.64, 1]` | Latte | + +Ils ont été placés entre les groupes observés : gris 80 (`0.080`) / gris +128 (`0.216`), gradient (`0.257`) / fond Sway bleu (`0.367`), gris 192 +(`0.527`) / gris 224 (`0.745`). Les fixtures noir, blanc, cinq gris, +gradient, moitié noir/blanc, 80 % sombre et 80 % clair sont générées dans un +dossier temporaire par les tests ; elles ne sont pas des fonds utilisateur. +La moitié noir/blanc donne un score voisin de `0.47` et Frappé ; les +compositions 80/20 donnent Mocha ou Latte selon la majorité. Les seuils ne +prétendent pas mesurer une préférence personnelle : ils rendent la règle +reproductible et révisable par version d'algorithme. + +Mesures locales de calibration (arrondies, 4 octobre 2026) : + +| Image | mean | median | p10 | p90 | score | Flavor | +| --- | ---: | ---: | ---: | ---: | ---: | --- | +| Sway bleu 1920×1080 | .3718 | .3647 | .2291 | .5182 | .3672 | Frappé | +| 662412 blur 3200×1999 | .0122 | .0140 | .0011 | .0230 | .0124 | Mocha | +| 662412 original 3200×1999 | .0125 | .0139 | .0011 | .0240 | .0124 | Mocha | +| arch_rainbow 6024×3401 | .0107 | .0078 | .0059 | .0118 | .0087 | Mocha | +| sway 1440×900 | .0662 | .0569 | .0569 | .0725 | .0602 | Mocha | +| swaylock 1440×900 | .0688 | .0569 | .0569 | .0817 | .0615 | Mocha | + +Le cache JSON se trouve dans `$XDG_CACHE_HOME/labfy-appearance/analyses/`. +Sa clé SHA-256 comprend version d'algorithme, chemin canonique, taille et +`mtime_ns`. L'écriture utilise temp/flush/fsync/replace. Un fichier changé +ou une version d'algorithme nouvelle force un recalcul. Au delà de 256 +entrées, seul ce cache est réduit à 224, au moment d'une nouvelle analyse ; +aucun daemon ou polling n'est ajouté. Sur les fonds locaux, les analyses +non cachées observées ont duré environ 0,03 à 0,10 s pour 1080p/4K et +0,2 à 0,3 s dans le premier essai sur une grande image 6024×3401. + +## Application et reprise + +En Auto, l'image est décodée et analysée avant toute écriture. Le backend +prépare `generated/wallpaper.conf` et `generated/theme.conf` si nécessaires, +valide Sway, recharge Sway, confirme `swaybg`, puis écrit `effective.json` +schéma 3 et les préférences schéma 2 avant publication IPC QuickShell. Un +verrou non bloquant commun empêche deux applications simultanées. Si une +étape échoue, les anciens fichiers générés et JSON sont restaurés et Sway +est rechargé. Un échec d'analyse est signalé distinctement avant écriture ; +il ne retire pas un fond déjà appliqué. En Manuel, appliquer un fond ne +réécrit pas le thème. La sélection dans la galerie n'analyse rien. + +Une transaction stricte entre fichiers, Sway et QuickShell n'existe pas. +Au démarrage, `status` considère `effective.json` comme dernier état publié +valide et répare les deux fichiers générés si une interruption les a laissés +en avance. Les schémas effectifs 1 et 2 migrent en conservant les champs et la +révision ; l'absence de préférences 17C initialise Manuel avec le flavor +effectif existant. Si l'analyse Auto manque ou change de version, `status` +la recalcule. Il ne remplace pas silencieusement un flavor appliqué par un +autre si le fichier a changé sur disque ; l'utilisateur réapplique ce fond +pour publier un nouvel état. La galerie et son dossier restent régis par +[`WALLPAPER.md`](../controlcenter/WALLPAPER.md). diff --git a/quickshell/.config/quickshell/labfy-sway/theme/AppearanceController.qml b/quickshell/.config/quickshell/labfy-sway/theme/AppearanceController.qml index fe84dfef2..e089d37e9 100644 --- a/quickshell/.config/quickshell/labfy-sway/theme/AppearanceController.qml +++ b/quickshell/.config/quickshell/labfy-sway/theme/AppearanceController.qml @@ -24,7 +24,8 @@ Singleton { function initialState() { try { const value = JSON.parse(stateFile.text()); - if ((value.version === 1 || value.version === 2) && flavors.includes(value.effectiveFlavor) + if ((value.version === 1 || value.version === 2 || value.version === 3) + && flavors.includes(value.effectiveFlavor) && accents.includes(value.effectiveAccent) && value.effectiveMode === "normal") return value; } catch (_) {} @@ -43,52 +44,123 @@ Singleton { property string effectiveWallpaper: initial.effectiveWallpaper || defaultWallpaper property string wallpaperMode: "fill" property string wallpaperDirectory: "" + property string themeMode: initial.themeMode === "wallpaper" ? "wallpaper" : "manual" + property string manualFlavor: flavors.includes(initial.manualFlavor) ? initial.manualFlavor : effectiveFlavor + property var wallpaperAnalysis: initial.wallpaperAnalysis || null + property bool appearanceBusy: false + property string appearanceError: "" property bool wallpaperApplying: false property string wallpaperError: "" + property string operationKind: "" signal wallpaperApplied() + signal appearanceApplied() + + // CONTRACT: effective.json est le dernier état appliqué valide. Le + // backend réconcilie ses deux fichiers générés au démarrage, puis le QML + // adopte exactement la révision persistée sans décision locale de thème. + Process { + id: startupReader + command: ["python", appearance.wallpaperBackend, "status"] + running: true + stdout: StdioCollector { id: startupOutput; waitForEnd: true } + stderr: StdioCollector { id: startupError; waitForEnd: true } + onExited: (code, status) => { + if (code !== 0 || status !== 0) { + appearance.appearanceError = startupError.text.trim() || "Réconciliation Appearance impossible"; + return; + } + try { appearance.acceptSnapshot(JSON.parse(startupOutput.text)); } + catch (_) { appearance.appearanceError = "État Appearance invalide"; } + } + } + function acceptSnapshot(value) { + if (!value || !flavors.includes(value.effectiveFlavor) + || value.effectiveAccent !== "lavender" + || !Number.isSafeInteger(value.revision) || value.revision < 0 + || (value.themeMode !== "manual" && value.themeMode !== "wallpaper") + || !flavors.includes(value.manualFlavor) + || !value.effectiveWallpaper) return false; + // Un status démarré avant une application ne peut pas revenir en + // arrière après la publication IPC de la révision plus récente. + if (value.revision < revision) return false; + effectiveFlavor = value.effectiveFlavor; + effectiveAccent = value.effectiveAccent; + effectiveMode = "normal"; + effectiveHighContrast = false; + effectiveWallpaper = value.effectiveWallpaper; + wallpaperMode = "fill"; + themeMode = value.themeMode; + manualFlavor = value.manualFlavor; + wallpaperDirectory = value.wallpaperDirectory || wallpaperDirectory; + wallpaperAnalysis = value.wallpaperAnalysis || value.analysis || null; + revision = value.revision; + appearanceError = ""; + wallpaperError = ""; + return true; + } + + function acceptPublished(payload) { + try { + const value = JSON.parse(payload); + return acceptSnapshot(Object.assign({}, value.state, value.preferences)); + } catch (_) { return false; } + } Process { id: wallpaperWriter stdout: StdioCollector { id: wallpaperOutput; waitForEnd: true } stderr: StdioCollector { id: wallpaperErrors; waitForEnd: true } onExited: (code, status) => { + appearance.appearanceBusy = false; appearance.wallpaperApplying = false; if (code !== 0 || status !== 0) { - appearance.wallpaperError = wallpaperErrors.text.trim() || "Application impossible"; + const message = wallpaperErrors.text.trim() || "Application impossible"; + if (message.includes("Analyse Auto Theme")) appearance.appearanceError = message; + else appearance.wallpaperError = message; return; } try { const state = JSON.parse(wallpaperOutput.text); - if (!state.effectiveWallpaper || !Number.isSafeInteger(state.revision)) throw new Error("État invalide"); - appearance.effectiveWallpaper = state.effectiveWallpaper; - appearance.wallpaperMode = state.wallpaperMode; - appearance.revision = state.revision; - appearance.wallpaperApplied(); - } catch (_) { appearance.wallpaperError = "Réponse wallpaper invalide"; } + if (!appearance.acceptSnapshot(state)) throw new Error("État invalide"); + if (appearance.operationKind === "wallpaper") appearance.wallpaperApplied(); + appearance.appearanceApplied(); + } catch (_) { appearance.appearanceError = "Réponse Appearance invalide"; } } } - function applyWallpaper(path) { - if (wallpaperApplying || !path || path === effectiveWallpaper) return false; + function runOperation(args, applyingWallpaper) { + if (appearanceBusy) return false; wallpaperError = ""; - wallpaperApplying = true; - wallpaperWriter.command = ["python", wallpaperBackend, "apply", path]; + appearanceError = ""; + appearanceBusy = true; + wallpaperApplying = applyingWallpaper; + operationKind = applyingWallpaper ? "wallpaper" : "theme"; + wallpaperWriter.command = ["python", wallpaperBackend].concat(args); wallpaperWriter.running = true; return true; } - // INVARIANT: une paire refusée ne change aucun champ ni la révision. - // La révision avance une seule fois après tout changement effectif. + function applyWallpaper(path) { + if (!path || path === effectiveWallpaper) return false; + return runOperation(["apply", path], true); + } + function setThemeMode(mode) { + if ((mode !== "manual" && mode !== "wallpaper") || mode === themeMode) return false; + return runOperation(["set-theme-mode", mode], false); + } + function setManualFlavor(flavor) { + if (!flavors.includes(flavor) || themeMode !== "manual" + || flavor === manualFlavor && flavor === effectiveFlavor) return false; + return runOperation(["set-manual-flavor", flavor], false); + } + + // CONTRACT: l'ancien point d'entrée IPC délègue au backend persistant ; + // il ne doit jamais créer un état QuickShell isolé de Sway/effective.json. function applyTheme(flavor, accent) { - if (!flavors.includes(flavor) || !accents.includes(accent)) return false; - if (effectiveFlavor === flavor && effectiveAccent === accent - && effectiveMode === "normal" && !effectiveHighContrast) return true; - effectiveFlavor = flavor; - effectiveAccent = accent; - effectiveMode = "normal"; - effectiveHighContrast = false; - revision++; - return true; + if (!flavors.includes(flavor) || accent !== "lavender" + || themeMode !== "manual") return false; + if (effectiveFlavor === flavor && manualFlavor === flavor) return true; + return setManualFlavor(flavor); } function effectiveState() { @@ -97,6 +169,7 @@ Singleton { effectiveHighContrast: effectiveHighContrast, effectiveAccent: effectiveAccent, effectiveWallpaper: effectiveWallpaper, wallpaperMode: wallpaperMode, wallpaperDirectory: wallpaperDirectory, + themeMode: themeMode, manualFlavor: manualFlavor, revision: revision }); } } diff --git a/quickshell/.config/quickshell/labfy-sway/theme/README.md b/quickshell/.config/quickshell/labfy-sway/theme/README.md index bdb35efa7..440c3741b 100644 --- a/quickshell/.config/quickshell/labfy-sway/theme/README.md +++ b/quickshell/.config/quickshell/labfy-sway/theme/README.md @@ -9,12 +9,13 @@ RGB/HSL/OKLCH de l'amont. ```text catppuccin.json (versionné) - → AppearanceController.qml (API live, sans persistance) + → AppearanceController.qml (API live, état publié par le backend) → Theme.qml (singleton effectif, rôles UI) → composants QuickShell catppuccin.json (versionné) - → generate-appearance.py (versionné) + → generate-appearance.py (rendu versionné) + → wallpaper-manager.py (transaction 17D) → generated/theme.conf (runtime, ignoré par Git) → Sway/SwayFX ``` @@ -44,23 +45,26 @@ Lavender. Les séries CPU/RAM du System Monitor restent intentionnellement Mauve. Ce choix rend les contrôles historiques Mauve légèrement plus bleus qu'avant. Les trois anciens Lavender restent l'accent principal. -L'API de thème est `AppearanceController.applyTheme(flavor, accent)` ; l'IPC +L'API de thème est `AppearanceController.setManualFlavor(flavor)` et +`AppearanceController.setThemeMode(mode)` ; l'IPC `qs -c labfy-sway ipc call appearance effectiveState` expose l'état complet -et `qs -c labfy-sway ipc call appearance setTheme mocha lavender` change -QuickShell en direct. La commande unifiée depuis le dépôt est : +et `qs -c labfy-sway ipc call appearance setTheme mocha lavender` déclenche +la même application persistante en mode Manuel. La commande unifiée depuis +le dépôt est : ```sh python bin/.local/bin/generate-appearance.py latte --accent lavender --apply ``` -Elle écrit atomiquement le fichier runtime Sway, valide la configuration, -recharge Sway, persiste l'état effectif, puis met à jour QuickShell. En cas -d'échec elle restaure la dernière génération valide, l'état persistant et -l'état QuickShell précédent. Aucune décision automatique de profil n'existe -encore. Le wallpaper 17C est séparé dans `generated/wallpaper.conf` ; voir +`--apply` délègue au gestionnaire d'apparence 17D et exige Manuel/Lavender ; +le rendu hors ligne reste disponible pour les autres accents. Ce backend +prépare les fichiers, valide Sway, recharge, persiste l'état effectif, puis +publie dans QuickShell. En cas d'échec il compense l'application. Le wallpaper +reste séparé dans `generated/wallpaper.conf` ; voir [WALLPAPER.md](../controlcenter/WALLPAPER.md). `generate-appearance.py` -préserve les champs wallpaper du schéma effectif 2 lors d'un changement de -thème. +préserve les champs wallpaper du schéma effectif 3 lors d'un changement de +thème. L'algorithme Auto, ses seuils et la migration sont documentés dans +[AUTO_THEME.md](AUTO_THEME.md). Le repli versionné `sway/theme-default.conf` fournit Mocha même avant toute génération. Ses quelques variables sont un artefact dérivé, pas une palette complète indépendante. `sway/style` inclut le repli, puis un glob de fichier diff --git a/tests/test_wallpaper_analysis.py b/tests/test_wallpaper_analysis.py new file mode 100644 index 000000000..54cb0e5fe --- /dev/null +++ b/tests/test_wallpaper_analysis.py @@ -0,0 +1,92 @@ +"""Propriétés de l'analyse locale, sur des images synthétiques temporaires.""" +import sys +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +from PIL import Image + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'bin/.local/bin')) +import wallpaper_analysis as analysis +import importlib.util + +source = Path(__file__).resolve().parents[1] / 'bin/.local/bin/wallpaper-manager.py' +spec = importlib.util.spec_from_file_location('wallpaper_manager_analysis_tests', source) +manager = importlib.util.module_from_spec(spec) +spec.loader.exec_module(manager) + + +class WallpaperAnalysisTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.cache_patch = mock.patch.object(analysis, 'CACHE', self.root / 'cache') + self.cache_patch.start() + + def tearDown(self): + self.cache_patch.stop() + self.temp.cleanup() + + def result(self, name, image): + path = self.root / name + image.save(path) + return analysis.analyze(manager.validate_image(path)) + + def test_linear_luminance_and_four_flavors(self): + self.assertAlmostEqual(analysis.linear_channel(0), 0) + self.assertAlmostEqual(analysis.linear_channel(1), 1) + self.assertAlmostEqual(analysis.linear_channel(128 / 255), 0.21586, places=4) + colors = [(0, 'mocha'), (40, 'mocha'), (80, 'mocha'), + (128, 'macchiato'), (192, 'frappe'), + (224, 'latte'), (255, 'latte')] + for value, flavor in colors: + with self.subTest(value=value): + result = self.result(f'gris {value} été.png', Image.new('RGB', (100, 80), (value, value, value))) + self.assertEqual(result['flavor'], flavor) + self.assertAlmostEqual(result['mean'], result['median'], places=6) + self.assertLessEqual(result['p10'], result['p90']) + + def test_bimodal_weight_and_gradient(self): + split = Image.new('RGB', (100, 40), 'black') + split.paste('white', (50, 0, 100, 40)) + result = self.result('moitié noir blanc.png', split) + self.assertEqual(result['flavor'], 'frappe') + self.assertGreater(result['p90'], 0.9) + self.assertLess(result['p10'], 0.1) + dark = Image.new('RGB', (100, 40), 'black') + dark.paste('white', (80, 0, 100, 40)) + self.assertEqual(self.result('majorité sombre.png', dark)['flavor'], 'mocha') + bright = Image.new('RGB', (100, 40), 'white') + bright.paste('black', (0, 0, 20, 40)) + self.assertEqual(self.result('majorité claire.png', bright)['flavor'], 'latte') + gradient = Image.new('RGB', (256, 8)) + gradient.putdata([(x, x, x) for _ in range(8) for x in range(256)]) + graded = self.result('gradient.png', gradient) + self.assertEqual(graded['flavor'], 'macchiato') + self.assertLess(graded['p10'], graded['median']) + self.assertLess(graded['median'], graded['p90']) + + def test_cache_invalidated_by_content_and_version(self): + path = self.root / 'image.png' + Image.new('RGB', (20, 20), 'black').save(path) + first = manager.validate_image(path) + self.assertEqual(analysis.analyze(first)['flavor'], 'mocha') + key = analysis.cache_key(first) + Image.new('RGB', (25, 25), 'white').save(path) + second = manager.validate_image(path) + self.assertNotEqual(key, analysis.cache_key(second)) + self.assertEqual(analysis.analyze(second)['flavor'], 'latte') + second_key = analysis.cache_key(second) + with mock.patch.object(analysis, 'ALGORITHM_VERSION', analysis.ALGORITHM_VERSION + 1): + self.assertNotEqual(analysis.cache_key(second), second_key) + + def test_invalid_image_rejected(self): + path = self.root / 'faux.png' + path.write_text('pas une image') + with self.assertRaises(Exception): + manager.validate_image(path) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_wallpaper_manager.py b/tests/test_wallpaper_manager.py index c839a4247..d357ad970 100644 --- a/tests/test_wallpaper_manager.py +++ b/tests/test_wallpaper_manager.py @@ -5,12 +5,14 @@ import json from pathlib import Path import tempfile import unittest +import sys from unittest import mock from PIL import Image SOURCE = Path(__file__).resolve().parents[1] / 'bin/.local/bin/wallpaper-manager.py' +sys.path.insert(0, str(SOURCE.parent)) SPEC = importlib.util.spec_from_file_location('wallpaper_manager', SOURCE) manager = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(manager) @@ -26,6 +28,7 @@ class WallpaperManagerTest(unittest.TestCase): mock.patch.object(manager, 'PREFERENCES', self.root / 'config/preferences.json'), mock.patch.object(manager, 'STATE', self.root / 'state/effective.json'), mock.patch.object(manager, 'GENERATED', self.root / 'sway/generated/wallpaper.conf'), + mock.patch.object(manager, 'GENERATED_THEME', self.root / 'sway/generated/theme.conf'), mock.patch.object(manager, 'SWAY', self.root / 'sway'), ] for patcher in self.patchers: @@ -62,7 +65,7 @@ class WallpaperManagerTest(unittest.TestCase): manager.validate_image(path) chosen = manager.set_directory(self.root.as_uri()) self.assertEqual(chosen['wallpaperDirectory'], str(self.root)) - self.assertEqual(json.loads(manager.PREFERENCES.read_text())['version'], 1) + self.assertEqual(json.loads(manager.PREFERENCES.read_text())['version'], 2) def test_atomic_replace_keeps_old_file_on_write_failure(self): target = self.root / 'atomic.txt' @@ -122,21 +125,134 @@ class WallpaperManagerTest(unittest.TestCase): manager.write_json(manager.STATE, old) manager.atomic_write(manager.GENERATED, b'ancien') with mock.patch.object(manager, 'command') as command, \ - mock.patch.object(manager, 'swaybg_matches', return_value=True): + mock.patch.object(manager, 'swaybg_matches', return_value=True), \ + mock.patch.object(manager, 'publish_live'): result = manager.apply(str(self.image)) self.assertEqual(result['revision'], 9) state = manager.read_json(manager.STATE) self.assertEqual(state['effectiveFlavor'], 'mocha') self.assertEqual(state['effectiveAccent'], 'lavender') - self.assertEqual(state['version'], 2) + self.assertEqual(state['version'], 3) self.assertIn(b'set $wallpaper ', manager.GENERATED.read_bytes()) self.assertEqual(command.call_count, 2) + newer = self.root / 'autre.png' + Image.new('RGB', (32, 32), 'black').save(newer) with mock.patch.object(manager, 'command', side_effect=[RuntimeError('validate'), None]): with self.assertRaises(RuntimeError): - manager.apply(str(self.image)) + manager.apply(str(newer)) self.assertEqual(manager.read_json(manager.STATE), state) self.assertIn(b'set $wallpaper ', manager.GENERATED.read_bytes()) + def test_manual_wallpaper_does_not_change_theme(self): + manager.write_json(manager.STATE, {'version': 3, 'effectiveFlavor': 'mocha', + 'effectiveWallpaper': str(self.image), 'wallpaperMtime': 0, 'revision': 3, + 'themeMode': 'manual', 'manualFlavor': 'mocha'}) + bright = self.root / 'clair.png' + Image.new('RGB', (48, 48), 'white').save(bright) + with mock.patch.object(manager, 'command'), mock.patch.object(manager, 'publish_live'), \ + mock.patch.object(manager, 'swaybg_matches', return_value=True): + result = manager.apply(str(bright)) + self.assertEqual(result['effectiveFlavor'], 'mocha') + self.assertFalse(manager.GENERATED_THEME.exists()) + self.assertEqual(result['revision'], 4) + + def test_auto_activation_and_manual_restoration(self): + manager.write_json(manager.STATE, {'version': 3, 'effectiveFlavor': 'mocha', + 'effectiveWallpaper': str(self.image), 'revision': 3, + 'themeMode': 'manual', 'manualFlavor': 'mocha'}) + with mock.patch.object(manager, 'command'), mock.patch.object(manager, 'publish_live'): + automatic = manager.set_theme_mode('wallpaper') + self.assertEqual(automatic['manualFlavor'], 'mocha') + self.assertEqual(automatic['effectiveFlavor'], 'mocha') + restored = manager.set_theme_mode('manual') + self.assertEqual(restored['effectiveFlavor'], 'mocha') + self.assertEqual(restored['manualFlavor'], 'mocha') + self.assertEqual(restored['revision'], 5) + + def test_auto_wallpaper_and_rollback(self): + manager.write_json(manager.STATE, {'version': 3, 'effectiveFlavor': 'mocha', + 'effectiveWallpaper': str(self.image), 'revision': 5, + 'themeMode': 'wallpaper', 'manualFlavor': 'mocha'}) + bright = self.root / 'clair.png' + Image.new('RGB', (48, 48), 'white').save(bright) + with mock.patch.object(manager, 'command'), mock.patch.object(manager, 'publish_live'), \ + mock.patch.object(manager, 'swaybg_matches', return_value=True): + result = manager.apply(str(bright)) + self.assertEqual(result['effectiveFlavor'], 'latte') + self.assertFalse(result['effectiveDark']) + previous_theme = manager.GENERATED_THEME.read_bytes() + previous_wallpaper = manager.GENERATED.read_bytes() + previous_state = manager.STATE.read_bytes() + dark = self.root / 'sombre.png' + Image.new('RGB', (48, 48), 'black').save(dark) + with mock.patch.object(manager, 'command', side_effect=[None, RuntimeError('reload'), None]): + with self.assertRaises(RuntimeError): + manager.apply(str(dark)) + self.assertEqual(manager.GENERATED_THEME.read_bytes(), previous_theme) + self.assertEqual(manager.GENERATED.read_bytes(), previous_wallpaper) + self.assertEqual(manager.STATE.read_bytes(), previous_state) + + def test_schema_two_migration_keeps_previous_fields(self): + legacy = {'version': 2, 'effectiveFlavor': 'macchiato', + 'effectiveAccent': 'lavender', 'effectiveDark': True, + 'effectiveHighContrast': False, 'effectiveMode': 'normal', + 'effectiveWallpaper': str(self.image), 'revision': 47, + 'customFutureField': 'conservé'} + manager.write_json(manager.STATE, legacy) + manager.write_json(manager.PREFERENCES, {'version': 1, + 'wallpaperDirectory': str(self.root), 'wallpaperMode': 'fill'}) + with mock.patch.object(manager, 'command') as command: + result = manager.reconcile_state() + self.assertEqual(result['version'], 3) + self.assertEqual(result['revision'], 47) + self.assertEqual(result['customFutureField'], 'conservé') + self.assertEqual(result['effectiveFlavor'], 'macchiato') + self.assertEqual(result['themeMode'], 'manual') + self.assertEqual(result['manualFlavor'], 'macchiato') + self.assertEqual(result['wallpaperDirectory'], str(self.root)) + self.assertEqual(command.call_count, 2) + + def test_crash_like_reconciliation_uses_effective_state(self): + manager.write_json(manager.STATE, {'version': 3, 'effectiveFlavor': 'mocha', + 'effectiveWallpaper': str(self.image), 'revision': 6, + 'themeMode': 'manual', 'manualFlavor': 'mocha'}) + manager.write_json(manager.PREFERENCES, {'version': 2, + 'themeMode': 'wallpaper', 'manualFlavor': 'latte', + 'wallpaperDirectory': str(self.root)}) + manager.atomic_write(manager.GENERATED_THEME, b'interrompu') + manager.atomic_write(manager.GENERATED, b'interrompu') + with mock.patch.object(manager, 'command') as command: + result = manager.reconcile_state() + self.assertEqual(result['revision'], 6) + self.assertEqual(result['themeMode'], 'manual') + self.assertEqual(result['manualFlavor'], 'mocha') + self.assertIn(b'set $base ', manager.GENERATED_THEME.read_bytes()) + self.assertIn(b'set $wallpaper ', manager.GENERATED.read_bytes()) + self.assertEqual(command.call_count, 2) + + def test_auto_analysis_failure_does_not_change_files(self): + old = {'version': 3, 'effectiveFlavor': 'mocha', + 'effectiveWallpaper': str(self.image), 'revision': 6, + 'themeMode': 'manual', 'manualFlavor': 'mocha'} + manager.write_json(manager.STATE, old) + with mock.patch.object(manager, 'analyze_wallpaper', side_effect=ValueError('décodeur')): + with self.assertRaisesRegex(ValueError, 'Analyse Auto Theme'): + manager.set_theme_mode('wallpaper') + self.assertEqual(manager.read_json(manager.STATE), old) + self.assertFalse(manager.GENERATED_THEME.exists()) + + def test_auto_reconcile_restores_missing_analysis_without_revision(self): + meta = manager.validate_image(self.image) + manager.write_json(manager.STATE, {'version': 3, 'effectiveFlavor': 'mocha', + 'effectiveWallpaper': str(self.image), 'wallpaperMtime': meta['mtime'], + 'revision': 12, 'themeMode': 'wallpaper', 'manualFlavor': 'latte'}) + with mock.patch.object(manager, 'command'): + result = manager.reconcile_state() + self.assertEqual(result['revision'], 12) + self.assertEqual(result['effectiveFlavor'], 'mocha') + self.assertEqual(result['manualFlavor'], 'latte') + self.assertEqual(result['wallpaperAnalysis']['algorithmVersion'], 1) + if __name__ == '__main__': unittest.main()