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':
|
if args.apply and args.output != SWAY / 'generated/theme.conf':
|
||||||
parser.error('--apply exige la sortie runtime par défaut')
|
parser.error('--apply exige la sortie runtime par défaut')
|
||||||
try:
|
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')
|
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)
|
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}')
|
print(f'{args.flavor}/{args.accent}: {args.output}')
|
||||||
except (OSError, ValueError, subprocess.CalledProcessError, RuntimeError) as exc:
|
except (OSError, ValueError, subprocess.CalledProcessError, RuntimeError) as exc:
|
||||||
print(f'Erreur : {exc}', file=sys.stderr)
|
print(f'Erreur : {exc}', file=sys.stderr)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
#!/usr/bin/env python3
|
#!/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 argparse
|
||||||
import fcntl
|
import fcntl
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -15,6 +16,7 @@ import warnings
|
||||||
from urllib.parse import unquote, urlsplit
|
from urllib.parse import unquote, urlsplit
|
||||||
|
|
||||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
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
|
# 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.
|
# 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'
|
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'
|
CACHE = Path(os.environ.get('XDG_CACHE_HOME', Path.home() / '.cache')) / 'labfy-appearance/wallpapers'
|
||||||
GENERATED = SWAY / 'generated/wallpaper.conf'
|
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'
|
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):
|
def atomic_write(path, data):
|
||||||
|
|
@ -241,61 +247,172 @@ def current():
|
||||||
'revision': state.get('revision', 0)}
|
'revision': state.get('revision', 0)}
|
||||||
|
|
||||||
|
|
||||||
def apply(path):
|
def preferences(state=None):
|
||||||
# INVARIANT: lock couvre config, reload et état ; deux appels ne s'entremêlent pas.
|
"""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)
|
STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(STATE.parent / 'wallpaper.lock', 'a+b') as lock:
|
with open(STATE.parent / 'wallpaper.lock', 'a+b') as lock:
|
||||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
meta = validate_image(path)
|
old_wallpaper = GENERATED.read_bytes() if GENERATED.exists() else None
|
||||||
target = meta['path']
|
old_theme = GENERATED_THEME.read_bytes() if GENERATED_THEME.exists() else None
|
||||||
old_config = GENERATED.read_bytes() if GENERATED.exists() else None
|
|
||||||
old_state = STATE.read_bytes() if STATE.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)
|
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:
|
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:
|
||||||
|
if wallpaper_changed:
|
||||||
alias = safe_alias(meta)
|
alias = safe_alias(meta)
|
||||||
if old.get('effectiveWallpaper', active_wallpaper()) == target and swaybg_matches(alias):
|
|
||||||
return current()
|
|
||||||
atomic_write(GENERATED, render_wallpaper(alias))
|
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('sway', '--validate', '-c', str(SWAY / 'config'))
|
||||||
command('swaymsg', 'reload')
|
command('swaymsg', 'reload')
|
||||||
|
if wallpaper_changed:
|
||||||
deadline = time.monotonic() + 4
|
deadline = time.monotonic() + 4
|
||||||
while time.monotonic() < deadline and not swaybg_matches(alias):
|
while time.monotonic() < deadline and not swaybg_matches(alias):
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
if not swaybg_matches(alias):
|
if not swaybg_matches(alias):
|
||||||
raise RuntimeError('Sway n’a pas appliqué le fond demandé')
|
raise RuntimeError('Sway n’a pas appliqué le fond demandé')
|
||||||
# WHY: préserver tous les champs 17B et inconnus pour la migration
|
write_json(STATE, state)
|
||||||
# et les futurs consommateurs ; seul le wallpaper et sa révision changent.
|
write_json(PREFERENCES, pref)
|
||||||
new_state = dict(old)
|
publish_live(state, pref)
|
||||||
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)
|
|
||||||
# Seul l'alias actif est requis au prochain login ; les anciens
|
# Seul l'alias actif est requis au prochain login ; les anciens
|
||||||
# liens créés par ce backend ne doivent pas s'accumuler.
|
# liens créés par ce backend ne doivent pas s'accumuler.
|
||||||
|
if alias:
|
||||||
for previous_alias in ALIASES.glob('image-*'):
|
for previous_alias in ALIASES.glob('image-*'):
|
||||||
if str(previous_alias) != alias and previous_alias.is_symlink():
|
if str(previous_alias) != alias and previous_alias.is_symlink():
|
||||||
try:
|
try:
|
||||||
previous_alias.unlink()
|
previous_alias.unlink()
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
return current()
|
return state_response(state, pref, analysis)
|
||||||
except Exception:
|
except Exception:
|
||||||
if old_config is None:
|
restore_file(GENERATED, old_wallpaper)
|
||||||
GENERATED.unlink(missing_ok=True)
|
restore_file(GENERATED_THEME, old_theme)
|
||||||
else:
|
restore_file(STATE, old_state)
|
||||||
atomic_write(GENERATED, old_config)
|
restore_file(PREFERENCES, old_preferences)
|
||||||
if old_state is None:
|
if wrote_config:
|
||||||
STATE.unlink(missing_ok=True)
|
|
||||||
else:
|
|
||||||
atomic_write(STATE, old_state)
|
|
||||||
command('swaymsg', 'reload')
|
command('swaymsg', 'reload')
|
||||||
raise
|
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):
|
def set_directory(path):
|
||||||
if path.startswith('file:'):
|
if path.startswith('file:'):
|
||||||
uri = urlsplit(path)
|
uri = urlsplit(path)
|
||||||
|
|
@ -305,10 +422,15 @@ def set_directory(path):
|
||||||
directory = Path(path).expanduser().resolve(strict=True)
|
directory = Path(path).expanduser().resolve(strict=True)
|
||||||
if not directory.is_dir():
|
if not directory.is_dir():
|
||||||
raise ValueError('Dossier introuvable')
|
raise ValueError('Dossier introuvable')
|
||||||
pref = read_json(PREFERENCES)
|
STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
pref.update(version=1, wallpaperDirectory=str(directory), wallpaperMode='fill')
|
# 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)
|
write_json(PREFERENCES, pref)
|
||||||
return {'version': 1, 'wallpaperDirectory': str(directory), 'wallpaperMode': 'fill'}
|
return {'version': PREF_VERSION, 'wallpaperDirectory': str(directory), 'wallpaperMode': 'fill'}
|
||||||
|
|
||||||
|
|
||||||
def reconcile():
|
def reconcile():
|
||||||
|
|
@ -322,20 +444,100 @@ def reconcile():
|
||||||
return result
|
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():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
sub = parser.add_subparsers(dest='action', required=True)
|
sub = parser.add_subparsers(dest='action', required=True)
|
||||||
sub.add_parser('current')
|
sub.add_parser('current')
|
||||||
sub.add_parser('reconcile')
|
sub.add_parser('reconcile')
|
||||||
|
sub.add_parser('status')
|
||||||
|
sub.add_parser('preferences')
|
||||||
scan_parser = sub.add_parser('scan')
|
scan_parser = sub.add_parser('scan')
|
||||||
scan_parser.add_argument('directory')
|
scan_parser.add_argument('directory')
|
||||||
scan_parser.add_argument('--page', type=int, default=0)
|
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')
|
sub.add_parser(action).add_argument('path')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
try:
|
try:
|
||||||
if args.action == 'current': result = current()
|
if args.action == 'current': result = current()
|
||||||
elif args.action == 'reconcile': result = reconcile()
|
elif args.action == 'reconcile': result = reconcile()
|
||||||
|
elif args.action == 'status': result = reconcile_state()
|
||||||
|
elif args.action == 'preferences': result = preferences()
|
||||||
elif args.action == 'scan':
|
elif args.action == 'scan':
|
||||||
if args.page < 0: raise ValueError('Page invalide')
|
if args.page < 0: raise ValueError('Page invalide')
|
||||||
result = scan(args.directory, args.page)
|
result = scan(args.directory, args.page)
|
||||||
|
|
@ -343,7 +545,10 @@ def main():
|
||||||
elif args.action == 'thumbnail':
|
elif args.action == 'thumbnail':
|
||||||
result = validate_image(args.path)
|
result = validate_image(args.path)
|
||||||
result['thumbnail'] = thumbnail(result)
|
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 == '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)
|
else: result = set_directory(args.path)
|
||||||
print(json.dumps(result, ensure_ascii=False, separators=(',', ':')))
|
print(json.dumps(result, ensure_ascii=False, separators=(',', ':')))
|
||||||
return 0
|
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
|
id: page
|
||||||
signal backRequested()
|
signal backRequested()
|
||||||
signal wallpaperRequested()
|
signal wallpaperRequested()
|
||||||
|
signal themeRequested()
|
||||||
|
readonly property var flavorNames: ({ latte: "Latte", frappe: "Frappé", macchiato: "Macchiato", mocha: "Mocha" })
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
|
@ -23,17 +25,24 @@ Item {
|
||||||
anchors.left: parent.left; anchors.leftMargin: 47; anchors.verticalCenter: parent.verticalCenter
|
anchors.left: parent.left; anchors.leftMargin: 47; anchors.verticalCenter: parent.verticalCenter
|
||||||
spacing: 4
|
spacing: 4
|
||||||
Text { text: "Fond d'écran"; color: Theme.foreground; font.pixelSize: 14; font.bold: true }
|
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() }
|
MouseArea { id: pointer; anchors.fill: parent; hoverEnabled: true; onClicked: page.wallpaperRequested() }
|
||||||
}
|
}
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: parent.width; height: 58; radius: 4; color: Theme.buttonBackground
|
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 {
|
||||||
anchors.left: parent.left; anchors.leftMargin: 12; anchors.verticalCenter: parent.verticalCenter
|
text: (page.flavorNames[AppearanceController.effectiveFlavor] || "Mocha")
|
||||||
text: "Thème • " + AppearanceController.effectiveFlavor + " / " + AppearanceController.effectiveAccent
|
+ " · " + (AppearanceController.themeMode === "wallpaper" ? "Selon le fond" : "Manuel")
|
||||||
color: Theme.foreground; font.pixelSize: 12
|
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
|
// CONTRACT: indices externes historiques conservés ; tout nouveau routage
|
||||||
// passe par ces noms afin de ne pas multiplier les indices StackLayout.
|
// passe par ces noms afin de ne pas multiplier les indices StackLayout.
|
||||||
readonly property var pages: ({ main: 0, wifi: 1, bluetooth: 2, session: 3,
|
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
|
property int currentPage: pages.main
|
||||||
function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; }
|
function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; }
|
||||||
signal thresholdApplied()
|
signal thresholdApplied()
|
||||||
|
|
@ -78,7 +78,7 @@ PopupWindow {
|
||||||
: currentPage === 3 ? sessionPage.implicitHeight + 32
|
: currentPage === 3 ? sessionPage.implicitHeight + 32
|
||||||
: currentPage === 4 ? confirmationPage.implicitHeight + 32
|
: currentPage === 4 ? confirmationPage.implicitHeight + 32
|
||||||
: currentPage === 5 ? batteryPage.implicitHeight + 32
|
: currentPage === 5 ? batteryPage.implicitHeight + 32
|
||||||
: currentPage === 7 ? 550 : 440
|
: currentPage === pages.wallpaper ? 550 : 440
|
||||||
visible: false
|
visible: false
|
||||||
// CONTRACT: PopupWindow n'applique un changement de grabFocus qu'après
|
// CONTRACT: PopupWindow n'applique un changement de grabFocus qu'après
|
||||||
// fermeture/réouverture ; le prendre dès MAIN garde le clavier disponible
|
// fermeture/réouverture ; le prendre dès MAIN garde le clavier disponible
|
||||||
|
|
@ -207,12 +207,17 @@ PopupWindow {
|
||||||
AppearancePage {
|
AppearancePage {
|
||||||
onBackRequested: popup.openPage("main")
|
onBackRequested: popup.openPage("main")
|
||||||
onWallpaperRequested: popup.openPage("wallpaper")
|
onWallpaperRequested: popup.openPage("wallpaper")
|
||||||
|
onThemeRequested: popup.openPage("theme")
|
||||||
}
|
}
|
||||||
|
|
||||||
WallpaperPage {
|
WallpaperPage {
|
||||||
activePage: popup.visible && popup.currentPage === popup.pages.wallpaper
|
activePage: popup.visible && popup.currentPage === popup.pages.wallpaper
|
||||||
onBackRequested: popup.openPage("appearance")
|
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.
|
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
|
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.
|
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 bool scanning: false
|
||||||
property int nextPage: 0
|
property int nextPage: 0
|
||||||
readonly property string backend: AppearanceController.wallpaperBackend
|
readonly property string backend: AppearanceController.wallpaperBackend
|
||||||
|
readonly property var flavorNames: ({ latte: "Latte", frappe: "Frappé", macchiato: "Macchiato", mocha: "Mocha" })
|
||||||
readonly property var previewMeta: selected || currentMeta
|
readonly property var previewMeta: selected || currentMeta
|
||||||
onSelectedChanged: {
|
onSelectedChanged: {
|
||||||
selectionValid = false;
|
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: "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 { 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 {
|
Rectangle {
|
||||||
width: parent.width; height: 110; radius: 4; color: Theme.buttonBackground
|
width: parent.width; height: 110; radius: 4; color: Theme.buttonBackground
|
||||||
Image {
|
Image {
|
||||||
|
|
@ -218,15 +224,15 @@ Item {
|
||||||
ActionButton { label: "Suivant"; enabled: page.items.length > 0; onClicked: page.move(1) }
|
ActionButton { label: "Suivant"; enabled: page.items.length > 0; onClicked: page.move(1) }
|
||||||
ActionButton {
|
ActionButton {
|
||||||
label: AppearanceController.wallpaperApplying ? "Application…" : "Appliquer"
|
label: AppearanceController.wallpaperApplying ? "Application…" : "Appliquer"
|
||||||
enabled: !!page.selected && page.selectionValid && !AppearanceController.wallpaperApplying
|
enabled: !!page.selected && page.selectionValid && !AppearanceController.appearanceBusy
|
||||||
&& page.selected.path !== AppearanceController.effectiveWallpaper
|
&& page.selected.path !== AppearanceController.effectiveWallpaper
|
||||||
onClicked: { page.feedback = ""; AppearanceController.applyWallpaper(page.selected.path); }
|
onClicked: { page.feedback = ""; AppearanceController.applyWallpaper(page.selected.path); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
width: parent.width; visible: !!page.feedback || !!AppearanceController.wallpaperError
|
width: parent.width; visible: !!page.feedback || !!AppearanceController.wallpaperError || !!AppearanceController.appearanceError
|
||||||
text: AppearanceController.wallpaperError || page.feedback
|
text: AppearanceController.wallpaperError || AppearanceController.appearanceError || page.feedback
|
||||||
color: AppearanceController.wallpaperError ? Theme.warningForeground : Theme.successForeground
|
color: AppearanceController.wallpaperError || AppearanceController.appearanceError ? Theme.warningForeground : Theme.successForeground
|
||||||
wrapMode: Text.Wrap; font.pixelSize: 11
|
wrapMode: Text.Wrap; font.pixelSize: 11
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ ShellRoot {
|
||||||
function effectiveState(): string {
|
function effectiveState(): string {
|
||||||
return AppearanceController.effectiveState();
|
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.
|
// Une barre par écran, y compris si les sorties changent pendant la session.
|
||||||
// CONTRACT: un seul serveur D-Bus pour toutes les sorties.
|
// 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() {
|
function initialState() {
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(stateFile.text());
|
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)
|
&& accents.includes(value.effectiveAccent)
|
||||||
&& value.effectiveMode === "normal") return value;
|
&& value.effectiveMode === "normal") return value;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
@ -43,52 +44,123 @@ Singleton {
|
||||||
property string effectiveWallpaper: initial.effectiveWallpaper || defaultWallpaper
|
property string effectiveWallpaper: initial.effectiveWallpaper || defaultWallpaper
|
||||||
property string wallpaperMode: "fill"
|
property string wallpaperMode: "fill"
|
||||||
property string wallpaperDirectory: ""
|
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 bool wallpaperApplying: false
|
||||||
property string wallpaperError: ""
|
property string wallpaperError: ""
|
||||||
|
property string operationKind: ""
|
||||||
signal wallpaperApplied()
|
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 {
|
Process {
|
||||||
id: wallpaperWriter
|
id: wallpaperWriter
|
||||||
stdout: StdioCollector { id: wallpaperOutput; waitForEnd: true }
|
stdout: StdioCollector { id: wallpaperOutput; waitForEnd: true }
|
||||||
stderr: StdioCollector { id: wallpaperErrors; waitForEnd: true }
|
stderr: StdioCollector { id: wallpaperErrors; waitForEnd: true }
|
||||||
onExited: (code, status) => {
|
onExited: (code, status) => {
|
||||||
|
appearance.appearanceBusy = false;
|
||||||
appearance.wallpaperApplying = false;
|
appearance.wallpaperApplying = false;
|
||||||
if (code !== 0 || status !== 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const state = JSON.parse(wallpaperOutput.text);
|
const state = JSON.parse(wallpaperOutput.text);
|
||||||
if (!state.effectiveWallpaper || !Number.isSafeInteger(state.revision)) throw new Error("État invalide");
|
if (!appearance.acceptSnapshot(state)) throw new Error("État invalide");
|
||||||
appearance.effectiveWallpaper = state.effectiveWallpaper;
|
if (appearance.operationKind === "wallpaper") appearance.wallpaperApplied();
|
||||||
appearance.wallpaperMode = state.wallpaperMode;
|
appearance.appearanceApplied();
|
||||||
appearance.revision = state.revision;
|
} catch (_) { appearance.appearanceError = "Réponse Appearance invalide"; }
|
||||||
appearance.wallpaperApplied();
|
|
||||||
} catch (_) { appearance.wallpaperError = "Réponse wallpaper invalide"; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyWallpaper(path) {
|
function runOperation(args, applyingWallpaper) {
|
||||||
if (wallpaperApplying || !path || path === effectiveWallpaper) return false;
|
if (appearanceBusy) return false;
|
||||||
wallpaperError = "";
|
wallpaperError = "";
|
||||||
wallpaperApplying = true;
|
appearanceError = "";
|
||||||
wallpaperWriter.command = ["python", wallpaperBackend, "apply", path];
|
appearanceBusy = true;
|
||||||
|
wallpaperApplying = applyingWallpaper;
|
||||||
|
operationKind = applyingWallpaper ? "wallpaper" : "theme";
|
||||||
|
wallpaperWriter.command = ["python", wallpaperBackend].concat(args);
|
||||||
wallpaperWriter.running = true;
|
wallpaperWriter.running = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// INVARIANT: une paire refusée ne change aucun champ ni la révision.
|
function applyWallpaper(path) {
|
||||||
// La révision avance une seule fois après tout changement effectif.
|
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) {
|
function applyTheme(flavor, accent) {
|
||||||
if (!flavors.includes(flavor) || !accents.includes(accent)) return false;
|
if (!flavors.includes(flavor) || accent !== "lavender"
|
||||||
if (effectiveFlavor === flavor && effectiveAccent === accent
|
|| themeMode !== "manual") return false;
|
||||||
&& effectiveMode === "normal" && !effectiveHighContrast) return true;
|
if (effectiveFlavor === flavor && manualFlavor === flavor) return true;
|
||||||
effectiveFlavor = flavor;
|
return setManualFlavor(flavor);
|
||||||
effectiveAccent = accent;
|
|
||||||
effectiveMode = "normal";
|
|
||||||
effectiveHighContrast = false;
|
|
||||||
revision++;
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function effectiveState() {
|
function effectiveState() {
|
||||||
|
|
@ -97,6 +169,7 @@ Singleton {
|
||||||
effectiveHighContrast: effectiveHighContrast,
|
effectiveHighContrast: effectiveHighContrast,
|
||||||
effectiveAccent: effectiveAccent, effectiveWallpaper: effectiveWallpaper,
|
effectiveAccent: effectiveAccent, effectiveWallpaper: effectiveWallpaper,
|
||||||
wallpaperMode: wallpaperMode, wallpaperDirectory: wallpaperDirectory,
|
wallpaperMode: wallpaperMode, wallpaperDirectory: wallpaperDirectory,
|
||||||
|
themeMode: themeMode, manualFlavor: manualFlavor,
|
||||||
revision: revision });
|
revision: revision });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,13 @@ RGB/HSL/OKLCH de l'amont.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
catppuccin.json (versionné)
|
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)
|
→ Theme.qml (singleton effectif, rôles UI)
|
||||||
→ composants QuickShell
|
→ composants QuickShell
|
||||||
|
|
||||||
catppuccin.json (versionné)
|
catppuccin.json (versionné)
|
||||||
→ generate-appearance.py (versionné)
|
→ generate-appearance.py (rendu versionné)
|
||||||
|
→ wallpaper-manager.py (transaction 17D)
|
||||||
→ generated/theme.conf (runtime, ignoré par Git)
|
→ generated/theme.conf (runtime, ignoré par Git)
|
||||||
→ Sway/SwayFX
|
→ 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
|
Mauve. Ce choix rend les contrôles historiques Mauve légèrement plus bleus
|
||||||
qu'avant. Les trois anciens Lavender restent l'accent principal.
|
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
|
`qs -c labfy-sway ipc call appearance effectiveState` expose l'état complet
|
||||||
et `qs -c labfy-sway ipc call appearance setTheme mocha lavender` change
|
et `qs -c labfy-sway ipc call appearance setTheme mocha lavender` déclenche
|
||||||
QuickShell en direct. La commande unifiée depuis le dépôt est :
|
la même application persistante en mode Manuel. La commande unifiée depuis
|
||||||
|
le dépôt est :
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python bin/.local/bin/generate-appearance.py latte --accent lavender --apply
|
python bin/.local/bin/generate-appearance.py latte --accent lavender --apply
|
||||||
```
|
```
|
||||||
|
|
||||||
Elle écrit atomiquement le fichier runtime Sway, valide la configuration,
|
`--apply` délègue au gestionnaire d'apparence 17D et exige Manuel/Lavender ;
|
||||||
recharge Sway, persiste l'état effectif, puis met à jour QuickShell. En cas
|
le rendu hors ligne reste disponible pour les autres accents. Ce backend
|
||||||
d'échec elle restaure la dernière génération valide, l'état persistant et
|
prépare les fichiers, valide Sway, recharge, persiste l'état effectif, puis
|
||||||
l'état QuickShell précédent. Aucune décision automatique de profil n'existe
|
publie dans QuickShell. En cas d'échec il compense l'application. Le wallpaper
|
||||||
encore. Le wallpaper 17C est séparé dans `generated/wallpaper.conf` ; voir
|
reste séparé dans `generated/wallpaper.conf` ; voir
|
||||||
[WALLPAPER.md](../controlcenter/WALLPAPER.md). `generate-appearance.py`
|
[WALLPAPER.md](../controlcenter/WALLPAPER.md). `generate-appearance.py`
|
||||||
préserve les champs wallpaper du schéma effectif 2 lors d'un changement de
|
préserve les champs wallpaper du schéma effectif 3 lors d'un changement de
|
||||||
thème.
|
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
|
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
|
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
|
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
|
from pathlib import Path
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
import sys
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
SOURCE = Path(__file__).resolve().parents[1] / 'bin/.local/bin/wallpaper-manager.py'
|
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)
|
SPEC = importlib.util.spec_from_file_location('wallpaper_manager', SOURCE)
|
||||||
manager = importlib.util.module_from_spec(SPEC)
|
manager = importlib.util.module_from_spec(SPEC)
|
||||||
SPEC.loader.exec_module(manager)
|
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, 'PREFERENCES', self.root / 'config/preferences.json'),
|
||||||
mock.patch.object(manager, 'STATE', self.root / 'state/effective.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', 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'),
|
mock.patch.object(manager, 'SWAY', self.root / 'sway'),
|
||||||
]
|
]
|
||||||
for patcher in self.patchers:
|
for patcher in self.patchers:
|
||||||
|
|
@ -62,7 +65,7 @@ class WallpaperManagerTest(unittest.TestCase):
|
||||||
manager.validate_image(path)
|
manager.validate_image(path)
|
||||||
chosen = manager.set_directory(self.root.as_uri())
|
chosen = manager.set_directory(self.root.as_uri())
|
||||||
self.assertEqual(chosen['wallpaperDirectory'], str(self.root))
|
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):
|
def test_atomic_replace_keeps_old_file_on_write_failure(self):
|
||||||
target = self.root / 'atomic.txt'
|
target = self.root / 'atomic.txt'
|
||||||
|
|
@ -122,21 +125,134 @@ class WallpaperManagerTest(unittest.TestCase):
|
||||||
manager.write_json(manager.STATE, old)
|
manager.write_json(manager.STATE, old)
|
||||||
manager.atomic_write(manager.GENERATED, b'ancien')
|
manager.atomic_write(manager.GENERATED, b'ancien')
|
||||||
with mock.patch.object(manager, 'command') as command, \
|
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))
|
result = manager.apply(str(self.image))
|
||||||
self.assertEqual(result['revision'], 9)
|
self.assertEqual(result['revision'], 9)
|
||||||
state = manager.read_json(manager.STATE)
|
state = manager.read_json(manager.STATE)
|
||||||
self.assertEqual(state['effectiveFlavor'], 'mocha')
|
self.assertEqual(state['effectiveFlavor'], 'mocha')
|
||||||
self.assertEqual(state['effectiveAccent'], 'lavender')
|
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.assertIn(b'set $wallpaper ', manager.GENERATED.read_bytes())
|
||||||
self.assertEqual(command.call_count, 2)
|
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 mock.patch.object(manager, 'command', side_effect=[RuntimeError('validate'), None]):
|
||||||
with self.assertRaises(RuntimeError):
|
with self.assertRaises(RuntimeError):
|
||||||
manager.apply(str(self.image))
|
manager.apply(str(newer))
|
||||||
self.assertEqual(manager.read_json(manager.STATE), state)
|
self.assertEqual(manager.read_json(manager.STATE), state)
|
||||||
self.assertIn(b'set $wallpaper ', manager.GENERATED.read_bytes())
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue