feat(appearance): add integrated wallpaper manager
This commit is contained in:
parent
9274fbfd3a
commit
51b65624e8
14 changed files with 940 additions and 23 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -7,6 +7,8 @@ hyprwhspr/.local/share/
|
|||
**/node_modules/
|
||||
sway/.config/sway/generated/theme.conf
|
||||
sway/.config/sway/generated/theme.conf.tmp
|
||||
sway/.config/sway/generated/wallpaper.conf
|
||||
sway/.config/sway/generated/wallpaper.conf.tmp
|
||||
|
||||
# Local editor/application backups
|
||||
*.bak
|
||||
|
|
|
|||
|
|
@ -98,10 +98,14 @@ def main():
|
|||
or previous.get('effectiveAccent') != args.accent
|
||||
or previous.get('effectiveMode') != 'normal')
|
||||
revision = max(0, int(previous.get('revision', 0))) + int(changed)
|
||||
state = {'version': 1, 'effectiveFlavor': args.flavor,
|
||||
'effectiveMode': 'normal', 'effectiveDark': args.flavor != 'latte',
|
||||
'effectiveHighContrast': False, 'effectiveAccent': args.accent,
|
||||
'revision': revision}
|
||||
# CONTRACT: le moteur de thème conserve les champs wallpaper
|
||||
# du schéma 2 ; changer une palette n'efface pas l'état 17C.
|
||||
state = dict(previous)
|
||||
state.update(version=max(1, int(previous.get('version', 1))),
|
||||
effectiveFlavor=args.flavor, effectiveMode='normal',
|
||||
effectiveDark=args.flavor != 'latte',
|
||||
effectiveHighContrast=False, effectiveAccent=args.accent,
|
||||
revision=revision)
|
||||
# CONTRACT: le backend persiste l'état après validation Sway ;
|
||||
# le contrôleur l'expose aux consommateurs et le lit au restart.
|
||||
atomic_write(STATE, (json.dumps(state, separators=(',', ':')) + '\n').encode('utf-8'))
|
||||
|
|
|
|||
358
bin/.local/bin/wallpaper-manager.py
Executable file
358
bin/.local/bin/wallpaper-manager.py
Executable file
|
|
@ -0,0 +1,358 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Galerie locale et publication transactionnelle du wallpaper Sway (schéma 2)."""
|
||||
import argparse
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import warnings
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
|
||||
# WHY: une image piégée ne doit pas faire exploser la mémoire du processus
|
||||
# de scan ; la limite s'applique à un fichier, pas à la taille du dossier.
|
||||
Image.MAX_IMAGE_PIXELS = 50_000_000
|
||||
warnings.simplefilter('error', Image.DecompressionBombWarning)
|
||||
|
||||
|
||||
DEFAULT = Path('/usr/share/backgrounds/sway/Sway_Wallpaper_Blue_1920x1080.png')
|
||||
SUFFIXES = {'.jpg', '.jpeg', '.png', '.webp'}
|
||||
THUMB_VERSION = 1
|
||||
PAGE_SIZE = 24
|
||||
SWAY = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'sway'
|
||||
PREFERENCES = 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'
|
||||
CACHE = Path(os.environ.get('XDG_CACHE_HOME', Path.home() / '.cache')) / 'labfy-appearance/wallpapers'
|
||||
GENERATED = SWAY / 'generated/wallpaper.conf'
|
||||
ALIASES = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'labfy-appearance/wallpapers'
|
||||
|
||||
|
||||
def atomic_write(path, data):
|
||||
"""Un rename sur le même volume préserve l'ancien fichier en cas d'échec."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, name = tempfile.mkstemp(prefix=path.name + '.', suffix='.tmp', dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, 'wb') as stream:
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(name, path)
|
||||
directory = os.open(path.parent, os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
finally:
|
||||
Path(name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def write_json(path, value):
|
||||
atomic_write(path, (json.dumps(value, ensure_ascii=False, separators=(',', ':')) + '\n').encode())
|
||||
|
||||
|
||||
def read_json(path):
|
||||
try:
|
||||
return json.loads(path.read_text(encoding='utf-8'))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def initial_directory():
|
||||
"""Préférence valide, .wallpapers, Pictures/Wallpapers, XDG Pictures, HOME."""
|
||||
pref = read_json(PREFERENCES).get('wallpaperDirectory')
|
||||
home = Path.home()
|
||||
pictures = home / 'Pictures'
|
||||
user_dirs = home / '.config/user-dirs.dirs'
|
||||
try:
|
||||
for line in user_dirs.read_text(encoding='utf-8').splitlines():
|
||||
if line.startswith('XDG_PICTURES_DIR='):
|
||||
pictures = Path(line.split('=', 1)[1].strip('"').replace('$HOME', str(home)))
|
||||
except OSError:
|
||||
pass
|
||||
for item in (pref, str(home / '.wallpapers'), str(home / 'Pictures/Wallpapers'), str(pictures), str(home)):
|
||||
if item and Path(item).is_dir():
|
||||
return str(Path(item).expanduser().resolve())
|
||||
return str(home)
|
||||
|
||||
|
||||
def validate_image(path):
|
||||
"""La cible d'un symlink doit être régulière et décodable ; l'extension seule ne suffit pas."""
|
||||
path = Path(path).expanduser()
|
||||
if path.suffix.lower() not in SUFFIXES or not path.is_file():
|
||||
raise ValueError('Fichier image absent ou format non pris en charge')
|
||||
canonical = path.resolve(strict=True)
|
||||
if not canonical.is_file():
|
||||
raise ValueError('Cible image non régulière')
|
||||
with Image.open(canonical) as image:
|
||||
image.verify()
|
||||
with Image.open(canonical) as image:
|
||||
if image.format not in {'JPEG', 'PNG', 'WEBP'} or image.width < 1 or image.height < 1:
|
||||
raise ValueError('Image invalide ou format non pris en charge')
|
||||
image.load()
|
||||
width, height, fmt = image.width, image.height, image.format
|
||||
stat = canonical.stat()
|
||||
return {'path': str(path.absolute()), 'canonicalPath': str(canonical),
|
||||
'filename': path.name, 'width': width, 'height': height, 'format': fmt,
|
||||
'size': stat.st_size, 'mtime': stat.st_mtime_ns}
|
||||
|
||||
|
||||
def cache_key(meta):
|
||||
payload = json.dumps([THUMB_VERSION, meta['canonicalPath'], meta['size'], meta['mtime']],
|
||||
ensure_ascii=False, separators=(',', ':'))
|
||||
return hashlib.sha256(payload.encode()).hexdigest()
|
||||
|
||||
|
||||
def thumbnail(meta):
|
||||
target = CACHE / (cache_key(meta) + '.png')
|
||||
if not target.is_file():
|
||||
with Image.open(meta['canonicalPath']) as image:
|
||||
image = ImageOps.exif_transpose(image)
|
||||
image.thumbnail((320, 320), Image.Resampling.LANCZOS)
|
||||
if image.mode not in ('RGB', 'RGBA'):
|
||||
image = image.convert('RGBA' if 'A' in image.getbands() else 'RGB')
|
||||
CACHE.mkdir(parents=True, exist_ok=True)
|
||||
fd, name = tempfile.mkstemp(prefix='.thumbnail-', suffix='.png', dir=CACHE)
|
||||
try:
|
||||
with os.fdopen(fd, 'wb') as stream:
|
||||
image.save(stream, format='PNG')
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(name, target)
|
||||
finally:
|
||||
Path(name).unlink(missing_ok=True)
|
||||
# Le nettoyage n'inspecte que ce cache et garde une borne locale.
|
||||
cached = list(CACHE.glob('*.png'))
|
||||
if len(cached) > 512:
|
||||
for old in sorted(cached, key=lambda p: p.stat().st_mtime_ns)[:len(cached) - 448]:
|
||||
if old != target:
|
||||
old.unlink(missing_ok=True)
|
||||
return target.as_uri()
|
||||
|
||||
|
||||
def scan(directory, page):
|
||||
if not Path(directory).is_dir():
|
||||
raise ValueError('Dossier introuvable')
|
||||
# WHY: seules 24 images par requête sont décodées ; l'UI demande la page
|
||||
# suivante sur action explicite, sans daemon, polling ni processus par delegate.
|
||||
candidates = sorted((p for p in Path(directory).iterdir() if not p.name.startswith('.')
|
||||
and p.suffix.lower() in SUFFIXES and p.is_file()),
|
||||
key=lambda p: (p.name.casefold(), p.name))
|
||||
entries = []
|
||||
offset = page * PAGE_SIZE
|
||||
for item in candidates[offset:offset + PAGE_SIZE]:
|
||||
try:
|
||||
meta = validate_image(item)
|
||||
meta['thumbnail'] = thumbnail(meta)
|
||||
entries.append(meta)
|
||||
except (OSError, ValueError, UnidentifiedImageError,
|
||||
Image.DecompressionBombError, Image.DecompressionBombWarning):
|
||||
continue
|
||||
return {'version': 1, 'directory': str(Path(directory).resolve()), 'page': page,
|
||||
'hasMore': offset + PAGE_SIZE < len(candidates), 'items': entries}
|
||||
|
||||
|
||||
def render_wallpaper(path):
|
||||
# CONTRACT: SwayFX 0.6 ne parse pas les chemins cités avec espaces.
|
||||
# Le chemin transmis ici est un alias ASCII du fichier utilisateur.
|
||||
if not re.fullmatch(r'/[A-Za-z0-9_./-]+', path):
|
||||
raise ValueError('Chemin alias non représentable dans Sway')
|
||||
return ('# Généré par wallpaper-manager.py ; ne pas éditer.\n'
|
||||
+ 'set $wallpaper ' + path + '\n').encode('utf-8')
|
||||
|
||||
|
||||
def safe_alias(meta):
|
||||
"""Lien persistant sans caractères spéciaux pour le parseur Sway."""
|
||||
ALIASES.mkdir(parents=True, exist_ok=True)
|
||||
digest = hashlib.sha256(meta['canonicalPath'].encode()).hexdigest()[:24]
|
||||
alias = ALIASES / ('image-' + digest + Path(meta['path']).suffix.lower())
|
||||
fd, temporary = tempfile.mkstemp(prefix='.link-', dir=ALIASES)
|
||||
os.close(fd)
|
||||
Path(temporary).unlink()
|
||||
try:
|
||||
os.symlink(meta['canonicalPath'], temporary)
|
||||
os.replace(temporary, alias)
|
||||
finally:
|
||||
Path(temporary).unlink(missing_ok=True)
|
||||
return str(alias)
|
||||
|
||||
|
||||
def command(*args):
|
||||
result = subprocess.run(args, capture_output=True, text=True, timeout=15, check=False)
|
||||
# WHY: SwayFX 0.6 peut renvoyer 0 avec « Error(s) loading config! ».
|
||||
if result.returncode or 'Error(s) loading config!' in result.stderr:
|
||||
raise RuntimeError((result.stderr or result.stdout).strip() or 'Commande Sway échouée')
|
||||
if args[0] == 'swaymsg':
|
||||
try:
|
||||
if any(not item.get('success', False) for item in json.loads(result.stdout)):
|
||||
raise RuntimeError('Sway a refusé le rechargement')
|
||||
except (ValueError, TypeError, AttributeError) as exc:
|
||||
raise RuntimeError('Réponse swaymsg invalide') from exc
|
||||
return result
|
||||
|
||||
|
||||
def swaybg_matches(path):
|
||||
"""Vérifie le fond réellement transmis au swaybg lancé par Sway."""
|
||||
for proc in Path('/proc').iterdir():
|
||||
if not proc.name.isdecimal():
|
||||
continue
|
||||
try:
|
||||
args = (proc / 'cmdline').read_bytes().split(b'\0')
|
||||
if args and Path(os.fsdecode(args[0])).name == 'swaybg':
|
||||
decoded = [os.fsdecode(a) for a in args]
|
||||
if '-i' in decoded and decoded[decoded.index('-i') + 1] == path and '-m' in decoded \
|
||||
and decoded[decoded.index('-m') + 1] == 'fill':
|
||||
return True
|
||||
except (OSError, IndexError):
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def active_wallpaper():
|
||||
"""Migration 17B : découvrir l'image en cours sans changer Sway."""
|
||||
for proc in Path('/proc').iterdir():
|
||||
if not proc.name.isdecimal():
|
||||
continue
|
||||
try:
|
||||
args = [os.fsdecode(a) for a in (proc / 'cmdline').read_bytes().split(b'\0')]
|
||||
if args and Path(args[0]).name == 'swaybg' and '-i' in args:
|
||||
path = Path(args[args.index('-i') + 1])
|
||||
return str(path.resolve()) if path.is_file() else str(path)
|
||||
except (OSError, IndexError):
|
||||
pass
|
||||
return str(DEFAULT)
|
||||
|
||||
|
||||
def current():
|
||||
state = read_json(STATE)
|
||||
path = state.get('effectiveWallpaper') or active_wallpaper()
|
||||
if not Path(path).is_file():
|
||||
return {'version': 2, 'effectiveWallpaper': path, 'wallpaperMissing': True,
|
||||
'fallback': str(DEFAULT), 'wallpaperDirectory': initial_directory(),
|
||||
'wallpaperMode': 'fill', 'revision': state.get('revision', 0)}
|
||||
return {'version': 2, 'effectiveWallpaper': path, 'wallpaperMissing': False,
|
||||
'wallpaperDirectory': initial_directory(), 'wallpaperMode': 'fill',
|
||||
'revision': state.get('revision', 0)}
|
||||
|
||||
|
||||
def apply(path):
|
||||
# INVARIANT: lock couvre config, reload et état ; deux appels ne s'entremêlent pas.
|
||||
STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(STATE.parent / 'wallpaper.lock', 'a+b') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
meta = validate_image(path)
|
||||
target = meta['path']
|
||||
old_config = GENERATED.read_bytes() if GENERATED.exists() else None
|
||||
old_state = STATE.read_bytes() if STATE.exists() else None
|
||||
old = read_json(STATE)
|
||||
try:
|
||||
alias = safe_alias(meta)
|
||||
if old.get('effectiveWallpaper', active_wallpaper()) == target and swaybg_matches(alias):
|
||||
return current()
|
||||
atomic_write(GENERATED, render_wallpaper(alias))
|
||||
command('sway', '--validate', '-c', str(SWAY / 'config'))
|
||||
command('swaymsg', 'reload')
|
||||
deadline = time.monotonic() + 4
|
||||
while time.monotonic() < deadline and not swaybg_matches(alias):
|
||||
time.sleep(0.1)
|
||||
if not swaybg_matches(alias):
|
||||
raise RuntimeError('Sway n’a pas appliqué le fond demandé')
|
||||
# WHY: préserver tous les champs 17B et inconnus pour la migration
|
||||
# et les futurs consommateurs ; seul le wallpaper et sa révision changent.
|
||||
new_state = dict(old)
|
||||
new_state.setdefault('effectiveFlavor', 'mocha')
|
||||
new_state.setdefault('effectiveAccent', 'lavender')
|
||||
new_state.setdefault('effectiveMode', 'normal')
|
||||
new_state.setdefault('effectiveDark', new_state['effectiveFlavor'] != 'latte')
|
||||
new_state.setdefault('effectiveHighContrast', False)
|
||||
new_state.update(version=2, effectiveWallpaper=target, wallpaperMode='fill',
|
||||
revision=max(0, int(old.get('revision', 0))) + 1)
|
||||
write_json(STATE, new_state)
|
||||
# Seul l'alias actif est requis au prochain login ; les anciens
|
||||
# liens créés par ce backend ne doivent pas s'accumuler.
|
||||
for previous_alias in ALIASES.glob('image-*'):
|
||||
if str(previous_alias) != alias and previous_alias.is_symlink():
|
||||
try:
|
||||
previous_alias.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return current()
|
||||
except Exception:
|
||||
if old_config is None:
|
||||
GENERATED.unlink(missing_ok=True)
|
||||
else:
|
||||
atomic_write(GENERATED, old_config)
|
||||
if old_state is None:
|
||||
STATE.unlink(missing_ok=True)
|
||||
else:
|
||||
atomic_write(STATE, old_state)
|
||||
command('swaymsg', 'reload')
|
||||
raise
|
||||
|
||||
|
||||
def set_directory(path):
|
||||
if path.startswith('file:'):
|
||||
uri = urlsplit(path)
|
||||
if uri.scheme != 'file' or uri.netloc not in ('', 'localhost'):
|
||||
raise ValueError('URI de dossier non locale')
|
||||
path = unquote(uri.path)
|
||||
directory = Path(path).expanduser().resolve(strict=True)
|
||||
if not directory.is_dir():
|
||||
raise ValueError('Dossier introuvable')
|
||||
pref = read_json(PREFERENCES)
|
||||
pref.update(version=1, wallpaperDirectory=str(directory), wallpaperMode='fill')
|
||||
write_json(PREFERENCES, pref)
|
||||
return {'version': 1, 'wallpaperDirectory': str(directory), 'wallpaperMode': 'fill'}
|
||||
|
||||
|
||||
def reconcile():
|
||||
"""Après suppression de l'image active, revenir explicitement au repli versionné."""
|
||||
state = current()
|
||||
if not state['wallpaperMissing']:
|
||||
return state
|
||||
missing = state['effectiveWallpaper']
|
||||
result = apply(str(DEFAULT))
|
||||
result['recoveredFromMissing'] = missing
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest='action', required=True)
|
||||
sub.add_parser('current')
|
||||
sub.add_parser('reconcile')
|
||||
scan_parser = sub.add_parser('scan')
|
||||
scan_parser.add_argument('directory')
|
||||
scan_parser.add_argument('--page', type=int, default=0)
|
||||
for action in ('validate', 'thumbnail', 'apply', 'set-directory'):
|
||||
sub.add_parser(action).add_argument('path')
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.action == 'current': result = current()
|
||||
elif args.action == 'reconcile': result = reconcile()
|
||||
elif args.action == 'scan':
|
||||
if args.page < 0: raise ValueError('Page invalide')
|
||||
result = scan(args.directory, args.page)
|
||||
elif args.action == 'validate': result = validate_image(args.path)
|
||||
elif args.action == 'thumbnail':
|
||||
result = validate_image(args.path)
|
||||
result['thumbnail'] = thumbnail(result)
|
||||
elif args.action == 'apply': result = apply(args.path)
|
||||
else: result = set_directory(args.path)
|
||||
print(json.dumps(result, ensure_ascii=False, separators=(',', ':')))
|
||||
return 0
|
||||
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired,
|
||||
UnidentifiedImageError, Image.DecompressionBombError,
|
||||
Image.DecompressionBombWarning, BlockingIOError) as exc:
|
||||
print('Erreur : ' + str(exc), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
|
|
@ -26,6 +26,8 @@ Bar
|
|||
├── Volume
|
||||
├── Brightness
|
||||
├── Power Profile
|
||||
├── Apparence
|
||||
│ └── Fond d'écran
|
||||
├── Battery limit
|
||||
└── Session
|
||||
```
|
||||
|
|
@ -50,6 +52,10 @@ le System Monitor résout dynamiquement les noeuds DRM AMD et hwmon
|
|||
`k10temp`/`amdgpu` par identité, noms et labels. Les scans Wi-Fi et Bluetooth
|
||||
ne sont possédés que par les pages ouvertes.
|
||||
|
||||
Le gestionnaire de fonds d'écran local est décrit dans
|
||||
[WALLPAPER.md](controlcenter/WALLPAPER.md). Il utilise Sway et `swaybg`, sans
|
||||
service permanent ni changement automatique du thème.
|
||||
|
||||
## Parité avec i3status-rs
|
||||
|
||||
| Ancien bloc | QuickShell |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import QtQuick
|
||||
import "../components"
|
||||
import "../theme"
|
||||
|
||||
Item {
|
||||
id: page
|
||||
signal backRequested()
|
||||
signal wallpaperRequested()
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
spacing: 14
|
||||
Row {
|
||||
spacing: 10
|
||||
ActionButton { label: ""; onClicked: page.backRequested() }
|
||||
Text { text: "Apparence"; color: Theme.foreground; font.pixelSize: 16; font.bold: true; height: 30; verticalAlignment: Text.AlignVCenter }
|
||||
}
|
||||
Rectangle {
|
||||
width: parent.width; height: 72; radius: 4
|
||||
color: pointer.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: "Fond d'écran"; color: Theme.foreground; font.pixelSize: 14; font.bold: true }
|
||||
Text { text: "Galerie locale et choix explicite"; color: Theme.secondaryForeground; font.pixelSize: 11 }
|
||||
}
|
||||
MouseArea { id: pointer; anchors.fill: parent; hoverEnabled: true; onClicked: page.wallpaperRequested() }
|
||||
}
|
||||
Rectangle {
|
||||
width: parent.width; height: 58; radius: 4; color: Theme.buttonBackground
|
||||
Text {
|
||||
anchors.left: parent.left; anchors.leftMargin: 12; anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Thème • " + AppearanceController.effectiveFlavor + " / " + AppearanceController.effectiveAccent
|
||||
color: Theme.foreground; font.pixelSize: 12
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,12 @@ PopupWindow {
|
|||
id: popup
|
||||
|
||||
required property var barWindow
|
||||
property int currentPage: 0 // 0 = MAIN, 1 = Wi-Fi, 2 = Bluetooth, 3 = Session, 4 = confirmation, 5 = Batterie.
|
||||
// CONTRACT: indices externes historiques conservés ; tout nouveau routage
|
||||
// passe par ces noms afin de ne pas multiplier les indices StackLayout.
|
||||
readonly property var pages: ({ main: 0, wifi: 1, bluetooth: 2, session: 3,
|
||||
confirmation: 4, battery: 5, appearance: 6, wallpaper: 7 })
|
||||
property int currentPage: pages.main
|
||||
function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; }
|
||||
signal thresholdApplied()
|
||||
property var pendingAction: null
|
||||
// Une seule table associe les libellés, confirmations et commandes de session.
|
||||
|
|
@ -38,7 +43,7 @@ PopupWindow {
|
|||
|
||||
// Une nouvelle ouverture recommence sur MAIN ; les pages libèrent leurs scans à la fermeture.
|
||||
onVisibleChanged: if (!visible) {
|
||||
currentPage = 0;
|
||||
openPage("main");
|
||||
pendingAction = null;
|
||||
}
|
||||
|
||||
|
|
@ -50,7 +55,7 @@ PopupWindow {
|
|||
return;
|
||||
}
|
||||
pendingAction = selected;
|
||||
currentPage = 4;
|
||||
openPage("confirmation");
|
||||
}
|
||||
|
||||
function executeSessionAction(id) {
|
||||
|
|
@ -72,7 +77,8 @@ PopupWindow {
|
|||
implicitHeight: currentPage === 0 ? mainPage.implicitHeight + 32
|
||||
: currentPage === 3 ? sessionPage.implicitHeight + 32
|
||||
: currentPage === 4 ? confirmationPage.implicitHeight + 32
|
||||
: currentPage === 5 ? batteryPage.implicitHeight + 32 : 440
|
||||
: currentPage === 5 ? batteryPage.implicitHeight + 32
|
||||
: currentPage === 7 ? 550 : 440
|
||||
visible: false
|
||||
// CONTRACT: PopupWindow n'applique un changement de grabFocus qu'après
|
||||
// fermeture/réouverture ; le prendre dès MAIN garde le clavier disponible
|
||||
|
|
@ -109,12 +115,12 @@ PopupWindow {
|
|||
WifiTile {
|
||||
width: (parent.width - parent.spacing) / 2
|
||||
wifiDevice: popup.wifiDevice
|
||||
onDetailsRequested: popup.currentPage = 1
|
||||
onDetailsRequested: popup.openPage("wifi")
|
||||
}
|
||||
BluetoothTile {
|
||||
width: (parent.width - parent.spacing) / 2
|
||||
adapter: popup.adapter
|
||||
onDetailsRequested: popup.currentPage = 2
|
||||
onDetailsRequested: popup.openPage("bluetooth")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -122,6 +128,14 @@ PopupWindow {
|
|||
BrightnessSlider { width: parent.width }
|
||||
PowerProfile { width: parent.width }
|
||||
|
||||
Rectangle {
|
||||
width: parent.width; height: 38; radius: 4
|
||||
color: appearancePointer.containsMouse ? Theme.buttonHover : Theme.buttonBackground
|
||||
NerdIcon { anchors.left: parent.left; anchors.leftMargin: 10; anchors.verticalCenter: parent.verticalCenter; text: ""; color: Theme.accent; font.pixelSize: 18 }
|
||||
Text { anchors.left: parent.left; anchors.leftMargin: 42; anchors.verticalCenter: parent.verticalCenter; text: "Apparence"; color: Theme.foreground; font.pixelSize: 13 }
|
||||
MouseArea { id: appearancePointer; anchors.fill: parent; hoverEnabled: true; onClicked: popup.openPage("appearance") }
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 30
|
||||
|
|
@ -142,7 +156,7 @@ PopupWindow {
|
|||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton
|
||||
onClicked: popup.currentPage = 3
|
||||
onClicked: popup.openPage("session")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -152,20 +166,20 @@ PopupWindow {
|
|||
id: wifiPage
|
||||
wifiDevice: popup.wifiDevice
|
||||
activePage: popup.visible && popup.currentPage === 1
|
||||
onBackRequested: popup.currentPage = 0
|
||||
onBackRequested: popup.openPage("main")
|
||||
}
|
||||
|
||||
BluetoothPage {
|
||||
id: bluetoothPage
|
||||
adapter: popup.adapter
|
||||
activePage: popup.visible && popup.currentPage === 2
|
||||
onBackRequested: popup.currentPage = 0
|
||||
onBackRequested: popup.openPage("main")
|
||||
}
|
||||
|
||||
SessionPage {
|
||||
id: sessionPage
|
||||
actions: popup.sessionActions
|
||||
onBackRequested: popup.currentPage = 0
|
||||
onBackRequested: popup.openPage("main")
|
||||
onActionRequested: id => popup.requestSessionAction(id)
|
||||
}
|
||||
|
||||
|
|
@ -175,7 +189,7 @@ PopupWindow {
|
|||
description: "", accent: Theme.foreground })
|
||||
onCancelled: {
|
||||
popup.pendingAction = null;
|
||||
popup.currentPage = 3;
|
||||
popup.openPage("session");
|
||||
}
|
||||
onConfirmed: id => {
|
||||
if (popup.pendingAction && popup.pendingAction.id === id)
|
||||
|
|
@ -186,9 +200,19 @@ PopupWindow {
|
|||
BatterySettings {
|
||||
id: batteryPage
|
||||
activePage: popup.visible && popup.currentPage === 5
|
||||
onBackRequested: popup.currentPage = 0
|
||||
onBackRequested: popup.openPage("main")
|
||||
onThresholdApplied: popup.thresholdApplied()
|
||||
}
|
||||
|
||||
AppearancePage {
|
||||
onBackRequested: popup.openPage("main")
|
||||
onWallpaperRequested: popup.openPage("wallpaper")
|
||||
}
|
||||
|
||||
WallpaperPage {
|
||||
activePage: popup.visible && popup.currentPage === popup.pages.wallpaper
|
||||
onBackRequested: popup.openPage("appearance")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
# Fond d'écran 17C
|
||||
|
||||
Réglages rapides → Apparence → Fond d'écran utilise `FolderDialog` de
|
||||
`QtQuick.Dialogs`, disponible dans le Qt local. Le choix d'un dossier enregistre
|
||||
uniquement la préférence et relance le scan. Un clic ou Précédent/Suivant ne
|
||||
fait que sélectionner et prévisualiser ; seul Appliquer passe par
|
||||
`AppearanceController.applyWallpaper(path)`.
|
||||
|
||||
`bin/.local/bin/wallpaper-manager.py` expose `current`, `scan DIRECTORY
|
||||
--page N`, `validate FILE`, `thumbnail FILE`, `set-directory DIRECTORY`,
|
||||
`apply FILE` et `reconcile`. Les réponses réussies sont des objets JSON sur
|
||||
stdout ; les erreurs vont sur stderr avec code non nul. Le backend n'est pas
|
||||
un daemon. Chaque page charge au plus 24 images, y compris ses miniatures ;
|
||||
« Voir plus » demande la page suivante. Les fichiers cassés sont sautés et
|
||||
une extension ne suffit pas : Pillow doit ouvrir et décoder l'image. Formats
|
||||
validés : JPEG, PNG et WebP. Les chemins via symlink restent acceptés.
|
||||
|
||||
Le manifeste contient `path`, `filename`, `thumbnail`, `width`, `height`,
|
||||
`format`, `size`, `mtime` et `canonicalPath`. Le cache dans
|
||||
`$XDG_CACHE_HOME/labfy-appearance/wallpapers/` contient des PNG de 320 px
|
||||
maximum, créés par fichier temporaire puis `os.replace`. La clé SHA-256
|
||||
comprend le chemin canonique, la taille, `mtime_ns` et la version du schéma
|
||||
thumbnail. Au delà de 512 PNG, le backend supprime seulement les plus anciens
|
||||
de ce cache jusqu'à 448 entrées ; aucune maintenance périodique n'est lancée.
|
||||
|
||||
Le dossier initial suit cet ordre : dernière préférence valide dans
|
||||
`$XDG_CONFIG_HOME/labfy-appearance/preferences.json`, `~/.wallpapers`,
|
||||
`~/Pictures/Wallpapers`, `XDG_PICTURES_DIR`, puis `$HOME`. Aucun dossier n'est
|
||||
créé par cette recherche. Le mode de rendu reste `fill`.
|
||||
|
||||
Le fichier runtime `generated/wallpaper.conf` est distinct de
|
||||
`generated/theme.conf` et n'est pas versionné. Le repli versionné Sway est
|
||||
`wallpaper-default.conf`. SwayFX 0.6 ne lit pas correctement un chemin avec
|
||||
espaces dans `output bg`, même cité. Le backend garde donc le chemin original
|
||||
dans l'état utilisateur et crée atomiquement un symlink persistant avec un nom
|
||||
ASCII sûr dans `$XDG_CONFIG_HOME/labfy-appearance/wallpapers/`. Sway lit cet
|
||||
alias. Le chemin de l'utilisateur n'est jamais une commande shell.
|
||||
|
||||
L'application tient un verrou non bloquant, vérifie le décodage, écrit
|
||||
`wallpaper.conf` par temp/flush/fsync/replace, lance `sway --validate`,
|
||||
`swaymsg reload`, puis vérifie le `swaybg` effectif avant de publier
|
||||
`$XDG_STATE_HOME/labfy-appearance/effective.json` en schéma 2. En cas d'échec,
|
||||
elle restaure la configuration et l'état précédents puis recharge Sway.
|
||||
Les champs 17B de thème restent intacts ; la révision augmente une fois par
|
||||
application réussie et jamais par sélection. Les états 17B sans wallpaper
|
||||
sont lus avec le fond actif de `swaybg` comme valeur initiale.
|
||||
|
||||
Si l'image courante disparaît, `current` signale `wallpaperMissing` sans
|
||||
interrompre le rendu déjà présent. `reconcile` restaure le repli versionné au
|
||||
prochain appel explicite ; un nouvel `apply` valide peut aussi la remplacer.
|
||||
Le thème, Night Light et Sun Mode ne sont jamais calculés depuis l'image en
|
||||
17C. L'état final validé de ce lot reste Mocha/Lavender et le fond Sway bleu.
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
import QtQuick
|
||||
import QtQuick.Dialogs
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import "../theme"
|
||||
|
||||
Item {
|
||||
id: page
|
||||
required property bool activePage
|
||||
signal backRequested()
|
||||
property var items: []
|
||||
property var selected: null
|
||||
property bool selectionValid: false
|
||||
property var currentMeta: null
|
||||
property string directory: ""
|
||||
property string feedback: ""
|
||||
property bool hasMore: false
|
||||
property bool scanning: false
|
||||
property int nextPage: 0
|
||||
readonly property string backend: AppearanceController.wallpaperBackend
|
||||
readonly property var previewMeta: selected || currentMeta
|
||||
onSelectedChanged: {
|
||||
selectionValid = false;
|
||||
if (validationProcess.running) validationProcess.running = false;
|
||||
if (selected) {
|
||||
validationProcess.command = ["python", backend, "validate", selected.path];
|
||||
validationProcess.running = true;
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
currentProcess.command = ["python", backend, "current"];
|
||||
currentProcess.running = true;
|
||||
refreshCurrent();
|
||||
}
|
||||
function refreshCurrent() {
|
||||
currentImage.command = ["python", backend, "thumbnail", AppearanceController.effectiveWallpaper];
|
||||
currentImage.running = true;
|
||||
}
|
||||
function scan(reset) {
|
||||
if (!directory) return;
|
||||
if (reset) { items = []; selected = null; nextPage = 0; hasMore = false; }
|
||||
if (scanProcess.running) scanProcess.running = false;
|
||||
scanning = true;
|
||||
scanProcess.command = ["python", backend, "scan", directory, "--page", String(nextPage)];
|
||||
scanProcess.running = true;
|
||||
}
|
||||
function changeDirectory(url) {
|
||||
// CONTRACT: le picker ne sélectionne ni n'applique de wallpaper.
|
||||
if (scanProcess.running) scanProcess.running = false;
|
||||
directoryProcess.command = ["python", backend, "set-directory", url.toString()];
|
||||
directoryProcess.running = true;
|
||||
}
|
||||
function move(step) {
|
||||
const index = items.findIndex(item => selected && item.path === selected.path);
|
||||
const next = index + step;
|
||||
if (next >= 0 && next < items.length) selected = items[next];
|
||||
else if (index < 0 && items.length) selected = items[step > 0 ? 0 : items.length - 1];
|
||||
}
|
||||
onActivePageChanged: {
|
||||
if (activePage) start();
|
||||
else {
|
||||
if (scanProcess.running) scanProcess.running = false;
|
||||
if (validationProcess.running) validationProcess.running = false;
|
||||
if (currentImage.running) currentImage.running = false;
|
||||
if (currentProcess.running) currentProcess.running = false;
|
||||
}
|
||||
}
|
||||
Connections {
|
||||
target: AppearanceController
|
||||
function onWallpaperApplied() { page.feedback = "Fond d'écran appliqué"; page.refreshCurrent(); }
|
||||
}
|
||||
|
||||
Process {
|
||||
id: validationProcess
|
||||
stdout: StdioCollector { id: validationOutput; waitForEnd: true }
|
||||
onExited: (code, status) => {
|
||||
if (!page.activePage || !page.selected || code !== 0 || status !== 0) return;
|
||||
try {
|
||||
const result = JSON.parse(validationOutput.text);
|
||||
page.selectionValid = result.path === page.selected.path;
|
||||
} catch (_) { page.selectionValid = false; }
|
||||
}
|
||||
}
|
||||
Process {
|
||||
id: currentProcess
|
||||
stdout: StdioCollector { id: currentOutput; waitForEnd: true }
|
||||
stderr: StdioCollector { waitForEnd: true }
|
||||
onExited: (code, status) => {
|
||||
if (!page.activePage || code !== 0 || status !== 0) return;
|
||||
try {
|
||||
const data = JSON.parse(currentOutput.text);
|
||||
AppearanceController.effectiveWallpaper = data.effectiveWallpaper;
|
||||
page.directory = data.wallpaperDirectory;
|
||||
AppearanceController.wallpaperDirectory = data.wallpaperDirectory;
|
||||
page.feedback = data.wallpaperMissing ? "Fond actuel introuvable : le prochain choix utilisera le repli." : "";
|
||||
page.scan(true);
|
||||
} catch (_) { page.feedback = "État des fonds d'écran indisponible"; }
|
||||
}
|
||||
}
|
||||
Process {
|
||||
id: currentImage
|
||||
stdout: StdioCollector { id: imageOutput; waitForEnd: true }
|
||||
onExited: (code, status) => {
|
||||
if (!page.activePage || code !== 0 || status !== 0) return;
|
||||
try { page.currentMeta = JSON.parse(imageOutput.text); }
|
||||
catch (_) { page.currentMeta = null; }
|
||||
}
|
||||
}
|
||||
Process {
|
||||
id: scanProcess
|
||||
stdout: StdioCollector { id: scanOutput; waitForEnd: true }
|
||||
stderr: StdioCollector { id: scanError; waitForEnd: true }
|
||||
onExited: (code, status) => {
|
||||
if (!page.activePage) return;
|
||||
page.scanning = false;
|
||||
if (code !== 0 || status !== 0) { page.feedback = scanError.text.trim() || "Lecture du dossier impossible"; return; }
|
||||
try {
|
||||
const data = JSON.parse(scanOutput.text);
|
||||
if (data.directory !== page.directory && data.directory !== page.directory.replace(/\/$/, "")) return;
|
||||
page.items = page.items.concat(data.items);
|
||||
page.nextPage = data.page + 1;
|
||||
page.hasMore = data.hasMore;
|
||||
} catch (_) { page.feedback = "Galerie invalide"; }
|
||||
}
|
||||
}
|
||||
Process {
|
||||
id: directoryProcess
|
||||
stdout: StdioCollector { id: directoryOutput; waitForEnd: true }
|
||||
stderr: StdioCollector { id: directoryError; waitForEnd: true }
|
||||
onExited: (code, status) => {
|
||||
if (!page.activePage) return;
|
||||
if (code !== 0 || status !== 0) { page.feedback = directoryError.text.trim() || "Dossier invalide"; return; }
|
||||
try {
|
||||
const data = JSON.parse(directoryOutput.text);
|
||||
page.directory = data.wallpaperDirectory;
|
||||
AppearanceController.wallpaperDirectory = page.directory;
|
||||
page.feedback = "";
|
||||
page.scan(true);
|
||||
} catch (_) { page.feedback = "Dossier invalide"; }
|
||||
}
|
||||
}
|
||||
FolderDialog {
|
||||
id: folderPicker
|
||||
title: "Choisir un dossier de fonds d'écran"
|
||||
onAccepted: page.changeDirectory(selectedFolder)
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
spacing: 8
|
||||
Row {
|
||||
spacing: 7; height: 30
|
||||
ActionButton { label: ""; onClicked: page.backRequested() }
|
||||
Text { text: "Fond d'écran"; height: 30; verticalAlignment: Text.AlignVCenter; color: Theme.foreground; font.pixelSize: 16; font.bold: true }
|
||||
}
|
||||
Text { text: page.selected ? "APERÇU DE LA SÉLECTION" : "APERÇU ACTUEL"; color: Theme.accentForeground; font.bold: true; font.pixelSize: 11 }
|
||||
Rectangle {
|
||||
width: parent.width; height: 110; radius: 4; color: Theme.buttonBackground
|
||||
Image {
|
||||
anchors.fill: parent; anchors.margins: 3
|
||||
source: page.previewMeta ? page.previewMeta.thumbnail : ""
|
||||
fillMode: Image.PreserveAspectFit; asynchronous: true; sourceSize.width: 320; sourceSize.height: 320
|
||||
}
|
||||
}
|
||||
Text {
|
||||
width: parent.width; text: page.previewMeta
|
||||
? page.previewMeta.filename + " • " + page.previewMeta.width + " × " + page.previewMeta.height + " • " + page.previewMeta.format
|
||||
: AppearanceController.effectiveWallpaper
|
||||
elide: Text.ElideMiddle; color: Theme.secondaryForeground; font.pixelSize: 10
|
||||
}
|
||||
Row {
|
||||
width: parent.width; spacing: 6; height: 30
|
||||
Text { text: "GALERIE"; width: parent.width - 100; height: 30; verticalAlignment: Text.AlignVCenter; color: Theme.accentForeground; font.bold: true; font.pixelSize: 11 }
|
||||
ActionButton { label: "Dossier…"; onClicked: folderPicker.open() }
|
||||
}
|
||||
Text { width: parent.width; text: page.directory; elide: Text.ElideMiddle; color: Theme.secondaryForeground; font.pixelSize: 10 }
|
||||
GridView {
|
||||
id: gallery
|
||||
width: parent.width; height: 158; clip: true
|
||||
cellWidth: Math.floor(width / 3); cellHeight: 78
|
||||
model: page.items
|
||||
delegate: Rectangle {
|
||||
required property var modelData
|
||||
readonly property bool chosen: page.selected && page.selected.path === modelData.path
|
||||
readonly property bool active: AppearanceController.effectiveWallpaper === modelData.path
|
||||
width: gallery.cellWidth - 5; height: gallery.cellHeight - 5; radius: 4
|
||||
color: Theme.buttonBackground
|
||||
border.width: chosen ? 2 : 1; border.color: chosen ? Theme.accent : Theme.border
|
||||
Image {
|
||||
anchors.fill: parent; anchors.margins: 3
|
||||
source: modelData.thumbnail; fillMode: Image.PreserveAspectFit
|
||||
asynchronous: true; sourceSize.width: 160; sourceSize.height: 160
|
||||
}
|
||||
Rectangle {
|
||||
visible: parent.active; anchors.right: parent.right; anchors.top: parent.top
|
||||
width: 20; height: 20; radius: 4; color: Theme.accent
|
||||
Text { anchors.centerIn: parent; text: "✓"; color: Theme.onAccent; font.pixelSize: 13; font.bold: true }
|
||||
}
|
||||
MouseArea { anchors.fill: parent; onClicked: page.selected = modelData }
|
||||
}
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: !page.scanning && page.items.length === 0
|
||||
text: "Aucun fond d'écran trouvé"; color: Theme.secondaryForeground; font.pixelSize: 11
|
||||
}
|
||||
}
|
||||
ActionButton { visible: page.hasMore; label: page.scanning ? "Chargement…" : "Voir plus"; enabled: !page.scanning; onClicked: page.scan(false) }
|
||||
Text {
|
||||
width: parent.width; text: page.selected
|
||||
? page.selected.filename + " • " + page.selected.width + " × " + page.selected.height + " • " + page.selected.format
|
||||
: "Choisir une miniature pour l'aperçu"
|
||||
elide: Text.ElideMiddle; color: Theme.foreground; font.pixelSize: 11
|
||||
}
|
||||
Row {
|
||||
spacing: 7
|
||||
ActionButton { label: "Précédent"; enabled: page.items.length > 0; onClicked: page.move(-1) }
|
||||
ActionButton { label: "Suivant"; enabled: page.items.length > 0; onClicked: page.move(1) }
|
||||
ActionButton {
|
||||
label: AppearanceController.wallpaperApplying ? "Application…" : "Appliquer"
|
||||
enabled: !!page.selected && page.selectionValid && !AppearanceController.wallpaperApplying
|
||||
&& page.selected.path !== AppearanceController.effectiveWallpaper
|
||||
onClicked: { page.feedback = ""; AppearanceController.applyWallpaper(page.selected.path); }
|
||||
}
|
||||
}
|
||||
Text {
|
||||
width: parent.width; visible: !!page.feedback || !!AppearanceController.wallpaperError
|
||||
text: AppearanceController.wallpaperError || page.feedback
|
||||
color: AppearanceController.wallpaperError ? Theme.warningForeground : Theme.successForeground
|
||||
wrapMode: Text.Wrap; font.pixelSize: 11
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@ Singleton {
|
|||
// 17B ne définit que le mode normal ; les décisions Sun arrivent après.
|
||||
readonly property var flavors: ["latte", "frappe", "macchiato", "mocha"]
|
||||
readonly property var accents: ["rosewater", "flamingo", "pink", "mauve", "red", "maroon", "peach", "yellow", "green", "teal", "sky", "sapphire", "blue", "lavender"]
|
||||
readonly property string defaultWallpaper: "/usr/share/backgrounds/sway/Sway_Wallpaper_Blue_1920x1080.png"
|
||||
readonly property string wallpaperBackend: (Quickshell.env("HOME") || "/home/fy59") + "/.local/bin/wallpaper-manager.py"
|
||||
// CONTRACT: le backend écrit cet état atomiquement après validation Sway.
|
||||
// Le repli Mocha fonctionne avant la première génération ; aucune lecture
|
||||
// par frame ni polling. Les futurs adaptateurs lisent cet état via l'IPC.
|
||||
|
|
@ -22,7 +24,7 @@ Singleton {
|
|||
function initialState() {
|
||||
try {
|
||||
const value = JSON.parse(stateFile.text());
|
||||
if (value.version === 1 && flavors.includes(value.effectiveFlavor)
|
||||
if ((value.version === 1 || value.version === 2) && flavors.includes(value.effectiveFlavor)
|
||||
&& accents.includes(value.effectiveAccent)
|
||||
&& value.effectiveMode === "normal") return value;
|
||||
} catch (_) {}
|
||||
|
|
@ -36,6 +38,44 @@ Singleton {
|
|||
property string effectiveAccent: initial.effectiveAccent
|
||||
property int revision: Number.isSafeInteger(initial.revision) && initial.revision >= 0
|
||||
? initial.revision : 0
|
||||
// CONTRACT: la sélection QML ne touche pas à ces propriétés ; le backend
|
||||
// ne publie le chemin et la révision qu'après reload et vérification swaybg.
|
||||
property string effectiveWallpaper: initial.effectiveWallpaper || defaultWallpaper
|
||||
property string wallpaperMode: "fill"
|
||||
property string wallpaperDirectory: ""
|
||||
property bool wallpaperApplying: false
|
||||
property string wallpaperError: ""
|
||||
signal wallpaperApplied()
|
||||
|
||||
Process {
|
||||
id: wallpaperWriter
|
||||
stdout: StdioCollector { id: wallpaperOutput; waitForEnd: true }
|
||||
stderr: StdioCollector { id: wallpaperErrors; waitForEnd: true }
|
||||
onExited: (code, status) => {
|
||||
appearance.wallpaperApplying = false;
|
||||
if (code !== 0 || status !== 0) {
|
||||
appearance.wallpaperError = wallpaperErrors.text.trim() || "Application impossible";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const state = JSON.parse(wallpaperOutput.text);
|
||||
if (!state.effectiveWallpaper || !Number.isSafeInteger(state.revision)) throw new Error("État invalide");
|
||||
appearance.effectiveWallpaper = state.effectiveWallpaper;
|
||||
appearance.wallpaperMode = state.wallpaperMode;
|
||||
appearance.revision = state.revision;
|
||||
appearance.wallpaperApplied();
|
||||
} catch (_) { appearance.wallpaperError = "Réponse wallpaper invalide"; }
|
||||
}
|
||||
}
|
||||
|
||||
function applyWallpaper(path) {
|
||||
if (wallpaperApplying || !path || path === effectiveWallpaper) return false;
|
||||
wallpaperError = "";
|
||||
wallpaperApplying = true;
|
||||
wallpaperWriter.command = ["python", wallpaperBackend, "apply", path];
|
||||
wallpaperWriter.running = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// INVARIANT: une paire refusée ne change aucun champ ni la révision.
|
||||
// La révision avance une seule fois après tout changement effectif.
|
||||
|
|
@ -55,6 +95,8 @@ Singleton {
|
|||
return JSON.stringify({ effectiveFlavor: effectiveFlavor,
|
||||
effectiveMode: effectiveMode, effectiveDark: effectiveDark,
|
||||
effectiveHighContrast: effectiveHighContrast,
|
||||
effectiveAccent: effectiveAccent, revision: revision });
|
||||
effectiveAccent: effectiveAccent, effectiveWallpaper: effectiveWallpaper,
|
||||
wallpaperMode: wallpaperMode, wallpaperDirectory: wallpaperDirectory,
|
||||
revision: revision });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ Lavender. Les séries CPU/RAM du System Monitor restent intentionnellement
|
|||
Mauve. Ce choix rend les contrôles historiques Mauve légèrement plus bleus
|
||||
qu'avant. Les trois anciens Lavender restent l'accent principal.
|
||||
|
||||
L'API pour 17C est `AppearanceController.applyTheme(flavor, accent)` ; l'IPC
|
||||
L'API de thème est `AppearanceController.applyTheme(flavor, accent)` ; l'IPC
|
||||
`qs -c labfy-sway ipc call appearance effectiveState` expose l'état complet
|
||||
et `qs -c labfy-sway ipc call appearance setTheme mocha lavender` change
|
||||
QuickShell en direct. La commande unifiée depuis le dépôt est :
|
||||
|
|
@ -56,8 +56,11 @@ python bin/.local/bin/generate-appearance.py latte --accent lavender --apply
|
|||
Elle écrit atomiquement le fichier runtime Sway, valide la configuration,
|
||||
recharge Sway, persiste l'état effectif, puis met à jour QuickShell. En cas
|
||||
d'échec elle restaure la dernière génération valide, l'état persistant et
|
||||
l'état QuickShell précédent. Aucune décision automatique de profil ni gestion
|
||||
du wallpaper n'existe encore dans le QML.
|
||||
l'état QuickShell précédent. Aucune décision automatique de profil n'existe
|
||||
encore. Le wallpaper 17C est séparé dans `generated/wallpaper.conf` ; voir
|
||||
[WALLPAPER.md](../controlcenter/WALLPAPER.md). `generate-appearance.py`
|
||||
préserve les champs wallpaper du schéma effectif 2 lors d'un changement de
|
||||
thème.
|
||||
Le repli versionné `sway/theme-default.conf` fournit Mocha même avant toute
|
||||
génération. Ses quelques variables sont un artefact dérivé, pas une palette
|
||||
complète indépendante. `sway/style` inclut le repli, puis un glob de fichier
|
||||
|
|
|
|||
|
|
@ -5,6 +5,14 @@ Les couleurs et variables d'apparence viennent du
|
|||
`theme-default.conf` assure le démarrage Mocha avant génération ;
|
||||
`generated/theme.conf` est l'état runtime créé atomiquement par
|
||||
`bin/.local/bin/generate-appearance.py` depuis la palette QuickShell.
|
||||
`wallpaper-default.conf` définit le repli bleu `fill` ;
|
||||
`generated/wallpaper.conf` redéfinit seulement `$wallpaper`. Une unique
|
||||
directive `output * bg $wallpaper fill` applique le résultat. Le glob
|
||||
`generated/*.conf` peut être vide et le repli reste valide. Les chemins
|
||||
utilisateur contenant des espaces ou accents sont représentés par un lien
|
||||
symbolique persistant à nom ASCII dans
|
||||
`$XDG_CONFIG_HOME/labfy-appearance/wallpapers/`, car SwayFX 0.6 refuse
|
||||
ces chemins dans une directive `output bg`, même entre guillemets.
|
||||
|
||||
Le compositeur actif est **SwayFX 0.6** avec `scenefx`. La session UWSM
|
||||
sélectionne `sway.desktop` et charge `config` : `bind`, `input`, `rules`,
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
### Layout
|
||||
#
|
||||
|
||||
# Default wallpaper (more resolutions are available in /usr/share/backgrounds/sway/)
|
||||
output * bg /usr/share/backgrounds/sway/Sway_Wallpaper_Blue_1920x1080.png fill
|
||||
# CONTRACT: le repli versionné reste valide si le fichier runtime est absent.
|
||||
include ~/.config/sway/wallpaper-default.conf
|
||||
# Seul écran utilisé : origine logique stable pour les fenêtres et la barre.
|
||||
output eDP-1 mode 3200x2000@60Hz pos 0 0 scale 1.4
|
||||
|
||||
|
|
@ -14,6 +14,8 @@
|
|||
# Le fichier runtime, s'il existe, remplace les variables sans palette parallèle.
|
||||
include ~/.config/sway/theme-default.conf
|
||||
include ~/.config/sway/generated/*.conf
|
||||
# INVARIANT: une seule directive output lit la dernière variable wallpaper.
|
||||
output * bg $wallpaper fill
|
||||
|
||||
# target title bg text indicator border
|
||||
client.focused $accent $base $text $rosewater $accent
|
||||
|
|
|
|||
2
sway/.config/sway/wallpaper-default.conf
Normal file
2
sway/.config/sway/wallpaper-default.conf
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Repli wallpaper 17C : ne dépend pas de l'état utilisateur généré.
|
||||
set $wallpaper /usr/share/backgrounds/sway/Sway_Wallpaper_Blue_1920x1080.png
|
||||
142
tests/test_wallpaper_manager.py
Normal file
142
tests/test_wallpaper_manager.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
"""Contrats locaux du gestionnaire wallpaper, sans recharger la session réelle."""
|
||||
import importlib.util
|
||||
import fcntl
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
SOURCE = Path(__file__).resolve().parents[1] / 'bin/.local/bin/wallpaper-manager.py'
|
||||
SPEC = importlib.util.spec_from_file_location('wallpaper_manager', SOURCE)
|
||||
manager = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(manager)
|
||||
|
||||
|
||||
class WallpaperManagerTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
self.patchers = [
|
||||
mock.patch.object(manager, 'CACHE', self.root / 'cache'),
|
||||
mock.patch.object(manager, 'ALIASES', self.root / 'aliases'),
|
||||
mock.patch.object(manager, 'PREFERENCES', self.root / 'config/preferences.json'),
|
||||
mock.patch.object(manager, 'STATE', self.root / 'state/effective.json'),
|
||||
mock.patch.object(manager, 'GENERATED', self.root / 'sway/generated/wallpaper.conf'),
|
||||
mock.patch.object(manager, 'SWAY', self.root / 'sway'),
|
||||
]
|
||||
for patcher in self.patchers:
|
||||
patcher.start()
|
||||
self.image = self.root / "fond d'écran (été).png"
|
||||
Image.new('RGB', (640, 360), 'blue').save(self.image)
|
||||
|
||||
def tearDown(self):
|
||||
for patcher in reversed(self.patchers):
|
||||
patcher.stop()
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_scan_thumbnail_and_cache_key(self):
|
||||
bad = self.root / 'cassé.png'
|
||||
bad.write_text('pas une image')
|
||||
(self.root / 'notes.json').write_text('{}')
|
||||
link = self.root / 'lien.png'
|
||||
link.symlink_to(self.image)
|
||||
result = manager.scan(self.root, 0)
|
||||
self.assertEqual({x['filename'] for x in result['items']}, {self.image.name, link.name})
|
||||
self.assertEqual(len({x['thumbnail'] for x in result['items']}), 1)
|
||||
meta = manager.validate_image(self.image)
|
||||
with Image.open(Path(manager.thumbnail(meta).removeprefix('file://'))) as thumb:
|
||||
self.assertEqual(thumb.size, (320, 180))
|
||||
self.assertEqual(thumb.format, 'PNG')
|
||||
key = manager.cache_key(meta)
|
||||
meta['mtime'] += 1
|
||||
self.assertNotEqual(key, manager.cache_key(meta))
|
||||
|
||||
def test_invalid_inputs_and_directory_uri(self):
|
||||
for path in (self.root / 'absent.png', self.root, self.root / 'texte.png'):
|
||||
if path.name == 'texte.png': path.write_text('invalide')
|
||||
with self.assertRaises((ValueError, OSError)):
|
||||
manager.validate_image(path)
|
||||
chosen = manager.set_directory(self.root.as_uri())
|
||||
self.assertEqual(chosen['wallpaperDirectory'], str(self.root))
|
||||
self.assertEqual(json.loads(manager.PREFERENCES.read_text())['version'], 1)
|
||||
|
||||
def test_atomic_replace_keeps_old_file_on_write_failure(self):
|
||||
target = self.root / 'atomic.txt'
|
||||
target.write_bytes(b'ancien')
|
||||
with mock.patch.object(manager.os, 'replace', side_effect=OSError('échec')):
|
||||
with self.assertRaises(OSError):
|
||||
manager.atomic_write(target, b'nouveau')
|
||||
self.assertEqual(target.read_bytes(), b'ancien')
|
||||
|
||||
def test_safe_alias_and_sway_syntax(self):
|
||||
meta = manager.validate_image(self.image)
|
||||
alias = manager.safe_alias(meta)
|
||||
self.assertEqual(Path(alias).resolve(), self.image.resolve())
|
||||
self.assertNotIn('é', alias)
|
||||
self.assertNotIn(' ', alias)
|
||||
content = manager.render_wallpaper(alias).decode()
|
||||
self.assertIn(alias, content)
|
||||
self.assertNotIn(str(self.image), content)
|
||||
with self.assertRaises(ValueError):
|
||||
manager.render_wallpaper(str(self.image))
|
||||
|
||||
def test_validate_checks_sway_stderr_even_with_zero_exit(self):
|
||||
completed = mock.Mock(returncode=0, stderr='Error(s) loading config!', stdout='')
|
||||
with mock.patch.object(manager.subprocess, 'run', return_value=completed):
|
||||
with self.assertRaises(RuntimeError):
|
||||
manager.command('sway', '--validate')
|
||||
|
||||
def test_simultaneous_apply_is_rejected_before_writing(self):
|
||||
manager.STATE.parent.mkdir(parents=True)
|
||||
lock_path = manager.STATE.parent / 'wallpaper.lock'
|
||||
with open(lock_path, 'a+b') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
with self.assertRaises(BlockingIOError):
|
||||
manager.apply(str(self.image))
|
||||
self.assertFalse(manager.GENERATED.exists())
|
||||
|
||||
def test_interrupted_thumbnail_is_not_published(self):
|
||||
meta = manager.validate_image(self.image)
|
||||
target = manager.CACHE / (manager.cache_key(meta) + '.png')
|
||||
with mock.patch.object(manager.os, 'replace', side_effect=OSError('interrompu')):
|
||||
with self.assertRaises(OSError):
|
||||
manager.thumbnail(meta)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(manager.CACHE.glob('.thumbnail-*')), [])
|
||||
|
||||
def test_reconcile_missing_image_uses_versioned_default(self):
|
||||
manager.write_json(manager.STATE, {'version': 2,
|
||||
'effectiveWallpaper': str(self.root / 'gone.png')})
|
||||
with mock.patch.object(manager, 'apply', return_value={'effectiveWallpaper': str(manager.DEFAULT)}) as apply:
|
||||
result = manager.reconcile()
|
||||
apply.assert_called_once_with(str(manager.DEFAULT))
|
||||
self.assertEqual(result['recoveredFromMissing'], str(self.root / 'gone.png'))
|
||||
|
||||
def test_apply_preserves_theme_and_rolls_back_on_failure(self):
|
||||
old = {'version': 1, 'effectiveFlavor': 'mocha', 'effectiveAccent': 'lavender',
|
||||
'effectiveDark': True, 'effectiveHighContrast': False, 'revision': 8}
|
||||
manager.write_json(manager.STATE, old)
|
||||
manager.atomic_write(manager.GENERATED, b'ancien')
|
||||
with mock.patch.object(manager, 'command') as command, \
|
||||
mock.patch.object(manager, 'swaybg_matches', return_value=True):
|
||||
result = manager.apply(str(self.image))
|
||||
self.assertEqual(result['revision'], 9)
|
||||
state = manager.read_json(manager.STATE)
|
||||
self.assertEqual(state['effectiveFlavor'], 'mocha')
|
||||
self.assertEqual(state['effectiveAccent'], 'lavender')
|
||||
self.assertEqual(state['version'], 2)
|
||||
self.assertIn(b'set $wallpaper ', manager.GENERATED.read_bytes())
|
||||
self.assertEqual(command.call_count, 2)
|
||||
with mock.patch.object(manager, 'command', side_effect=[RuntimeError('validate'), None]):
|
||||
with self.assertRaises(RuntimeError):
|
||||
manager.apply(str(self.image))
|
||||
self.assertEqual(manager.read_json(manager.STATE), state)
|
||||
self.assertIn(b'set $wallpaper ', manager.GENERATED.read_bytes())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Loading…
Reference in a new issue