feat(appearance): add Sun Mode and travel location controls

This commit is contained in:
fy59 2026-10-05 05:23:24 +02:00
parent f96f75cc2b
commit 172fa98f1d
35 changed files with 1294 additions and 105 deletions

View file

@ -133,6 +133,7 @@ pipewire-alsa
pipewire-jack
pipewire-libcamera
pipewire-pulse
polkit-gnome
podman
powertop
protonmail-bridge

View file

@ -27,23 +27,32 @@ def palette_data():
return data
def render(flavor, accent, profile='default'):
def render(flavor, accent, profile='normal'):
data = palette_data()
if flavor not in data:
raise ValueError('flavor invalide: ' + flavor)
if accent not in ACCENTS:
raise ValueError('accent invalide: ' + accent)
if profile != 'default':
if profile not in ('normal', 'sun-light', 'sun-dark', 'default'):
raise ValueError('profile invalide: ' + profile)
if profile == 'sun-light' and flavor != 'latte' or profile == 'sun-dark' and flavor == 'latte':
raise ValueError('flavor incompatible avec le profil Soleil')
p = data[flavor]
# CONTRACT: toutes les valeurs interpolées proviennent de clés bornées ou
# de chaînes hex validées ; aucune entrée utilisateur ne devient une commande.
lines = ['# Généré depuis theme/catppuccin.json ; ne pas éditer.']
lines += [f'set ${name} {value}' for name, value in p.items()]
sun = profile.startswith('sun-')
# CONTRACT: variables SwayFX consommées dans swayfx ; le bloc layer_effects
# reste unique et le profil normal retrouve exactement les valeurs 17E.
lines += [f'set $accent {p[accent]}',
'set $shadow_active #00000055',
'set $shadow_inactive #00000035',
'set $inactive_opacity 0.85']
f'set $shadow_active {"#00000077" if sun else "#00000055"}',
f'set $shadow_inactive {"#00000055" if sun else "#00000035"}',
f'set $shadow_blur_radius {4 if sun else 10}',
f'set $bar_blur {"disable" if sun else "enable"}',
f'set $inactive_opacity {"1.0" if sun else "0.85"}',
f'set $focused_border {p["text"] if sun else p[accent]}',
f'set $unfocused_border {p["overlay1"] if sun else p["overlay0"]}']
return '\n'.join(lines) + '\n'
@ -74,7 +83,7 @@ def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('flavor', choices=('latte', 'frappe', 'macchiato', 'mocha'))
parser.add_argument('--accent', default='lavender', choices=ACCENTS)
parser.add_argument('--profile', default='default')
parser.add_argument('--profile', default='normal')
parser.add_argument('--output', type=Path, default=SWAY / 'generated/theme.conf')
parser.add_argument('--apply', action='store_true', help='valider puis recharger Sway et QuickShell')
args = parser.parse_args()
@ -84,8 +93,8 @@ def main():
# 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')
if args.accent != 'lavender' or args.profile not in ('normal', 'default'):
raise ValueError('Application directe limitée au profil normal Lavender')
checked([sys.executable, str(Path(__file__).with_name('wallpaper-manager.py')),
'set-manual-flavor', args.flavor])
print(f'{args.flavor}/lavender: {args.output}')

View file

@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""Gestion locale du fuseau système et de la position solaire Appearance."""
import argparse
import fcntl
import json
import math
import os
from pathlib import Path
import re
import subprocess
import sys
import importlib.util
BASE = Path(__file__).resolve().parent
CONFIG = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'labfy-appearance/preferences.json'
STATE = Path(os.environ.get('XDG_STATE_HOME', Path.home() / '.local/state')) / 'labfy-appearance/effective.json'
LOCK = STATE.parent / 'wallpaper.lock'
ZONE_TAB = Path('/usr/share/zoneinfo/zone1970.tab')
ZONE_ROOT = Path('/usr/share/zoneinfo')
TZ_PATTERN = re.compile(r'^[A-Za-z0-9_+][A-Za-z0-9_+.-]*(?:/[A-Za-z0-9_+][A-Za-z0-9_+.-]*)*$')
def timezone_valid(value):
# INVARIANT: une identité IANA ne peut jamais être un chemin relatif ou absolu.
if not isinstance(value, str) or len(value) > 128 or not TZ_PATTERN.fullmatch(value):
return False
if any(part in ('.', '..') for part in value.split('/')):
return False
path = ZONE_ROOT / value
return path.is_file() and path.resolve().is_relative_to(ZONE_ROOT.resolve())
def coordinates(latitude, longitude):
# CONTRACT: aucune conversion permissive de NaN, booléen ou texte vide.
def parse(raw, bound):
if isinstance(raw, bool) or raw is None or str(raw).strip() == '':
raise ValueError('Coordonnées invalides')
try:
value = float(raw)
except (TypeError, ValueError):
raise ValueError('Coordonnées invalides') from None
if not math.isfinite(value) or not -bound <= value <= bound:
raise ValueError('Coordonnées invalides')
return value
return parse(latitude, 90), parse(longitude, 180)
def decimal_coordinate(part, degree_digits):
sign = -1 if part[0] == '-' else 1
digits = part[1:]
degree = int(digits[:degree_digits])
minute = int(digits[degree_digits:degree_digits + 2])
second = int(digits[degree_digits + 2:]) if len(digits) > degree_digits + 2 else 0
if minute >= 60 or second >= 60:
raise ValueError('Coordonnées zone1970.tab invalides')
return sign * (degree + minute / 60 + second / 3600)
def presets(path=ZONE_TAB):
# CONTRACT: zone1970.tab est une base locale représentative ; ses points
# ne prétendent jamais être la position exacte de l'utilisateur.
result = []
for line in path.read_text(encoding='utf-8').splitlines():
if not line or line.startswith('#'):
continue
fields = line.split('\t')
if len(fields) < 3:
continue
countries, compact, timezone = fields[:3]
match = re.fullmatch(r'([+-]\d{4}(?:\d{2})?)([+-]\d{5}(?:\d{2})?)', compact)
if not match or not timezone_valid(timezone):
continue
latitude = decimal_coordinate(match.group(1), 2)
longitude = decimal_coordinate(match.group(2), 3)
coordinates(latitude, longitude)
result.append({'timezone': timezone, 'latitude': latitude, 'longitude': longitude,
'countries': countries.split(','), 'comment': fields[3] if len(fields) > 3 else '',
'city': timezone.split('/')[-1].replace('_', ' ')})
return sorted(result, key=lambda item: item['timezone'])
def search(entries, query):
query = query.strip().casefold()
return [entry for entry in entries if query in ' '.join((entry['timezone'], entry['city'], entry['comment'],
' '.join(entry['countries']))).casefold()]
def current_timezone():
result = subprocess.run(['timedatectl', 'show', '-p', 'Timezone', '--value'],
capture_output=True, text=True, check=True, timeout=10)
return result.stdout.strip()
def set_timezone(timezone):
if not timezone_valid(timezone):
raise ValueError('Fuseau horaire invalide')
# WHY: timedated demande l'authentification Polkit de la session. Le mot
# de passe ne traverse jamais QuickShell ni ce processus Python.
subprocess.run(['timedatectl', 'set-timezone', timezone], check=True,
capture_output=True, text=True, timeout=120)
if current_timezone() != timezone:
raise RuntimeError('Le fuseau système ne correspond pas à la demande')
def night_module():
spec = importlib.util.spec_from_file_location('night_light_manager', BASE / 'night-light-manager.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
module.CONFIG, module.STATE, module.LOCK = CONFIG, STATE, LOCK
return module
def read_preferences(night):
return night.normalized(night.read_json(CONFIG))
def saved_locations(pref):
locations = pref.get('savedLocations', [])
if not isinstance(locations, list) or len(locations) > 100:
raise ValueError('Liste de lieux invalide')
for entry in locations:
if not isinstance(entry, dict) or not isinstance(entry.get('name'), str) \
or not entry['name'].strip() or len(entry['name']) > 80 \
or not timezone_valid(entry.get('timezone')):
raise ValueError('Lieu enregistré invalide')
coordinates(entry.get('latitude'), entry.get('longitude'))
return locations
def location_name(value, required=False):
if not isinstance(value, str) or len(value.strip()) > 80 or '\n' in value or '\r' in value \
or (required and not value.strip()):
raise ValueError('Nom de lieu invalide')
return value.strip()
def public_state():
night = night_module()
pref = read_preferences(night)
locations = saved_locations(pref)
# WHY: la position solaire courante n'est jamais renvoyée à la page ; seuls
# les lieux enregistrés portent leurs coordonnées pour pouvoir être rejoués.
return {'effectiveTimezone': current_timezone(), 'solarScheduleConfigured':
'latitude' in pref and 'longitude' in pref,
'activeLocationName': pref.get('activeLocationName', ''),
'savedLocations': locations}
def apply(kind, timezone=None, latitude=None, longitude=None, name=None):
if kind in ('timezone', 'both') and not timezone_valid(timezone):
raise ValueError('Fuseau horaire invalide')
if kind in ('solar', 'both'):
lat, lon = coordinates(latitude, longitude)
if name is not None:
name = location_name(name)
night = night_module()
LOCK.parent.mkdir(parents=True, exist_ok=True)
with open(LOCK, 'a+b') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
before_tz = current_timezone()
before_bytes = CONFIG.read_bytes() if CONFIG.exists() else None
before_pref = read_preferences(night)
suspended = night.suspended_state()
changed_tz = False
changed_solar = False
try:
if kind in ('timezone', 'both') and timezone != before_tz:
set_timezone(timezone)
changed_tz = True
if kind in ('solar', 'both'):
pref = dict(before_pref)
pref.update(latitude=lat, longitude=lon)
if name is not None:
pref['activeLocationName'] = name
else:
pref.pop('activeLocationName', None)
night.atomic_json(CONFIG, pref)
changed_solar = True
# CONTRACT: Auto seul redémarre avec les nouvelles coordonnées.
# ON conserve sa température forcée ; OFF et Sun restent éteints.
if pref['nightLightMode'] == 'auto' and not suspended:
night.apply_service(pref, False)
else:
night.verify(pref, suspended)
if kind in ('timezone', 'both') and current_timezone() != timezone:
raise RuntimeError('Vérification du fuseau système échouée')
return public_state()
except Exception as original_error:
rollback_errors = []
if changed_solar:
try:
night.restore(CONFIG, before_bytes)
if before_pref['nightLightMode'] == 'auto' and not suspended:
night.apply_service(before_pref, False)
except Exception:
rollback_errors.append('position solaire ou Night Light')
if changed_tz:
try:
set_timezone(before_tz)
except Exception:
rollback_errors.append('fuseau système')
if rollback_errors:
raise RuntimeError('Échec de transaction ; rollback incomplet : '
+ ', '.join(rollback_errors)) from original_error
raise
def save_location(name, timezone, latitude, longitude):
name = location_name(name, required=True)
if not timezone_valid(timezone):
raise ValueError('Fuseau horaire invalide')
lat, lon = coordinates(latitude, longitude)
night = night_module()
LOCK.parent.mkdir(parents=True, exist_ok=True)
with open(LOCK, 'a+b') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
pref = read_preferences(night)
locations = saved_locations(pref)
item = {'name': name.strip(), 'timezone': timezone, 'latitude': lat, 'longitude': lon}
pref['savedLocations'] = [entry for entry in locations if entry.get('name') != item['name']] + [item]
night.atomic_json(CONFIG, pref)
return public_state()
def main():
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest='action', required=True)
sub.add_parser('status')
listing = sub.add_parser('list')
listing.add_argument('query', nargs='?', default='')
for kind in ('timezone', 'solar', 'both'):
command = sub.add_parser('apply-' + kind)
if kind in ('timezone', 'both'):
command.add_argument('timezone')
if kind in ('solar', 'both'):
command.add_argument('latitude')
command.add_argument('longitude')
command.add_argument('--name')
save = sub.add_parser('save')
for field in ('name', 'timezone', 'latitude', 'longitude'):
save.add_argument(field)
args = parser.parse_args()
try:
if args.action == 'status': result = public_state()
elif args.action == 'list': result = search(presets(), args.query)
elif args.action == 'save': result = save_location(args.name, args.timezone, args.latitude, args.longitude)
else: result = apply(args.action.removeprefix('apply-'),
getattr(args, 'timezone', None), getattr(args, 'latitude', None),
getattr(args, 'longitude', None), getattr(args, 'name', None))
print(json.dumps(result, ensure_ascii=False, separators=(',', ':')))
return 0
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError, BlockingIOError) as exc:
# CONTRACT: aucune coordonnée privée dans les messages d'erreur.
print('Erreur : ' + str(exc), file=sys.stderr)
return 1
if __name__ == '__main__':
raise SystemExit(main())

View file

@ -106,9 +106,16 @@ def effective(mode, suspended=False):
return 'off' if suspended else mode
def argv(pref):
def suspended_state(state=None):
# CONTRACT: effective.json est la source persistante commune ; launch et
# reconcile respectent Sun dès le boot, même sans QuickShell actif.
state = read_json(STATE) if state is None else state
return state.get('effectiveMode') in ('sun-light', 'sun-dark')
def argv(pref, suspended=False):
pref = normalized(pref)
mode = effective(pref['nightLightMode'])
mode = effective(pref['nightLightMode'], suspended)
if mode == 'off':
return None
temperatures = ['-t', str(pref['nightTemperature']), '-T', str(pref['dayTemperature'])]
@ -148,10 +155,10 @@ def wlsunset_pids():
return found
def verify(pref):
def verify(pref, suspended=False):
active, main_pid = unit_state()
pids = wlsunset_pids()
mode = effective(pref['nightLightMode'])
mode = effective(pref['nightLightMode'], suspended)
if mode == 'off':
if active or pids:
raise RuntimeError('Night Light Off : wlsunset encore actif')
@ -159,14 +166,14 @@ def verify(pref):
raise RuntimeError('wlsunset absent, dupliqué ou hors unité systemd')
else:
actual = (Path('/proc') / str(main_pid) / 'cmdline').read_bytes().split(b'\0')[:-1]
if actual != [part.encode() for part in argv(pref)]:
if actual != [part.encode() for part in argv(pref, suspended)]:
raise RuntimeError('Arguments wlsunset incohérents')
def apply_service(pref):
mode = effective(pref['nightLightMode'])
def apply_service(pref, suspended=False):
mode = effective(pref['nightLightMode'], suspended)
systemctl('stop' if mode == 'off' else 'restart', UNIT)
verify(pref)
verify(pref, suspended)
def snapshot(pref, state):
@ -205,16 +212,16 @@ def change(mode=None, night=None):
pref['nightTemperature'] = temperature(night)
pref = normalized(pref)
state = read_json(STATE)
previous_mode = old_pref['nightLightMode']
suspended = suspended_state(state)
if pref == old_pref:
verify(pref)
verify(pref, suspended)
return snapshot(pref, state)
new_state = dict(state)
new_state.update(effectiveNightLightMode=effective(pref['nightLightMode']),
nightLightSuspended=False)
new_state.update(effectiveNightLightMode=effective(pref['nightLightMode'], suspended),
nightLightSuspended=suspended)
try:
atomic_json(CONFIG, pref)
apply_service(pref)
apply_service(pref, suspended)
atomic_json(STATE, new_state)
result = snapshot(pref, new_state)
publish(result)
@ -223,7 +230,7 @@ def change(mode=None, night=None):
restore(CONFIG, old_pref_bytes)
restore(STATE, old_state_bytes)
try:
apply_service(old_pref)
apply_service(old_pref, suspended)
except Exception as rollback_error:
raise RuntimeError('Échec Night Light et rollback du service') from rollback_error
raise
@ -235,16 +242,17 @@ def reconcile():
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
pref = normalized(read_json(CONFIG))
state = read_json(STATE)
suspended = suspended_state(state)
try:
verify(pref)
verify(pref, suspended)
except RuntimeError:
# Une instance étrangère ne peut être supprimée par cette unité.
active, main_pid = unit_state()
if any(pid != main_pid for pid in wlsunset_pids()):
raise RuntimeError('wlsunset externe détecté ; réconciliation refusée')
apply_service(pref)
state.update(effectiveNightLightMode=effective(pref['nightLightMode']),
nightLightSuspended=False)
apply_service(pref, suspended)
state.update(effectiveNightLightMode=effective(pref['nightLightMode'], suspended),
nightLightSuspended=suspended)
if read_json(CONFIG) != pref:
atomic_json(CONFIG, pref)
if read_json(STATE) != state:
@ -254,7 +262,7 @@ def reconcile():
def launch():
pref = normalized(read_json(CONFIG))
command = argv(pref)
command = argv(pref, suspended_state())
if command is None:
return 0
os.execv(command[0], command)
@ -262,7 +270,7 @@ def launch():
def post_start():
pref = normalized(read_json(CONFIG))
if pref['nightLightMode'] != 'on':
if pref['nightLightMode'] != 'on' or suspended_state():
return 0
# systemd ne fournit pas MAINPID dans cet ExecStartPost sur cette version.
# Interroger le PID de l'unité et attendre la fin de l'exec Python -> wlsunset.
@ -312,7 +320,7 @@ def main():
pref = normalized(read_json(CONFIG))
result = snapshot(pref, read_json(STATE))
if args.action == 'current':
verify(pref)
verify(pref, suspended_state())
print(json.dumps(result, ensure_ascii=False, separators=(',', ':')))
return 0
except (OSError, ValueError, RuntimeError, BlockingIOError) as exc:

View file

@ -36,8 +36,10 @@ 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 = 3
STATE_VERSION = 4
PREF_VERSION = 4
SUN_VARIANTS = ('light', 'dark')
SUN_DARK_FLAVORS = ('frappe', 'macchiato', 'mocha')
def atomic_write(path, data):
@ -259,16 +261,47 @@ def preferences(state=None):
manual = 'mocha'
return {**value, 'version': PREF_VERSION, 'themeMode': mode,
'manualFlavor': manual, 'accent': 'lavender', 'wallpaperMode': 'fill',
'sunMode': value.get('sunMode', False) is True,
'sunVariant': value.get('sunVariant') if value.get('sunVariant') in SUN_VARIANTS else 'light',
'sunDarkFlavor': value.get('sunDarkFlavor') if value.get('sunDarkFlavor') in SUN_DARK_FLAVORS else 'mocha',
'wallpaperDirectory': initial_directory()}
def rendered_theme(flavor):
def rendered_theme(flavor, profile='normal'):
"""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')
return module.render(flavor, 'lavender', profile).encode('utf-8')
def resolve(pref, normal_flavor):
# INVARIANT: Sun ne mute jamais la préférence normale ni l'analyse Auto.
if not pref['sunMode']:
return normal_flavor, 'normal'
if pref['sunVariant'] == 'light':
return 'latte', 'sun-light'
return pref['sunDarkFlavor'], 'sun-dark'
def night_module():
spec = importlib.util.spec_from_file_location('night_light_manager', Path(__file__).with_name('night-light-manager.py'))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
module.STATE = STATE
module.CONFIG = PREFERENCES
return module
def refresh_opacity():
# CONTRACT: commande ponctuelle et vérifiable pour les fenêtres présentes ;
# le listener historique traite ensuite les événements focus sans polling.
script = SWAY / 'scripts/inactive-windows-transparency.py'
result = subprocess.run([sys.executable, str(script), '--refresh'],
capture_output=True, text=True, timeout=5)
if result.returncode:
raise RuntimeError(result.stderr.strip() or 'Opacité des fenêtres non appliquée')
def publish_live(state, pref):
@ -291,13 +324,16 @@ def restore_file(path, previous):
def state_response(state, pref, analysis=None):
result = dict(state)
result.update(themeMode=pref['themeMode'], manualFlavor=pref['manualFlavor'],
sunMode=pref['sunMode'], sunVariant=pref['sunVariant'],
sunDarkFlavor=pref['sunDarkFlavor'],
wallpaperDirectory=pref['wallpaperDirectory'])
if analysis is not None:
result['analysis'] = analysis
return result
def transaction(wallpaper=None, mode=None, manual_flavor=None):
def transaction(wallpaper=None, mode=None, manual_flavor=None, sun_mode=None,
sun_variant=None, sun_dark_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:
@ -319,6 +355,18 @@ def transaction(wallpaper=None, mode=None, manual_flavor=None):
if pref['themeMode'] != 'manual':
raise ValueError('Revenir en mode Manuel avant de choisir un flavor')
pref['manualFlavor'] = manual_flavor
if sun_mode is not None:
if sun_mode not in ('on', 'off'):
raise ValueError('Mode Soleil invalide')
pref['sunMode'] = sun_mode == 'on'
if sun_variant is not None:
if sun_variant not in SUN_VARIANTS:
raise ValueError('Variante Soleil invalide')
pref['sunVariant'] = sun_variant
if sun_dark_flavor is not None:
if sun_dark_flavor not in SUN_DARK_FLAVORS:
raise ValueError('Flavor Soleil sombre invalide')
pref['sunDarkFlavor'] = sun_dark_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():
@ -326,15 +374,22 @@ def transaction(wallpaper=None, mode=None, manual_flavor=None):
analysis = None
if pref['themeMode'] == 'wallpaper':
try:
analysis = analyze_wallpaper(meta or validate_image(target))
cached = old.get('wallpaperAnalysis')
if (meta is None and isinstance(cached, dict)
and cached.get('path') == target
and cached.get('algorithmVersion') == ALGORITHM_VERSION
and old.get('wallpaperMtime') == Path(target).stat().st_mtime_ns):
analysis = cached
else:
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']
normal_flavor = analysis['flavor']
else:
flavor = pref['manualFlavor'] if mode is not None or manual_flavor is not None \
else old.get('effectiveFlavor', pref['manualFlavor'])
normal_flavor = pref['manualFlavor']
flavor, profile = resolve(pref, normal_flavor)
if flavor not in FLAVORS:
raise ValueError('Flavor effectif invalide')
previous_wallpaper = old.get('effectiveWallpaper') or active_wallpaper()
@ -342,32 +397,42 @@ def transaction(wallpaper=None, mode=None, manual_flavor=None):
or old.get('wallpaperMtime') != meta['mtime'])
flavor_changed = flavor != old.get('effectiveFlavor', 'mocha')
mode_changed = pref['themeMode'] != previous_pref['themeMode']
profile_changed = profile != old.get('effectiveMode', 'normal')
visual_changed = wallpaper_changed or flavor_changed or profile_changed or mode_changed
state = dict(old)
state.update(version=STATE_VERSION, effectiveFlavor=flavor,
effectiveMode='normal', effectiveDark=flavor != 'latte',
effectiveHighContrast=False, effectiveAccent='lavender',
effectiveMode=profile, effectiveDark=flavor != 'latte',
effectiveHighContrast=pref['sunMode'], effectiveAccent='lavender',
sunMode=pref['sunMode'], sunVariant=pref['sunVariant'],
sunDarkFlavor=pref['sunDarkFlavor'],
nightLightSuspended=pref['sunMode'],
effectiveNightLightMode='off' if pref['sunMode'] else pref.get('nightLightMode', 'off'),
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))
+ int(visual_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
if not (visual_changed
or pref != previous_pref or old.get('version') != STATE_VERSION):
return state_response(state, pref, analysis)
alias = None
wrote_config = False
night = night_module()
night_pref = night.normalized(pref)
old_suspended = old.get('effectiveMode') in ('sun-light', 'sun-dark')
night_transition = old_suspended != pref['sunMode']
try:
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))
if flavor_changed or profile_changed:
atomic_write(GENERATED_THEME, rendered_theme(flavor, profile))
wrote_config = True
if wrote_config:
command('sway', '--validate', '-c', str(SWAY / 'config'))
@ -378,7 +443,13 @@ def transaction(wallpaper=None, mode=None, manual_flavor=None):
time.sleep(0.1)
if not swaybg_matches(alias):
raise RuntimeError('Sway n’a pas appliqué le fond demandé')
# CONTRACT: publication durable précède le service ; ExecStart lit
# le profil voulu. En erreur, restaurer les octets puis réconcilier.
write_json(STATE, state)
if night_transition:
night.apply_service(night_pref, pref['sunMode'])
if profile_changed:
refresh_opacity()
write_json(PREFERENCES, pref)
publish_live(state, pref)
# Seul l'alias actif est requis au prochain login ; les anciens
@ -398,6 +469,10 @@ def transaction(wallpaper=None, mode=None, manual_flavor=None):
restore_file(PREFERENCES, old_preferences)
if wrote_config:
command('swaymsg', 'reload')
if night_transition:
night.apply_service(night_pref, old_suspended)
if profile_changed:
refresh_opacity()
raise
@ -450,13 +525,17 @@ def reconcile_state():
with open(STATE.parent / 'wallpaper.lock', 'a+b') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
state = read_json(STATE)
original_version = state.get('version')
pref = preferences(state)
if state.get('version') == STATE_VERSION:
if state.get('version') in (3, 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']
pref['sunMode'] = state.get('sunMode') is True
pref['sunVariant'] = state.get('sunVariant') if state.get('sunVariant') in SUN_VARIANTS else pref['sunVariant']
pref['sunDarkFlavor'] = state.get('sunDarkFlavor') if state.get('sunDarkFlavor') in SUN_DARK_FLAVORS else pref['sunDarkFlavor']
state.update(themeMode=pref['themeMode'], manualFlavor=pref['manualFlavor'])
else:
state.update(version=STATE_VERSION, themeMode=pref['themeMode'],
@ -464,16 +543,37 @@ def reconcile_state():
flavor = state.get('effectiveFlavor', 'mocha')
if flavor not in FLAVORS:
raise ValueError('État effectif invalide')
if pref['themeMode'] == 'wallpaper':
previous_analysis = state.get('wallpaperAnalysis') or {}
normal_flavor = previous_analysis.get('flavor')
if (normal_flavor not in FLAVORS and original_version == STATE_VERSION
and Path(state.get('effectiveWallpaper', '')).is_file()):
# WHY: sous Soleil, effectiveFlavor ne révèle plus le flavor Auto.
# Une analyse manquante doit être reconstruite avant résolution.
previous_analysis = analyze_wallpaper(validate_image(state['effectiveWallpaper']))
state['wallpaperAnalysis'] = previous_analysis
normal_flavor = previous_analysis['flavor']
if normal_flavor not in FLAVORS and original_version == 3:
normal_flavor = state.get('effectiveFlavor')
else:
normal_flavor = pref['manualFlavor']
if normal_flavor not in FLAVORS:
normal_flavor = pref['manualFlavor']
flavor, profile = resolve(pref, normal_flavor)
state.update(effectiveFlavor=flavor, effectiveAccent='lavender',
effectiveDark=flavor != 'latte', effectiveHighContrast=False,
effectiveMode='normal', wallpaperMode='fill',
effectiveDark=flavor != 'latte', effectiveHighContrast=pref['sunMode'],
effectiveMode=profile, sunMode=pref['sunMode'],
sunVariant=pref['sunVariant'], sunDarkFlavor=pref['sunDarkFlavor'],
nightLightSuspended=pref['sunMode'],
effectiveNightLightMode='off' if pref['sunMode'] else pref.get('nightLightMode', 'off'),
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)
expected_theme = rendered_theme(flavor, profile)
meta = validate_image(state['effectiveWallpaper'])
if pref['themeMode'] == 'wallpaper':
previous_analysis = state.get('wallpaperAnalysis')
@ -503,6 +603,13 @@ def reconcile_state():
if repair_theme or repair_wallpaper:
command('sway', '--validate', '-c', str(SWAY / 'config'))
command('swaymsg', 'reload')
night = night_module()
night_pref = night.normalized(pref)
try:
night.verify(night_pref, pref['sunMode'])
except RuntimeError:
night.apply_service(night_pref, pref['sunMode'])
refresh_opacity()
if STATE.exists():
if read_json(STATE) != state:
write_json(STATE, state)
@ -530,7 +637,8 @@ def main():
scan_parser.add_argument('directory')
scan_parser.add_argument('--page', type=int, default=0)
for action in ('validate', 'thumbnail', 'analyze', 'apply', 'set-directory',
'set-theme-mode', 'set-manual-flavor'):
'set-theme-mode', 'set-manual-flavor', 'set-sun-mode',
'set-sun-variant', 'set-sun-dark-flavor'):
sub.add_parser(action).add_argument('path')
args = parser.parse_args()
try:
@ -549,6 +657,9 @@ def main():
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)
elif args.action == 'set-sun-mode': result = transaction(sun_mode=args.path)
elif args.action == 'set-sun-variant': result = transaction(sun_variant=args.path)
elif args.action == 'set-sun-dark-flavor': result = transaction(sun_dark_flavor=args.path)
else: result = set_directory(args.path)
print(json.dumps(result, ensure_ascii=False, separators=(',', ':')))
return 0

View file

@ -0,0 +1,79 @@
# Appearance 17F — Mode Soleil
Le profil Soleil augmente la visibilité de QuickShell et SwayFX. Il ne modifie
ni le fond d'écran, ni la luminosité, ni les thèmes GTK, Qt, Kitty, Neovim et
Firefox. Ces cinq familles d'applications relèvent obligatoirement de 17G ;
17F ne constitue donc pas une intégration complète du bureau clair.
## État et priorité
`preferences.json` et `effective.json` utilisent le schéma 4. Les anciennes
préférences migrent vers `sunMode=false`, `sunVariant=light`,
`sunDarkFlavor=mocha`. Seuls `frappe`, `macchiato` et `mocha` sont admis pour
`sunDarkFlavor`. Les coordonnées Night Light restent locales et intactes.
`themeMode`, `manualFlavor` et `wallpaperAnalysis` décrivent le choix normal.
La résolution centrale calcule d'abord ce flavor normal, puis applique la
priorité Soleil : `sun-light` donne Latte ; `sun-dark` donne
`sunDarkFlavor` ; Soleil désactivé restitue le flavor normal. Aucun instantané
des préférences normales n'est utilisé. `effectiveDark` dérive du flavor,
`effectiveHighContrast` vaut exactement `sunMode`, et `effectiveAccent` reste
Lavender. `effectiveMode` vaut `normal`, `sun-light` ou `sun-dark`.
Une modification effective du profil ou du flavor augmente `revision` une
fois. Un changement de `sunDarkFlavor` lorsque Soleil est désactivé est
persisté sans révision visuelle. Les consommateurs 17G devront utiliser
`effectiveFlavor`, `effectiveAccent`, `effectiveDark`,
`effectiveHighContrast`, `effectiveMode` et `revision` comme source unique.
Un futur `AppThemeSynchronizer` distribuera cet état à ses adaptateurs GTK,
Qt, Kitty, Neovim et Firefox ; aucune logique spécifique à ces applications
n'appartient à `Theme.qml`.
## Transaction et reprise
`wallpaper-manager.py` tient le verrou Appearance commun. Il résout l'état,
écrit atomiquement `generated/theme.conf` et éventuellement
`generated/wallpaper.conf`, valide Sway, recharge SwayFX, vérifie le fond si
nécessaire, publie `effective.json`, réconcilie le service Night Light,
réapplique les opacités existantes par `--refresh`, écrit les préférences,
puis publie l'état QuickShell via IPC. En cas d'échec, il restaure les octets
précédents des fichiers et de l'état, recharge Sway, rétablit Night Light et
les opacités, puis renvoie une erreur. Les transitions sont sérialisées ; les
contrôles QML attendent la fin de la transaction.
Le listener d'opacité lit l'état uniquement à chaque événement fenêtre ;
`--refresh` lit une fois lors d'un changement de profil. Normal utilise
focused `1.0`, inactive `0.85` ; Soleil utilise `1.0` pour les deux. SwayFX
consomme les variables du générateur dans son unique bloc `layer_effects` :
blur de la barre `enable` en normal, `disable` en Soleil. Le rayon d'ombre
passe de 10 à 4 et les couleurs d'ombre sont renforcées en Soleil. Les
bordures restent de 2 px.
Le lanceur de `wlsunset.service` lit `effective.json` avant tout démarrage :
en Soleil, il ne lance pas de correction gamma. La préférence Off, Auto ou On
reste intacte ; en sortie de Soleil le service est réconcilié selon cette
préférence. Au redémarrage de QuickShell, Appearance se réconcilie avant Night
Light et reprend la révision persistée. Le repli versionné Mocha reste valide
sans fichiers générés.
## Contraste et limites
La barre QuickShell passe de `235/255` en normal à `1,0` en Soleil ; les popups
restent opaques (`1,0`) dans les deux profils. Les rôles `border`,
`strongBorder`, `outline`, `emphasisBackground`, `separator`,
`buttonBackground`, `buttonHover`, `secondaryForeground`, `inputBorder`
et `shadow` dérivent de la palette
Catppuccin et du contraste effectif. L'accent Lavender et `onAccent` suivent
le moteur 17B. Les ratios WCAG des paires principales sont :
| Flavor Soleil | texte/panel | secondaire/panel | texte/popup | onAccent/accent | texte/input |
| --- | ---: | ---: | ---: | ---: | ---: |
| Latte | 7,06 | 7,06 | 7,06 | 6,60 | 5,17 |
| Frappé | 8,06 | 8,06 | 8,06 | 11,47 | 6,19 |
| Macchiato | 9,92 | 9,92 | 9,92 | 11,65 | 7,55 |
| Mocha | 11,34 | 11,34 | 11,34 | 11,74 | 8,69 |
La paire sélectionnée reprend `onAccent/accent`. Les bordures et séparateurs
sont décoratifs ; le seuil de texte AA de 4,5:1 ne s'y applique pas. Les
icônes colorées des applications dans le tray ne sont pas recolorées.
La lisibilité réelle en plein soleil exige une validation sur l'écran physique.

View file

@ -84,6 +84,10 @@ printf '%s\n' "${packages[@]}"
SwayFX 0.6 est un paquet foreign/local à reconstruire depuis sa source
vérifiée ; `scenefx` et `quickshell` sont présents dans la liste Pacman
officielle de cette machine. Voir les inventaires `.assets/`.
`polkit-gnome` fournit l'agent graphique requis pour authentifier les
changements de fuseau effectués par `timedatectl` depuis QuickShell. Le package
`sway` le lance une seule fois à l'ouverture de session via `autostart` ;
aucune règle `sudoers` propre au fuseau n'est nécessaire.
Le backend batterie exige TLP et `/usr/local/sbin/batlimit-set`, déployé
séparément avec les droits root selon `bin/root/README.md`.

View file

