feat(appearance): add automatic wallpaper theme selection

This commit is contained in:
fy59 2026-10-05 03:35:52 +02:00
parent 51b65624e8
commit 3a37c90080
14 changed files with 922 additions and 145 deletions

View file

@ -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)

View file

@ -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

View 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

View file

@ -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() }
}
}
}

View file

@ -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")
}
}
}
}

View file

@ -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
}
}
}

View file

@ -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.

View file

@ -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
}
}

View file

@ -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.

View 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).

View file

@ -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 });
}
}

View file

@ -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

View 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()

View file

@ -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()