feat(appearance): add Sun Mode and travel location controls
This commit is contained in:
parent
f96f75cc2b
commit
172fa98f1d
35 changed files with 1294 additions and 105 deletions
|
|
@ -133,6 +133,7 @@ pipewire-alsa
|
||||||
pipewire-jack
|
pipewire-jack
|
||||||
pipewire-libcamera
|
pipewire-libcamera
|
||||||
pipewire-pulse
|
pipewire-pulse
|
||||||
|
polkit-gnome
|
||||||
podman
|
podman
|
||||||
powertop
|
powertop
|
||||||
protonmail-bridge
|
protonmail-bridge
|
||||||
|
|
|
||||||
|
|
@ -27,23 +27,32 @@ def palette_data():
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
def render(flavor, accent, profile='default'):
|
def render(flavor, accent, profile='normal'):
|
||||||
data = palette_data()
|
data = palette_data()
|
||||||
if flavor not in data:
|
if flavor not in data:
|
||||||
raise ValueError('flavor invalide: ' + flavor)
|
raise ValueError('flavor invalide: ' + flavor)
|
||||||
if accent not in ACCENTS:
|
if accent not in ACCENTS:
|
||||||
raise ValueError('accent invalide: ' + accent)
|
raise ValueError('accent invalide: ' + accent)
|
||||||
if profile != 'default':
|
if profile not in ('normal', 'sun-light', 'sun-dark', 'default'):
|
||||||
raise ValueError('profile invalide: ' + profile)
|
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]
|
p = data[flavor]
|
||||||
# CONTRACT: toutes les valeurs interpolées proviennent de clés bornées ou
|
# 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.
|
# 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 = ['# Généré depuis theme/catppuccin.json ; ne pas éditer.']
|
||||||
lines += [f'set ${name} {value}' for name, value in p.items()]
|
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]}',
|
lines += [f'set $accent {p[accent]}',
|
||||||
'set $shadow_active #00000055',
|
f'set $shadow_active {"#00000077" if sun else "#00000055"}',
|
||||||
'set $shadow_inactive #00000035',
|
f'set $shadow_inactive {"#00000055" if sun else "#00000035"}',
|
||||||
'set $inactive_opacity 0.85']
|
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'
|
return '\n'.join(lines) + '\n'
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -74,7 +83,7 @@ def main():
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument('flavor', choices=('latte', 'frappe', 'macchiato', 'mocha'))
|
parser.add_argument('flavor', choices=('latte', 'frappe', 'macchiato', 'mocha'))
|
||||||
parser.add_argument('--accent', default='lavender', choices=ACCENTS)
|
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('--output', type=Path, default=SWAY / 'generated/theme.conf')
|
||||||
parser.add_argument('--apply', action='store_true', help='valider puis recharger Sway et QuickShell')
|
parser.add_argument('--apply', action='store_true', help='valider puis recharger Sway et QuickShell')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
@ -84,8 +93,8 @@ def main():
|
||||||
# CONTRACT: les applications 17D empruntent le verrou et la publication
|
# CONTRACT: les applications 17D empruntent le verrou et la publication
|
||||||
# uniques du gestionnaire Appearance ; ce CLI garde le rendu hors ligne.
|
# uniques du gestionnaire Appearance ; ce CLI garde le rendu hors ligne.
|
||||||
if args.apply:
|
if args.apply:
|
||||||
if args.accent != 'lavender' or args.profile != 'default':
|
if args.accent != 'lavender' or args.profile not in ('normal', 'default'):
|
||||||
raise ValueError('17D applique uniquement Lavender avec le profil default')
|
raise ValueError('Application directe limitée au profil normal Lavender')
|
||||||
checked([sys.executable, str(Path(__file__).with_name('wallpaper-manager.py')),
|
checked([sys.executable, str(Path(__file__).with_name('wallpaper-manager.py')),
|
||||||
'set-manual-flavor', args.flavor])
|
'set-manual-flavor', args.flavor])
|
||||||
print(f'{args.flavor}/lavender: {args.output}')
|
print(f'{args.flavor}/lavender: {args.output}')
|
||||||
|
|
|
||||||
260
bin/.local/bin/location-time-manager.py
Executable file
260
bin/.local/bin/location-time-manager.py
Executable file
|
|
@ -0,0 +1,260 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Gestion locale du fuseau système et de la position solaire Appearance."""
|
||||||
|
import argparse
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
BASE = Path(__file__).resolve().parent
|
||||||
|
CONFIG = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'labfy-appearance/preferences.json'
|
||||||
|
STATE = Path(os.environ.get('XDG_STATE_HOME', Path.home() / '.local/state')) / 'labfy-appearance/effective.json'
|
||||||
|
LOCK = STATE.parent / 'wallpaper.lock'
|
||||||
|
ZONE_TAB = Path('/usr/share/zoneinfo/zone1970.tab')
|
||||||
|
ZONE_ROOT = Path('/usr/share/zoneinfo')
|
||||||
|
TZ_PATTERN = re.compile(r'^[A-Za-z0-9_+][A-Za-z0-9_+.-]*(?:/[A-Za-z0-9_+][A-Za-z0-9_+.-]*)*$')
|
||||||
|
|
||||||
|
|
||||||
|
def timezone_valid(value):
|
||||||
|
# INVARIANT: une identité IANA ne peut jamais être un chemin relatif ou absolu.
|
||||||
|
if not isinstance(value, str) or len(value) > 128 or not TZ_PATTERN.fullmatch(value):
|
||||||
|
return False
|
||||||
|
if any(part in ('.', '..') for part in value.split('/')):
|
||||||
|
return False
|
||||||
|
path = ZONE_ROOT / value
|
||||||
|
return path.is_file() and path.resolve().is_relative_to(ZONE_ROOT.resolve())
|
||||||
|
|
||||||
|
|
||||||
|
def coordinates(latitude, longitude):
|
||||||
|
# CONTRACT: aucune conversion permissive de NaN, booléen ou texte vide.
|
||||||
|
def parse(raw, bound):
|
||||||
|
if isinstance(raw, bool) or raw is None or str(raw).strip() == '':
|
||||||
|
raise ValueError('Coordonnées invalides')
|
||||||
|
try:
|
||||||
|
value = float(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError('Coordonnées invalides') from None
|
||||||
|
if not math.isfinite(value) or not -bound <= value <= bound:
|
||||||
|
raise ValueError('Coordonnées invalides')
|
||||||
|
return value
|
||||||
|
return parse(latitude, 90), parse(longitude, 180)
|
||||||
|
|
||||||
|
|
||||||
|
def decimal_coordinate(part, degree_digits):
|
||||||
|
sign = -1 if part[0] == '-' else 1
|
||||||
|
digits = part[1:]
|
||||||
|
degree = int(digits[:degree_digits])
|
||||||
|
minute = int(digits[degree_digits:degree_digits + 2])
|
||||||
|
second = int(digits[degree_digits + 2:]) if len(digits) > degree_digits + 2 else 0
|
||||||
|
if minute >= 60 or second >= 60:
|
||||||
|
raise ValueError('Coordonnées zone1970.tab invalides')
|
||||||
|
return sign * (degree + minute / 60 + second / 3600)
|
||||||
|
|
||||||
|
|
||||||
|
def presets(path=ZONE_TAB):
|
||||||
|
# CONTRACT: zone1970.tab est une base locale représentative ; ses points
|
||||||
|
# ne prétendent jamais être la position exacte de l'utilisateur.
|
||||||
|
result = []
|
||||||
|
for line in path.read_text(encoding='utf-8').splitlines():
|
||||||
|
if not line or line.startswith('#'):
|
||||||
|
continue
|
||||||
|
fields = line.split('\t')
|
||||||
|
if len(fields) < 3:
|
||||||
|
continue
|
||||||
|
countries, compact, timezone = fields[:3]
|
||||||
|
match = re.fullmatch(r'([+-]\d{4}(?:\d{2})?)([+-]\d{5}(?:\d{2})?)', compact)
|
||||||
|
if not match or not timezone_valid(timezone):
|
||||||
|
continue
|
||||||
|
latitude = decimal_coordinate(match.group(1), 2)
|
||||||
|
longitude = decimal_coordinate(match.group(2), 3)
|
||||||
|
coordinates(latitude, longitude)
|
||||||
|
result.append({'timezone': timezone, 'latitude': latitude, 'longitude': longitude,
|
||||||
|
'countries': countries.split(','), 'comment': fields[3] if len(fields) > 3 else '',
|
||||||
|
'city': timezone.split('/')[-1].replace('_', ' ')})
|
||||||
|
return sorted(result, key=lambda item: item['timezone'])
|
||||||
|
|
||||||
|
|
||||||
|
def search(entries, query):
|
||||||
|
query = query.strip().casefold()
|
||||||
|
return [entry for entry in entries if query in ' '.join((entry['timezone'], entry['city'], entry['comment'],
|
||||||
|
' '.join(entry['countries']))).casefold()]
|
||||||
|
|
||||||
|
|
||||||
|
def current_timezone():
|
||||||
|
result = subprocess.run(['timedatectl', 'show', '-p', 'Timezone', '--value'],
|
||||||
|
capture_output=True, text=True, check=True, timeout=10)
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def set_timezone(timezone):
|
||||||
|
if not timezone_valid(timezone):
|
||||||
|
raise ValueError('Fuseau horaire invalide')
|
||||||
|
# WHY: timedated demande l'authentification Polkit de la session. Le mot
|
||||||
|
# de passe ne traverse jamais QuickShell ni ce processus Python.
|
||||||
|
subprocess.run(['timedatectl', 'set-timezone', timezone], check=True,
|
||||||
|
capture_output=True, text=True, timeout=120)
|
||||||
|
if current_timezone() != timezone:
|
||||||
|
raise RuntimeError('Le fuseau système ne correspond pas à la demande')
|
||||||
|
|
||||||
|
|
||||||
|
def night_module():
|
||||||
|
spec = importlib.util.spec_from_file_location('night_light_manager', BASE / 'night-light-manager.py')
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
module.CONFIG, module.STATE, module.LOCK = CONFIG, STATE, LOCK
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def read_preferences(night):
|
||||||
|
return night.normalized(night.read_json(CONFIG))
|
||||||
|
|
||||||
|
|
||||||
|
def saved_locations(pref):
|
||||||
|
locations = pref.get('savedLocations', [])
|
||||||
|
if not isinstance(locations, list) or len(locations) > 100:
|
||||||
|
raise ValueError('Liste de lieux invalide')
|
||||||
|
for entry in locations:
|
||||||
|
if not isinstance(entry, dict) or not isinstance(entry.get('name'), str) \
|
||||||
|
or not entry['name'].strip() or len(entry['name']) > 80 \
|
||||||
|
or not timezone_valid(entry.get('timezone')):
|
||||||
|
raise ValueError('Lieu enregistré invalide')
|
||||||
|
coordinates(entry.get('latitude'), entry.get('longitude'))
|
||||||
|
return locations
|
||||||
|
|
||||||
|
|
||||||
|
def location_name(value, required=False):
|
||||||
|
if not isinstance(value, str) or len(value.strip()) > 80 or '\n' in value or '\r' in value \
|
||||||
|
or (required and not value.strip()):
|
||||||
|
raise ValueError('Nom de lieu invalide')
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def public_state():
|
||||||
|
night = night_module()
|
||||||
|
pref = read_preferences(night)
|
||||||
|
locations = saved_locations(pref)
|
||||||
|
# WHY: la position solaire courante n'est jamais renvoyée à la page ; seuls
|
||||||
|
# les lieux enregistrés portent leurs coordonnées pour pouvoir être rejoués.
|
||||||
|
return {'effectiveTimezone': current_timezone(), 'solarScheduleConfigured':
|
||||||
|
'latitude' in pref and 'longitude' in pref,
|
||||||
|
'activeLocationName': pref.get('activeLocationName', ''),
|
||||||
|
'savedLocations': locations}
|
||||||
|
|
||||||
|
|
||||||
|
def apply(kind, timezone=None, latitude=None, longitude=None, name=None):
|
||||||
|
if kind in ('timezone', 'both') and not timezone_valid(timezone):
|
||||||
|
raise ValueError('Fuseau horaire invalide')
|
||||||
|
if kind in ('solar', 'both'):
|
||||||
|
lat, lon = coordinates(latitude, longitude)
|
||||||
|
if name is not None:
|
||||||
|
name = location_name(name)
|
||||||
|
night = night_module()
|
||||||
|
LOCK.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(LOCK, 'a+b') as lock:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
before_tz = current_timezone()
|
||||||
|
before_bytes = CONFIG.read_bytes() if CONFIG.exists() else None
|
||||||
|
before_pref = read_preferences(night)
|
||||||
|
suspended = night.suspended_state()
|
||||||
|
changed_tz = False
|
||||||
|
changed_solar = False
|
||||||
|
try:
|
||||||
|
if kind in ('timezone', 'both') and timezone != before_tz:
|
||||||
|
set_timezone(timezone)
|
||||||
|
changed_tz = True
|
||||||
|
if kind in ('solar', 'both'):
|
||||||
|
pref = dict(before_pref)
|
||||||
|
pref.update(latitude=lat, longitude=lon)
|
||||||
|
if name is not None:
|
||||||
|
pref['activeLocationName'] = name
|
||||||
|
else:
|
||||||
|
pref.pop('activeLocationName', None)
|
||||||
|
night.atomic_json(CONFIG, pref)
|
||||||
|
changed_solar = True
|
||||||
|
# CONTRACT: Auto seul redémarre avec les nouvelles coordonnées.
|
||||||
|
# ON conserve sa température forcée ; OFF et Sun restent éteints.
|
||||||
|
if pref['nightLightMode'] == 'auto' and not suspended:
|
||||||
|
night.apply_service(pref, False)
|
||||||
|
else:
|
||||||
|
night.verify(pref, suspended)
|
||||||
|
if kind in ('timezone', 'both') and current_timezone() != timezone:
|
||||||
|
raise RuntimeError('Vérification du fuseau système échouée')
|
||||||
|
return public_state()
|
||||||
|
except Exception as original_error:
|
||||||
|
rollback_errors = []
|
||||||
|
if changed_solar:
|
||||||
|
try:
|
||||||
|
night.restore(CONFIG, before_bytes)
|
||||||
|
if before_pref['nightLightMode'] == 'auto' and not suspended:
|
||||||
|
night.apply_service(before_pref, False)
|
||||||
|
except Exception:
|
||||||
|
rollback_errors.append('position solaire ou Night Light')
|
||||||
|
if changed_tz:
|
||||||
|
try:
|
||||||
|
set_timezone(before_tz)
|
||||||
|
except Exception:
|
||||||
|
rollback_errors.append('fuseau système')
|
||||||
|
if rollback_errors:
|
||||||
|
raise RuntimeError('Échec de transaction ; rollback incomplet : '
|
||||||
|
+ ', '.join(rollback_errors)) from original_error
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def save_location(name, timezone, latitude, longitude):
|
||||||
|
name = location_name(name, required=True)
|
||||||
|
if not timezone_valid(timezone):
|
||||||
|
raise ValueError('Fuseau horaire invalide')
|
||||||
|
lat, lon = coordinates(latitude, longitude)
|
||||||
|
night = night_module()
|
||||||
|
LOCK.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(LOCK, 'a+b') as lock:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
pref = read_preferences(night)
|
||||||
|
locations = saved_locations(pref)
|
||||||
|
item = {'name': name.strip(), 'timezone': timezone, 'latitude': lat, 'longitude': lon}
|
||||||
|
pref['savedLocations'] = [entry for entry in locations if entry.get('name') != item['name']] + [item]
|
||||||
|
night.atomic_json(CONFIG, pref)
|
||||||
|
return public_state()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
sub = parser.add_subparsers(dest='action', required=True)
|
||||||
|
sub.add_parser('status')
|
||||||
|
listing = sub.add_parser('list')
|
||||||
|
listing.add_argument('query', nargs='?', default='')
|
||||||
|
for kind in ('timezone', 'solar', 'both'):
|
||||||
|
command = sub.add_parser('apply-' + kind)
|
||||||
|
if kind in ('timezone', 'both'):
|
||||||
|
command.add_argument('timezone')
|
||||||
|
if kind in ('solar', 'both'):
|
||||||
|
command.add_argument('latitude')
|
||||||
|
command.add_argument('longitude')
|
||||||
|
command.add_argument('--name')
|
||||||
|
save = sub.add_parser('save')
|
||||||
|
for field in ('name', 'timezone', 'latitude', 'longitude'):
|
||||||
|
save.add_argument(field)
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
if args.action == 'status': result = public_state()
|
||||||
|
elif args.action == 'list': result = search(presets(), args.query)
|
||||||
|
elif args.action == 'save': result = save_location(args.name, args.timezone, args.latitude, args.longitude)
|
||||||
|
else: result = apply(args.action.removeprefix('apply-'),
|
||||||
|
getattr(args, 'timezone', None), getattr(args, 'latitude', None),
|
||||||
|
getattr(args, 'longitude', None), getattr(args, 'name', None))
|
||||||
|
print(json.dumps(result, ensure_ascii=False, separators=(',', ':')))
|
||||||
|
return 0
|
||||||
|
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError, BlockingIOError) as exc:
|
||||||
|
# CONTRACT: aucune coordonnée privée dans les messages d'erreur.
|
||||||
|
print('Erreur : ' + str(exc), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -106,9 +106,16 @@ def effective(mode, suspended=False):
|
||||||
return 'off' if suspended else mode
|
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)
|
pref = normalized(pref)
|
||||||
mode = effective(pref['nightLightMode'])
|
mode = effective(pref['nightLightMode'], suspended)
|
||||||
if mode == 'off':
|
if mode == 'off':
|
||||||
return None
|
return None
|
||||||
temperatures = ['-t', str(pref['nightTemperature']), '-T', str(pref['dayTemperature'])]
|
temperatures = ['-t', str(pref['nightTemperature']), '-T', str(pref['dayTemperature'])]
|
||||||
|
|
@ -148,10 +155,10 @@ def wlsunset_pids():
|
||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
def verify(pref):
|
def verify(pref, suspended=False):
|
||||||
active, main_pid = unit_state()
|
active, main_pid = unit_state()
|
||||||
pids = wlsunset_pids()
|
pids = wlsunset_pids()
|
||||||
mode = effective(pref['nightLightMode'])
|
mode = effective(pref['nightLightMode'], suspended)
|
||||||
if mode == 'off':
|
if mode == 'off':
|
||||||
if active or pids:
|
if active or pids:
|
||||||
raise RuntimeError('Night Light Off : wlsunset encore actif')
|
raise RuntimeError('Night Light Off : wlsunset encore actif')
|
||||||
|
|
@ -159,14 +166,14 @@ def verify(pref):
|
||||||
raise RuntimeError('wlsunset absent, dupliqué ou hors unité systemd')
|
raise RuntimeError('wlsunset absent, dupliqué ou hors unité systemd')
|
||||||
else:
|
else:
|
||||||
actual = (Path('/proc') / str(main_pid) / 'cmdline').read_bytes().split(b'\0')[:-1]
|
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')
|
raise RuntimeError('Arguments wlsunset incohérents')
|
||||||
|
|
||||||
|
|
||||||
def apply_service(pref):
|
def apply_service(pref, suspended=False):
|
||||||
mode = effective(pref['nightLightMode'])
|
mode = effective(pref['nightLightMode'], suspended)
|
||||||
systemctl('stop' if mode == 'off' else 'restart', UNIT)
|
systemctl('stop' if mode == 'off' else 'restart', UNIT)
|
||||||
verify(pref)
|
verify(pref, suspended)
|
||||||
|
|
||||||
|
|
||||||
def snapshot(pref, state):
|
def snapshot(pref, state):
|
||||||
|
|
@ -205,16 +212,16 @@ def change(mode=None, night=None):
|
||||||
pref['nightTemperature'] = temperature(night)
|
pref['nightTemperature'] = temperature(night)
|
||||||
pref = normalized(pref)
|
pref = normalized(pref)
|
||||||
state = read_json(STATE)
|
state = read_json(STATE)
|
||||||
previous_mode = old_pref['nightLightMode']
|
suspended = suspended_state(state)
|
||||||
if pref == old_pref:
|
if pref == old_pref:
|
||||||
verify(pref)
|
verify(pref, suspended)
|
||||||
return snapshot(pref, state)
|
return snapshot(pref, state)
|
||||||
new_state = dict(state)
|
new_state = dict(state)
|
||||||
new_state.update(effectiveNightLightMode=effective(pref['nightLightMode']),
|
new_state.update(effectiveNightLightMode=effective(pref['nightLightMode'], suspended),
|
||||||
nightLightSuspended=False)
|
nightLightSuspended=suspended)
|
||||||
try:
|
try:
|
||||||
atomic_json(CONFIG, pref)
|
atomic_json(CONFIG, pref)
|
||||||
apply_service(pref)
|
apply_service(pref, suspended)
|
||||||
atomic_json(STATE, new_state)
|
atomic_json(STATE, new_state)
|
||||||
result = snapshot(pref, new_state)
|
result = snapshot(pref, new_state)
|
||||||
publish(result)
|
publish(result)
|
||||||
|
|
@ -223,7 +230,7 @@ def change(mode=None, night=None):
|
||||||
restore(CONFIG, old_pref_bytes)
|
restore(CONFIG, old_pref_bytes)
|
||||||
restore(STATE, old_state_bytes)
|
restore(STATE, old_state_bytes)
|
||||||
try:
|
try:
|
||||||
apply_service(old_pref)
|
apply_service(old_pref, suspended)
|
||||||
except Exception as rollback_error:
|
except Exception as rollback_error:
|
||||||
raise RuntimeError('Échec Night Light et rollback du service') from rollback_error
|
raise RuntimeError('Échec Night Light et rollback du service') from rollback_error
|
||||||
raise
|
raise
|
||||||
|
|
@ -235,16 +242,17 @@ def reconcile():
|
||||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
pref = normalized(read_json(CONFIG))
|
pref = normalized(read_json(CONFIG))
|
||||||
state = read_json(STATE)
|
state = read_json(STATE)
|
||||||
|
suspended = suspended_state(state)
|
||||||
try:
|
try:
|
||||||
verify(pref)
|
verify(pref, suspended)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
# Une instance étrangère ne peut être supprimée par cette unité.
|
# Une instance étrangère ne peut être supprimée par cette unité.
|
||||||
active, main_pid = unit_state()
|
active, main_pid = unit_state()
|
||||||
if any(pid != main_pid for pid in wlsunset_pids()):
|
if any(pid != main_pid for pid in wlsunset_pids()):
|
||||||
raise RuntimeError('wlsunset externe détecté ; réconciliation refusée')
|
raise RuntimeError('wlsunset externe détecté ; réconciliation refusée')
|
||||||
apply_service(pref)
|
apply_service(pref, suspended)
|
||||||
state.update(effectiveNightLightMode=effective(pref['nightLightMode']),
|
state.update(effectiveNightLightMode=effective(pref['nightLightMode'], suspended),
|
||||||
nightLightSuspended=False)
|
nightLightSuspended=suspended)
|
||||||
if read_json(CONFIG) != pref:
|
if read_json(CONFIG) != pref:
|
||||||
atomic_json(CONFIG, pref)
|
atomic_json(CONFIG, pref)
|
||||||
if read_json(STATE) != state:
|
if read_json(STATE) != state:
|
||||||
|
|
@ -254,7 +262,7 @@ def reconcile():
|
||||||
|
|
||||||
def launch():
|
def launch():
|
||||||
pref = normalized(read_json(CONFIG))
|
pref = normalized(read_json(CONFIG))
|
||||||
command = argv(pref)
|
command = argv(pref, suspended_state())
|
||||||
if command is None:
|
if command is None:
|
||||||
return 0
|
return 0
|
||||||
os.execv(command[0], command)
|
os.execv(command[0], command)
|
||||||
|
|
@ -262,7 +270,7 @@ def launch():
|
||||||
|
|
||||||
def post_start():
|
def post_start():
|
||||||
pref = normalized(read_json(CONFIG))
|
pref = normalized(read_json(CONFIG))
|
||||||
if pref['nightLightMode'] != 'on':
|
if pref['nightLightMode'] != 'on' or suspended_state():
|
||||||
return 0
|
return 0
|
||||||
# systemd ne fournit pas MAINPID dans cet ExecStartPost sur cette version.
|
# 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.
|
# 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))
|
pref = normalized(read_json(CONFIG))
|
||||||
result = snapshot(pref, read_json(STATE))
|
result = snapshot(pref, read_json(STATE))
|
||||||
if args.action == 'current':
|
if args.action == 'current':
|
||||||
verify(pref)
|
verify(pref, suspended_state())
|
||||||
print(json.dumps(result, ensure_ascii=False, separators=(',', ':')))
|
print(json.dumps(result, ensure_ascii=False, separators=(',', ':')))
|
||||||
return 0
|
return 0
|
||||||
except (OSError, ValueError, RuntimeError, BlockingIOError) as exc:
|
except (OSError, ValueError, RuntimeError, BlockingIOError) as exc:
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,10 @@ GENERATED = SWAY / 'generated/wallpaper.conf'
|
||||||
GENERATED_THEME = SWAY / 'generated/theme.conf'
|
GENERATED_THEME = SWAY / 'generated/theme.conf'
|
||||||
ALIASES = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'labfy-appearance/wallpapers'
|
ALIASES = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'labfy-appearance/wallpapers'
|
||||||
FLAVORS = ('latte', 'frappe', 'macchiato', 'mocha')
|
FLAVORS = ('latte', 'frappe', 'macchiato', 'mocha')
|
||||||
STATE_VERSION = 3
|
STATE_VERSION = 4
|
||||||
PREF_VERSION = 3
|
PREF_VERSION = 4
|
||||||
|
SUN_VARIANTS = ('light', 'dark')
|
||||||
|
SUN_DARK_FLAVORS = ('frappe', 'macchiato', 'mocha')
|
||||||
|
|
||||||
|
|
||||||
def atomic_write(path, data):
|
def atomic_write(path, data):
|
||||||
|
|
@ -259,16 +261,47 @@ def preferences(state=None):
|
||||||
manual = 'mocha'
|
manual = 'mocha'
|
||||||
return {**value, 'version': PREF_VERSION, 'themeMode': mode,
|
return {**value, 'version': PREF_VERSION, 'themeMode': mode,
|
||||||
'manualFlavor': manual, 'accent': 'lavender', 'wallpaperMode': 'fill',
|
'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()}
|
'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."""
|
"""Réutilise le générateur 17B : aucune seconde palette ni formule Sway."""
|
||||||
source = Path(__file__).with_name('generate-appearance.py')
|
source = Path(__file__).with_name('generate-appearance.py')
|
||||||
spec = importlib.util.spec_from_file_location('generate_appearance', source)
|
spec = importlib.util.spec_from_file_location('generate_appearance', source)
|
||||||
module = importlib.util.module_from_spec(spec)
|
module = importlib.util.module_from_spec(spec)
|
||||||
spec.loader.exec_module(module)
|
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):
|
def publish_live(state, pref):
|
||||||
|
|
@ -291,13 +324,16 @@ def restore_file(path, previous):
|
||||||
def state_response(state, pref, analysis=None):
|
def state_response(state, pref, analysis=None):
|
||||||
result = dict(state)
|
result = dict(state)
|
||||||
result.update(themeMode=pref['themeMode'], manualFlavor=pref['manualFlavor'],
|
result.update(themeMode=pref['themeMode'], manualFlavor=pref['manualFlavor'],
|
||||||
|
sunMode=pref['sunMode'], sunVariant=pref['sunVariant'],
|
||||||
|
sunDarkFlavor=pref['sunDarkFlavor'],
|
||||||
wallpaperDirectory=pref['wallpaperDirectory'])
|
wallpaperDirectory=pref['wallpaperDirectory'])
|
||||||
if analysis is not None:
|
if analysis is not None:
|
||||||
result['analysis'] = analysis
|
result['analysis'] = analysis
|
||||||
return result
|
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."""
|
"""Applique un état compensable sous un verrou commun thème/wallpaper."""
|
||||||
STATE.parent.mkdir(parents=True, exist_ok=True)
|
STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(STATE.parent / 'wallpaper.lock', 'a+b') as lock:
|
with open(STATE.parent / 'wallpaper.lock', 'a+b') as lock:
|
||||||
|
|
@ -319,6 +355,18 @@ def transaction(wallpaper=None, mode=None, manual_flavor=None):
|
||||||
if pref['themeMode'] != 'manual':
|
if pref['themeMode'] != 'manual':
|
||||||
raise ValueError('Revenir en mode Manuel avant de choisir un flavor')
|
raise ValueError('Revenir en mode Manuel avant de choisir un flavor')
|
||||||
pref['manualFlavor'] = manual_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
|
meta = validate_image(wallpaper) if wallpaper is not None else None
|
||||||
target = meta['path'] if meta else old.get('effectiveWallpaper') or active_wallpaper()
|
target = meta['path'] if meta else old.get('effectiveWallpaper') or active_wallpaper()
|
||||||
if not Path(target).is_file():
|
if not Path(target).is_file():
|
||||||
|
|
@ -326,15 +374,22 @@ def transaction(wallpaper=None, mode=None, manual_flavor=None):
|
||||||
analysis = None
|
analysis = None
|
||||||
if pref['themeMode'] == 'wallpaper':
|
if pref['themeMode'] == 'wallpaper':
|
||||||
try:
|
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:
|
except Exception as exc:
|
||||||
# L'analyse précède toute écriture ; un échec ne laisse aucun
|
# L'analyse précède toute écriture ; un échec ne laisse aucun
|
||||||
# état hybride et ne retire jamais un wallpaper déjà appliqué.
|
# état hybride et ne retire jamais un wallpaper déjà appliqué.
|
||||||
raise ValueError('Analyse Auto Theme : ' + str(exc)) from exc
|
raise ValueError('Analyse Auto Theme : ' + str(exc)) from exc
|
||||||
flavor = analysis['flavor']
|
normal_flavor = analysis['flavor']
|
||||||
else:
|
else:
|
||||||
flavor = pref['manualFlavor'] if mode is not None or manual_flavor is not None \
|
normal_flavor = pref['manualFlavor']
|
||||||
else old.get('effectiveFlavor', pref['manualFlavor'])
|
flavor, profile = resolve(pref, normal_flavor)
|
||||||
if flavor not in FLAVORS:
|
if flavor not in FLAVORS:
|
||||||
raise ValueError('Flavor effectif invalide')
|
raise ValueError('Flavor effectif invalide')
|
||||||
previous_wallpaper = old.get('effectiveWallpaper') or active_wallpaper()
|
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'])
|
or old.get('wallpaperMtime') != meta['mtime'])
|
||||||
flavor_changed = flavor != old.get('effectiveFlavor', 'mocha')
|
flavor_changed = flavor != old.get('effectiveFlavor', 'mocha')
|
||||||
mode_changed = pref['themeMode'] != previous_pref['themeMode']
|
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 = dict(old)
|
||||||
state.update(version=STATE_VERSION, effectiveFlavor=flavor,
|
state.update(version=STATE_VERSION, effectiveFlavor=flavor,
|
||||||
effectiveMode='normal', effectiveDark=flavor != 'latte',
|
effectiveMode=profile, effectiveDark=flavor != 'latte',
|
||||||
effectiveHighContrast=False, effectiveAccent='lavender',
|
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',
|
effectiveWallpaper=target, wallpaperMode='fill',
|
||||||
themeMode=pref['themeMode'], manualFlavor=pref['manualFlavor'],
|
themeMode=pref['themeMode'], manualFlavor=pref['manualFlavor'],
|
||||||
revision=max(0, int(old.get('revision', 0)))
|
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:
|
if meta is not None:
|
||||||
state['wallpaperMtime'] = meta['mtime']
|
state['wallpaperMtime'] = meta['mtime']
|
||||||
if analysis is not None:
|
if analysis is not None:
|
||||||
state['wallpaperAnalysis'] = analysis
|
state['wallpaperAnalysis'] = analysis
|
||||||
elif pref['themeMode'] == 'manual':
|
elif pref['themeMode'] == 'manual':
|
||||||
state.pop('wallpaperAnalysis', None)
|
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):
|
or pref != previous_pref or old.get('version') != STATE_VERSION):
|
||||||
return state_response(state, pref, analysis)
|
return state_response(state, pref, analysis)
|
||||||
alias = None
|
alias = None
|
||||||
wrote_config = False
|
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:
|
try:
|
||||||
if wallpaper_changed:
|
if wallpaper_changed:
|
||||||
alias = safe_alias(meta)
|
alias = safe_alias(meta)
|
||||||
atomic_write(GENERATED, render_wallpaper(alias))
|
atomic_write(GENERATED, render_wallpaper(alias))
|
||||||
wrote_config = True
|
wrote_config = True
|
||||||
if flavor_changed:
|
if flavor_changed or profile_changed:
|
||||||
atomic_write(GENERATED_THEME, rendered_theme(flavor))
|
atomic_write(GENERATED_THEME, rendered_theme(flavor, profile))
|
||||||
wrote_config = True
|
wrote_config = True
|
||||||
if wrote_config:
|
if wrote_config:
|
||||||
command('sway', '--validate', '-c', str(SWAY / '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)
|
time.sleep(0.1)
|
||||||
if not swaybg_matches(alias):
|
if not swaybg_matches(alias):
|
||||||
raise RuntimeError('Sway n’a pas appliqué le fond demandé')
|
raise RuntimeError('Sway n’a pas appliqué le fond demandé')
|
||||||
|
# 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)
|
write_json(STATE, state)
|
||||||
|
if night_transition:
|
||||||
|
night.apply_service(night_pref, pref['sunMode'])
|
||||||
|
if profile_changed:
|
||||||
|
refresh_opacity()
|
||||||
write_json(PREFERENCES, pref)
|
write_json(PREFERENCES, pref)
|
||||||
publish_live(state, pref)
|
publish_live(state, pref)
|
||||||
# Seul l'alias actif est requis au prochain login ; les anciens
|
# 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)
|
restore_file(PREFERENCES, old_preferences)
|
||||||
if wrote_config:
|
if wrote_config:
|
||||||
command('swaymsg', 'reload')
|
command('swaymsg', 'reload')
|
||||||
|
if night_transition:
|
||||||
|
night.apply_service(night_pref, old_suspended)
|
||||||
|
if profile_changed:
|
||||||
|
refresh_opacity()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -450,13 +525,17 @@ def reconcile_state():
|
||||||
with open(STATE.parent / 'wallpaper.lock', 'a+b') as lock:
|
with open(STATE.parent / 'wallpaper.lock', 'a+b') as lock:
|
||||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
state = read_json(STATE)
|
state = read_json(STATE)
|
||||||
|
original_version = state.get('version')
|
||||||
pref = preferences(state)
|
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.
|
# Une préférence écrite avant un crash n'est pas une application.
|
||||||
applied_mode = state.get('themeMode')
|
applied_mode = state.get('themeMode')
|
||||||
applied_manual = state.get('manualFlavor')
|
applied_manual = state.get('manualFlavor')
|
||||||
pref['themeMode'] = applied_mode if applied_mode in ('manual', 'wallpaper') else pref['themeMode']
|
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['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'])
|
state.update(themeMode=pref['themeMode'], manualFlavor=pref['manualFlavor'])
|
||||||
else:
|
else:
|
||||||
state.update(version=STATE_VERSION, themeMode=pref['themeMode'],
|
state.update(version=STATE_VERSION, themeMode=pref['themeMode'],
|
||||||
|
|
@ -464,16 +543,37 @@ def reconcile_state():
|
||||||
flavor = state.get('effectiveFlavor', 'mocha')
|
flavor = state.get('effectiveFlavor', 'mocha')
|
||||||
if flavor not in FLAVORS:
|
if flavor not in FLAVORS:
|
||||||
raise ValueError('État effectif invalide')
|
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',
|
state.update(effectiveFlavor=flavor, effectiveAccent='lavender',
|
||||||
effectiveDark=flavor != 'latte', effectiveHighContrast=False,
|
effectiveDark=flavor != 'latte', effectiveHighContrast=pref['sunMode'],
|
||||||
effectiveMode='normal', wallpaperMode='fill',
|
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(),
|
effectiveWallpaper=state.get('effectiveWallpaper') or active_wallpaper(),
|
||||||
revision=max(0, int(state.get('revision', 0))))
|
revision=max(0, int(state.get('revision', 0))))
|
||||||
if not Path(state['effectiveWallpaper']).is_file():
|
if not Path(state['effectiveWallpaper']).is_file():
|
||||||
# Le fond déjà rendu peut survivre à la suppression ; l'UI reçoit
|
# 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.
|
# le signal de manque et un futur apply/reconcile utilise le repli.
|
||||||
return state_response(state, pref)
|
return state_response(state, pref)
|
||||||
expected_theme = rendered_theme(flavor)
|
expected_theme = rendered_theme(flavor, profile)
|
||||||
meta = validate_image(state['effectiveWallpaper'])
|
meta = validate_image(state['effectiveWallpaper'])
|
||||||
if pref['themeMode'] == 'wallpaper':
|
if pref['themeMode'] == 'wallpaper':
|
||||||
previous_analysis = state.get('wallpaperAnalysis')
|
previous_analysis = state.get('wallpaperAnalysis')
|
||||||
|
|
@ -503,6 +603,13 @@ def reconcile_state():
|
||||||
if repair_theme or repair_wallpaper:
|
if repair_theme or repair_wallpaper:
|
||||||
command('sway', '--validate', '-c', str(SWAY / 'config'))
|
command('sway', '--validate', '-c', str(SWAY / 'config'))
|
||||||
command('swaymsg', 'reload')
|
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 STATE.exists():
|
||||||
if read_json(STATE) != state:
|
if read_json(STATE) != state:
|
||||||
write_json(STATE, state)
|
write_json(STATE, state)
|
||||||
|
|
@ -530,7 +637,8 @@ def main():
|
||||||
scan_parser.add_argument('directory')
|
scan_parser.add_argument('directory')
|
||||||
scan_parser.add_argument('--page', type=int, default=0)
|
scan_parser.add_argument('--page', type=int, default=0)
|
||||||
for action in ('validate', 'thumbnail', 'analyze', 'apply', 'set-directory',
|
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')
|
sub.add_parser(action).add_argument('path')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
try:
|
try:
|
||||||
|
|
@ -549,6 +657,9 @@ def main():
|
||||||
elif args.action == 'apply': result = apply(args.path)
|
elif args.action == 'apply': result = apply(args.path)
|
||||||
elif args.action == 'set-theme-mode': result = set_theme_mode(args.path)
|
elif args.action == 'set-theme-mode': result = set_theme_mode(args.path)
|
||||||
elif args.action == 'set-manual-flavor': result = set_manual_flavor(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)
|
else: result = set_directory(args.path)
|
||||||
print(json.dumps(result, ensure_ascii=False, separators=(',', ':')))
|
print(json.dumps(result, ensure_ascii=False, separators=(',', ':')))
|
||||||
return 0
|
return 0
|
||||||
|
|
|
||||||
79
docs/appearance-sun-mode-17f.md
Normal file
79
docs/appearance-sun-mode-17f.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Appearance 17F — Mode Soleil
|
||||||
|
|
||||||
|
Le profil Soleil augmente la visibilité de QuickShell et SwayFX. Il ne modifie
|
||||||
|
ni le fond d'écran, ni la luminosité, ni les thèmes GTK, Qt, Kitty, Neovim et
|
||||||
|
Firefox. Ces cinq familles d'applications relèvent obligatoirement de 17G ;
|
||||||
|
17F ne constitue donc pas une intégration complète du bureau clair.
|
||||||
|
|
||||||
|
## État et priorité
|
||||||
|
|
||||||
|
`preferences.json` et `effective.json` utilisent le schéma 4. Les anciennes
|
||||||
|
préférences migrent vers `sunMode=false`, `sunVariant=light`,
|
||||||
|
`sunDarkFlavor=mocha`. Seuls `frappe`, `macchiato` et `mocha` sont admis pour
|
||||||
|
`sunDarkFlavor`. Les coordonnées Night Light restent locales et intactes.
|
||||||
|
|
||||||
|
`themeMode`, `manualFlavor` et `wallpaperAnalysis` décrivent le choix normal.
|
||||||
|
La résolution centrale calcule d'abord ce flavor normal, puis applique la
|
||||||
|
priorité Soleil : `sun-light` donne Latte ; `sun-dark` donne
|
||||||
|
`sunDarkFlavor` ; Soleil désactivé restitue le flavor normal. Aucun instantané
|
||||||
|
des préférences normales n'est utilisé. `effectiveDark` dérive du flavor,
|
||||||
|
`effectiveHighContrast` vaut exactement `sunMode`, et `effectiveAccent` reste
|
||||||
|
Lavender. `effectiveMode` vaut `normal`, `sun-light` ou `sun-dark`.
|
||||||
|
|
||||||
|
Une modification effective du profil ou du flavor augmente `revision` une
|
||||||
|
fois. Un changement de `sunDarkFlavor` lorsque Soleil est désactivé est
|
||||||
|
persisté sans révision visuelle. Les consommateurs 17G devront utiliser
|
||||||
|
`effectiveFlavor`, `effectiveAccent`, `effectiveDark`,
|
||||||
|
`effectiveHighContrast`, `effectiveMode` et `revision` comme source unique.
|
||||||
|
Un futur `AppThemeSynchronizer` distribuera cet état à ses adaptateurs GTK,
|
||||||
|
Qt, Kitty, Neovim et Firefox ; aucune logique spécifique à ces applications
|
||||||
|
n'appartient à `Theme.qml`.
|
||||||
|
|
||||||
|
## Transaction et reprise
|
||||||
|
|
||||||
|
`wallpaper-manager.py` tient le verrou Appearance commun. Il résout l'état,
|
||||||
|
écrit atomiquement `generated/theme.conf` et éventuellement
|
||||||
|
`generated/wallpaper.conf`, valide Sway, recharge SwayFX, vérifie le fond si
|
||||||
|
nécessaire, publie `effective.json`, réconcilie le service Night Light,
|
||||||
|
réapplique les opacités existantes par `--refresh`, écrit les préférences,
|
||||||
|
puis publie l'état QuickShell via IPC. En cas d'échec, il restaure les octets
|
||||||
|
précédents des fichiers et de l'état, recharge Sway, rétablit Night Light et
|
||||||
|
les opacités, puis renvoie une erreur. Les transitions sont sérialisées ; les
|
||||||
|
contrôles QML attendent la fin de la transaction.
|
||||||
|
|
||||||
|
Le listener d'opacité lit l'état uniquement à chaque événement fenêtre ;
|
||||||
|
`--refresh` lit une fois lors d'un changement de profil. Normal utilise
|
||||||
|
focused `1.0`, inactive `0.85` ; Soleil utilise `1.0` pour les deux. SwayFX
|
||||||
|
consomme les variables du générateur dans son unique bloc `layer_effects` :
|
||||||
|
blur de la barre `enable` en normal, `disable` en Soleil. Le rayon d'ombre
|
||||||
|
passe de 10 à 4 et les couleurs d'ombre sont renforcées en Soleil. Les
|
||||||
|
bordures restent de 2 px.
|
||||||
|
|
||||||
|
Le lanceur de `wlsunset.service` lit `effective.json` avant tout démarrage :
|
||||||
|
en Soleil, il ne lance pas de correction gamma. La préférence Off, Auto ou On
|
||||||
|
reste intacte ; en sortie de Soleil le service est réconcilié selon cette
|
||||||
|
préférence. Au redémarrage de QuickShell, Appearance se réconcilie avant Night
|
||||||
|
Light et reprend la révision persistée. Le repli versionné Mocha reste valide
|
||||||
|
sans fichiers générés.
|
||||||
|
|
||||||
|
## Contraste et limites
|
||||||
|
|
||||||
|
La barre QuickShell passe de `235/255` en normal à `1,0` en Soleil ; les popups
|
||||||
|
restent opaques (`1,0`) dans les deux profils. Les rôles `border`,
|
||||||
|
`strongBorder`, `outline`, `emphasisBackground`, `separator`,
|
||||||
|
`buttonBackground`, `buttonHover`, `secondaryForeground`, `inputBorder`
|
||||||
|
et `shadow` dérivent de la palette
|
||||||
|
Catppuccin et du contraste effectif. L'accent Lavender et `onAccent` suivent
|
||||||
|
le moteur 17B. Les ratios WCAG des paires principales sont :
|
||||||
|
|
||||||
|
| Flavor Soleil | texte/panel | secondaire/panel | texte/popup | onAccent/accent | texte/input |
|
||||||
|
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
| Latte | 7,06 | 7,06 | 7,06 | 6,60 | 5,17 |
|
||||||
|
| Frappé | 8,06 | 8,06 | 8,06 | 11,47 | 6,19 |
|
||||||
|
| Macchiato | 9,92 | 9,92 | 9,92 | 11,65 | 7,55 |
|
||||||
|
| Mocha | 11,34 | 11,34 | 11,34 | 11,74 | 8,69 |
|
||||||
|
|
||||||
|
La paire sélectionnée reprend `onAccent/accent`. Les bordures et séparateurs
|
||||||
|
sont décoratifs ; le seuil de texte AA de 4,5:1 ne s'y applique pas. Les
|
||||||
|
icônes colorées des applications dans le tray ne sont pas recolorées.
|
||||||
|
La lisibilité réelle en plein soleil exige une validation sur l'écran physique.
|
||||||
|
|
@ -84,6 +84,10 @@ printf '%s\n' "${packages[@]}"
|
||||||
SwayFX 0.6 est un paquet foreign/local à reconstruire depuis sa source
|
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
|
vérifiée ; `scenefx` et `quickshell` sont présents dans la liste Pacman
|
||||||
officielle de cette machine. Voir les inventaires `.assets/`.
|
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é
|
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`.
|
séparément avec les droits root selon `bin/root/README.md`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ Rectangle {
|
||||||
implicitWidth: caption.implicitWidth + 20
|
implicitWidth: caption.implicitWidth + 20
|
||||||
implicitHeight: 30
|
implicitHeight: 30
|
||||||
radius: 4
|
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
|
opacity: enabled ? 1 : 0.55
|
||||||
Text {
|
Text {
|
||||||
id: caption
|
id: caption
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ Item {
|
||||||
signal wallpaperRequested()
|
signal wallpaperRequested()
|
||||||
signal themeRequested()
|
signal themeRequested()
|
||||||
signal nightLightRequested()
|
signal nightLightRequested()
|
||||||
|
signal sunModeRequested()
|
||||||
|
signal locationTimeRequested()
|
||||||
readonly property var flavorNames: ({ latte: "Latte", frappe: "Frappé", macchiato: "Macchiato", mocha: "Mocha" })
|
readonly property var flavorNames: ({ latte: "Latte", frappe: "Frappé", macchiato: "Macchiato", mocha: "Mocha" })
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
|
|
@ -39,7 +41,7 @@ Item {
|
||||||
Text { text: "Thème"; color: Theme.foreground; font.pixelSize: 14; font.bold: true }
|
Text { text: "Thème"; color: Theme.foreground; font.pixelSize: 14; font.bold: true }
|
||||||
Text {
|
Text {
|
||||||
text: (page.flavorNames[AppearanceController.effectiveFlavor] || "Mocha")
|
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
|
color: Theme.secondaryForeground; font.pixelSize: 11
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -53,7 +55,8 @@ Item {
|
||||||
anchors.left: parent.left; anchors.leftMargin: 47; anchors.verticalCenter: parent.verticalCenter; spacing: 4
|
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: "Lumière nocturne"; color: Theme.foreground; font.pixelSize: 14; font.bold: true }
|
||||||
Text {
|
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"
|
: AppearanceController.nightLightMode === "on" ? "Activée · " + AppearanceController.nightTemperature + " K"
|
||||||
: "Désactivée"
|
: "Désactivée"
|
||||||
color: Theme.secondaryForeground; font.pixelSize: 11
|
color: Theme.secondaryForeground; font.pixelSize: 11
|
||||||
|
|
@ -61,5 +64,27 @@ Item {
|
||||||
}
|
}
|
||||||
MouseArea { id: nightPointer; anchors.fill: parent; hoverEnabled: true; onClicked: page.nightLightRequested() }
|
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() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -370,7 +370,7 @@ Item {
|
||||||
delegate: Rectangle {
|
delegate: Rectangle {
|
||||||
required property int modelData
|
required property int modelData
|
||||||
width: 60; height: 32; radius: 4
|
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
|
border.color: page.validProposed && Number(valueField.text) === modelData
|
||||||
? Theme.accent : "transparent"
|
? Theme.accent : "transparent"
|
||||||
Text {
|
Text {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ PopupWindow {
|
||||||
// CONTRACT: indices externes historiques conservés ; tout nouveau routage
|
// CONTRACT: indices externes historiques conservés ; tout nouveau routage
|
||||||
// passe par ces noms afin de ne pas multiplier les indices StackLayout.
|
// passe par ces noms afin de ne pas multiplier les indices StackLayout.
|
||||||
readonly property var pages: ({ main: 0, wifi: 1, bluetooth: 2, session: 3,
|
readonly property var pages: ({ main: 0, wifi: 1, bluetooth: 2, session: 3,
|
||||||
confirmation: 4, battery: 5, appearance: 6, wallpaper: 7, 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
|
property int currentPage: pages.main
|
||||||
function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; }
|
function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; }
|
||||||
signal thresholdApplied()
|
signal thresholdApplied()
|
||||||
|
|
@ -78,7 +78,9 @@ PopupWindow {
|
||||||
: currentPage === 3 ? sessionPage.implicitHeight + 32
|
: currentPage === 3 ? sessionPage.implicitHeight + 32
|
||||||
: currentPage === 4 ? confirmationPage.implicitHeight + 32
|
: currentPage === 4 ? confirmationPage.implicitHeight + 32
|
||||||
: currentPage === 5 ? batteryPage.implicitHeight + 32
|
: currentPage === 5 ? batteryPage.implicitHeight + 32
|
||||||
: currentPage === pages.wallpaper ? 550 : 440
|
: currentPage === pages.wallpaper ? 550
|
||||||
|
: currentPage === pages.appearance ? 520
|
||||||
|
: currentPage === pages.locationTime ? 650 : 440
|
||||||
visible: false
|
visible: false
|
||||||
// CONTRACT: PopupWindow n'applique un changement de grabFocus qu'après
|
// CONTRACT: PopupWindow n'applique un changement de grabFocus qu'après
|
||||||
// fermeture/réouverture ; le prendre dès MAIN garde le clavier disponible
|
// fermeture/réouverture ; le prendre dès MAIN garde le clavier disponible
|
||||||
|
|
@ -90,7 +92,7 @@ PopupWindow {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
radius: 4
|
radius: 4
|
||||||
color: Theme.popupBackground
|
color: Theme.popupBackground
|
||||||
border.color: Theme.border
|
border.color: Theme.outline
|
||||||
|
|
||||||
StackLayout {
|
StackLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
|
@ -209,6 +211,8 @@ PopupWindow {
|
||||||
onWallpaperRequested: popup.openPage("wallpaper")
|
onWallpaperRequested: popup.openPage("wallpaper")
|
||||||
onThemeRequested: popup.openPage("theme")
|
onThemeRequested: popup.openPage("theme")
|
||||||
onNightLightRequested: popup.openPage("nightLight")
|
onNightLightRequested: popup.openPage("nightLight")
|
||||||
|
onSunModeRequested: popup.openPage("sunMode")
|
||||||
|
onLocationTimeRequested: popup.openPage("locationTime")
|
||||||
}
|
}
|
||||||
|
|
||||||
WallpaperPage {
|
WallpaperPage {
|
||||||
|
|
@ -222,6 +226,12 @@ PopupWindow {
|
||||||
NightLightPage {
|
NightLightPage {
|
||||||
onBackRequested: popup.openPage("appearance")
|
onBackRequested: popup.openPage("appearance")
|
||||||
}
|
}
|
||||||
|
SunModePage {
|
||||||
|
onBackRequested: popup.openPage("appearance")
|
||||||
|
}
|
||||||
|
LocationTimePage {
|
||||||
|
onBackRequested: popup.openPage("appearance")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ Item {
|
||||||
id: page
|
id: page
|
||||||
signal backRequested()
|
signal backRequested()
|
||||||
property int draftTemperature: AppearanceController.nightTemperature
|
property int draftTemperature: AppearanceController.nightTemperature
|
||||||
readonly property bool busy: AppearanceController.nightLightBusy
|
readonly property bool busy: AppearanceController.nightLightBusy || AppearanceController.appearanceBusy
|
||||||
Connections {
|
Connections {
|
||||||
target: AppearanceController
|
target: AppearanceController
|
||||||
function onNightTemperatureChanged() { page.draftTemperature = AppearanceController.nightTemperature; }
|
function onNightTemperatureChanged() { page.draftTemperature = AppearanceController.nightTemperature; }
|
||||||
|
|
@ -48,6 +48,12 @@ Item {
|
||||||
: "Couleurs neutres"
|
: "Couleurs neutres"
|
||||||
color: Theme.secondaryForeground; font.pixelSize: 12
|
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 }
|
Text { text: "TEMPÉRATURE DE NUIT"; color: Theme.accentForeground; font.pixelSize: 11; font.bold: true }
|
||||||
Row {
|
Row {
|
||||||
width: parent.width; spacing: 10
|
width: parent.width; spacing: 10
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,7 @@ Item {
|
||||||
{ key: "mocha", name: "Mocha", description: "Sombre profond" }
|
{ key: "mocha", name: "Mocha", description: "Sombre profond" }
|
||||||
]
|
]
|
||||||
readonly property var names: ({ latte: "Latte", frappe: "Frappé", macchiato: "Macchiato", mocha: "Mocha" })
|
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 {
|
Column {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
|
@ -45,13 +45,16 @@ Item {
|
||||||
Text {
|
Text {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
text: "Thème effectif : " + (page.names[AppearanceController.effectiveFlavor] || "Mocha")
|
text: "Thème effectif : " + (page.names[AppearanceController.effectiveFlavor] || "Mocha")
|
||||||
|
+ (AppearanceController.sunMode ? " · Mode Soleil" : "")
|
||||||
color: Theme.foreground; font.pixelSize: 12
|
color: Theme.foreground; font.pixelSize: 12
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
visible: AppearanceController.themeMode === "wallpaper"
|
visible: AppearanceController.themeMode === "wallpaper" || AppearanceController.sunMode
|
||||||
width: parent.width; wrapMode: Text.Wrap
|
width: parent.width; wrapMode: Text.Wrap
|
||||||
text: "Le thème suit le fond appliqué. Choix manuel conservé : "
|
text: (AppearanceController.themeMode === "wallpaper" ? "Préférence normale : Selon le fond → "
|
||||||
+ (page.names[AppearanceController.manualFlavor] || "Mocha") + "."
|
+ (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
|
color: Theme.secondaryForeground; font.pixelSize: 11
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
|
|
|
||||||
|
|
@ -224,7 +224,7 @@ Item {
|
||||||
ActionButton { label: "Suivant"; enabled: page.items.length > 0; onClicked: page.move(1) }
|
ActionButton { label: "Suivant"; enabled: page.items.length > 0; onClicked: page.move(1) }
|
||||||
ActionButton {
|
ActionButton {
|
||||||
label: AppearanceController.wallpaperApplying ? "Application…" : "Appliquer"
|
label: AppearanceController.wallpaperApplying ? "Application…" : "Appliquer"
|
||||||
enabled: !!page.selected && page.selectionValid && !AppearanceController.appearanceBusy
|
enabled: !!page.selected && page.selectionValid && !AppearanceController.appearanceBusy && !AppearanceController.nightLightBusy
|
||||||
&& page.selected.path !== AppearanceController.effectiveWallpaper
|
&& page.selected.path !== AppearanceController.effectiveWallpaper
|
||||||
onClicked: { page.feedback = ""; AppearanceController.applyWallpaper(page.selected.path); }
|
onClicked: { page.feedback = ""; AppearanceController.applyWallpaper(page.selected.path); }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,15 @@ PopupWindow {
|
||||||
function requestClose() { opening = false; visible = false; }
|
function requestClose() { opening = false; visible = false; }
|
||||||
|
|
||||||
SystemClock { id: clock; precision: SystemClock.Minutes }
|
SystemClock { id: clock; precision: SystemClock.Minutes }
|
||||||
|
Connections {
|
||||||
|
target: AppearanceController
|
||||||
|
function onTimezoneRevisionChanged() { clock.enabled = false; clock.enabled = true; }
|
||||||
|
}
|
||||||
Rectangle {
|
Rectangle {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
radius: 4
|
radius: 4
|
||||||
color: Theme.popupBackground
|
color: Theme.popupBackground
|
||||||
border.color: Theme.border
|
border.color: Theme.outline
|
||||||
Column {
|
Column {
|
||||||
id: content
|
id: content
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ Rectangle {
|
||||||
width: 32
|
width: 32
|
||||||
height: 27
|
height: 27
|
||||||
radius: 4
|
radius: 4
|
||||||
color: controlPointer.containsMouse && available ? Theme.strongBorder : Theme.border
|
color: controlPointer.containsMouse && available ? Theme.emphasisBackground : Theme.border
|
||||||
|
|
||||||
NerdIcon {
|
NerdIcon {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,12 @@ Rectangle {
|
||||||
id: clock
|
id: clock
|
||||||
precision: SystemClock.Minutes
|
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 {
|
Text {
|
||||||
id: clockText
|
id: clockText
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ Rectangle {
|
||||||
implicitHeight: content.implicitHeight + 20
|
implicitHeight: content.implicitHeight + 20
|
||||||
radius: 4
|
radius: 4
|
||||||
color: Theme.popupBackground
|
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.
|
// CONTRACT: 0 interdit l'expiration automatique. Les durées sont en ms.
|
||||||
Timer {
|
Timer {
|
||||||
interval: Math.max(1, card.timeout)
|
interval: Math.max(1, card.timeout)
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ PopupWindow {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
radius: 4
|
radius: 4
|
||||||
color: Theme.buttonBackground
|
color: Theme.buttonBackground
|
||||||
border.color: Theme.border
|
border.color: Theme.outline
|
||||||
Text {
|
Text {
|
||||||
id: content
|
id: content
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ PopupWindow {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
radius: 4
|
radius: 4
|
||||||
color: Theme.popupBackground
|
color: Theme.popupBackground
|
||||||
border.color: Theme.border
|
border.color: Theme.outline
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
id: content
|
id: content
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,21 @@ Singleton {
|
||||||
id: appearance
|
id: appearance
|
||||||
// CONTRACT: état effectif unique du bureau. Les futurs adaptateurs ne
|
// CONTRACT: état effectif unique du bureau. Les futurs adaptateurs ne
|
||||||
// déduisent jamais Light/Dark du nom de flavor ; ils lisent effectiveDark.
|
// 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 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 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 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 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 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.
|
// CONTRACT: le backend écrit cet état atomiquement après validation Sway.
|
||||||
// Le repli Mocha fonctionne avant la première génération ; aucune lecture
|
// 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.
|
// par frame ni polling. Les futurs adaptateurs lisent cet état via l'IPC.
|
||||||
|
|
@ -25,18 +34,21 @@ Singleton {
|
||||||
function initialState() {
|
function initialState() {
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(stateFile.text());
|
const value = JSON.parse(stateFile.text());
|
||||||
if ((value.version === 1 || value.version === 2 || value.version === 3)
|
if ((value.version === 1 || value.version === 2 || value.version === 3 || value.version === 4)
|
||||||
&& flavors.includes(value.effectiveFlavor)
|
&& flavors.includes(value.effectiveFlavor)
|
||||||
&& accents.includes(value.effectiveAccent)
|
&& accents.includes(value.effectiveAccent)
|
||||||
&& value.effectiveMode === "normal") return value;
|
&& ["normal", "sun-light", "sun-dark"].includes(value.effectiveMode)) return value;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
return { effectiveFlavor: "mocha", effectiveAccent: "lavender", revision: 0 };
|
return { effectiveFlavor: "mocha", effectiveAccent: "lavender", revision: 0 };
|
||||||
}
|
}
|
||||||
readonly property var initial: initialState()
|
readonly property var initial: initialState()
|
||||||
property string effectiveFlavor: initial.effectiveFlavor
|
property string effectiveFlavor: initial.effectiveFlavor
|
||||||
property string effectiveMode: "normal"
|
property string effectiveMode: initial.effectiveMode || "normal"
|
||||||
readonly property bool effectiveDark: effectiveFlavor !== "latte"
|
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 string effectiveAccent: initial.effectiveAccent
|
||||||
property int revision: Number.isSafeInteger(initial.revision) && initial.revision >= 0
|
property int revision: Number.isSafeInteger(initial.revision) && initial.revision >= 0
|
||||||
? initial.revision : 0
|
? initial.revision : 0
|
||||||
|
|
@ -51,8 +63,8 @@ Singleton {
|
||||||
// CONTRACT: le choix persistant reste distinct du mode effectif ; 17F
|
// CONTRACT: le choix persistant reste distinct du mode effectif ; 17F
|
||||||
// pourra suspendre la correction sans écraser la préférence utilisateur.
|
// pourra suspendre la correction sans écraser la préférence utilisateur.
|
||||||
property string nightLightMode: "auto"
|
property string nightLightMode: "auto"
|
||||||
property string effectiveNightLightMode: "auto"
|
property string effectiveNightLightMode: initial.effectiveNightLightMode || "auto"
|
||||||
property bool nightLightSuspended: false
|
property bool nightLightSuspended: initial.nightLightSuspended === true
|
||||||
property int nightTemperature: 3000
|
property int nightTemperature: 3000
|
||||||
property int dayTemperature: 6500
|
property int dayTemperature: 6500
|
||||||
property bool nightScheduleConfigured: false
|
property bool nightScheduleConfigured: false
|
||||||
|
|
@ -78,10 +90,14 @@ Singleton {
|
||||||
onExited: (code, status) => {
|
onExited: (code, status) => {
|
||||||
if (code !== 0 || status !== 0) {
|
if (code !== 0 || status !== 0) {
|
||||||
appearance.appearanceError = startupError.text.trim() || "Réconciliation Appearance impossible";
|
appearance.appearanceError = startupError.text.trim() || "Réconciliation Appearance impossible";
|
||||||
|
nightStartup.running = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try { appearance.acceptSnapshot(JSON.parse(startupOutput.text)); }
|
try { appearance.acceptSnapshot(JSON.parse(startupOutput.text)); }
|
||||||
catch (_) { appearance.appearanceError = "État Appearance invalide"; }
|
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) {
|
function acceptSnapshot(value) {
|
||||||
|
|
@ -90,14 +106,18 @@ Singleton {
|
||||||
|| !Number.isSafeInteger(value.revision) || value.revision < 0
|
|| !Number.isSafeInteger(value.revision) || value.revision < 0
|
||||||
|| (value.themeMode !== "manual" && value.themeMode !== "wallpaper")
|
|| (value.themeMode !== "manual" && value.themeMode !== "wallpaper")
|
||||||
|| !flavors.includes(value.manualFlavor)
|
|| !flavors.includes(value.manualFlavor)
|
||||||
|
|| !["normal", "sun-light", "sun-dark"].includes(value.effectiveMode)
|
||||||
|| !value.effectiveWallpaper) return false;
|
|| !value.effectiveWallpaper) return false;
|
||||||
// Un status démarré avant une application ne peut pas revenir en
|
// 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.
|
// arrière après la publication IPC de la révision plus récente.
|
||||||
if (value.revision < revision) return false;
|
if (value.revision < revision) return false;
|
||||||
effectiveFlavor = value.effectiveFlavor;
|
effectiveFlavor = value.effectiveFlavor;
|
||||||
effectiveAccent = value.effectiveAccent;
|
effectiveAccent = value.effectiveAccent;
|
||||||
effectiveMode = "normal";
|
effectiveMode = value.effectiveMode;
|
||||||
effectiveHighContrast = false;
|
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;
|
effectiveWallpaper = value.effectiveWallpaper;
|
||||||
wallpaperMode = "fill";
|
wallpaperMode = "fill";
|
||||||
themeMode = value.themeMode;
|
themeMode = value.themeMode;
|
||||||
|
|
@ -145,7 +165,7 @@ Singleton {
|
||||||
Process {
|
Process {
|
||||||
id: nightStartup
|
id: nightStartup
|
||||||
command: ["python", appearance.nightLightBackend, "reconcile"]
|
command: ["python", appearance.nightLightBackend, "reconcile"]
|
||||||
running: true
|
running: false
|
||||||
stdout: StdioCollector { id: nightStartupOutput; waitForEnd: true }
|
stdout: StdioCollector { id: nightStartupOutput; waitForEnd: true }
|
||||||
stderr: StdioCollector { id: nightStartupError; waitForEnd: true }
|
stderr: StdioCollector { id: nightStartupError; waitForEnd: true }
|
||||||
onExited: (code, status) => {
|
onExited: (code, status) => {
|
||||||
|
|
@ -174,7 +194,7 @@ Singleton {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function runNightLight(args) {
|
function runNightLight(args) {
|
||||||
if (nightLightBusy) return false;
|
if (nightLightBusy || appearanceBusy || locationBusy) return false;
|
||||||
nightLightBusy = true;
|
nightLightBusy = true;
|
||||||
nightLightError = "";
|
nightLightError = "";
|
||||||
nightWriter.command = ["python", nightLightBackend].concat(args);
|
nightWriter.command = ["python", nightLightBackend].concat(args);
|
||||||
|
|
@ -190,6 +210,84 @@ Singleton {
|
||||||
return runNightLight(["set-night-temperature", String(value)]);
|
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 {
|
Process {
|
||||||
id: wallpaperWriter
|
id: wallpaperWriter
|
||||||
stdout: StdioCollector { id: wallpaperOutput; waitForEnd: true }
|
stdout: StdioCollector { id: wallpaperOutput; waitForEnd: true }
|
||||||
|
|
@ -213,7 +311,7 @@ Singleton {
|
||||||
}
|
}
|
||||||
|
|
||||||
function runOperation(args, applyingWallpaper) {
|
function runOperation(args, applyingWallpaper) {
|
||||||
if (appearanceBusy) return false;
|
if (appearanceBusy || nightLightBusy || locationBusy) return false;
|
||||||
wallpaperError = "";
|
wallpaperError = "";
|
||||||
appearanceError = "";
|
appearanceError = "";
|
||||||
appearanceBusy = true;
|
appearanceBusy = true;
|
||||||
|
|
@ -237,6 +335,18 @@ Singleton {
|
||||||
|| flavor === manualFlavor && flavor === effectiveFlavor) return false;
|
|| flavor === manualFlavor && flavor === effectiveFlavor) return false;
|
||||||
return runOperation(["set-manual-flavor", flavor], 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 ;
|
// 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.
|
// il ne doit jamais créer un état QuickShell isolé de Sway/effective.json.
|
||||||
|
|
@ -254,6 +364,7 @@ Singleton {
|
||||||
effectiveAccent: effectiveAccent, effectiveWallpaper: effectiveWallpaper,
|
effectiveAccent: effectiveAccent, effectiveWallpaper: effectiveWallpaper,
|
||||||
wallpaperMode: wallpaperMode, wallpaperDirectory: wallpaperDirectory,
|
wallpaperMode: wallpaperMode, wallpaperDirectory: wallpaperDirectory,
|
||||||
themeMode: themeMode, manualFlavor: manualFlavor,
|
themeMode: themeMode, manualFlavor: manualFlavor,
|
||||||
|
sunMode: sunMode, sunVariant: sunVariant, sunDarkFlavor: sunDarkFlavor,
|
||||||
nightLightMode: nightLightMode, effectiveNightLightMode: effectiveNightLightMode,
|
nightLightMode: nightLightMode, effectiveNightLightMode: effectiveNightLightMode,
|
||||||
nightLightSuspended: nightLightSuspended, nightTemperature: nightTemperature,
|
nightLightSuspended: nightLightSuspended, nightTemperature: nightTemperature,
|
||||||
dayTemperature: dayTemperature,
|
dayTemperature: dayTemperature,
|
||||||
|
|
|
||||||
|
|
@ -54,18 +54,28 @@ Singleton {
|
||||||
readonly property color crust: palette.crust
|
readonly property color crust: palette.crust
|
||||||
|
|
||||||
// CONTRACT: l'alpha de la barre est un profil UI, jamais une entrée Catppuccin.
|
// 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 panelBackground: Qt.rgba(base.r, base.g, base.b, panelOpacity)
|
||||||
readonly property color popupBackground: base
|
readonly property color popupBackground: base
|
||||||
readonly property color buttonBackground: surface0
|
// INVARIANT: même sur hover/pressed, le texte garde au moins 4,5:1 ;
|
||||||
readonly property color buttonHover: surface1
|
// surface1/surface2 Latte et surface2 Frappé sont trop proches du texte.
|
||||||
readonly property color buttonPressed: surface2
|
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
|
readonly property color foreground: text
|
||||||
// Latte sur surface0 a besoin d'un texte plus sombre que subtext0.
|
// 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 disabledForeground: flavor === "latte" ? subtext1 : overlay0
|
||||||
readonly property color border: surface1
|
// Plusieurs contrôles historiques utilisent border comme fond de hover :
|
||||||
readonly property color strongBorder: surface2
|
// 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]
|
readonly property color accent: palette[accentName]
|
||||||
// WHY: Lavender Latte n'offre aucun texte Catppuccin à contraste 4.5:1.
|
// 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.
|
// 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 danger: red
|
||||||
readonly property color urgentForeground: flavor === "latte" ? text : peach
|
readonly property color urgentForeground: flavor === "latte" ? text : peach
|
||||||
readonly property color successForeground: flavor === "latte" ? text : green
|
readonly property color successForeground: flavor === "latte" ? text : green
|
||||||
readonly property color shadow: "#00000055"
|
readonly property color shadow: highContrast ? "#00000077" : "#00000055"
|
||||||
readonly property color separator: surface1
|
readonly property color separator: highContrast ? overlay1 : surface1
|
||||||
readonly property color inputBackground: surface0
|
readonly property color inputBackground: highContrast ? surface0 : surface0
|
||||||
readonly property color inputBorder: surface2
|
readonly property color inputBorder: highContrast ? overlay2 : surface2
|
||||||
readonly property color selectedBackground: accent
|
readonly property color selectedBackground: accent
|
||||||
readonly property color selectedForeground: onAccent
|
readonly property color selectedForeground: onAccent
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ PopupWindow {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
radius: 4
|
radius: 4
|
||||||
color: Theme.popupBackground
|
color: Theme.popupBackground
|
||||||
border.color: Theme.border
|
border.color: Theme.outline
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
id: content
|
id: content
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@
|
||||||
# Listener IPC démarré une fois par session ; exec ne se rejoue pas au reload.
|
# 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
|
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
|
# Règles d'assignation automatique
|
||||||
# =================================================
|
# =================================================
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,34 @@
|
||||||
# transparency strength in range of 0…1 or use the command line argument -o.
|
# transparency strength in range of 0…1 or use the command line argument -o.
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import i3ipc
|
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):
|
def on_window(args, ipc, event):
|
||||||
global focused_set
|
global focused_set
|
||||||
|
|
@ -20,6 +42,7 @@ def on_window(args, ipc, event):
|
||||||
# parents, so fetch the whole tree
|
# parents, so fetch the whole tree
|
||||||
tree = ipc.get_tree()
|
tree = ipc.get_tree()
|
||||||
|
|
||||||
|
args.opacity = inactive_opacity()
|
||||||
focused = tree.find_focused()
|
focused = tree.find_focused()
|
||||||
if focused is None:
|
if focused is None:
|
||||||
return
|
return
|
||||||
|
|
@ -54,6 +77,8 @@ if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="This script allows you to set the transparency of unfocused windows in sway."
|
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(
|
parser.add_argument(
|
||||||
"--opacity",
|
"--opacity",
|
||||||
"-o",
|
"-o",
|
||||||
|
|
@ -77,14 +102,16 @@ if __name__ == "__main__":
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
ipc = i3ipc.Connection()
|
ipc = i3ipc.Connection()
|
||||||
|
if args.refresh:
|
||||||
|
apply_all(ipc)
|
||||||
|
sys.exit(0)
|
||||||
focused_set = set()
|
focused_set = set()
|
||||||
|
|
||||||
for window in ipc.get_tree():
|
args.opacity = inactive_opacity()
|
||||||
if window.focused:
|
apply_all(ipc)
|
||||||
focused_set.add(window.id)
|
focused = ipc.get_tree().find_focused()
|
||||||
window.command("opacity " + args.focused)
|
if focused:
|
||||||
else:
|
focused_set.add(focused.id)
|
||||||
window.command("opacity " + args.opacity)
|
|
||||||
for sig in [signal.SIGINT, signal.SIGTERM]:
|
for sig in [signal.SIGINT, signal.SIGTERM]:
|
||||||
signal.signal(sig, lambda signal, frame: remove_opacity(ipc, args.focused))
|
signal.signal(sig, lambda signal, frame: remove_opacity(ipc, args.focused))
|
||||||
ipc.on("window", partial(on_window, args))
|
ipc.on("window", partial(on_window, args))
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,9 @@
|
||||||
output * bg $wallpaper fill
|
output * bg $wallpaper fill
|
||||||
|
|
||||||
# target title bg text indicator border
|
# target title bg text indicator border
|
||||||
client.focused $accent $base $text $rosewater $accent
|
client.focused $focused_border $base $text $rosewater $focused_border
|
||||||
client.focused_inactive $overlay0 $base $text $rosewater $overlay0
|
client.focused_inactive $unfocused_border $base $text $rosewater $unfocused_border
|
||||||
client.unfocused $overlay0 $base $text $rosewater $overlay0
|
client.unfocused $unfocused_border $base $text $rosewater $unfocused_border
|
||||||
client.urgent $peach $base $peach $overlay0 $peach
|
client.urgent $peach $base $peach $overlay0 $peach
|
||||||
client.placeholder $overlay0 $base $text $overlay0 $overlay0
|
client.placeholder $overlay0 $base $text $overlay0 $overlay0
|
||||||
client.background $base
|
client.background $base
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ animation_duration_ms 0
|
||||||
corner_radius 4
|
corner_radius 4
|
||||||
shadows enable
|
shadows enable
|
||||||
shadows_on_csd disable
|
shadows_on_csd disable
|
||||||
shadow_blur_radius 10
|
shadow_blur_radius $shadow_blur_radius
|
||||||
shadow_color $shadow_active
|
shadow_color $shadow_active
|
||||||
shadow_inactive_color $shadow_inactive
|
shadow_inactive_color $shadow_inactive
|
||||||
shadow_offset 0 2
|
shadow_offset 0 2
|
||||||
|
|
@ -20,7 +20,7 @@ blur_radius 2
|
||||||
|
|
||||||
# Namespace observé dans get_outputs ; les autres couches restent intactes.
|
# Namespace observé dans get_outputs ; les autres couches restent intactes.
|
||||||
layer_effects "labfy-sway-bar" {
|
layer_effects "labfy-sway-bar" {
|
||||||
blur enable
|
blur $bar_blur
|
||||||
blur_xray disable
|
blur_xray disable
|
||||||
blur_ignore_transparent enable
|
blur_ignore_transparent enable
|
||||||
shadows enable
|
shadows enable
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,7 @@ set $accent #b4befe
|
||||||
set $shadow_active #00000055
|
set $shadow_active #00000055
|
||||||
set $shadow_inactive #00000035
|
set $shadow_inactive #00000035
|
||||||
set $inactive_opacity 0.85
|
set $inactive_opacity 0.85
|
||||||
|
set $shadow_blur_radius 10
|
||||||
|
set $bar_blur enable
|
||||||
|
set $focused_border #b4befe
|
||||||
|
set $unfocused_border #6c7086
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,18 @@ class AppearanceGeneratorTest(unittest.TestCase):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
module.render(flavor, accent, profile)
|
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):
|
def test_atomic_replace_and_failed_replace(self):
|
||||||
with tempfile.TemporaryDirectory() as folder:
|
with tempfile.TemporaryDirectory() as folder:
|
||||||
output = Path(folder) / 'theme.conf'
|
output = Path(folder) / 'theme.conf'
|
||||||
|
|
|
||||||
27
tests/test_inactive_window_opacity.py
Normal file
27
tests/test_inactive_window_opacity.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""Réapplication événementielle de l'opacité, sans serveur Sway réel."""
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
SOURCE = Path(__file__).resolve().parents[1] / 'sway/.config/sway/scripts/inactive-windows-transparency.py'
|
||||||
|
SPEC = importlib.util.spec_from_file_location('inactive_opacity', SOURCE)
|
||||||
|
module = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(module)
|
||||||
|
|
||||||
|
|
||||||
|
class OpacityTest(unittest.TestCase):
|
||||||
|
def test_normal_sun_and_immediate_restore(self):
|
||||||
|
with tempfile.TemporaryDirectory() as folder, mock.patch.object(module, 'STATE', Path(folder) / 'effective.json'):
|
||||||
|
focused = mock.Mock(focused=True)
|
||||||
|
inactive = mock.Mock(focused=False)
|
||||||
|
ipc = mock.Mock()
|
||||||
|
ipc.get_tree.return_value.leaves.return_value = [focused, inactive]
|
||||||
|
for profile, expected in [('normal', '0.85'), ('sun-light', '1.0'),
|
||||||
|
('sun-dark', '1.0'), ('normal', '0.85')]:
|
||||||
|
module.STATE.write_text(json.dumps({'effectiveMode': profile}))
|
||||||
|
module.apply_all(ipc)
|
||||||
|
focused.command.assert_called_with('opacity 1.0')
|
||||||
|
inactive.command.assert_called_with('opacity ' + expected)
|
||||||
148
tests/test_location_time_manager.py
Normal file
148
tests/test_location_time_manager.py
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
"""Tests du contrat voyage, avec coordonnées exclusivement synthétiques."""
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
SOURCE = Path(__file__).resolve().parents[1] / 'bin/.local/bin/location-time-manager.py'
|
||||||
|
SPEC = importlib.util.spec_from_file_location('location_time_manager', SOURCE)
|
||||||
|
MODULE = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(MODULE)
|
||||||
|
|
||||||
|
|
||||||
|
class LocationTimeTests(unittest.TestCase):
|
||||||
|
def test_zone1970_and_search(self):
|
||||||
|
entries = MODULE.presets()
|
||||||
|
self.assertGreater(len(entries), 200)
|
||||||
|
self.assertTrue(MODULE.search(entries, 'Europe/Paris'))
|
||||||
|
self.assertTrue(MODULE.search(entries, 'London'))
|
||||||
|
self.assertTrue(MODULE.search(entries, 'madrid'))
|
||||||
|
self.assertEqual(MODULE.search(entries, 'lieu-introuvable'), [])
|
||||||
|
|
||||||
|
def test_zone1970_synthetic_fixture(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
fixture = Path(directory) / 'zone1970.tab'
|
||||||
|
fixture.write_text('# synthetic\nXX,YY\t+1230-04515\tEurope/Paris\tLieu synthétique\n', encoding='utf-8')
|
||||||
|
items = MODULE.presets(fixture)
|
||||||
|
self.assertEqual(len(items), 1)
|
||||||
|
self.assertEqual(items[0]['countries'], ['XX', 'YY'])
|
||||||
|
self.assertEqual(items[0]['comment'], 'Lieu synthétique')
|
||||||
|
self.assertEqual((items[0]['latitude'], items[0]['longitude']), (12.5, -45.25))
|
||||||
|
|
||||||
|
def test_invalid_timezone(self):
|
||||||
|
for value in ('', '../../etc/passwd', '/etc/passwd', 'Europe/Impossible',
|
||||||
|
'Europe/Paris\n', 'Europe/Paris\0', 'Europe/../Paris'):
|
||||||
|
with self.subTest(value=repr(value)):
|
||||||
|
self.assertFalse(MODULE.timezone_valid(value))
|
||||||
|
self.assertTrue(MODULE.timezone_valid('Europe/Paris'))
|
||||||
|
self.assertTrue(MODULE.timezone_valid('UTC'))
|
||||||
|
|
||||||
|
def test_invalid_coordinates(self):
|
||||||
|
for lat, lon in ((91, 0), (0, 181), ('nan', 0), ('', 0), ('texte', 0), (0, '')):
|
||||||
|
with self.subTest(lat=lat, lon=lon):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
MODULE.coordinates(lat, lon)
|
||||||
|
self.assertEqual(MODULE.coordinates('12.5', '-30'), (12.5, -30.0))
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
base = Path(self.temp.name)
|
||||||
|
self.config = base / 'preferences.json'
|
||||||
|
self.state = base / 'effective.json'
|
||||||
|
self.lock = base / 'wallpaper.lock'
|
||||||
|
self.config.write_text(json.dumps({'version': 4, 'nightLightMode': 'auto',
|
||||||
|
'nightTemperature': 3000, 'dayTemperature': 6500, 'scheduleType': 'solar',
|
||||||
|
'latitude': 10, 'longitude': 20}), encoding='utf-8')
|
||||||
|
self.state.write_text('{}', encoding='utf-8')
|
||||||
|
self.original = self.config.read_bytes()
|
||||||
|
for name, value in (('CONFIG', self.config), ('STATE', self.state), ('LOCK', self.lock)):
|
||||||
|
patcher = patch.object(MODULE, name, value)
|
||||||
|
patcher.start(); self.addCleanup(patcher.stop)
|
||||||
|
self.zone = ['Europe/Paris']
|
||||||
|
patcher = patch.object(MODULE, 'current_timezone', side_effect=lambda: self.zone[0])
|
||||||
|
patcher.start(); self.addCleanup(patcher.stop)
|
||||||
|
patcher = patch.object(MODULE, 'set_timezone', side_effect=lambda tz: self.zone.__setitem__(0, tz))
|
||||||
|
self.setter = patcher.start(); self.addCleanup(patcher.stop)
|
||||||
|
original_night = MODULE.night_module
|
||||||
|
def mocked_night():
|
||||||
|
night = original_night()
|
||||||
|
night.apply_service = self.service
|
||||||
|
night.verify = self.verify
|
||||||
|
return night
|
||||||
|
self.service_calls = []
|
||||||
|
self.service = lambda pref, suspended: self.service_calls.append((pref['nightLightMode'], suspended))
|
||||||
|
self.verify = lambda pref, suspended: None
|
||||||
|
patcher = patch.object(MODULE, 'night_module', side_effect=mocked_night)
|
||||||
|
patcher.start(); self.addCleanup(patcher.stop)
|
||||||
|
|
||||||
|
def test_timezone_only(self):
|
||||||
|
MODULE.apply('timezone', timezone='Europe/London')
|
||||||
|
self.assertEqual(self.zone[0], 'Europe/London')
|
||||||
|
self.assertEqual(self.config.read_bytes(), self.original)
|
||||||
|
self.assertEqual(self.service_calls, [])
|
||||||
|
|
||||||
|
def test_solar_only_auto(self):
|
||||||
|
MODULE.apply('solar', latitude=11, longitude=21)
|
||||||
|
self.assertEqual(self.zone[0], 'Europe/Paris')
|
||||||
|
self.assertEqual(self.service_calls, [('auto', False)])
|
||||||
|
pref = json.loads(self.config.read_text())
|
||||||
|
self.assertEqual((pref['latitude'], pref['longitude']), (11, 21))
|
||||||
|
|
||||||
|
def test_both_and_timezone_failure(self):
|
||||||
|
MODULE.apply('both', timezone='Europe/London', latitude=11, longitude=21)
|
||||||
|
self.assertEqual(self.zone[0], 'Europe/London')
|
||||||
|
self.assertEqual(len(self.service_calls), 1)
|
||||||
|
self.zone[0] = 'Europe/Paris'
|
||||||
|
self.config.write_bytes(self.original)
|
||||||
|
self.setter.side_effect = RuntimeError('authentification refusée')
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
MODULE.apply('both', timezone='Europe/London', latitude=11, longitude=21)
|
||||||
|
self.assertEqual(self.config.read_bytes(), self.original)
|
||||||
|
|
||||||
|
def test_rollback_on_reconcile_failure(self):
|
||||||
|
def fail(pref, suspended):
|
||||||
|
if pref['latitude'] == 11:
|
||||||
|
raise RuntimeError('service indisponible')
|
||||||
|
self.service = fail
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
MODULE.apply('both', timezone='Europe/London', latitude=11, longitude=21)
|
||||||
|
self.assertEqual(self.zone[0], 'Europe/Paris')
|
||||||
|
self.assertEqual(self.config.read_bytes(), self.original)
|
||||||
|
|
||||||
|
def test_timezone_rollback_even_if_service_rollback_fails(self):
|
||||||
|
self.service = lambda pref, suspended: (_ for _ in ()).throw(RuntimeError('service indisponible'))
|
||||||
|
with self.assertRaisesRegex(RuntimeError, 'rollback incomplet'):
|
||||||
|
MODULE.apply('both', timezone='Europe/London', latitude=11, longitude=21)
|
||||||
|
self.assertEqual(self.zone[0], 'Europe/Paris')
|
||||||
|
self.assertEqual(self.config.read_bytes(), self.original)
|
||||||
|
|
||||||
|
def test_off_on_and_sun_do_not_restart(self):
|
||||||
|
pref = json.loads(self.original)
|
||||||
|
pref['nightLightMode'] = 'off'
|
||||||
|
self.config.write_text(json.dumps(pref))
|
||||||
|
MODULE.apply('solar', latitude=11, longitude=21)
|
||||||
|
self.assertEqual(self.service_calls, [])
|
||||||
|
pref['nightLightMode'] = 'on'
|
||||||
|
self.config.write_text(json.dumps(pref))
|
||||||
|
MODULE.apply('solar', latitude=12, longitude=22)
|
||||||
|
self.assertEqual(self.service_calls, [])
|
||||||
|
pref['nightLightMode'] = 'auto'
|
||||||
|
self.config.write_text(json.dumps(pref))
|
||||||
|
self.state.write_text(json.dumps({'effectiveMode': 'sun-light'}))
|
||||||
|
MODULE.apply('solar', latitude=12, longitude=22)
|
||||||
|
self.assertEqual(self.service_calls, [])
|
||||||
|
|
||||||
|
def test_saved_location(self):
|
||||||
|
MODULE.save_location('Lieu synthétique', 'Europe/London', 11, 22)
|
||||||
|
result = json.loads(self.config.read_text())
|
||||||
|
self.assertEqual(len(result['savedLocations']), 1)
|
||||||
|
self.assertEqual(result['savedLocations'][0]['name'], 'Lieu synthétique')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
MODULE.save_location('', 'Europe/London', 11, 22)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
|
|
@ -30,13 +30,21 @@ class WallpaperManagerTest(unittest.TestCase):
|
||||||
mock.patch.object(manager, 'GENERATED', self.root / 'sway/generated/wallpaper.conf'),
|
mock.patch.object(manager, 'GENERATED', self.root / 'sway/generated/wallpaper.conf'),
|
||||||
mock.patch.object(manager, 'GENERATED_THEME', self.root / 'sway/generated/theme.conf'),
|
mock.patch.object(manager, 'GENERATED_THEME', self.root / 'sway/generated/theme.conf'),
|
||||||
mock.patch.object(manager, 'SWAY', self.root / 'sway'),
|
mock.patch.object(manager, 'SWAY', self.root / 'sway'),
|
||||||
|
mock.patch.object(manager, 'refresh_opacity'),
|
||||||
]
|
]
|
||||||
for patcher in self.patchers:
|
for patcher in self.patchers:
|
||||||
patcher.start()
|
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"
|
self.image = self.root / "fond d'écran (été).png"
|
||||||
Image.new('RGB', (640, 360), 'blue').save(self.image)
|
Image.new('RGB', (640, 360), 'blue').save(self.image)
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
|
self.night_patch.stop()
|
||||||
for patcher in reversed(self.patchers):
|
for patcher in reversed(self.patchers):
|
||||||
patcher.stop()
|
patcher.stop()
|
||||||
self.temp.cleanup()
|
self.temp.cleanup()
|
||||||
|
|
@ -65,7 +73,7 @@ class WallpaperManagerTest(unittest.TestCase):
|
||||||
manager.validate_image(path)
|
manager.validate_image(path)
|
||||||
chosen = manager.set_directory(self.root.as_uri())
|
chosen = manager.set_directory(self.root.as_uri())
|
||||||
self.assertEqual(chosen['wallpaperDirectory'], str(self.root))
|
self.assertEqual(chosen['wallpaperDirectory'], str(self.root))
|
||||||
self.assertEqual(json.loads(manager.PREFERENCES.read_text())['version'], 3)
|
self.assertEqual(json.loads(manager.PREFERENCES.read_text())['version'], 4)
|
||||||
|
|
||||||
def test_atomic_replace_keeps_old_file_on_write_failure(self):
|
def test_atomic_replace_keeps_old_file_on_write_failure(self):
|
||||||
target = self.root / 'atomic.txt'
|
target = self.root / 'atomic.txt'
|
||||||
|
|
@ -132,7 +140,7 @@ class WallpaperManagerTest(unittest.TestCase):
|
||||||
state = manager.read_json(manager.STATE)
|
state = manager.read_json(manager.STATE)
|
||||||
self.assertEqual(state['effectiveFlavor'], 'mocha')
|
self.assertEqual(state['effectiveFlavor'], 'mocha')
|
||||||
self.assertEqual(state['effectiveAccent'], 'lavender')
|
self.assertEqual(state['effectiveAccent'], 'lavender')
|
||||||
self.assertEqual(state['version'], 3)
|
self.assertEqual(state['version'], 4)
|
||||||
self.assertIn(b'set $wallpaper ', manager.GENERATED.read_bytes())
|
self.assertIn(b'set $wallpaper ', manager.GENERATED.read_bytes())
|
||||||
self.assertEqual(command.call_count, 2)
|
self.assertEqual(command.call_count, 2)
|
||||||
newer = self.root / 'autre.png'
|
newer = self.root / 'autre.png'
|
||||||
|
|
@ -203,7 +211,7 @@ class WallpaperManagerTest(unittest.TestCase):
|
||||||
'wallpaperDirectory': str(self.root), 'wallpaperMode': 'fill'})
|
'wallpaperDirectory': str(self.root), 'wallpaperMode': 'fill'})
|
||||||
with mock.patch.object(manager, 'command') as command:
|
with mock.patch.object(manager, 'command') as command:
|
||||||
result = manager.reconcile_state()
|
result = manager.reconcile_state()
|
||||||
self.assertEqual(result['version'], 3)
|
self.assertEqual(result['version'], 4)
|
||||||
self.assertEqual(result['revision'], 47)
|
self.assertEqual(result['revision'], 47)
|
||||||
self.assertEqual(result['customFutureField'], 'conservé')
|
self.assertEqual(result['customFutureField'], 'conservé')
|
||||||
self.assertEqual(result['effectiveFlavor'], 'macchiato')
|
self.assertEqual(result['effectiveFlavor'], 'macchiato')
|
||||||
|
|
@ -253,6 +261,69 @@ class WallpaperManagerTest(unittest.TestCase):
|
||||||
self.assertEqual(result['manualFlavor'], 'latte')
|
self.assertEqual(result['manualFlavor'], 'latte')
|
||||||
self.assertEqual(result['wallpaperAnalysis']['algorithmVersion'], 1)
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue