feat(appearance): add automatic wallpaper theme selection
This commit is contained in:
parent
51b65624e8
commit
3a37c90080
14 changed files with 922 additions and 145 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
111
bin/.local/bin/wallpaper_analysis.py
Normal file
111
bin/.local/bin/wallpaper_analysis.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
92
quickshell/.config/quickshell/labfy-sway/theme/AUTO_THEME.md
Normal file
92
quickshell/.config/quickshell/labfy-sway/theme/AUTO_THEME.md
Normal file
|
|
@ -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).
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
92
tests/test_wallpaper_analysis.py
Normal file
92
tests/test_wallpaper_analysis.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue