From 172fa98f1d6d63b0baf3b821ce119140adac74cb Mon Sep 17 00:00:00 2001 From: fy59 Date: Mon, 5 Oct 2026 05:23:24 +0200 Subject: [PATCH] feat(appearance): add Sun Mode and travel location controls --- .assets/pkglist-pacman.txt | 1 + bin/.local/bin/generate-appearance.py | 25 +- bin/.local/bin/location-time-manager.py | 260 ++++++++++++++++++ bin/.local/bin/night-light-manager.py | 50 ++-- bin/.local/bin/wallpaper-manager.py | 151 ++++++++-- docs/appearance-sun-mode-17f.md | 79 ++++++ docs/installation.md | 4 + .../labfy-sway/controlcenter/ActionButton.qml | 2 +- .../controlcenter/AppearancePage.qml | 29 +- .../controlcenter/BatterySettings.qml | 2 +- .../controlcenter/ControlCenter.qml | 16 +- .../labfy-sway/controlcenter/LOCATION_TIME.md | 21 ++ .../controlcenter/LocationTimePage.qml | 144 ++++++++++ .../controlcenter/NightLightPage.qml | 8 +- .../labfy-sway/controlcenter/SunModePage.qml | 84 ++++++ .../labfy-sway/controlcenter/ThemePage.qml | 11 +- .../controlcenter/WallpaperPage.qml | 2 +- .../labfy-sway/datecenter/DateCenter.qml | 6 +- .../labfy-sway/datecenter/MediaPlayer.qml | 2 +- .../quickshell/labfy-sway/modules/Clock.qml | 6 + .../notifications/NotificationToast.qml | 2 +- .../labfy-sway/status/StatusTooltip.qml | 2 +- .../systemmonitor/SystemMonitor.qml | 2 +- .../labfy-sway/theme/AppearanceController.qml | 135 ++++++++- .../quickshell/labfy-sway/theme/Theme.qml | 32 ++- .../labfy-sway/windows/WindowMenu.qml | 2 +- sway/.config/sway/autostart | 4 + .../scripts/inactive-windows-transparency.py | 39 ++- sway/.config/sway/style | 6 +- sway/.config/sway/swayfx | 4 +- sway/.config/sway/theme-default.conf | 4 + tests/test_generate_appearance.py | 12 + tests/test_inactive_window_opacity.py | 27 ++ tests/test_location_time_manager.py | 148 ++++++++++ tests/test_wallpaper_manager.py | 77 +++++- 35 files changed, 1294 insertions(+), 105 deletions(-) create mode 100755 bin/.local/bin/location-time-manager.py create mode 100644 docs/appearance-sun-mode-17f.md create mode 100644 quickshell/.config/quickshell/labfy-sway/controlcenter/LOCATION_TIME.md create mode 100644 quickshell/.config/quickshell/labfy-sway/controlcenter/LocationTimePage.qml create mode 100644 quickshell/.config/quickshell/labfy-sway/controlcenter/SunModePage.qml create mode 100644 tests/test_inactive_window_opacity.py create mode 100644 tests/test_location_time_manager.py diff --git a/.assets/pkglist-pacman.txt b/.assets/pkglist-pacman.txt index 72f2f0414..9a5179734 100644 --- a/.assets/pkglist-pacman.txt +++ b/.assets/pkglist-pacman.txt @@ -133,6 +133,7 @@ pipewire-alsa pipewire-jack pipewire-libcamera pipewire-pulse +polkit-gnome podman powertop protonmail-bridge diff --git a/bin/.local/bin/generate-appearance.py b/bin/.local/bin/generate-appearance.py index b97264e8f..9a709e094 100755 --- a/bin/.local/bin/generate-appearance.py +++ b/bin/.local/bin/generate-appearance.py @@ -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}') diff --git a/bin/.local/bin/location-time-manager.py b/bin/.local/bin/location-time-manager.py new file mode 100755 index 000000000..67222b857 --- /dev/null +++ b/bin/.local/bin/location-time-manager.py @@ -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()) diff --git a/bin/.local/bin/night-light-manager.py b/bin/.local/bin/night-light-manager.py index 9295ca49a..726dd6c04 100644 --- a/bin/.local/bin/night-light-manager.py +++ b/bin/.local/bin/night-light-manager.py @@ -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: diff --git a/bin/.local/bin/wallpaper-manager.py b/bin/.local/bin/wallpaper-manager.py index e73c0b40d..20f22b348 100755 --- a/bin/.local/bin/wallpaper-manager.py +++ b/bin/.local/bin/wallpaper-manager.py @@ -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 diff --git a/docs/appearance-sun-mode-17f.md b/docs/appearance-sun-mode-17f.md new file mode 100644 index 000000000..ff9013a93 --- /dev/null +++ b/docs/appearance-sun-mode-17f.md @@ -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. diff --git a/docs/installation.md b/docs/installation.md index 9878fa67c..a6d078d8e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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`. diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/ActionButton.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/ActionButton.qml index b368a3d69..a6d569797 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/ActionButton.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/ActionButton.qml @@ -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 diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/AppearancePage.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/AppearancePage.qml index e49b6ddff..90110bf41 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/AppearancePage.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/AppearancePage.qml @@ -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() } + } } } diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/BatterySettings.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/BatterySettings.qml index dcd0659a4..655affd41 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/BatterySettings.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/BatterySettings.qml @@ -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 { diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml index 7e1e79d92..4dba3893a 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml @@ -14,7 +14,7 @@ PopupWindow { // CONTRACT: indices externes historiques conservés ; tout nouveau routage // passe par ces noms afin de ne pas multiplier les indices StackLayout. readonly property var pages: ({ main: 0, wifi: 1, bluetooth: 2, session: 3, - confirmation: 4, battery: 5, appearance: 6, wallpaper: 7, 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") + } } } } diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/LOCATION_TIME.md b/quickshell/.config/quickshell/labfy-sway/controlcenter/LOCATION_TIME.md new file mode 100644 index 000000000..27b921b28 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/LOCATION_TIME.md @@ -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. diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/LocationTimePage.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/LocationTimePage.qml new file mode 100644 index 000000000..86f5cd6bf --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/LocationTimePage.qml @@ -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 } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/NightLightPage.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/NightLightPage.qml index 5643c6964..c935980a4 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/NightLightPage.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/NightLightPage.qml @@ -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 diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/SunModePage.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/SunModePage.qml new file mode 100644 index 000000000..87aff086d --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/SunModePage.qml @@ -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 + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/ThemePage.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/ThemePage.qml index 518fd7041..1f7a5ca00 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/ThemePage.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/ThemePage.qml @@ -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 { diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/WallpaperPage.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/WallpaperPage.qml index 2c6397716..6bc9ca0e1 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/WallpaperPage.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/WallpaperPage.qml @@ -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); } } diff --git a/quickshell/.config/quickshell/labfy-sway/datecenter/DateCenter.qml b/quickshell/.config/quickshell/labfy-sway/datecenter/DateCenter.qml index aafbdc006..969fd9ffb 100644 --- a/quickshell/.config/quickshell/labfy-sway/datecenter/DateCenter.qml +++ b/quickshell/.config/quickshell/labfy-sway/datecenter/DateCenter.qml @@ -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 diff --git a/quickshell/.config/quickshell/labfy-sway/datecenter/MediaPlayer.qml b/quickshell/.config/quickshell/labfy-sway/datecenter/MediaPlayer.qml index 826ea2376..0264500a5 100644 --- a/quickshell/.config/quickshell/labfy-sway/datecenter/MediaPlayer.qml +++ b/quickshell/.config/quickshell/labfy-sway/datecenter/MediaPlayer.qml @@ -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 diff --git a/quickshell/.config/quickshell/labfy-sway/modules/Clock.qml b/quickshell/.config/quickshell/labfy-sway/modules/Clock.qml index 71c1ae21d..5fd525273 100644 --- a/quickshell/.config/quickshell/labfy-sway/modules/Clock.qml +++ b/quickshell/.config/quickshell/labfy-sway/modules/Clock.qml @@ -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 diff --git a/quickshell/.config/quickshell/labfy-sway/notifications/NotificationToast.qml b/quickshell/.config/quickshell/labfy-sway/notifications/NotificationToast.qml index 8f465efec..cec39bfe8 100644 --- a/quickshell/.config/quickshell/labfy-sway/notifications/NotificationToast.qml +++ b/quickshell/.config/quickshell/labfy-sway/notifications/NotificationToast.qml @@ -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) diff --git a/quickshell/.config/quickshell/labfy-sway/status/StatusTooltip.qml b/quickshell/.config/quickshell/labfy-sway/status/StatusTooltip.qml index ded82490d..97c1e1230 100644 --- a/quickshell/.config/quickshell/labfy-sway/status/StatusTooltip.qml +++ b/quickshell/.config/quickshell/labfy-sway/status/StatusTooltip.qml @@ -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 diff --git a/quickshell/.config/quickshell/labfy-sway/systemmonitor/SystemMonitor.qml b/quickshell/.config/quickshell/labfy-sway/systemmonitor/SystemMonitor.qml index a30701650..7ac3e1886 100644 --- a/quickshell/.config/quickshell/labfy-sway/systemmonitor/SystemMonitor.qml +++ b/quickshell/.config/quickshell/labfy-sway/systemmonitor/SystemMonitor.qml @@ -26,7 +26,7 @@ PopupWindow { anchors.fill: parent radius: 4 color: Theme.popupBackground - border.color: Theme.border + border.color: Theme.outline Column { id: content diff --git a/quickshell/.config/quickshell/labfy-sway/theme/AppearanceController.qml b/quickshell/.config/quickshell/labfy-sway/theme/AppearanceController.qml index a43246ece..9fbfc9589 100644 --- a/quickshell/.config/quickshell/labfy-sway/theme/AppearanceController.qml +++ b/quickshell/.config/quickshell/labfy-sway/theme/AppearanceController.qml @@ -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, diff --git a/quickshell/.config/quickshell/labfy-sway/theme/Theme.qml b/quickshell/.config/quickshell/labfy-sway/theme/Theme.qml index e8a015c9b..3992bbaa7 100644 --- a/quickshell/.config/quickshell/labfy-sway/theme/Theme.qml +++ b/quickshell/.config/quickshell/labfy-sway/theme/Theme.qml @@ -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 } diff --git a/quickshell/.config/quickshell/labfy-sway/windows/WindowMenu.qml b/quickshell/.config/quickshell/labfy-sway/windows/WindowMenu.qml index 3d010c1f7..b5bd57449 100644 --- a/quickshell/.config/quickshell/labfy-sway/windows/WindowMenu.qml +++ b/quickshell/.config/quickshell/labfy-sway/windows/WindowMenu.qml @@ -32,7 +32,7 @@ PopupWindow { anchors.fill: parent radius: 4 color: Theme.popupBackground - border.color: Theme.border + border.color: Theme.outline Column { id: content diff --git a/sway/.config/sway/autostart b/sway/.config/sway/autostart index 9077452ec..4cb5890a3 100644 --- a/sway/.config/sway/autostart +++ b/sway/.config/sway/autostart @@ -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 # ================================================= diff --git a/sway/.config/sway/scripts/inactive-windows-transparency.py b/sway/.config/sway/scripts/inactive-windows-transparency.py index 13b2cbf93..e85247096 100755 --- a/sway/.config/sway/scripts/inactive-windows-transparency.py +++ b/sway/.config/sway/scripts/inactive-windows-transparency.py @@ -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)) diff --git a/sway/.config/sway/style b/sway/.config/sway/style index 00614fae9..5cc42638d 100644 --- a/sway/.config/sway/style +++ b/sway/.config/sway/style @@ -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 diff --git a/sway/.config/sway/swayfx b/sway/.config/sway/swayfx index 01b3dd39d..b8081104c 100644 --- a/sway/.config/sway/swayfx +++ b/sway/.config/sway/swayfx @@ -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 diff --git a/sway/.config/sway/theme-default.conf b/sway/.config/sway/theme-default.conf index c2a911828..a2f41d4b8 100644 --- a/sway/.config/sway/theme-default.conf +++ b/sway/.config/sway/theme-default.conf @@ -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 diff --git a/tests/test_generate_appearance.py b/tests/test_generate_appearance.py index 392db1db4..2bf541931 100644 --- a/tests/test_generate_appearance.py +++ b/tests/test_generate_appearance.py @@ -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' diff --git a/tests/test_inactive_window_opacity.py b/tests/test_inactive_window_opacity.py new file mode 100644 index 000000000..70cbfd467 --- /dev/null +++ b/tests/test_inactive_window_opacity.py @@ -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) diff --git a/tests/test_location_time_manager.py b/tests/test_location_time_manager.py new file mode 100644 index 000000000..7b6ca5ab1 --- /dev/null +++ b/tests/test_location_time_manager.py @@ -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() diff --git a/tests/test_wallpaper_manager.py b/tests/test_wallpaper_manager.py index c788718d8..14811a063 100644 --- a/tests/test_wallpaper_manager.py +++ b/tests/test_wallpaper_manager.py @@ -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()