@ -9,7 +9,7 @@ Rectangle {
implicitWidth: caption.implicitWidth + 20
implicitHeight: 30
radius: 4
color: !enabled ? Theme.buttonBackground : pointer.containsMouse ? Theme.strongBorder : Theme.border
color: !enabled ? Theme.buttonBackground : pointer.containsMouse ? Theme.emphasisBackground : Theme.border
opacity: enabled ? 1 : 0.55
Text {
id: caption

View file

@ -8,6 +8,8 @@ Item {
signal wallpaperRequested()
signal themeRequested()
signal nightLightRequested()
signal sunModeRequested()
signal locationTimeRequested()
readonly property var flavorNames: ({ latte: "Latte", frappe: "Frappé", macchiato: "Macchiato", mocha: "Mocha" })
Column {
@ -39,7 +41,7 @@ Item {
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")
+ " · " + (AppearanceController.sunMode ? "Soleil" : AppearanceController.themeMode === "wallpaper" ? "Selon le fond" : "Manuel")
color: Theme.secondaryForeground; font.pixelSize: 11
}
}
@ -53,7 +55,8 @@ Item {
anchors.left: parent.left; anchors.leftMargin: 47; anchors.verticalCenter: parent.verticalCenter; spacing: 4
Text { text: "Lumière nocturne"; color: Theme.foreground; font.pixelSize: 14; font.bold: true }
Text {
text: AppearanceController.nightLightMode === "auto" ? "Auto · " + AppearanceController.nightTemperature + " K"
text: AppearanceController.nightLightSuspended ? (AppearanceController.nightLightMode === "auto" ? "Auto" : AppearanceController.nightLightMode === "on" ? "Activée" : "Désactivée") + " · Suspendue"
: AppearanceController.nightLightMode === "auto" ? "Auto · " + AppearanceController.nightTemperature + " K"
: AppearanceController.nightLightMode === "on" ? "Activée · " + AppearanceController.nightTemperature + " K"
: "Désactivée"
color: Theme.secondaryForeground; font.pixelSize: 11
@ -61,5 +64,27 @@ Item {
}
MouseArea { id: nightPointer; anchors.fill: parent; hoverEnabled: true; onClicked: page.nightLightRequested() }
}
Rectangle {
width: parent.width; height: 72; radius: 4
color: sunPointer.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: "Mode Soleil"; color: Theme.foreground; font.pixelSize: 14; font.bold: true }
Text { text: !AppearanceController.sunMode ? "Désactivé" : AppearanceController.sunVariant === "light" ? "Clair · Haute visibilité" : "Sombre · Haute visibilité"; color: Theme.secondaryForeground; font.pixelSize: 11 }
}
MouseArea { id: sunPointer; anchors.fill: parent; hoverEnabled: true; onClicked: page.sunModeRequested() }
}
Rectangle {
width: parent.width; height: 72; radius: 4
color: locationPointer.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: "Localisation & heure"; color: Theme.foreground; font.pixelSize: 14; font.bold: true }
Text { text: AppearanceController.effectiveTimezone || "Fuseau système"; color: Theme.secondaryForeground; font.pixelSize: 11 }
}
MouseArea { id: locationPointer; anchors.fill: parent; hoverEnabled: true; onClicked: page.locationTimeRequested() }
}
}
}

View file

@ -370,7 +370,7 @@ Item {
delegate: Rectangle {
required property int modelData
width: 60; height: 32; radius: 4
color: page.reportedThreshold === modelData ? Theme.strongBorder : Theme.buttonBackground
color: page.reportedThreshold === modelData ? Theme.emphasisBackground : Theme.buttonBackground
border.color: page.validProposed && Number(valueField.text) === modelData
? Theme.accent : "transparent"
Text {

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, theme: 8, nightLight: 9 })
confirmation: 4, battery: 5, appearance: 6, wallpaper: 7, theme: 8, nightLight: 9, sunMode: 10, locationTime: 11 })
property int currentPage: pages.main
function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; }
signal thresholdApplied()
@ -78,7 +78,9 @@ PopupWindow {
: currentPage === 3 ? sessionPage.implicitHeight + 32
: currentPage === 4 ? confirmationPage.implicitHeight + 32
: currentPage === 5 ? batteryPage.implicitHeight + 32
: currentPage === pages.wallpaper ? 550 : 440
: currentPage === pages.wallpaper ? 550
: currentPage === pages.appearance ? 520
: currentPage === pages.locationTime ? 650 : 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
@ -90,7 +92,7 @@ PopupWindow {
anchors.fill: parent
radius: 4
color: Theme.popupBackground
border.color: Theme.border
border.color: Theme.outline
StackLayout {
anchors.fill: parent
@ -209,6 +211,8 @@ PopupWindow {
onWallpaperRequested: popup.openPage("wallpaper")
onThemeRequested: popup.openPage("theme")
onNightLightRequested: popup.openPage("nightLight")
onSunModeRequested: popup.openPage("sunMode")
onLocationTimeRequested: popup.openPage("locationTime")
}
WallpaperPage {
@ -222,6 +226,12 @@ PopupWindow {
NightLightPage {
onBackRequested: popup.openPage("appearance")
}
SunModePage {
onBackRequested: popup.openPage("appearance")
}
LocationTimePage {
onBackRequested: popup.openPage("appearance")
}
}
}
}

View file

@ -0,0 +1,21 @@
# Localisation & heure
La page **Réglages rapides → Apparence → Localisation & heure** sépare le fuseau horaire système de la position solaire utilisée par wlsunset. Le fuseau vient exclusivement de `timedatectl` ; les coordonnées, les lieux enregistrés et le nom actif facultatif viennent de `~/.config/labfy-appearance/preferences.json` (mode 0600). Aucun service réseau, aucune détection IP et aucun lieu personnel ne font partie du dépôt.
## Données locales et voyage
`location-time-manager.py` lit `/usr/share/zoneinfo/zone1970.tab`, convertit ses coordonnées compactes et vérifie les identifiants dans `/usr/share/zoneinfo`. La recherche est locale et instantanée. Les coordonnées de cette base sont des points représentatifs associés aux fuseaux ; elles ne sont pas forcément la position exacte de l'utilisateur. La position manuelle accepte une latitude de −90 à 90 et une longitude de −180 à 180. La liste facultative `savedLocations` est limitée à 100 entrées validées et permet de réutiliser un choix, mais ouvre toujours un résumé avant application. `activeLocationName` est un libellé facultatif sans effet sur le calcul.
Une sélection propose **Appliquer les deux**, **Fuseau uniquement** et **Position solaire uniquement**. Le clic dans la liste ne change rien. La préférence de thème, le fond d'écran et le mode Night Light ne changent pas. Le nom actif n'est qu'un libellé d'interface.
## Privilèges et transaction
`timedatectl set-timezone` est appelé directement, avec un tableau d'arguments, via l'authentification Polkit standard de la session. L'agent graphique `polkit-gnome` est lancé au démarrage Sway. QuickShell ne reçoit jamais le mot de passe, ne dispose d'aucune permission root permanente et ne modifie jamais `/etc/localtime` directement. Si l'authentification est annulée ou indisponible, l'opération échoue sans écrire la position solaire.
L'application combinée valide d'abord les entrées, mémorise l'état précédent, change le fuseau, écrit la position locale, réconcilie Night Light en Auto, puis vérifie le fuseau. Un échec de fuseau laisse la position intacte. Un échec ultérieur restaure la préférence et tente de restaurer le fuseau et le service. Un échec de rollback est affiché comme erreur explicite.
## Night Light et persistance
En Auto hors Mode Soleil, la nouvelle position redémarre l'unique `wlsunset.service`. En mode Activée, la température forcée reste active et la position est seulement enregistrée. En mode Désactivée ou pendant le Mode Soleil, aucune instance n'est démarrée. À la sortie du Mode Soleil ou à la prochaine session Auto, wlsunset relit la nouvelle position. Changer seulement le fuseau ne modifie jamais les coordonnées : wlsunset utilise le temps système et les coordonnées, pas une association implicite ville/fuseau.
Le fuseau persiste via `systemd-timedated`, la position via les préférences. En mode solaire, le [code de wlsunset](https://github.com/kennylevinsen/wlsunset/blob/master/main.c) déduit le jour de la longitude et calcule les événements avec l'heure système UTC ; son décalage de fuseau n'est utilisé que pour l'horaire manuel (`longitude_time_offset` et `wlrun`). Un changement de fuseau seul ne nécessite donc pas de redémarrer Auto ni de modifier les coordonnées. `SystemClock` est rafraîchi à la publication d'un changement de fuseau et continue son tick minute normal. Aucun paramètre horaire propre à Firefox, Kitty, Neovim, GTK ou Qt n'est écrit.

View file

@ -0,0 +1,144 @@
import QtQuick
import QtQuick.Controls
import "../components"
import "../theme"
Item {
id: page
signal backRequested()
property string view: "home"
property var selected: null
property string query: ""
property string latitudeDraft: ""
property string longitudeDraft: ""
property string nameDraft: ""
property string validationError: ""
readonly property bool busy: AppearanceController.locationBusy
readonly property var filtered: AppearanceController.locationPresets.filter(item =>
(item.timezone + " " + item.city + " " + item.comment + " " + item.countries.join(" ")).toLowerCase().includes(query.toLowerCase()))
function choose(item) { selected = item; view = "detail"; validationError = ""; }
function validNumber(value, bound) {
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(value.trim())) return false;
let n = Number(value);
return Number.isFinite(n) && n >= -bound && n <= bound;
}
function manualItem() {
if (!validNumber(latitudeDraft, 90) || !validNumber(longitudeDraft, 180)) {
validationError = "Latitude ou longitude invalide";
return null;
}
validationError = "";
return {name: nameDraft.trim(), timezone: AppearanceController.effectiveTimezone,
latitude: Number(latitudeDraft), longitude: Number(longitudeDraft)};
}
function run(kind) {
if (!selected) return;
if (AppearanceController.applyLocation(kind, selected)) view = "home";
}
Column {
anchors.fill: parent
spacing: 10
Row {
spacing: 8; height: 30
ActionButton { label: ""; onClicked: page.view === "home" ? page.backRequested() : page.view = "home" }
Text { text: "Localisation & heure"; color: Theme.foreground; font.pixelSize: 16; font.bold: true; height: 30; verticalAlignment: Text.AlignVCenter }
}
Text { visible: page.view === "home"; text: "FUSEAU HORAIRE ACTUEL"; color: Theme.accentForeground; font.pixelSize: 11; font.bold: true }
Text { visible: page.view === "home"; text: AppearanceController.effectiveTimezone || "Lecture…"; color: Theme.foreground; font.pixelSize: 15 }
Text { visible: page.view === "home"; text: "POSITION SOLAIRE"; color: Theme.accentForeground; font.pixelSize: 11; font.bold: true }
Text {
visible: page.view === "home"
text: AppearanceController.solarScheduleConfigured
? (AppearanceController.activeLocationName || "Position configurée") : "Non configurée"
color: Theme.foreground; font.pixelSize: 13
}
Text {
visible: page.view === "home"; width: parent.width; wrapMode: Text.Wrap
text: "Le fuseau système et la position solaire sont indépendants."
color: Theme.secondaryForeground; font.pixelSize: 11
}
ActionButton { visible: page.view === "home"; label: "󰖉 Choisir un lieu local"; onClicked: page.view = "search" }
ActionButton { visible: page.view === "home"; label: "󰐀 Position manuelle"; onClicked: page.view = "manual" }
ActionButton { visible: page.view === "home"; label: "󰃭 Lieux enregistrés"; onClicked: page.view = "saved" }
TextField {
visible: page.view === "search"; width: parent.width
placeholderText: "Rechercher Paris, Madrid, Europe/Paris…"
onTextChanged: page.query = text
}
Text {
visible: page.view === "search"; width: parent.width; wrapMode: Text.Wrap
text: "Points représentatifs de zone1970.tab ; une position manuelle peut être plus précise."
color: Theme.secondaryForeground; font.pixelSize: 11
}
ScrollView {
visible: page.view === "search"; width: parent.width; height: 470
clip: true
Column {
width: page.width - 14; spacing: 3
Repeater {
model: page.filtered
delegate: Rectangle {
required property var modelData
width: parent.width; height: 38; radius: 4
color: pointer.containsMouse ? Theme.buttonHover : Theme.buttonBackground
Text { anchors.left: parent.left; anchors.leftMargin: 8; anchors.verticalCenter: parent.verticalCenter
text: modelData.city + " · " + modelData.timezone; color: Theme.foreground
width: parent.width - 16; elide: Text.ElideRight; font.pixelSize: 12 }
MouseArea { id: pointer; anchors.fill: parent; hoverEnabled: true; onClicked: page.choose(modelData) }
}
}
}
}
Text { visible: page.view === "detail"; text: page.selected ? (page.selected.name || page.selected.city || page.selected.timezone) : ""; color: Theme.foreground; font.pixelSize: 15; font.bold: true }
Text { visible: page.view === "detail"; text: "Fuseau système : " + (page.selected ? page.selected.timezone : ""); color: Theme.foreground; font.pixelSize: 12 }
Text { visible: page.view === "detail"; text: "Position solaire : coordonnées représentatives disponibles"; color: Theme.secondaryForeground; font.pixelSize: 11 }
Text { visible: page.view === "detail"; width: parent.width; wrapMode: Text.Wrap
text: "Cette position associée au fuseau n'est pas nécessairement votre position exacte."
color: Theme.secondaryForeground; font.pixelSize: 11 }
ActionButton { visible: page.view === "detail"; enabled: !page.busy; label: "Appliquer les deux"; onClicked: page.run("both") }
ActionButton { visible: page.view === "detail"; enabled: !page.busy; label: "Fuseau uniquement"; onClicked: page.run("timezone") }
ActionButton { visible: page.view === "detail"; enabled: !page.busy; label: "Position solaire uniquement"; onClicked: page.run("solar") }
ActionButton { visible: page.view === "detail"; enabled: !page.busy; label: "Enregistrer ce lieu"; onClicked: {
AppearanceController.saveLocation({name: page.selected.city || page.selected.name, timezone: page.selected.timezone,
latitude: page.selected.latitude, longitude: page.selected.longitude});
} }
Text { visible: page.view === "manual"; text: "Position solaire manuelle"; color: Theme.foreground; font.pixelSize: 14; font.bold: true }
TextField { visible: page.view === "manual"; width: parent.width; placeholderText: "Latitude (−90 à 90)"; text: page.latitudeDraft; onTextEdited: page.latitudeDraft = text }
TextField { visible: page.view === "manual"; width: parent.width; placeholderText: "Longitude (−180 à 180)"; text: page.longitudeDraft; onTextEdited: page.longitudeDraft = text }
TextField { visible: page.view === "manual"; width: parent.width; placeholderText: "Nom facultatif pour enregistrer"; text: page.nameDraft; onTextEdited: page.nameDraft = text }
Text { visible: page.view === "manual"; text: page.validationError; color: Theme.warningForeground; font.pixelSize: 11 }
ActionButton { visible: page.view === "manual"; enabled: !page.busy; label: "Appliquer la position"; onClicked: {
let item = page.manualItem();
if (item && AppearanceController.applyLocation("solar", item)) page.view = "home";
} }
ActionButton { visible: page.view === "manual"; enabled: !page.busy; label: "Enregistrer ce lieu"; onClicked: {
let item = page.manualItem();
if (item && item.name) AppearanceController.saveLocation(item);
else if (item) page.validationError = "Saisir un nom pour enregistrer";
} }
Text { visible: page.view === "saved"; text: "Lieux enregistrés"; color: Theme.foreground; font.pixelSize: 14; font.bold: true }
ScrollView {
visible: page.view === "saved"; width: parent.width; height: 470; clip: true
Column {
width: page.width - 14; spacing: 4
Repeater {
model: AppearanceController.savedLocations
delegate: Rectangle {
required property var modelData
width: parent.width; height: 46; radius: 4
color: savedPointer.containsMouse ? Theme.buttonHover : Theme.buttonBackground
Column { anchors.left: parent.left; anchors.leftMargin: 8; anchors.verticalCenter: parent.verticalCenter
Text { text: modelData.name; color: Theme.foreground; font.pixelSize: 12; font.bold: true }
Text { text: modelData.timezone; color: Theme.secondaryForeground; font.pixelSize: 11 } }
MouseArea { id: savedPointer; anchors.fill: parent; hoverEnabled: true; onClicked: page.choose(modelData) }
}
}
}
}
Text { visible: !!AppearanceController.locationError; text: AppearanceController.locationError;
width: parent.width; wrapMode: Text.Wrap; color: Theme.warningForeground; font.pixelSize: 11 }
}
}

View file

@ -6,7 +6,7 @@ Item {
id: page
signal backRequested()
property int draftTemperature: AppearanceController.nightTemperature
readonly property bool busy: AppearanceController.nightLightBusy
readonly property bool busy: AppearanceController.nightLightBusy || AppearanceController.appearanceBusy
Connections {
target: AppearanceController
function onNightTemperatureChanged() { page.draftTemperature = AppearanceController.nightTemperature; }
@ -48,6 +48,12 @@ Item {
: "Couleurs neutres"
color: Theme.secondaryForeground; font.pixelSize: 12
}
Text {
visible: AppearanceController.nightLightSuspended
width: parent.width; wrapMode: Text.Wrap
text: "Préférence : " + (AppearanceController.nightLightMode === "auto" ? "Auto" : AppearanceController.nightLightMode === "on" ? "Activée" : "Désactivée") + " · État : temporairement suspendue par le Mode Soleil"
color: Theme.foreground; font.pixelSize: 12
}
Text { text: "TEMPÉRATURE DE NUIT"; color: Theme.accentForeground; font.pixelSize: 11; font.bold: true }
Row {
width: parent.width; spacing: 10

View file

@ -0,0 +1,84 @@
import QtQuick
import "../theme"
Item {
id: page
signal backRequested()
readonly property bool busy: AppearanceController.appearanceBusy || AppearanceController.nightLightBusy
readonly property var names: ({ frappe: "Frappé", macchiato: "Macchiato", mocha: "Mocha" })
Column {
anchors.fill: parent
spacing: 13
Row {
spacing: 8; height: 30
ActionButton { label: ""; onClicked: page.backRequested() }
Text { text: "Mode Soleil"; color: Theme.foreground; font.pixelSize: 16; font.bold: true; height: 30; verticalAlignment: Text.AlignVCenter }
}
Text { text: "PROFIL HAUTE VISIBILITÉ"; color: Theme.accentForeground; font.pixelSize: 11; font.bold: true }
Row {
spacing: 8
Repeater {
model: [ { key: false, label: "Désactivé" }, { key: true, label: "Activé" } ]
delegate: Rectangle {
required property var modelData
readonly property bool selected: AppearanceController.sunMode === modelData.key
width: (page.width - 8) / 2; height: 43; radius: 4
color: selected ? Theme.selectedBackground : Theme.buttonBackground
border.width: selected ? 2 : 1; border.color: selected ? Theme.accent : Theme.border
Text { anchors.centerIn: parent; text: modelData.label; color: parent.selected ? Theme.selectedForeground : Theme.foreground; font.pixelSize: 12 }
MouseArea { anchors.fill: parent; enabled: !page.busy && !parent.selected; onClicked: AppearanceController.setSunMode(modelData.key) }
}
}
}
Text { text: "STYLE"; color: Theme.accentForeground; font.pixelSize: 11; font.bold: true }
Row {
spacing: 8
Repeater {
model: [ { key: "light", label: "Clair" }, { key: "dark", label: "Sombre" } ]
delegate: Rectangle {
required property var modelData
readonly property bool selected: AppearanceController.sunVariant === modelData.key
width: (page.width - 8) / 2; height: 43; radius: 4
color: selected ? Theme.selectedBackground : Theme.buttonBackground
border.width: selected ? 2 : 1; border.color: selected ? Theme.accent : Theme.border
Text { anchors.centerIn: parent; text: modelData.label; color: parent.selected ? Theme.selectedForeground : Theme.foreground; font.pixelSize: 12 }
MouseArea { anchors.fill: parent; enabled: !page.busy && !parent.selected; onClicked: AppearanceController.setSunVariant(modelData.key) }
}
}
}
Text { visible: AppearanceController.sunVariant === "dark"; text: "FLAVOR SOMBRE"; color: Theme.accentForeground; font.pixelSize: 11; font.bold: true }
Row {
visible: AppearanceController.sunVariant === "dark"
spacing: 6
Repeater {
model: ["frappe", "macchiato", "mocha"]
delegate: Rectangle {
required property string modelData
readonly property bool selected: AppearanceController.sunDarkFlavor === modelData
width: (page.width - 12) / 3; height: 40; radius: 4
color: selected ? Theme.selectedBackground : Theme.buttonBackground
border.width: selected ? 2 : 1; border.color: selected ? Theme.accent : Theme.border
Text { anchors.centerIn: parent; text: page.names[modelData]; color: parent.selected ? Theme.selectedForeground : Theme.foreground; font.pixelSize: 11 }
MouseArea { anchors.fill: parent; enabled: !page.busy && !parent.selected; onClicked: AppearanceController.setSunDarkFlavor(modelData) }
}
}
}
Text {
width: parent.width; wrapMode: Text.Wrap
text: AppearanceController.sunVariant === "light" ? "Latte · contraste renforcé" : page.names[AppearanceController.sunDarkFlavor] + " · contraste renforcé"
color: Theme.foreground; font.pixelSize: 12
}
Text {
width: parent.width; wrapMode: Text.Wrap
text: "La lumière nocturne est temporairement suspendue pendant le Mode Soleil."
color: Theme.secondaryForeground; font.pixelSize: 11
}
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

@ -11,7 +11,7 @@ Item {
{ 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
readonly property bool busy: AppearanceController.appearanceBusy || AppearanceController.nightLightBusy
Column {
anchors.fill: parent
@ -45,13 +45,16 @@ Item {
Text {
width: parent.width
text: "Thème effectif : " + (page.names[AppearanceController.effectiveFlavor] || "Mocha")
+ (AppearanceController.sunMode ? " · Mode Soleil" : "")
color: Theme.foreground; font.pixelSize: 12
}
Text {
visible: AppearanceController.themeMode === "wallpaper"
visible: AppearanceController.themeMode === "wallpaper" || AppearanceController.sunMode
width: parent.width; wrapMode: Text.Wrap
text: "Le thème suit le fond appliqué. Choix manuel conservé : "
+ (page.names[AppearanceController.manualFlavor] || "Mocha") + "."
text: (AppearanceController.themeMode === "wallpaper" ? "Préférence normale : Selon le fond → "
+ (page.names[AppearanceController.wallpaperAnalysis ? AppearanceController.wallpaperAnalysis.flavor : "mocha"] || "Mocha") : "Préférence normale : Manuel → "
+ (page.names[AppearanceController.manualFlavor] || "Mocha"))
+ ". Choix manuel conservé : " + (page.names[AppearanceController.manualFlavor] || "Mocha") + "."
color: Theme.secondaryForeground; font.pixelSize: 11
}
Text {

View file

@ -224,7 +224,7 @@ 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.appearanceBusy
enabled: !!page.selected && page.selectionValid && !AppearanceController.appearanceBusy && !AppearanceController.nightLightBusy
&& page.selected.path !== AppearanceController.effectiveWallpaper
onClicked: { page.feedback = ""; AppearanceController.applyWallpaper(page.selected.path); }
}

View file

@ -30,11 +30,15 @@ PopupWindow {
function requestClose() { opening = false; visible = false; }
SystemClock { id: clock; precision: SystemClock.Minutes }
Connections {
target: AppearanceController
function onTimezoneRevisionChanged() { clock.enabled = false; clock.enabled = true; }
}
Rectangle {
anchors.fill: parent
radius: 4
color: Theme.popupBackground
border.color: Theme.border
border.color: Theme.outline
Column {
id: content
anchors.left: parent.left

View file

@ -93,7 +93,7 @@ Rectangle {
width: 32
height: 27
radius: 4
color: controlPointer.containsMouse && available ? Theme.strongBorder : Theme.border
color: controlPointer.containsMouse && available ? Theme.emphasisBackground : Theme.border
NerdIcon {
anchors.centerIn: parent

View file

@ -23,6 +23,12 @@ Rectangle {
id: clock
precision: SystemClock.Minutes
}
// WHY: SystemClock ne garantit qu'un tick par minute ; une publication de
// fuseau force immédiatement la relecture sans boucle de polling.
Connections {
target: AppearanceController
function onTimezoneRevisionChanged() { clock.enabled = false; clock.enabled = true; }
}
Text {
id: clockText

View file

@ -25,7 +25,7 @@ Rectangle {
implicitHeight: content.implicitHeight + 20
radius: 4
color: Theme.popupBackground
border.color: notification.urgency === NotificationUrgency.Critical ? Theme.danger : Theme.border
border.color: notification.urgency === NotificationUrgency.Critical ? Theme.danger : Theme.outline
// CONTRACT: 0 interdit l'expiration automatique. Les durées sont en ms.
Timer {
interval: Math.max(1, card.timeout)

View file

@ -27,7 +27,7 @@ PopupWindow {
anchors.fill: parent
radius: 4
color: Theme.buttonBackground
border.color: Theme.border
border.color: Theme.outline
Text {
id: content
anchors.centerIn: parent

View file

@ -26,7 +26,7 @@ PopupWindow {
anchors.fill: parent
radius: 4
color: Theme.popupBackground
border.color: Theme.border
border.color: Theme.outline
Column {
id: content

View file

@ -6,12 +6,21 @@ Singleton {
id: appearance
// CONTRACT: état effectif unique du bureau. Les futurs adaptateurs ne
// déduisent jamais Light/Dark du nom de flavor ; ils lisent effectiveDark.
// 17B ne définit que le mode normal ; les décisions Sun arrivent après.
// CONTRACT: Sun est un profil effectif ; les préférences normales survivent.
readonly property var flavors: ["latte", "frappe", "macchiato", "mocha"]
readonly property var accents: ["rosewater", "flamingo", "pink", "mauve", "red", "maroon", "peach", "yellow", "green", "teal", "sky", "sapphire", "blue", "lavender"]
readonly property string defaultWallpaper: "/usr/share/backgrounds/sway/Sway_Wallpaper_Blue_1920x1080.png"
readonly property string wallpaperBackend: (Quickshell.env("HOME") || "/home/fy59") + "/.local/bin/wallpaper-manager.py"
readonly property string nightLightBackend: (Quickshell.env("HOME") || "/home/fy59") + "/.local/bin/night-light-manager.py"
readonly property string locationBackend: (Quickshell.env("HOME") || "/home/fy59") + "/.local/bin/location-time-manager.py"
property string effectiveTimezone: ""
property bool solarScheduleConfigured: false
property string activeLocationName: ""
property var savedLocations: []
property var locationPresets: []
property bool locationBusy: false
property string locationError: ""
property int timezoneRevision: 0
// CONTRACT: le backend écrit cet état atomiquement après validation Sway.
// Le repli Mocha fonctionne avant la première génération ; aucune lecture
// par frame ni polling. Les futurs adaptateurs lisent cet état via l'IPC.
@ -25,18 +34,21 @@ Singleton {
function initialState() {
try {
const value = JSON.parse(stateFile.text());
if ((value.version === 1 || value.version === 2 || value.version === 3)
if ((value.version === 1 || value.version === 2 || value.version === 3 || value.version === 4)
&& flavors.includes(value.effectiveFlavor)
&& accents.includes(value.effectiveAccent)
&& value.effectiveMode === "normal") return value;
&& ["normal", "sun-light", "sun-dark"].includes(value.effectiveMode)) return value;
} catch (_) {}
return { effectiveFlavor: "mocha", effectiveAccent: "lavender", revision: 0 };
}
readonly property var initial: initialState()
property string effectiveFlavor: initial.effectiveFlavor
property string effectiveMode: "normal"
property string effectiveMode: initial.effectiveMode || "normal"
readonly property bool effectiveDark: effectiveFlavor !== "latte"
property bool effectiveHighContrast: false
property bool effectiveHighContrast: initial.effectiveHighContrast === true
property bool sunMode: initial.sunMode === true
property string sunVariant: initial.sunVariant === "dark" ? "dark" : "light"
property string sunDarkFlavor: ["frappe", "macchiato", "mocha"].includes(initial.sunDarkFlavor) ? initial.sunDarkFlavor : "mocha"
property string effectiveAccent: initial.effectiveAccent
property int revision: Number.isSafeInteger(initial.revision) && initial.revision >= 0
? initial.revision : 0
@ -51,8 +63,8 @@ Singleton {
// CONTRACT: le choix persistant reste distinct du mode effectif ; 17F
// pourra suspendre la correction sans écraser la préférence utilisateur.
property string nightLightMode: "auto"
property string effectiveNightLightMode: "auto"
property bool nightLightSuspended: false
property string effectiveNightLightMode: initial.effectiveNightLightMode || "auto"
property bool nightLightSuspended: initial.nightLightSuspended === true
property int nightTemperature: 3000
property int dayTemperature: 6500
property bool nightScheduleConfigured: false
@ -78,10 +90,14 @@ Singleton {
onExited: (code, status) => {
if (code !== 0 || status !== 0) {
appearance.appearanceError = startupError.text.trim() || "Réconciliation Appearance impossible";
nightStartup.running = true;
return;
}
try { appearance.acceptSnapshot(JSON.parse(startupOutput.text)); }
catch (_) { appearance.appearanceError = "État Appearance invalide"; }
// INVARIANT: une seule transaction de démarrage à la fois emprunte
// le verrou Appearance commun avec Night Light.
nightStartup.running = true;
}
}
function acceptSnapshot(value) {
@ -90,14 +106,18 @@ Singleton {
|| !Number.isSafeInteger(value.revision) || value.revision < 0
|| (value.themeMode !== "manual" && value.themeMode !== "wallpaper")
|| !flavors.includes(value.manualFlavor)
|| !["normal", "sun-light", "sun-dark"].includes(value.effectiveMode)
|| !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;
effectiveMode = value.effectiveMode;
effectiveHighContrast = value.effectiveMode !== "normal";
sunMode = value.sunMode === true;
sunVariant = value.sunVariant === "dark" ? "dark" : "light";
sunDarkFlavor = ["frappe", "macchiato", "mocha"].includes(value.sunDarkFlavor) ? value.sunDarkFlavor : "mocha";
effectiveWallpaper = value.effectiveWallpaper;
wallpaperMode = "fill";
themeMode = value.themeMode;
@ -145,7 +165,7 @@ Singleton {
Process {
id: nightStartup
command: ["python", appearance.nightLightBackend, "reconcile"]
running: true
running: false
stdout: StdioCollector { id: nightStartupOutput; waitForEnd: true }
stderr: StdioCollector { id: nightStartupError; waitForEnd: true }
onExited: (code, status) => {
@ -174,7 +194,7 @@ Singleton {
}
}
function runNightLight(args) {
if (nightLightBusy) return false;
if (nightLightBusy || appearanceBusy || locationBusy) return false;
nightLightBusy = true;
nightLightError = "";
nightWriter.command = ["python", nightLightBackend].concat(args);
@ -190,6 +210,84 @@ Singleton {
return runNightLight(["set-night-temperature", String(value)]);
}
// CONTRACT: timedated fait autorité pour le fuseau ; seules les coordonnées
// solaires et les lieux enregistrés résident dans preferences.json.
Process {
id: locationReader
command: ["python", appearance.locationBackend, "status"]
running: true
stdout: StdioCollector { id: locationStatusOutput; waitForEnd: true }
stderr: StdioCollector { id: locationStatusError; waitForEnd: true }
onExited: (code, status) => {
// INVARIANT: une lecture lancée avant une écriture ne republie pas
// un fuseau obsolète après la réponse de la transaction.
if (appearance.locationBusy) return;
if (code === 0 && status === 0) {
try { appearance.acceptLocation(JSON.parse(locationStatusOutput.text)); }
catch (_) { appearance.locationError = "État de localisation invalide"; }
} else appearance.locationError = locationStatusError.text.trim() || "Lecture du fuseau impossible";
}
}
Process {
id: locationListReader
command: ["python", appearance.locationBackend, "list"]
running: true
stdout: StdioCollector { id: locationListOutput; waitForEnd: true }
onExited: (code, status) => {
if (code === 0 && status === 0) {
try { appearance.locationPresets = JSON.parse(locationListOutput.text); }
catch (_) { appearance.locationError = "Liste locale invalide"; }
}
}
}
function acceptLocation(value) {
if (!value || typeof value.effectiveTimezone !== "string"
|| !Array.isArray(value.savedLocations)) return false;
if (effectiveTimezone !== value.effectiveTimezone) timezoneRevision++;
effectiveTimezone = value.effectiveTimezone;
solarScheduleConfigured = value.solarScheduleConfigured === true;
activeLocationName = value.activeLocationName || "";
savedLocations = value.savedLocations;
locationError = "";
return true;
}
Process {
id: locationWriter
stdout: StdioCollector { id: locationOutput; waitForEnd: true }
stderr: StdioCollector { id: locationErrors; waitForEnd: true }
onExited: (code, status) => {
appearance.locationBusy = false;
if (code !== 0 || status !== 0) {
appearance.locationError = locationErrors.text.trim() || "Application du lieu impossible";
locationReader.running = true;
return;
}
try {
if (!appearance.acceptLocation(JSON.parse(locationOutput.text)))
appearance.locationError = "Réponse du lieu invalide";
} catch (_) { appearance.locationError = "Réponse du lieu invalide"; }
// La position peut avoir redémarré Auto ; relire l'état effectif.
nightStartup.running = true;
}
}
function runLocation(args) {
if (locationBusy || nightLightBusy || appearanceBusy || nightStartup.running) return false;
locationBusy = true;
locationError = "";
locationWriter.command = ["python", locationBackend].concat(args);
locationWriter.running = true;
return true;
}
function applyLocation(kind, value) {
if (kind === "timezone") return runLocation(["apply-timezone", value.timezone]);
if (kind === "solar") return runLocation(["apply-solar", String(value.latitude), String(value.longitude), "--name", value.name || ""]);
if (kind === "both") return runLocation(["apply-both", value.timezone, String(value.latitude), String(value.longitude), "--name", value.name || ""]);
return false;
}
function saveLocation(value) {
return runLocation(["save", value.name, value.timezone, String(value.latitude), String(value.longitude)]);
}
Process {
id: wallpaperWriter
stdout: StdioCollector { id: wallpaperOutput; waitForEnd: true }
@ -213,7 +311,7 @@ Singleton {
}
function runOperation(args, applyingWallpaper) {
if (appearanceBusy) return false;
if (appearanceBusy || nightLightBusy || locationBusy) return false;
wallpaperError = "";
appearanceError = "";
appearanceBusy = true;
@ -237,6 +335,18 @@ Singleton {
|| flavor === manualFlavor && flavor === effectiveFlavor) return false;
return runOperation(["set-manual-flavor", flavor], false);
}
function setSunMode(enabled) {
if (typeof enabled !== "boolean" || enabled === sunMode) return false;
return runOperation(["set-sun-mode", enabled ? "on" : "off"], false);
}
function setSunVariant(value) {
if (!["light", "dark"].includes(value) || value === sunVariant) return false;
return runOperation(["set-sun-variant", value], false);
}
function setSunDarkFlavor(value) {
if (!["frappe", "macchiato", "mocha"].includes(value) || value === sunDarkFlavor) return false;
return runOperation(["set-sun-dark-flavor", value], 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.
@ -254,6 +364,7 @@ Singleton {
effectiveAccent: effectiveAccent, effectiveWallpaper: effectiveWallpaper,
wallpaperMode: wallpaperMode, wallpaperDirectory: wallpaperDirectory,
themeMode: themeMode, manualFlavor: manualFlavor,
sunMode: sunMode, sunVariant: sunVariant, sunDarkFlavor: sunDarkFlavor,
nightLightMode: nightLightMode, effectiveNightLightMode: effectiveNightLightMode,
nightLightSuspended: nightLightSuspended, nightTemperature: nightTemperature,
dayTemperature: dayTemperature,

View file

@ -54,18 +54,28 @@ Singleton {
readonly property color crust: palette.crust
// CONTRACT: l'alpha de la barre est un profil UI, jamais une entrée Catppuccin.
property real panelOpacity: 235 / 255
// CONTRACT: les composants lisent des rôles ; seul l'état effectif choisit
// les surfaces et limites du profil haute visibilité.
readonly property bool highContrast: AppearanceController.effectiveHighContrast
readonly property real panelOpacity: highContrast ? 1.0 : 235 / 255
readonly property real popupOpacity: 1.0
readonly property color panelBackground: Qt.rgba(base.r, base.g, base.b, panelOpacity)
readonly property color popupBackground: base
readonly property color buttonBackground: surface0
readonly property color buttonHover: surface1
readonly property color buttonPressed: surface2
// INVARIANT: même sur hover/pressed, le texte garde au moins 4,5:1 ;
// surface1/surface2 Latte et surface2 Frappé sont trop proches du texte.
readonly property color buttonBackground: highContrast && flavor === "latte" ? mantle : surface0
readonly property color buttonHover: highContrast && flavor === "latte" ? surface0 : surface1
readonly property color buttonPressed: highContrast ? (flavor === "latte" ? surface0 : surface1) : surface2
readonly property color foreground: text
// Latte sur surface0 a besoin d'un texte plus sombre que subtext0.
readonly property color secondaryForeground: flavor === "latte" ? text : subtext0
readonly property color secondaryForeground: highContrast ? text : flavor === "latte" ? text : subtext0
readonly property color disabledForeground: flavor === "latte" ? subtext1 : overlay0
readonly property color border: surface1
readonly property color strongBorder: surface2
// Plusieurs contrôles historiques utilisent border comme fond de hover :
// ce rôle doit donc rester compatible avec le contraste du texte.
readonly property color border: highContrast && flavor === "latte" ? surface0 : surface1
readonly property color strongBorder: highContrast ? overlay2 : surface2
readonly property color outline: highContrast ? overlay2 : border
readonly property color emphasisBackground: highContrast ? buttonHover : strongBorder
readonly property color accent: palette[accentName]
// WHY: Lavender Latte n'offre aucun texte Catppuccin à contraste 4.5:1.
// Choisir le noir ou le blanc technique par contraste WCAG pour chaque accent.
@ -87,10 +97,10 @@ Singleton {
readonly property color danger: red
readonly property color urgentForeground: flavor === "latte" ? text : peach
readonly property color successForeground: flavor === "latte" ? text : green
readonly property color shadow: "#00000055"
readonly property color separator: surface1
readonly property color inputBackground: surface0
readonly property color inputBorder: surface2
readonly property color shadow: highContrast ? "#00000077" : "#00000055"
readonly property color separator: highContrast ? overlay1 : surface1
readonly property color inputBackground: highContrast ? surface0 : surface0
readonly property color inputBorder: highContrast ? overlay2 : surface2
readonly property color selectedBackground: accent
readonly property color selectedForeground: onAccent
}

View file

@ -32,7 +32,7 @@ PopupWindow {
anchors.fill: parent
radius: 4
color: Theme.popupBackground
border.color: Theme.border
border.color: Theme.outline
Column {
id: content

View file

@ -9,6 +9,10 @@
# Listener IPC démarré une fois par session ; exec ne se rejoue pas au reload.
exec python3 ~/.config/sway/scripts/inactive-windows-transparency.py --opacity $inactive_opacity
# CONTRACT: l'agent Polkit affiche l'authentification standard de timedated.
# exec n'est lancé qu'à l'ouverture de session, pas à chaque reload Sway.
exec /usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1
# =================================================
# Règles d'assignation automatique
# =================================================

View file

@ -6,12 +6,34 @@
# transparency strength in range of 0…1 or use the command line argument -o.
import argparse
import json
import os
import signal
import sys
from functools import partial
from pathlib import Path
import i3ipc
STATE = Path(os.environ.get('XDG_STATE_HOME', Path.home() / '.local/state')) / 'labfy-appearance/effective.json'
def inactive_opacity():
# CONTRACT: lecture uniquement sur événement IPC ou SIGUSR1 de la
# transaction ; aucune boucle de sondage ni valeur Sun mémorisée.
try:
state = json.loads(STATE.read_text(encoding='utf-8'))
return '1.0' if state.get('effectiveMode') in ('sun-light', 'sun-dark') else '0.85'
except (OSError, ValueError):
return '0.85'
def apply_all(ipc):
tree = ipc.get_tree()
opacity = inactive_opacity()
for window in tree.leaves():
window.command('opacity ' + ('1.0' if window.focused else opacity))
def on_window(args, ipc, event):
global focused_set
@ -20,6 +42,7 @@ def on_window(args, ipc, event):
# parents, so fetch the whole tree
tree = ipc.get_tree()
args.opacity = inactive_opacity()
focused = tree.find_focused()
if focused is None:
return
@ -54,6 +77,8 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="This script allows you to set the transparency of unfocused windows in sway."
)
parser.add_argument('--refresh', action='store_true',
help='réappliquer immédiatement toutes les fenêtres puis quitter')
parser.add_argument(
"--opacity",
"-o",
@ -77,14 +102,16 @@ if __name__ == "__main__":
args = parser.parse_args()
ipc = i3ipc.Connection()
if args.refresh:
apply_all(ipc)
sys.exit(0)
focused_set = set()
for window in ipc.get_tree():
if window.focused:
focused_set.add(window.id)
window.command("opacity " + args.focused)
else:
window.command("opacity " + args.opacity)
args.opacity = inactive_opacity()
apply_all(ipc)
focused = ipc.get_tree().find_focused()
if focused:
focused_set.add(focused.id)
for sig in [signal.SIGINT, signal.SIGTERM]:
signal.signal(sig, lambda signal, frame: remove_opacity(ipc, args.focused))
ipc.on("window", partial(on_window, args))

View file

@ -18,9 +18,9 @@
output * bg $wallpaper fill
# target title bg text indicator border
client.focused $accent $base $text $rosewater $accent
client.focused_inactive $overlay0 $base $text $rosewater $overlay0
client.unfocused $overlay0 $base $text $rosewater $overlay0
client.focused $focused_border $base $text $rosewater $focused_border
client.focused_inactive $unfocused_border $base $text $rosewater $unfocused_border
client.unfocused $unfocused_border $base $text $rosewater $unfocused_border
client.urgent $peach $base $peach $overlay0 $peach
client.placeholder $overlay0 $base $text $overlay0 $overlay0
client.background $base

View file

@ -4,7 +4,7 @@ animation_duration_ms 0
corner_radius 4
shadows enable
shadows_on_csd disable
shadow_blur_radius 10
shadow_blur_radius $shadow_blur_radius
shadow_color $shadow_active
shadow_inactive_color $shadow_inactive
shadow_offset 0 2
@ -20,7 +20,7 @@ blur_radius 2
# Namespace observé dans get_outputs ; les autres couches restent intactes.
layer_effects "labfy-sway-bar" {
blur enable
blur $bar_blur
blur_xray disable
blur_ignore_transparent enable
shadows enable

View file

@ -10,3 +10,7 @@ set $accent #b4befe
set $shadow_active #00000055
set $shadow_inactive #00000035
set $inactive_opacity 0.85
set $shadow_blur_radius 10
set $bar_blur enable
set $focused_border #b4befe
set $unfocused_border #6c7086

View file

@ -29,6 +29,18 @@ class AppearanceGeneratorTest(unittest.TestCase):
with self.assertRaises(ValueError):
module.render(flavor, accent, profile)
def test_sun_profiles_change_only_effects_and_use_official_palette(self):
normal = module.render('mocha', 'lavender', 'normal')
sun = module.render('mocha', 'lavender', 'sun-dark')
self.assertIn('set $bar_blur enable', normal)
self.assertIn('set $inactive_opacity 0.85', normal)
self.assertIn('set $bar_blur disable', sun)
self.assertIn('set $inactive_opacity 1.0', sun)
self.assertIn('set $base #1e1e2e', sun)
self.assertIn('set $base #eff1f5', module.render('latte', 'lavender', 'sun-light'))
with self.assertRaises(ValueError):
module.render('latte', 'lavender', 'sun-dark')
def test_atomic_replace_and_failed_replace(self):
with tempfile.TemporaryDirectory() as folder:
output = Path(folder) / 'theme.conf'

View file

@ -0,0 +1,27 @@
"""Réapplication événementielle de l'opacité, sans serveur Sway réel."""
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
from unittest import mock
SOURCE = Path(__file__).resolve().parents[1] / 'sway/.config/sway/scripts/inactive-windows-transparency.py'
SPEC = importlib.util.spec_from_file_location('inactive_opacity', SOURCE)
module = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(module)
class OpacityTest(unittest.TestCase):
def test_normal_sun_and_immediate_restore(self):
with tempfile.TemporaryDirectory() as folder, mock.patch.object(module, 'STATE', Path(folder) / 'effective.json'):
focused = mock.Mock(focused=True)
inactive = mock.Mock(focused=False)
ipc = mock.Mock()
ipc.get_tree.return_value.leaves.return_value = [focused, inactive]
for profile, expected in [('normal', '0.85'), ('sun-light', '1.0'),
('sun-dark', '1.0'), ('normal', '0.85')]:
module.STATE.write_text(json.dumps({'effectiveMode': profile}))
module.apply_all(ipc)
focused.command.assert_called_with('opacity 1.0')
inactive.command.assert_called_with('opacity ' + expected)

View file

@ -0,0 +1,148 @@
"""Tests du contrat voyage, avec coordonnées exclusivement synthétiques."""
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
SOURCE = Path(__file__).resolve().parents[1] / 'bin/.local/bin/location-time-manager.py'
SPEC = importlib.util.spec_from_file_location('location_time_manager', SOURCE)
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
class LocationTimeTests(unittest.TestCase):
def test_zone1970_and_search(self):
entries = MODULE.presets()
self.assertGreater(len(entries), 200)
self.assertTrue(MODULE.search(entries, 'Europe/Paris'))
self.assertTrue(MODULE.search(entries, 'London'))
self.assertTrue(MODULE.search(entries, 'madrid'))
self.assertEqual(MODULE.search(entries, 'lieu-introuvable'), [])
def test_zone1970_synthetic_fixture(self):
with tempfile.TemporaryDirectory() as directory:
fixture = Path(directory) / 'zone1970.tab'
fixture.write_text('# synthetic\nXX,YY\t+1230-04515\tEurope/Paris\tLieu synthétique\n', encoding='utf-8')
items = MODULE.presets(fixture)
self.assertEqual(len(items), 1)
self.assertEqual(items[0]['countries'], ['XX', 'YY'])
self.assertEqual(items[0]['comment'], 'Lieu synthétique')
self.assertEqual((items[0]['latitude'], items[0]['longitude']), (12.5, -45.25))
def test_invalid_timezone(self):
for value in ('', '../../etc/passwd', '/etc/passwd', 'Europe/Impossible',
'Europe/Paris\n', 'Europe/Paris\0', 'Europe/../Paris'):
with self.subTest(value=repr(value)):
self.assertFalse(MODULE.timezone_valid(value))
self.assertTrue(MODULE.timezone_valid('Europe/Paris'))
self.assertTrue(MODULE.timezone_valid('UTC'))
def test_invalid_coordinates(self):
for lat, lon in ((91, 0), (0, 181), ('nan', 0), ('', 0), ('texte', 0), (0, '')):
with self.subTest(lat=lat, lon=lon):
with self.assertRaises(ValueError):
MODULE.coordinates(lat, lon)
self.assertEqual(MODULE.coordinates('12.5', '-30'), (12.5, -30.0))
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
base = Path(self.temp.name)
self.config = base / 'preferences.json'
self.state = base / 'effective.json'
self.lock = base / 'wallpaper.lock'
self.config.write_text(json.dumps({'version': 4, 'nightLightMode': 'auto',
'nightTemperature': 3000, 'dayTemperature': 6500, 'scheduleType': 'solar',
'latitude': 10, 'longitude': 20}), encoding='utf-8')
self.state.write_text('{}', encoding='utf-8')
self.original = self.config.read_bytes()
for name, value in (('CONFIG', self.config), ('STATE', self.state), ('LOCK', self.lock)):
patcher = patch.object(MODULE, name, value)
patcher.start(); self.addCleanup(patcher.stop)
self.zone = ['Europe/Paris']
patcher = patch.object(MODULE, 'current_timezone', side_effect=lambda: self.zone[0])
patcher.start(); self.addCleanup(patcher.stop)
patcher = patch.object(MODULE, 'set_timezone', side_effect=lambda tz: self.zone.__setitem__(0, tz))
self.setter = patcher.start(); self.addCleanup(patcher.stop)
original_night = MODULE.night_module
def mocked_night():
night = original_night()
night.apply_service = self.service
night.verify = self.verify
return night
self.service_calls = []
self.service = lambda pref, suspended: self.service_calls.append((pref['nightLightMode'], suspended))
self.verify = lambda pref, suspended: None
patcher = patch.object(MODULE, 'night_module', side_effect=mocked_night)
patcher.start(); self.addCleanup(patcher.stop)
def test_timezone_only(self):
MODULE.apply('timezone', timezone='Europe/London')
self.assertEqual(self.zone[0], 'Europe/London')
self.assertEqual(self.config.read_bytes(), self.original)
self.assertEqual(self.service_calls, [])
def test_solar_only_auto(self):
MODULE.apply('solar', latitude=11, longitude=21)
self.assertEqual(self.zone[0], 'Europe/Paris')
self.assertEqual(self.service_calls, [('auto', False)])
pref = json.loads(self.config.read_text())
self.assertEqual((pref['latitude'], pref['longitude']), (11, 21))
def test_both_and_timezone_failure(self):
MODULE.apply('both', timezone='Europe/London', latitude=11, longitude=21)
self.assertEqual(self.zone[0], 'Europe/London')
self.assertEqual(len(self.service_calls), 1)
self.zone[0] = 'Europe/Paris'
self.config.write_bytes(self.original)
self.setter.side_effect = RuntimeError('authentification refusée')
with self.assertRaises(RuntimeError):
MODULE.apply('both', timezone='Europe/London', latitude=11, longitude=21)
self.assertEqual(self.config.read_bytes(), self.original)
def test_rollback_on_reconcile_failure(self):
def fail(pref, suspended):
if pref['latitude'] == 11:
raise RuntimeError('service indisponible')
self.service = fail
with self.assertRaises(RuntimeError):
MODULE.apply('both', timezone='Europe/London', latitude=11, longitude=21)
self.assertEqual(self.zone[0], 'Europe/Paris')
self.assertEqual(self.config.read_bytes(), self.original)
def test_timezone_rollback_even_if_service_rollback_fails(self):
self.service = lambda pref, suspended: (_ for _ in ()).throw(RuntimeError('service indisponible'))
with self.assertRaisesRegex(RuntimeError, 'rollback incomplet'):
MODULE.apply('both', timezone='Europe/London', latitude=11, longitude=21)
self.assertEqual(self.zone[0], 'Europe/Paris')
self.assertEqual(self.config.read_bytes(), self.original)
def test_off_on_and_sun_do_not_restart(self):
pref = json.loads(self.original)
pref['nightLightMode'] = 'off'
self.config.write_text(json.dumps(pref))
MODULE.apply('solar', latitude=11, longitude=21)
self.assertEqual(self.service_calls, [])
pref['nightLightMode'] = 'on'
self.config.write_text(json.dumps(pref))
MODULE.apply('solar', latitude=12, longitude=22)
self.assertEqual(self.service_calls, [])
pref['nightLightMode'] = 'auto'
self.config.write_text(json.dumps(pref))
self.state.write_text(json.dumps({'effectiveMode': 'sun-light'}))
MODULE.apply('solar', latitude=12, longitude=22)
self.assertEqual(self.service_calls, [])
def test_saved_location(self):
MODULE.save_location('Lieu synthétique', 'Europe/London', 11, 22)
result = json.loads(self.config.read_text())
self.assertEqual(len(result['savedLocations']), 1)
self.assertEqual(result['savedLocations'][0]['name'], 'Lieu synthétique')
with self.assertRaises(ValueError):
MODULE.save_location('', 'Europe/London', 11, 22)
if __name__ == '__main__':
unittest.main()

View file

@ -30,13 +30,21 @@ class WallpaperManagerTest(unittest.TestCase):
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, 'refresh_opacity'),
]
for patcher in self.patchers:
patcher.start()
# Les tests de transaction utilisent un service simulé : aucune unité
# systemd réelle ne doit être touchée depuis des fixtures temporaires.
self.night = mock.Mock()
self.night.normalized.side_effect = lambda pref: pref
self.night_patch = mock.patch.object(manager, 'night_module', return_value=self.night)
self.night_patch.start()
self.image = self.root / "fond d'écran (été).png"
Image.new('RGB', (640, 360), 'blue').save(self.image)
def tearDown(self):
self.night_patch.stop()
for patcher in reversed(self.patchers):
patcher.stop()
self.temp.cleanup()
@ -65,7 +73,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'], 3)
self.assertEqual(json.loads(manager.PREFERENCES.read_text())['version'], 4)
def test_atomic_replace_keeps_old_file_on_write_failure(self):
target = self.root / 'atomic.txt'
@ -132,7 +140,7 @@ class WallpaperManagerTest(unittest.TestCase):
state = manager.read_json(manager.STATE)
self.assertEqual(state['effectiveFlavor'], 'mocha')
self.assertEqual(state['effectiveAccent'], 'lavender')
self.assertEqual(state['version'], 3)
self.assertEqual(state['version'], 4)
self.assertIn(b'set $wallpaper ', manager.GENERATED.read_bytes())
self.assertEqual(command.call_count, 2)
newer = self.root / 'autre.png'
@ -203,7 +211,7 @@ class WallpaperManagerTest(unittest.TestCase):
'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['version'], 4)
self.assertEqual(result['revision'], 47)
self.assertEqual(result['customFutureField'], 'conservé')
self.assertEqual(result['effectiveFlavor'], 'macchiato')
@ -253,6 +261,69 @@ class WallpaperManagerTest(unittest.TestCase):
self.assertEqual(result['manualFlavor'], 'latte')
self.assertEqual(result['wallpaperAnalysis']['algorithmVersion'], 1)
def test_sun_priority_and_exact_manual_restoration(self):
manager.write_json(manager.STATE, {'version': 4, 'effectiveFlavor': 'frappe',
'effectiveMode': 'normal', 'effectiveWallpaper': str(self.image),
'themeMode': 'manual', 'manualFlavor': 'frappe', 'revision': 9})
manager.write_json(manager.PREFERENCES, {'version': 4, 'themeMode': 'manual',
'manualFlavor': 'frappe', 'nightLightMode': 'off'})
with mock.patch.object(manager, 'command'), mock.patch.object(manager, 'publish_live'), \
mock.patch.object(manager, 'refresh_opacity') as opacity:
light = manager.transaction(sun_mode='on')
self.assertEqual((light['effectiveFlavor'], light['effectiveMode'], light['revision']),
('latte', 'sun-light', 10))
self.assertTrue(light['effectiveHighContrast'])
self.assertFalse(light['effectiveDark'])
self.assertEqual(light['manualFlavor'], 'frappe')
dark = manager.transaction(sun_variant='dark')
self.assertEqual((dark['effectiveFlavor'], dark['effectiveMode']), ('mocha', 'sun-dark'))
for flavor in ('frappe', 'macchiato', 'mocha'):
dark = manager.transaction(sun_dark_flavor=flavor)
self.assertEqual(dark['effectiveFlavor'], flavor)
self.assertTrue(dark['effectiveDark'])
self.assertTrue(dark['effectiveHighContrast'])
normal = manager.transaction(sun_mode='off')
self.assertEqual((normal['effectiveFlavor'], normal['effectiveMode']), ('frappe', 'normal'))
self.assertEqual(normal['manualFlavor'], 'frappe')
self.assertEqual(normal['themeMode'], 'manual')
self.assertFalse(normal['effectiveHighContrast'])
self.assertGreaterEqual(opacity.call_count, 3)
self.night.apply_service.assert_any_call(mock.ANY, True)
self.night.apply_service.assert_any_call(mock.ANY, False)
def test_inactive_sun_flavor_preference_does_not_advance_revision(self):
manager.write_json(manager.STATE, {'version': 4, 'effectiveFlavor': 'mocha',
'effectiveMode': 'normal', 'effectiveWallpaper': str(self.image),
'themeMode': 'manual', 'manualFlavor': 'mocha', 'revision': 8})
with mock.patch.object(manager, 'publish_live'):
result = manager.transaction(sun_dark_flavor='frappe')
self.assertEqual(result['revision'], 8)
self.assertEqual(result['effectiveFlavor'], 'mocha')
self.assertEqual(result['sunDarkFlavor'], 'frappe')
def test_sun_rollback_restores_state_files_and_night_service(self):
manager.write_json(manager.STATE, {'version': 4, 'effectiveFlavor': 'mocha',
'effectiveMode': 'normal', 'effectiveWallpaper': str(self.image),
'themeMode': 'manual', 'manualFlavor': 'mocha', 'revision': 4})
manager.write_json(manager.PREFERENCES, {'version': 4, 'themeMode': 'manual',
'manualFlavor': 'mocha', 'nightLightMode': 'off'})
before = (manager.STATE.read_bytes(), manager.PREFERENCES.read_bytes())
with mock.patch.object(manager, 'command'), mock.patch.object(manager, 'refresh_opacity'), \
mock.patch.object(manager, 'publish_live', side_effect=RuntimeError('IPC')):
with self.assertRaisesRegex(RuntimeError, 'IPC'):
manager.transaction(sun_mode='on')
self.assertEqual((manager.STATE.read_bytes(), manager.PREFERENCES.read_bytes()), before)
self.assertEqual(self.night.apply_service.call_count, 2)
def test_no_location_fresh_preferences_enable_and_disable_sun(self):
manager.write_json(manager.STATE, {'version': 4, 'effectiveFlavor': 'mocha',
'effectiveMode': 'normal', 'effectiveWallpaper': str(self.image),
'themeMode': 'manual', 'manualFlavor': 'mocha', 'revision': 0})
with mock.patch.object(manager, 'command'), mock.patch.object(manager, 'publish_live'), \
mock.patch.object(manager, 'refresh_opacity'):
self.assertEqual(manager.transaction(sun_mode='on')['effectiveNightLightMode'], 'off')
self.assertEqual(manager.transaction(sun_mode='off')['effectiveFlavor'], 'mocha')
if __name__ == '__main__':
unittest.main()