diff --git a/.assets/enabled-user-aux-units.txt b/.assets/enabled-user-aux-units.txt
index ed3c0576b..31b3ecc4a 100644
--- a/.assets/enabled-user-aux-units.txt
+++ b/.assets/enabled-user-aux-units.txt
@@ -1,6 +1,7 @@
arch-sentinel-sample.timer
battery-low-notify.timer
gnome-keyring-daemon.socket
+labfy-quickshell-updates.timer
p11-kit-server.socket
pipewire-pulse.socket
pipewire.socket
diff --git a/.assets/pkglist-aur.txt b/.assets/pkglist-aur.txt
index 0cfeb681c..4d7ef6341 100644
--- a/.assets/pkglist-aur.txt
+++ b/.assets/pkglist-aur.txt
@@ -13,5 +13,6 @@ meshlab-bin
mozjpeg-debug
proton-pass-bin
spicetify-marketplace-bin
+swayfx
vmware-workstation
yay
diff --git a/.assets/pkglist-pacman.txt b/.assets/pkglist-pacman.txt
index e628925de..72f2f0414 100644
--- a/.assets/pkglist-pacman.txt
+++ b/.assets/pkglist-pacman.txt
@@ -75,7 +75,6 @@ gtklock-userinfo-module
gvfs
heimdall
hplip
-i3status-rust
ifuse
imagemagick
inotify-tools
@@ -150,6 +149,7 @@ qt5-wayland
qt5ct
qt6-wayland
qt6ct
+quickshell
radeontop
rclone
reflector
@@ -165,6 +165,7 @@ samba
sane
sane-airscan
sbctl
+scenefx
slurp
smartmontools
snap-pac
@@ -177,7 +178,6 @@ starship
stow
strace
sudo
-sway
swaybg
swayidle
swaylock
diff --git a/README.md b/README.md
index eb58c8c86..4f8625f19 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
@@ -41,15 +41,14 @@ installateur automatique.
| Desktop | Shell |
| --- | --- |
-| `greetd → tuigreet → UWSM → Sway` | `Zsh → Starship → Atuin` |
-| `Swaybar → i3status-rs` | `FZF → Zoxide → Eza → Bat → Yazi` |
-| Mako · Wofi · Swaylock · Cliphist · Kitty | plugins Zsh et Tmux épinglés par sous-modules |
+| `greetd → tuigreet → UWSM → SwayFX` | `Zsh → Starship → Atuin` |
+| `QuickShell → barre et notifications` | `FZF → Zoxide → Eza → Bat → Yazi` |
+| Wofi · Swaylock · Cliphist · Kitty | plugins Zsh et Tmux épinglés par sous-modules |
```mermaid
flowchart LR
- G[greetd] --> T[tuigreet] --> U[UWSM] --> S[Sway]
- S --> B[Swaybar] --> I[i3status-rs]
- S --> M[Mako]
+ G[greetd] --> T[tuigreet] --> U[UWSM] --> S[SwayFX]
+ S --> B[QuickShell : barre et notifications]
S --> W[Wofi]
S --> L[Swaylock]
S --> C[Cliphist]
@@ -62,10 +61,10 @@ Les responsabilités et dépendances sont détaillées dans
## ✦ Preview
-
+
-Environnement ArchASP réel — Sway, applications terminal et palette Catppuccin.
+Capture historique avant la migration SwayFX et QuickShell.
## ✦ Repository
@@ -91,8 +90,8 @@ git clone --recurse-submodules \
cd "$HOME/.dotfiles"
packages=(atuin bat bin btop cliphist eza fastfetch fzf gtk-3.0 gtklock \
- hyprwhspr i3status-rust icons kitty mako nvim opencode qt5ct qt6ct \
- starship sway swaylock systemd themes tmux uwsm wallpapers wofi yazi \
+ hyprwhspr icons kitty mako nvim opencode qt5ct qt6ct \
+ quickshell starship sway swaylock systemd themes tmux uwsm wallpapers wofi yazi \
ytmusic-tui zsh)
stow --no --verbose=2 --target="$HOME" "${packages[@]}"
diff --git a/bin/.local/bin/batlimit b/bin/.local/bin/batlimit
index 42792a2e4..a0c59e237 100755
--- a/bin/.local/bin/batlimit
+++ b/bin/.local/bin/batlimit
@@ -8,32 +8,51 @@ readonly CAPACITY_FILE="/sys/class/power_supply/${BATTERY}/capacity"
readonly STATUS_FILE="/sys/class/power_supply/${BATTERY}/status"
readonly HELPER="/usr/local/sbin/batlimit-set"
+if (( $# > 1 )); then
+ echo "Usage : batlimit [SEUIL]" >&2
+ exit 2
+fi
+
if [[ ! -e "$THRESHOLD_FILE" ]]; then
echo "Erreur : aucun seuil de charge disponible pour ${BATTERY}."
exit 1
fi
-read -rp "Niveau max de charge voulu [%] : " STOP
+if (( $# == 1 )); then
+ STOP="$1"
+else
+ read -rp "Niveau max de charge voulu [%] : " STOP
+fi
-if [[ ! "$STOP" =~ ^[0-9]+$ ]]; then
- echo "Erreur : entre un nombre entier."
- exit 1
+if [[ ! "$STOP" =~ ^[1-9][0-9]{1,2}$ ]]; then
+ echo "Erreur : entre un nombre entier entre 40 et 100." >&2
+ exit 2
fi
if (( STOP < 40 || STOP > 100 )); then
- echo "Erreur : choisis une valeur entre 40 et 100."
- exit 1
+ echo "Erreur : choisis une valeur entre 40 et 100." >&2
+ exit 2
fi
-if ! sudo -n "$HELPER" "$STOP"; then
- echo "Erreur : impossible d’appliquer le seuil."
- exit 1
+if RESULT=$(sudo -n "$HELPER" "$STOP"); then
+ if [[ -n "$RESULT" && "$RESULT" != "SUCCESS $STOP" ]]; then
+ echo "Erreur : réponse inattendue du service de charge." >&2
+ exit 1
+ fi
+else
+ result_code=$?
+ echo "Erreur : impossible d’appliquer le seuil." >&2
+ exit "$result_code"
fi
CURRENT_THRESHOLD=$(<"$THRESHOLD_FILE")
+if [[ "$CURRENT_THRESHOLD" != "$STOP" ]]; then
+ echo "Erreur : seuil demandé ${STOP} %, valeur rapportée ${CURRENT_THRESHOLD} %." >&2
+ exit 1
+fi
echo
echo "Batterie : ${BATTERY}"
-echo "Seuil appliqué : ${CURRENT_THRESHOLD}%"
+echo "Seuil rapporté : ${CURRENT_THRESHOLD}%"
echo "Charge actuelle : $(<"$CAPACITY_FILE")%"
echo "État : $(<"$STATUS_FILE")"
diff --git a/bin/.local/bin/i3s_restart b/bin/.local/bin/i3s_restart
deleted file mode 100755
index 889e01d37..000000000
--- a/bin/.local/bin/i3s_restart
+++ /dev/null
@@ -1,3 +0,0 @@
-#!/bin/sh
-pkill i3status-rs
-i3status-rs ~/.config/i3status-rust/config.toml &
diff --git a/bin/root/README.md b/bin/root/README.md
new file mode 100644
index 000000000..82cd70b1d
--- /dev/null
+++ b/bin/root/README.md
@@ -0,0 +1,43 @@
+# Helper de limite de charge
+
+La source suivie est `usr/local/sbin/batlimit-set` dans ce répertoire. Le fichier
+exécuté via la règle sudoers existante est `/usr/local/sbin/batlimit-set`.
+
+Déploiement administrateur après revue :
+
+```sh
+sudo install -o root -g root -m 0755 \
+ /home/fy59/.dotfiles/bin/root/usr/local/sbin/batlimit-set \
+ /usr/local/sbin/batlimit-set
+```
+
+Le helper crée et maintient `/etc/tlp.d/90-labfy-charge-limit.conf` en
+`root:root` et mode `0644`. Ce fichier contient la préférence persistante et
+n'est pas suivi par Git. La configuration antérieure
+`/etc/tlp.d/10-archasp.conf` reste intacte avec le seuil de secours à 60 %.
+
+Le helper accepte exactement un entier de 40 à 100. Il affiche `SUCCESS X`
+avec le code 0 seulement après relecture du seuil sysfs. Il affiche
+`ERROR invalid_value` (code 2), `ERROR unavailable` ou
+`ERROR apply_failed` (code 1), ou `ERROR rollback_failed` (code 3).
+Une erreur d'application restaure l'ancien fichier et tente de réappliquer
+l'ancien seuil. `rollback_failed` exige une vérification administrative de
+la configuration et du seuil sysfs.
+
+`~/.local/bin/batlimit` est la CLI utilisateur suivie par GNU Stow via
+`bin/.local/bin/batlimit`. Sans argument elle garde l'invite historique ; avec
+un entier (`batlimit 80`) elle appelle le même helper privilégié que la page
+Batterie de QuickShell. La CLI affiche un compte rendu lisible, tandis que
+QuickShell appelle directement le helper et vérifie sa sortie structurée.
+Le wrapper ne modifie ni sysfs ni TLP lui-même.
+
+Rollback administrateur après revue :
+
+```sh
+sudo rm /etc/tlp.d/90-labfy-charge-limit.conf
+sudo tlp start
+cat /sys/class/power_supply/BAT1/charge_control_end_threshold
+```
+
+Après le prochain démarrage, vérifier le seuil avec la même lecture sysfs,
+ainsi que `sudo tlp-stat -b` et `journalctl -u tlp.service -b`.
diff --git a/bin/root/usr/local/sbin/batlimit-set b/bin/root/usr/local/sbin/batlimit-set
new file mode 100755
index 000000000..287a66540
--- /dev/null
+++ b/bin/root/usr/local/sbin/batlimit-set
@@ -0,0 +1,166 @@
+#!/usr/bin/env python3
+"""Apply one validated BAT1 charge limit and persist it through TLP.
+
+Protocol marker: LABFY_TLP_PERSISTENT_V1. QuickShell checks the installed
+root-owned helper for this marker before allowing a write.
+
+Deployment: install this source as root:root, mode 0755, at
+/usr/local/sbin/batlimit-set. The sudoers rule grants only that installed path.
+
+Exit codes: 0 success, 2 invalid argument, 1 unavailable/apply/readback error,
+3 rollback failure. stdout is exactly ``SUCCESS `` on success; stderr
+is exactly ``ERROR `` on failure.
+"""
+
+import fcntl
+import os
+from pathlib import Path
+import re
+import stat
+import subprocess
+import sys
+import tempfile
+
+
+CONFIG = Path("/etc/tlp.d/90-labfy-charge-limit.conf")
+SYSFS = Path("/sys/class/power_supply/BAT1/charge_control_end_threshold")
+LOCK = Path("/run/lock/labfy-batlimit-set.lock")
+TLP = "/usr/bin/tlp"
+
+
+def fail(reason: str, code: int = 1) -> int:
+ print(f"ERROR {reason}", file=sys.stderr)
+ return code
+
+
+def read_threshold() -> int:
+ raw = SYSFS.read_text(encoding="ascii").strip()
+ if not re.fullmatch(r"[0-9]{1,3}", raw, re.ASCII):
+ raise ValueError("invalid kernel threshold")
+ return int(raw)
+
+
+def prior_config() -> bytes | None:
+ try:
+ metadata = CONFIG.lstat()
+ except FileNotFoundError:
+ return None
+ # CONTRACT: never follow or replace a link supplied at this privileged path.
+ if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != 0 or metadata.st_size > 4096:
+ raise ValueError("unsafe TLP config")
+ return CONFIG.read_bytes()
+
+
+def sync_directory() -> None:
+ fd = os.open(CONFIG.parent, os.O_RDONLY | os.O_DIRECTORY)
+ try:
+ os.fsync(fd)
+ finally:
+ os.close(fd)
+
+
+def replace_config(content: bytes) -> None:
+ # INVARIANT: TLP must see either the entire old file or the entire new file.
+ fd, temporary = tempfile.mkstemp(prefix=".90-labfy-charge-limit.", dir=CONFIG.parent)
+ try:
+ os.fchmod(fd, 0o644)
+ with os.fdopen(fd, "wb") as output:
+ output.write(content)
+ output.flush()
+ os.fsync(output.fileno())
+ os.replace(temporary, CONFIG)
+ sync_directory()
+ finally:
+ if os.path.exists(temporary):
+ os.unlink(temporary)
+
+
+def restore_config(previous: bytes | None) -> None:
+ if previous is None:
+ try:
+ CONFIG.unlink()
+ except FileNotFoundError:
+ return
+ sync_directory()
+ else:
+ replace_config(previous)
+
+
+def apply_runtime(value: int) -> bool:
+ # TLP's setcharge command is temporary; the atomically replaced file owns boot persistence.
+ try:
+ result = subprocess.run(
+ [TLP, "setcharge", "0", str(value), "BAT1"],
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ timeout=20,
+ check=False,
+ )
+ except (OSError, subprocess.TimeoutExpired):
+ return False
+ return result.returncode == 0
+
+
+def locked_operation(value: int) -> int:
+ try:
+ old_value = read_threshold()
+ old_config = prior_config()
+ except (OSError, ValueError):
+ return fail("unavailable")
+
+ try:
+ replace_config(
+ f"START_CHARGE_THRESH_BAT1=0\nSTOP_CHARGE_THRESH_BAT1={value}\n".encode("ascii")
+ )
+ except OSError:
+ # Even a directory fsync failure can happen after rename; restore the old bytes.
+ try:
+ restore_config(old_config)
+ except OSError:
+ return fail("rollback_failed", 3)
+ return fail("unavailable")
+
+ try:
+ applied = apply_runtime(value)
+ if applied and read_threshold() == value:
+ print(f"SUCCESS {value}")
+ return 0
+ except (OSError, ValueError):
+ pass
+
+ # CONTRACT: a failed apply must not leave the new preference for the next boot.
+ try:
+ restore_config(old_config)
+ restored = apply_runtime(old_value) and read_threshold() == old_value
+ except (OSError, ValueError):
+ restored = False
+ return fail("apply_failed" if restored else "rollback_failed", 1 if restored else 3)
+
+
+def main(argv: list[str]) -> int:
+ if len(argv) != 1 or not re.fullmatch(r"[1-9][0-9]{1,2}", argv[0], re.ASCII):
+ return fail("invalid_value", 2)
+ value = int(argv[0])
+ if not 40 <= value <= 100:
+ return fail("invalid_value", 2)
+ if os.geteuid() != 0:
+ return fail("root_required")
+
+ # One fixed root-owned lock serializes callers; QuickShell also limits itself to one Process.
+ try:
+ lock_fd = os.open(LOCK, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
+ try:
+ metadata = os.fstat(lock_fd)
+ if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != 0:
+ return fail("unavailable")
+ fcntl.flock(lock_fd, fcntl.LOCK_EX)
+ return locked_operation(value)
+ finally:
+ os.close(lock_fd)
+ except OSError:
+ return fail("unavailable")
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
diff --git a/docs/architecture.md b/docs/architecture.md
index 5eb9d9042..4635630a0 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -41,7 +41,7 @@ Exemples de correspondance :
| Source versionnée | Cible utilisateur |
| --- | --- |
| `sway/.config/sway/` | `~/.config/sway/` |
-| `i3status-rust/.config/i3status-rust/` | `~/.config/i3status-rust/` |
+| `quickshell/.config/quickshell/labfy-sway/` | `~/.config/quickshell/labfy-sway/` |
| `bin/.local/bin/` | `~/.local/bin/` |
| `zsh/.zprofile` | `~/.zprofile` |
| `wallpapers/.wallpapers/` | `~/.wallpapers/` |
diff --git a/docs/desktop.md b/docs/desktop.md
index 4b226cb05..871787767 100644
--- a/docs/desktop.md
+++ b/docs/desktop.md
@@ -1,72 +1,35 @@
# Desktop
-## Stack canonique
+## Session actuelle
```mermaid
flowchart LR
- G[greetd] --> T[tuigreet]
- T --> U[UWSM]
- U --> S[Sway]
- S --> B[Swaybar]
- B --> I[i3status-rs]
- S --> M[Mako]
+ G[greetd] --> T[tuigreet] --> U[UWSM] --> S[SwayFX 0.6]
+ S --> Q[QuickShell : barre et notifications]
S --> W[Wofi]
S --> L[Swaylock]
S --> C[Cliphist]
- S --> K[Kitty]
```
-| Composant | Responsabilité | Propriété |
-| --- | --- | --- |
-| greetd | gestionnaire de connexion système | installé/configuré par `arch-system` |
-| tuigreet | interface terminal de greetd | paquet système ; commande référencée par greetd |
-| UWSM | cycle de vie de la session Wayland | package Stow `uwsm` et paquet système |
-| Sway | compositeur et configuration de session | package `sway` |
-| Swaybar | barre native de Sway | bloc `bar` de la configuration Sway |
-| i3status-rs | contenu et modules de la barre | package `i3status-rust` |
-| Mako | notifications Wayland | package `mako` |
-| Wofi | lanceur d'applications | package `wofi` |
-| Swaylock | verrouillage | package `swaylock`, appelé par les bindings et swayidle |
-| Cliphist | historique texte/image du presse-papiers | package `cliphist` et unités user |
-| Kitty | terminal principal | package `kitty` |
+`uwsm/.config/uwsm/default-id` sélectionne `sway.desktop` ; SwayFX fournit le
+binaire Sway compatible. `sway/.config/sway/config` inclut `swayfx`, mais
+aucune configuration `bar`. QuickShell démarre une seule fois par
+`quickshell-labfy-sway.service`, attaché à
+`wayland-session@sway.desktop.target`. Mako reste installé et masqué pour le
+rollback des notifications.
-La configuration `uwsm/.config/uwsm/default-id` sélectionne `sway.desktop`.
-La référence greetd de `.assets/greetd/config.toml` démarre la chaîne système,
-mais sa copie dans `/etc` ne relève pas des dotfiles.
+La sortie interne `eDP-1` est positionnée à l'origine ; les paramètres SwayFX
+sont conservés dans `sway/.config/sway/swayfx` : animations 0, coins 4,
+ombres validées, flou global désactivé, flou réservé à la
+couche QuickShell. Le script d'opacité inactive écoute l'IPC Sway au lancement
+de la session.
-## Session Sway
+## Services et outils
-Le package `sway` sépare la configuration principale, les bindings, les règles,
-le style, la barre, l'autostart et les scripts. Swaybar exécute :
-
-```text
-i3status-rs ~/.config/i3status-rust/config.toml
-```
-
-Les scripts locaux sous `~/.config/sway/scripts` restent propres à la session.
-Les commandes sous `~/.local/bin` sont fournies par le package `bin` ou, quand
-cela est documenté, par un projet externe.
-
-## Services de session
-
-- `cliphist-text.service` et `cliphist-image.service` observent `wl-paste` ;
-- `swayidle.service` verrouille, éteint/rallume les sorties et suspend les
- lecteurs selon les délais configurés ;
-- `wlsunset.service` gère la température de couleur ;
-- `gnome-keyring-daemon.service` et `ssh-agent.service` fournissent les agents
- de session ;
-- Mako est le fournisseur de notifications retenu.
-
-Les unités attachées à `graphical-session.target` ne remplacent pas UWSM :
-elles consomment le cycle de vie de la session qu'UWSM expose à systemd user.
-
-## Dépendances visibles
-
-Les bindings Sway appellent notamment `pactl`, `brightnessctl`, `grim`,
-`slurp`, `wl-copy`, `wofi`, `swaylock`, `yazi` et des scripts de
-`~/.local/bin`. Les programmes spécifiques à des projets ou à la machine sont
-inventoriés dans [`external-tools.md`](../.assets/external-tools.md).
-
-Les anciens choix Mango, Waybar, SwayNC et Wlogout ne font pas partie de la
-stack supportée. Leur éventuelle présence comme paquet installé ne leur donne
-aucun rôle dans l'architecture actuelle.
+`labfy-quickshell-updates.timer` déclenche la vérification Arch/AUR sans
+installer de paquet. `cliphist-text.service`, `cliphist-image.service`,
+`swayidle.service`, `wlsunset.service`, les agents SSH et keyring restent des
+unités utilisateur distinctes. Les raccourcis Sway utilisent notamment
+`pactl`, `brightnessctl`, `grim`, `slurp`, `wl-copy`, `wofi`, `swaylock` et
+`yazi`. Le détail de QuickShell et des dépendances figure dans son
+[README](../quickshell/.config/quickshell/labfy-sway/README.md).
diff --git a/docs/installation.md b/docs/installation.md
index 97c450ff5..9878fa67c 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -70,15 +70,22 @@ Les commandes suivantes sont destinées à Bash :
```bash
packages=(
atuin bat bin btop cliphist eza fastfetch fzf gtk-3.0 gtklock
- hyprwhspr i3status-rust icons kitty mako nvim opencode qt5ct qt6ct
- starship sway swaylock systemd themes tmux uwsm wallpapers wofi yazi
+ hyprwhspr icons kitty mako nvim opencode qt5ct qt6ct
+ quickshell starship sway swaylock systemd themes tmux uwsm wallpapers wofi yazi
ytmusic-tui zsh
)
printf '%s\n' "${packages[@]}"
```
-`docs/` et `.assets/` ne doivent pas être ajoutés à ce tableau.
+`docs/` et `.assets/` ne doivent pas être ajoutés à ce tableau. Le package
+`quickshell` déploie `~/.config/quickshell/labfy-sway/` ; une Nerd Font
+(`ttf-jetbrains-mono-nerd` sur cette machine) fournit les icônes.
+SwayFX 0.6 est un paquet foreign/local à reconstruire depuis sa source
+vérifiée ; `scenefx` et `quickshell` sont présents dans la liste Pacman
+officielle de cette machine. Voir les inventaires `.assets/`.
+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`.
## 6. Effectuer le dry-run
@@ -114,8 +121,17 @@ systemd-analyze --user verify \
"$HOME"/.config/systemd/user/*.service \
"$HOME"/.config/systemd/user/*.timer
systemctl --user --failed
+systemctl --user is-enabled quickshell-labfy-sway.service labfy-quickshell-updates.timer
+systemctl --user is-active quickshell-labfy-sway.service labfy-quickshell-updates.timer
```
+Si les liens `*.wants/` ne sont pas déjà en place sur la machine cible,
+activer les unités avec `systemctl --user enable quickshell-labfy-sway.service
+labfy-quickshell-updates.timer`. Dans une session SwayFX déjà ouverte, les
+démarrer avec `systemctl --user start quickshell-labfy-sway.service
+labfy-quickshell-updates.timer`. Le démarrage suivant est assuré par
+`wayland-session@sway.desktop.target` et `timers.target`.
+
Certaines unités dépendent de projets externes. Ne les démarrer que lorsque
leurs chemins et configurations locales existent ; voir [`systemd.md`](systemd.md).
diff --git a/docs/legacy/swaybar/README.md b/docs/legacy/swaybar/README.md
new file mode 100644
index 000000000..4eec5eae7
--- /dev/null
+++ b/docs/legacy/swaybar/README.md
@@ -0,0 +1,8 @@
+# Ancienne barre Sway
+
+`bar` et `i3status-config.toml` conservent la configuration de retour manuel
+pré-QuickShell. Les blocs Bluetooth qui contenaient des adresses matérielles
+personnelles ont été retirés de l'archive. Ces fichiers ne sont plus déployés
+par Stow. La procédure complète,
+y compris la réinstallation d'`i3status-rust`, se trouve dans
+`quickshell/.config/quickshell/labfy-sway/notifications/ROLLBACK.md`.
diff --git a/sway/.config/sway/bar b/docs/legacy/swaybar/bar
similarity index 100%
rename from sway/.config/sway/bar
rename to docs/legacy/swaybar/bar
diff --git a/i3status-rust/.config/i3status-rust/config.toml b/docs/legacy/swaybar/i3status-config.toml
similarity index 86%
rename from i3status-rust/.config/i3status-rust/config.toml
rename to docs/legacy/swaybar/i3status-config.toml
index 8e2181400..6d1756919 100644
--- a/i3status-rust/.config/i3status-rust/config.toml
+++ b/docs/legacy/swaybar/i3status-config.toml
@@ -32,24 +32,6 @@ button = "middle"
widget = "."
action = "toggle_format"
-[[block]]
-block = "bluetooth"
-mac = "E4:61:F4:F4:A1:F7"
-format = " JBL Tune "
-disconnected_format = " JBL Tune "
-[[block.click]]
-button = "left"
-action = "blueman-manager"
-
-[[block]]
-block = "bluetooth"
-mac = "1C:57:85:33:54:7F"
-format = " EDENWOOD "
-disconnected_format = " EDENWOOD "
-[[block.click]]
-button = "left"
-action = "blueman-manager"
-
[[block]]
block = "net"
device = "wlp1s0"
diff --git a/docs/recovery.md b/docs/recovery.md
index 485727e88..6966f84eb 100644
--- a/docs/recovery.md
+++ b/docs/recovery.md
@@ -94,8 +94,8 @@ systemctl --user --failed
sway --validate --config "$HOME/.config/sway/config"
```
-Contrôler ensuite le login greetd/tuigreet, le démarrage UWSM/Sway, Swaybar,
-i3status-rs, les notifications, le verrouillage, le presse-papiers, Kitty et
+Contrôler ensuite le login greetd/tuigreet, le démarrage UWSM/SwayFX,
+QuickShell et ses notifications, le verrouillage, le presse-papiers, Kitty et
un nouveau shell Zsh.
## Limites actuelles
diff --git a/docs/systemd.md b/docs/systemd.md
index ba6d9f874..6de152259 100644
--- a/docs/systemd.md
+++ b/docs/systemd.md
@@ -17,6 +17,7 @@ mais il ne l'active pas. Les unités effectivement versionnées sous
| Domaine | Unités | Fonction |
| --- | --- | --- |
| session | `gnome-keyring-daemon.service`, `ssh-agent.service`, `wlsunset.service` | agents et services de session |
+| desktop | `quickshell-labfy-sway.service`, `labfy-quickshell-updates.service/.timer` | barre, notifications et vérification des mises à jour |
| clipboard | `cliphist-text.service`, `cliphist-image.service` | collecte texte et images Wayland |
| idle | `swayidle.service` | verrouillage, alimentation des sorties et pause média |
| batterie | `battery-low-notify.service/.timer` | contrôle et notification périodiques |
@@ -27,7 +28,8 @@ mais il ne l'active pas. Les unités effectivement versionnées sous
| autres | `hyprwhspr.service`, `protonmail-bridge.service` | intégrations déclenchées selon l'usage |
Les liens versionnés dans `default.target.wants/`,
-`graphical-session.target.wants/` et `timers.target.wants/` expriment les
+`graphical-session.target.wants/`, `wayland-session@sway.desktop.target.wants/`
+et `timers.target.wants/` expriment les
activations utilisateur retenues. `.assets/enabled-user-aux-units.txt` capture
en complément les timers et sockets activés observés sur la machine.
diff --git a/quickshell/.config/quickshell/labfy-sway/Bar.qml b/quickshell/.config/quickshell/labfy-sway/Bar.qml
new file mode 100644
index 000000000..cc8dfa220
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/Bar.qml
@@ -0,0 +1,161 @@
+import QtQuick
+import Quickshell
+import Quickshell.Wayland
+import "modules"
+import "controlcenter"
+import "windows"
+import "datecenter"
+import "notifications"
+import "status"
+import "systemmonitor"
+
+PanelWindow {
+ id: bar
+ // Identité layer-shell stable pour cibler seulement cette barre dans SwayFX.
+ WlrLayershell.namespace: "labfy-sway-bar"
+
+ required property var modelData
+ required property var notificationService
+ screen: modelData
+
+ anchors {
+ left: true
+ right: true
+ top: true
+ }
+
+ margins {
+ top: 6
+ left: 8
+ right: 8
+ }
+
+ implicitHeight: 34
+ // La couche basse laisse les fenêtres plein écran conserver leur priorité.
+ aboveWindows: false
+ // CONTRACT: le PopupWindow reçoit les frappes seulement si son parent
+ // layer-shell est focusable dès son ouverture. OnDemand n'accapare pas
+ // le clavier hors interaction avec la barre ou son popup.
+ focusable: true
+ color: "#eb1e1e2e"
+
+ // CONTRACT: toute la capsule centrale partage le même popup et la même exclusion XOR.
+ function toggleDateCenter() {
+ const opening = !dateCenter.visible && !dateCenter.opening;
+ controlCenter.visible = false;
+ systemMonitor.visible = false;
+ windowStrip.closeMenu();
+ if (opening) dateCenter.requestOpen();
+ else dateCenter.requestClose();
+ }
+
+ Item {
+ id: leftArea
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ // Garder les titres longs hors de la capsule centrale.
+ width: Math.max(0, centerClock.x - 8)
+ height: bar.height
+ clip: true
+
+ Workspaces {
+ id: workspaces
+ screen: bar.screen
+ anchors.left: parent.left
+ anchors.leftMargin: 8
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ WindowStrip {
+ id: windowStrip
+ screen: bar.screen
+ anchors.left: workspaces.right
+ anchors.leftMargin: 10
+ anchors.verticalCenter: parent.verticalCenter
+ maxWidth: Math.max(0, leftArea.width - x - 8)
+ onMenuOpened: {
+ controlCenter.visible = false;
+ systemMonitor.visible = false;
+ dateCenter.requestClose();
+ }
+ }
+ }
+
+ Clock {
+ id: centerClock
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.verticalCenter: parent.verticalCenter
+ open: dateCenter.visible || dateCenter.opening
+ unreadCount: bar.notificationService.unreadCount
+ criticalUnreadCount: bar.notificationService.criticalUnreadCount
+ onToggled: bar.toggleDateCenter()
+ }
+
+ Item {
+ id: rightArea
+ anchors.left: centerClock.right
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ height: bar.height
+ clip: true
+
+ // INVARIANT: la zone droite s'étend depuis le bord sans modifier l'ancrage central.
+ RightStatusArea {
+ id: rightStatusArea
+ anchors.right: parent.right
+ anchors.rightMargin: 8
+ anchors.verticalCenter: parent.verticalCenter
+ barWindow: bar
+ wifiDevice: controlCenter.wifiDevice
+ adapter: controlCenter.adapter
+ controlCenterOpen: controlCenter.visible
+ systemMonitorOpen: systemMonitor.visible
+ onOpenPageRequested: page => {
+ systemMonitor.visible = false;
+ dateCenter.requestClose();
+ windowStrip.closeMenu();
+ controlCenter.currentPage = page;
+ controlCenter.visible = true;
+ }
+ onToggleControlCenterRequested: {
+ systemMonitor.visible = false;
+ const opening = !controlCenter.visible;
+ dateCenter.requestClose();
+ windowStrip.closeMenu();
+ controlCenter.currentPage = 0;
+ controlCenter.visible = opening;
+ }
+ onToggleSystemMonitorRequested: {
+ const opening = !systemMonitor.visible;
+ controlCenter.visible = false;
+ dateCenter.requestClose();
+ windowStrip.closeMenu();
+ systemMonitor.visible = opening;
+ }
+ }
+ }
+
+ ControlCenter {
+ id: controlCenter
+ barWindow: bar
+ onThresholdApplied: rightStatusArea.refreshBatteryThreshold()
+ }
+
+ SystemMonitor {
+ id: systemMonitor
+ barWindow: bar
+ }
+
+ DateCenter {
+ id: dateCenter
+ barWindow: bar
+ clockItem: centerClock
+ notificationService: bar.notificationService
+ }
+ NotificationToastArea {
+ barWindow: bar
+ service: bar.notificationService
+ monitorOpen: systemMonitor.visible
+ monitorWidth: systemMonitor.implicitWidth
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/README.md b/quickshell/.config/quickshell/labfy-sway/README.md
new file mode 100644
index 000000000..a299bed2d
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/README.md
@@ -0,0 +1,65 @@
+# QuickShell labfy-sway
+
+Barre principale et serveur de notifications de la session SwayFX.
+
+```text
+Bar
+├── Workspaces
+├── Window/task switcher
+├── Date Center
+│ ├── MPRIS
+│ └── Notifications
+├── Right Status
+│ ├── Network
+│ ├── Bluetooth
+│ ├── Battery
+│ ├── Updates
+│ ├── Tray
+│ ├── System Monitor
+│ └── Control Center
+└── Control Center
+ ├── Wi-Fi
+ ├── Bluetooth
+ ├── Volume
+ ├── Brightness
+ ├── Power Profile
+ ├── Battery limit
+ └── Session
+```
+
+`quickshell-labfy-sway.service` est activé par
+`wayland-session@sway.desktop.target`, sans autostart Sway parallèle.
+`NotificationService.qml` possède `org.freedesktop.Notifications` ; l'historique
+est écrit hors Git via `Quickshell.statePath("notifications.json")` dans
+`~/.local/state/quickshell/`. Mako demeure installé, inactif et masqué ; voir
+[ROLLBACK.md](notifications/ROLLBACK.md).
+
+`labfy-quickshell-updates.timer` exécute `bin/check-updates.py`, qui publie un
+état JSON dans `$XDG_RUNTIME_DIR`. `batlimit` (CLI) et Réglages rapides →
+Batterie (GUI) passent par `/usr/local/sbin/batlimit-set`. Le seuil courant
+est 60 % ; le helper root est documenté dans `bin/root/README.md`.
+
+Dépendances essentielles : `quickshell`, SwayFX 0.6, `scenefx`, une Nerd Font,
+NetworkManager, BlueZ, PipeWire, TLP/`tlpctl`, `checkupdates`, `yay`, `df`,
+`python3`, `swaylock` et le helper batterie pour le réglage du seuil. Le
+backlight utilise le noeud machine `amdgpu_bl1` dans `BrightnessSlider.qml` ;
+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.
+
+## Parité avec i3status-rs
+
+| Ancien bloc | QuickShell |
+| --- | --- |
+| scratchpad indicator | volontairement non repris |
+| focused_window | WindowStrip |
+| music | Date Center MPRIS |
+| bluetooth | status + Control Center |
+| net | status + Control Center |
+| sound | Control Center |
+| backlight | Control Center |
+| battery | status + page Batterie |
+| TLP | PowerProfile |
+| packages | Updates |
+| time | Clock / Date Center |
+| menu | page Session |
diff --git a/quickshell/.config/quickshell/labfy-sway/bin/check-updates.py b/quickshell/.config/quickshell/labfy-sway/bin/check-updates.py
new file mode 100644
index 000000000..4f8c88de0
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/bin/check-updates.py
@@ -0,0 +1,53 @@
+#!/usr/bin/python3
+"""Publie un instantané Arch/AUR borné pour la barre QuickShell."""
+
+import datetime
+import json
+import os
+import pathlib
+import subprocess
+import sys
+import tempfile
+
+
+def count(command):
+ # CONTRACT: aucune mise à jour n'est appliquée ; chaque vérification est bornée.
+ result = subprocess.run(command, capture_output=True, text=True, timeout=120, check=False)
+ # yay -Qua retourne 1 avec stdout/stderr vides quand aucune entrée AUR n'existe.
+ if command[0] == "yay" and result.returncode == 1 and not result.stdout.strip() and not result.stderr.strip():
+ return 0
+ if result.returncode != 0:
+ message = (result.stderr or result.stdout).strip().splitlines()
+ raise RuntimeError((message[-1] if message else f"{command[0]} : code {result.returncode}")[:160])
+ return len([line for line in result.stdout.splitlines() if line.strip()])
+
+
+def main():
+ runtime = pathlib.Path(os.environ["XDG_RUNTIME_DIR"])
+ destination = runtime / "labfy-quickshell-updates.json"
+ state = {"repoUpdates": 0, "aurUpdates": 0, "totalUpdates": 0,
+ "lastCheck": datetime.datetime.now().astimezone().isoformat(timespec="seconds"), "error": ""}
+ try:
+ state["repoUpdates"] = count(["checkupdates"])
+ state["aurUpdates"] = count(["yay", "-Qua"])
+ state["totalUpdates"] = state["repoUpdates"] + state["aurUpdates"]
+ except (OSError, subprocess.TimeoutExpired, RuntimeError) as exc:
+ # INVARIANT: une vérification incomplète ne peut jamais publier un faux zéro.
+ state["error"] = str(exc).splitlines()[0][:160]
+ print(state["error"], file=sys.stderr)
+ fd, temporary = tempfile.mkstemp(prefix="labfy-updates-", dir=runtime)
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
+ json.dump(state, stream, ensure_ascii=False)
+ stream.write("\n")
+ stream.flush()
+ os.fsync(stream.fileno())
+ os.replace(temporary, destination)
+ finally:
+ if os.path.exists(temporary):
+ os.unlink(temporary)
+ return 1 if state["error"] else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/quickshell/.config/quickshell/labfy-sway/bin/resolve-hardware.py b/quickshell/.config/quickshell/labfy-sway/bin/resolve-hardware.py
new file mode 100644
index 000000000..5114aafc2
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/bin/resolve-hardware.py
@@ -0,0 +1,53 @@
+#!/usr/bin/python3
+"""Résout les capteurs de cette session sans figer les numéros sysfs."""
+
+from pathlib import Path
+import re
+
+
+DRM = Path("/sys/class/drm")
+HWMON = Path("/sys/class/hwmon")
+
+
+def read(path):
+ try:
+ return path.read_text(encoding="ascii").strip()
+ except (OSError, UnicodeError):
+ return ""
+
+
+def gpu_busy():
+ # INVARIANT: seul un noeud cardN dont l'identité PCI est AMD est retenu.
+ for card in sorted(DRM.glob("card[0-9]*")):
+ if not re.fullmatch(r"card\d+", card.name):
+ continue
+ device = card / "device"
+ candidate = device / "gpu_busy_percent"
+ if read(device / "vendor").lower() == "0x1002" and candidate.is_file():
+ return candidate
+ return None
+
+
+def temperature(name, label):
+ for monitor in sorted(HWMON.glob("hwmon[0-9]*")):
+ if read(monitor / "name") != name:
+ continue
+ # CONTRACT: préférer le libellé physique ; temp1 sert aux pilotes sans labels.
+ inputs = sorted(monitor.glob("temp*_input"))
+ for candidate in inputs:
+ if read(candidate.with_name(candidate.name.replace("_input", "_label"))).lower() == label:
+ return candidate
+ fallback = monitor / "temp1_input"
+ if fallback.is_file():
+ return fallback
+ return None
+
+
+def main():
+ # Format fixe sur trois lignes, contrôlé côté QML. Aucun chemin utilisateur.
+ for path in (gpu_busy(), temperature("k10temp", "tctl"), temperature("amdgpu", "edge")):
+ print(path or "")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/quickshell/.config/quickshell/labfy-sway/components/NerdIcon.qml b/quickshell/.config/quickshell/labfy-sway/components/NerdIcon.qml
new file mode 100644
index 000000000..9e8c1433a
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/components/NerdIcon.qml
@@ -0,0 +1,10 @@
+import QtQuick
+
+Text {
+ font.family: "JetBrainsMono Nerd Font Mono"
+ // CONTRACT: taille de base des glyphes dans les zones de barre de 26 px ; les contextes spéciaux la surchargent.
+ font.pixelSize: 18
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ color: "#cdd6f4"
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/ActionButton.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/ActionButton.qml
new file mode 100644
index 000000000..01ca3508d
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/ActionButton.qml
@@ -0,0 +1,27 @@
+import QtQuick
+
+Rectangle {
+ id: button
+ property string label: ""
+ property bool danger: false
+ signal clicked()
+ implicitWidth: caption.implicitWidth + 20
+ implicitHeight: 30
+ radius: 4
+ color: !enabled ? "#313244" : pointer.containsMouse ? "#585b70" : "#45475a"
+ opacity: enabled ? 1 : 0.55
+ Text {
+ id: caption
+ anchors.centerIn: parent
+ text: button.label
+ color: button.danger ? "#f38ba8" : "#cdd6f4"
+ font.pixelSize: 12
+ }
+ MouseArea {
+ id: pointer
+ anchors.fill: parent
+ hoverEnabled: true
+ enabled: button.enabled
+ onClicked: button.clicked()
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/BatterySettings.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/BatterySettings.qml
new file mode 100644
index 000000000..ae12d1fb3
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/BatterySettings.qml
@@ -0,0 +1,431 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell.Io
+import Quickshell.Services.UPower
+import "../components"
+
+Item {
+ id: page
+ signal backRequested()
+ signal thresholdApplied()
+ property bool activePage: false
+ property string phase: "Idle"
+ property string feedback: ""
+ property bool edited: false
+ property bool keypadVisible: false
+ property bool keypadFresh: true
+ property int requestedValue: -1
+ property int readAttempts: 0
+ readonly property var battery: UPower.displayDevice
+ readonly property bool available: battery && battery.ready && battery.isPresent
+ readonly property int percent: available ? Math.round(battery.percentage * 100) : 0
+ readonly property string batteryState: !available ? "Indisponible"
+ : battery.state === UPowerDeviceState.Charging ? "En charge"
+ : battery.state === UPowerDeviceState.FullyCharged ? "Pleine"
+ : battery.state === UPowerDeviceState.PendingCharge ? "En attente de charge"
+ : battery.state === UPowerDeviceState.PendingDischarge ? "En attente de décharge"
+ : battery.state === UPowerDeviceState.Discharging ? "Décharge"
+ : battery.state === UPowerDeviceState.Empty ? "Vide" : "État inconnu"
+ readonly property int reportedThreshold: {
+ const raw = thresholdFile.text().trim();
+ if (!/^[0-9]{1,3}$/.test(raw)) return -1;
+ const value = Number(raw);
+ return value >= 0 && value <= 100 ? value : -1;
+ }
+ readonly property bool validProposed: /^[0-9]{1,3}$/.test(valueField.text)
+ && Number(valueField.text) >= 40 && Number(valueField.text) <= 100
+ readonly property bool helperReady: helperFile.text().includes("LABFY_TLP_PERSISTENT_V1")
+
+ implicitHeight: keypadVisible ? 502 : 350
+
+ // Une modification externe invalide le dernier message de succès local.
+ onReportedThresholdChanged: if (phase === "Success" && reportedThreshold !== requestedValue) {
+ phase = "Idle";
+ feedback = "";
+ }
+
+ function propose(value) {
+ if (phase === "Applying") return;
+ valueField.text = String(value);
+ edited = true;
+ phase = "Idle";
+ feedback = "";
+ }
+
+ function keypadPress(key) {
+ if (phase === "Applying") return;
+ if (key === "OK") {
+ keypadVisible = false;
+ return;
+ }
+ if (key === "C") valueField.text = "";
+ else if (key === "⌫") valueField.text = keypadFresh ? "" : valueField.text.slice(0, -1);
+ else {
+ const prefix = keypadFresh ? "" : valueField.text;
+ if (prefix.length >= 3) return;
+ valueField.text = prefix + String(key);
+ }
+ keypadFresh = false;
+ edited = true;
+ phase = "Idle";
+ feedback = "";
+ }
+
+ function apply() {
+ if (phase === "Applying" || writer.running || !validProposed
+ || reportedThreshold < 0 || !helperReady) return;
+ requestedValue = Number(valueField.text);
+ if (!Number.isInteger(requestedValue) || requestedValue < 40 || requestedValue > 100) return;
+ phase = "Applying";
+ feedback = "Application en cours…";
+ writer.command = ["sudo", "-n", "/usr/local/sbin/batlimit-set", String(requestedValue)];
+ writer.running = true;
+ applyTimeout.start();
+ }
+
+ function verifyReadback() {
+ if (phase !== "Applying") return;
+ if (reportedThreshold === requestedValue) {
+ phase = "Success";
+ feedback = "Limite configurée : " + reportedThreshold + " %";
+ edited = false;
+ thresholdApplied();
+ } else if (readAttempts < 3) {
+ readAttempts++;
+ readbackTimer.start();
+ } else {
+ phase = "Error";
+ feedback = reportedThreshold >= 0
+ ? "Valeur rapportée par le pilote : " + reportedThreshold + " %"
+ : "Impossible de relire la limite rapportée";
+ }
+ }
+
+ onActivePageChanged: {
+ if (activePage) {
+ thresholdFile.reload();
+ helperFile.reload();
+ Qt.callLater(() => valueField.forceActiveFocus());
+ }
+ if (phase !== "Applying") {
+ edited = false;
+ keypadVisible = false;
+ keypadFresh = true;
+ phase = "Idle";
+ feedback = "";
+ if (reportedThreshold >= 0) valueField.text = String(reportedThreshold);
+ }
+ }
+
+ // Le pilote peut ne pas émettre d'événement inotify : la relecture après écriture est obligatoire.
+ FileView {
+ id: thresholdFile
+ path: "/sys/class/power_supply/BAT1/charge_control_end_threshold"
+ watchChanges: true
+ onFileChanged: reload()
+ onLoaded: {
+ if (page.phase === "Applying" && !writer.running) page.verifyReadback();
+ else if (!page.edited && page.phase !== "Applying" && page.reportedThreshold >= 0)
+ valueField.text = String(page.reportedThreshold);
+ }
+ onLoadFailed: error => {
+ if (page.phase !== "Applying" || writer.running) return;
+ if (page.readAttempts < 3) {
+ page.readAttempts++;
+ readbackTimer.start();
+ } else {
+ page.phase = "Error";
+ page.feedback = "Impossible de relire la limite rapportée";
+ }
+ }
+ }
+
+
+ // L'ancien helper écrit seulement sysfs : interdire son appel tant que le déploiement manque.
+ FileView {
+ id: helperFile
+ path: "/usr/local/sbin/batlimit-set"
+ watchChanges: true
+ printErrors: false
+ onFileChanged: reload()
+ }
+
+ Process {
+ id: writer
+ stdout: StdioCollector { id: helperOutput; waitForEnd: true }
+ stderr: StdioCollector { id: helperError; waitForEnd: true }
+ onExited: (exitCode, exitStatus) => {
+ applyTimeout.stop();
+ if (exitCode !== 0 || exitStatus !== 0) {
+ page.phase = "Error";
+ page.feedback = helperError.text.includes("invalid_value")
+ ? "Valeur comprise entre 40 et 100"
+ : "Impossible d'appliquer la limite de charge";
+ return;
+ }
+ if (helperOutput.text.trim() !== "SUCCESS " + page.requestedValue) {
+ page.phase = "Error";
+ page.feedback = "Réponse inattendue du service de charge";
+ return;
+ }
+ page.readAttempts = 0;
+ thresholdFile.reload();
+ }
+ }
+
+ Timer {
+ id: readbackTimer
+ interval: 150
+ repeat: false
+ onTriggered: thresholdFile.reload()
+ }
+
+ Timer {
+ id: applyTimeout
+ interval: 25000
+ repeat: false
+ onTriggered: {
+ if (page.phase === "Applying") {
+ page.phase = "Error";
+ page.feedback = "Délai dépassé pour appliquer la limite";
+ writer.running = false;
+ }
+ }
+ }
+
+ Column {
+ anchors.fill: parent
+ spacing: 12
+
+ Item {
+ width: parent.width
+ height: 28
+ NerdIcon {
+ id: backIcon
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ text: ""
+ }
+ Text {
+ anchors.left: backIcon.right
+ anchors.leftMargin: 8
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Batterie"
+ color: "#cdd6f4"
+ font.pixelSize: 16
+ font.bold: true
+ }
+ MouseArea {
+ anchors.left: parent.left
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ width: 100
+ onClicked: page.backRequested()
+ }
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 68
+ radius: 4
+ color: "#313244"
+ NerdIcon {
+ id: batteryIcon
+ anchors.left: parent.left
+ anchors.leftMargin: 12
+ anchors.verticalCenter: parent.verticalCenter
+ font.pixelSize: 18
+ text: page.percent <= 15 ? "" : page.percent <= 30 ? ""
+ : page.percent <= 50 ? "" : page.percent <= 75 ? "" : ""
+ }
+ Column {
+ anchors.left: batteryIcon.right
+ anchors.leftMargin: 12
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 2
+ Text {
+ text: page.available ? page.percent + " %" : "Batterie indisponible"
+ color: "#cdd6f4"
+ font.pixelSize: 16
+ }
+ Text {
+ text: page.batteryState
+ color: "#a6adc8"
+ font.pixelSize: 12
+ }
+ }
+ }
+
+ Text {
+ text: "Limite de charge"
+ color: "#cdd6f4"
+ font.pixelSize: 14
+ }
+
+ Row {
+ width: parent.width
+ spacing: 8
+ Rectangle {
+ width: 42; height: 38; radius: 4
+ color: minusPointer.containsMouse ? "#45475a" : "#313244"
+ Text { anchors.centerIn: parent; text: "−"; color: "#cdd6f4"; font.pixelSize: 18 }
+ MouseArea {
+ id: minusPointer
+ anchors.fill: parent
+ hoverEnabled: true
+ enabled: page.phase !== "Applying" && page.reportedThreshold >= 0
+ onClicked: page.propose(Math.max(40, (page.validProposed
+ ? Number(valueField.text) : page.reportedThreshold) - 5))
+ }
+ }
+ TextField {
+ id: valueField
+ width: parent.width - 100
+ height: 38
+ horizontalAlignment: TextInput.AlignHCenter
+ color: "#cdd6f4"
+ font.pixelSize: 16
+ maximumLength: 3
+ inputMethodHints: Qt.ImhDigitsOnly
+ validator: RegularExpressionValidator { regularExpression: /[0-9]{0,3}/ }
+ enabled: page.phase !== "Applying"
+ selectByMouse: true
+ onTextEdited: {
+ page.edited = true;
+ page.phase = "Idle";
+ page.feedback = "";
+ }
+ TapHandler {
+ onTapped: {
+ page.keypadVisible = true;
+ page.keypadFresh = true;
+ }
+ }
+ background: Rectangle {
+ radius: 4
+ color: "#313244"
+ border.color: page.validProposed ? "#45475a" : "#f38ba8"
+ }
+ Text {
+ anchors.right: parent.right
+ anchors.rightMargin: 12
+ anchors.verticalCenter: parent.verticalCenter
+ text: "%"
+ color: "#a6adc8"
+ font.pixelSize: 13
+ }
+ }
+ Rectangle {
+ width: 42; height: 38; radius: 4
+ color: plusPointer.containsMouse ? "#45475a" : "#313244"
+ Text { anchors.centerIn: parent; text: "+"; color: "#cdd6f4"; font.pixelSize: 18 }
+ MouseArea {
+ id: plusPointer
+ anchors.fill: parent
+ hoverEnabled: true
+ enabled: page.phase !== "Applying" && page.reportedThreshold >= 0
+ onClicked: page.propose(Math.min(100, (page.validProposed
+ ? Number(valueField.text) : page.reportedThreshold) + 5))
+ }
+ }
+ }
+
+ Grid {
+ visible: page.keypadVisible
+ width: parent.width
+ columns: 4
+ spacing: 4
+ Repeater {
+ model: ["1", "2", "3", "⌫", "4", "5", "6", "C",
+ "7", "8", "9", "OK", "", "0", "", ""]
+ delegate: Rectangle {
+ required property string modelData
+ width: (parent.width - 12) / 4
+ height: 32
+ radius: 4
+ opacity: modelData !== "" ? 1 : 0
+ color: keyPointer.containsMouse ? "#45475a" : "#313244"
+ Text {
+ anchors.centerIn: parent
+ text: modelData
+ color: "#cdd6f4"
+ font.pixelSize: 13
+ }
+ MouseArea {
+ id: keyPointer
+ anchors.fill: parent
+ hoverEnabled: true
+ enabled: modelData !== ""
+ onClicked: page.keypadPress(modelData)
+ }
+ }
+ }
+ }
+
+ Row {
+ spacing: 8
+ Repeater {
+ model: [60, 80, 100]
+ delegate: Rectangle {
+ required property int modelData
+ width: 60; height: 32; radius: 4
+ color: page.reportedThreshold === modelData ? "#585b70" : "#313244"
+ border.color: page.validProposed && Number(valueField.text) === modelData
+ ? "#cba6f7" : "transparent"
+ Text {
+ anchors.centerIn: parent
+ text: modelData
+ color: "#cdd6f4"
+ font.pixelSize: 13
+ }
+ MouseArea {
+ anchors.fill: parent
+ enabled: page.phase !== "Applying"
+ onClicked: page.propose(modelData)
+ }
+ }
+ }
+ }
+
+ Text {
+ text: page.reportedThreshold >= 0
+ ? "Limite rapportée : " + page.reportedThreshold + " %" : "Limite indisponible"
+ color: "#a6adc8"
+ font.pixelSize: 12
+ }
+
+ Rectangle {
+ width: parent.width
+ height: 36
+ radius: 4
+ color: applyPointer.containsMouse && applyPointer.enabled ? "#b4befe" : "#cba6f7"
+ opacity: applyPointer.enabled ? 1 : 0.5
+ Text {
+ anchors.centerIn: parent
+ text: page.phase === "Applying" ? "Application…" : "Appliquer"
+ color: "#1e1e2e"
+ font.pixelSize: 13
+ }
+ MouseArea {
+ id: applyPointer
+ anchors.fill: parent
+ hoverEnabled: true
+ enabled: page.phase !== "Applying" && page.validProposed
+ && page.reportedThreshold >= 0 && page.helperReady
+ && Number(valueField.text) !== page.reportedThreshold
+ onClicked: page.apply()
+ }
+ }
+
+ Text {
+ width: parent.width
+ text: page.feedback.length ? page.feedback
+ : !page.validProposed ? "Valeur comprise entre 40 et 100"
+ : !page.helperReady ? "Service de charge non déployé" : ""
+ color: page.phase === "Error" || !page.validProposed || !page.helperReady
+ ? "#f38ba8" : "#a6e3a1"
+ font.pixelSize: 12
+ wrapMode: Text.WordWrap
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/BluetoothPage.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/BluetoothPage.qml
new file mode 100644
index 000000000..ab7985c73
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/BluetoothPage.qml
@@ -0,0 +1,222 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Bluetooth
+import "../components"
+
+Item {
+ id: page
+ required property var adapter
+ required property bool activePage
+ signal backRequested()
+ property var scanOwnedAdapter: null
+ property string stage: "main" // main, details, forget, block
+ property string selectedAddress: ""
+ property string message: ""
+ readonly property var devices: adapter && adapter.devices ? adapter.devices.values : []
+ readonly property var connectedDevices: devices.filter(d => d.connected)
+ readonly property var pairedDevices: devices.filter(d => !d.connected && d.paired)
+ readonly property var newDevices: devices.filter(d => !d.connected && !d.paired)
+ readonly property var selectedDevice: devices.find(d => d.address === selectedAddress) || null
+ readonly property bool scanWanted: activePage && adapter !== null && adapter.enabled
+
+ // CONTRACT: l'adaptateur revient à son état initial si ce panneau a lancé la découverte.
+ function syncScan() {
+ if (scanOwnedAdapter && (!scanWanted || scanOwnedAdapter !== adapter)) {
+ scanOwnedAdapter.discovering = false;
+ scanOwnedAdapter = null;
+ }
+ if (scanWanted && !adapter.discovering) {
+ adapter.discovering = true;
+ scanOwnedAdapter = adapter;
+ }
+ }
+ onScanWantedChanged: syncScan()
+ onAdapterChanged: syncScan()
+ onActivePageChanged: if (!activePage) { stage = "main"; selectedAddress = ""; message = ""; }
+ Component.onDestruction: if (scanOwnedAdapter) scanOwnedAdapter.discovering = false
+
+ function nameOf(device) { return device ? (device.name || device.deviceName || "Appareil Bluetooth") : "Appareil indisponible"; }
+ function stateOf(device) {
+ if (!device) return "Indisponible";
+ if (device.pairing) return "Appairage…";
+ if (device.state === BluetoothDeviceState.Connecting) return "Connexion…";
+ if (device.state === BluetoothDeviceState.Disconnecting) return "Déconnexion…";
+ if (device.connected) return "Connecté";
+ return device.paired ? "Appairé • déconnecté" : "Non appairé";
+ }
+ function choose(device) { selectedAddress = device.address; message = ""; stage = "details"; }
+ function back() {
+ if (stage === "main") backRequested();
+ else if (stage === "forget" || stage === "block") stage = "details";
+ else stage = "main";
+ message = "";
+ }
+
+ Column {
+ anchors.fill: parent; spacing: 10
+ Row {
+ width: parent.width; height: 30; spacing: 8
+ ActionButton { label: ""; onClicked: page.back() }
+ Text {
+ width: parent.width - 60; height: 30; verticalAlignment: Text.AlignVCenter
+ text: page.stage === "main" ? "Bluetooth" : page.nameOf(page.selectedDevice)
+ color: "#cdd6f4"; font.pixelSize: 16; font.bold: true; elide: Text.ElideRight
+ }
+ }
+ Text { width: parent.width; visible: page.message.length > 0; text: page.message; wrapMode: Text.Wrap; color: "#f9e2af"; font.pixelSize: 12 }
+ Loader {
+ width: parent.width; height: Math.max(0, parent.height - y)
+ sourceComponent: page.stage === "main" ? mainComponent : page.stage === "details"
+ ? detailsComponent : page.stage === "forget" ? forgetComponent : blockComponent
+ }
+ }
+
+ Component {
+ id: mainComponent
+ ScrollView {
+ clip: true; ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
+ Column {
+ width: page.width; spacing: 10
+ Row {
+ width: parent.width; spacing: 8
+ Text { width: parent.width - 100; text: !page.adapter ? "Adaptateur indisponible" : page.adapter.enabled ? "Bluetooth activé" : "Bluetooth désactivé"; color: "#cdd6f4"; font.pixelSize: 13 }
+ ActionButton { label: page.adapter && page.adapter.enabled ? "Désactiver" : "Activer"; enabled: !!page.adapter; onClicked: page.adapter.enabled = !page.adapter.enabled }
+ }
+ Text { text: page.scanWanted ? "Recherche d'appareils…" : "Scan arrêté"; color: "#a6adc8"; font.pixelSize: 11 }
+ Text { visible: page.devices.length === 0; text: "Aucun appareil connu"; color: "#a6adc8"; font.pixelSize: 12 }
+ Text { visible: page.connectedDevices.length > 0; text: "Connectés"; color: "#cba6f7"; font.bold: true; font.pixelSize: 13 }
+ Repeater {
+ model: ScriptModel { values: page.connectedDevices; objectProp: "address" }
+ delegate: deviceRowComponent
+ }
+ Text { visible: page.pairedDevices.length > 0; text: "Appairés"; color: "#cba6f7"; font.bold: true; font.pixelSize: 13 }
+ Repeater {
+ model: ScriptModel { values: page.pairedDevices; objectProp: "address" }
+ delegate: deviceRowComponent
+ }
+ Text { visible: page.newDevices.length > 0; text: "Nouveaux appareils"; color: "#cba6f7"; font.bold: true; font.pixelSize: 13 }
+ Repeater {
+ model: ScriptModel { values: page.newDevices; objectProp: "address" }
+ delegate: deviceRowComponent
+ }
+ }
+ }
+ }
+ Component {
+ id: deviceRowComponent
+ Rectangle {
+ required property var modelData
+ readonly property var device: modelData
+ width: page.width; height: 48; radius: 4
+ color: pointer.containsMouse ? "#45475a" : "#313244"
+ Image {
+ id: iconImage; anchors.left: parent.left; anchors.leftMargin: 9; anchors.verticalCenter: parent.verticalCenter
+ width: 19; height: 19; source: device.icon ? Quickshell.iconPath(device.icon, true) : ""
+ visible: status === Image.Ready; fillMode: Image.PreserveAspectFit
+ }
+ NerdIcon {
+ anchors.centerIn: iconImage; visible: !iconImage.visible
+ text: ""; color: "#cba6f7"; font.pixelSize: 17
+ }
+ Column {
+ anchors.left: parent.left; anchors.leftMargin: 38; anchors.right: parent.right
+ anchors.rightMargin: 10; anchors.verticalCenter: parent.verticalCenter; spacing: 2
+ Text { width: parent.width; text: page.nameOf(device); color: "#cdd6f4"; font.pixelSize: 12; elide: Text.ElideRight }
+ Text { text: page.stateOf(device) + (device.batteryAvailable ? " • " + Math.round(device.battery * 100) + " %" : ""); color: "#a6adc8"; font.pixelSize: 11 }
+ }
+ MouseArea { id: pointer; anchors.fill: parent; hoverEnabled: true; onClicked: page.choose(device) }
+ }
+ }
+ Component {
+ id: detailsComponent
+ ScrollView {
+ clip: true; ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
+ Column {
+ width: page.width; spacing: 10
+ Text { text: page.selectedDevice ? page.stateOf(page.selectedDevice) : "Appareil indisponible"; color: "#cdd6f4"; font.pixelSize: 13 }
+ Text { visible: !!page.selectedDevice && page.selectedDevice.deviceName !== page.selectedDevice.name; text: "Nom d'origine : " + (page.selectedDevice ? page.selectedDevice.deviceName : ""); color: "#a6adc8"; font.pixelSize: 11 }
+ Text { visible: !!page.selectedDevice && page.selectedDevice.batteryAvailable; text: "Batterie : " + (page.selectedDevice ? Math.round(page.selectedDevice.battery * 100) : 0) + " %"; color: "#cdd6f4"; font.pixelSize: 12 }
+ Text { text: page.selectedDevice ? "Appairé : " + (page.selectedDevice.paired ? "oui" : "non") + " • Lié : " + (page.selectedDevice.bonded ? "oui" : "non") : ""; color: "#a6adc8"; font.pixelSize: 11 }
+ Row {
+ spacing: 8
+ ActionButton {
+ label: page.selectedDevice && page.selectedDevice.pairing ? "Annuler l'appairage"
+ : page.selectedDevice && !page.selectedDevice.paired ? "Appairer"
+ : page.selectedDevice && page.selectedDevice.connected ? "Déconnecter" : "Connecter"
+ enabled: !!page.selectedDevice && (page.selectedDevice.pairing
+ || (page.selectedDevice.state !== BluetoothDeviceState.Connecting
+ && page.selectedDevice.state !== BluetoothDeviceState.Disconnecting))
+ onClicked: {
+ const device = page.selectedDevice;
+ if (device.pairing) device.cancelPair();
+ else if (!device.paired) device.pair();
+ else if (device.connected) device.disconnect();
+ else device.connect();
+ }
+ }
+ ActionButton { label: "Oublier"; danger: true; visible: !!page.selectedDevice && page.selectedDevice.paired; onClicked: page.stage = "forget" }
+ }
+ Row {
+ spacing: 8
+ Text { width: 165; text: "Appareil de confiance"; color: "#cdd6f4"; font.pixelSize: 12 }
+ ActionButton { label: page.selectedDevice && page.selectedDevice.trusted ? "Activé" : "Désactivé"; enabled: !!page.selectedDevice && page.selectedDevice.paired; onClicked: page.selectedDevice.trusted = !page.selectedDevice.trusted }
+ }
+ Row {
+ spacing: 8
+ Text { width: 165; text: "Bloqué"; color: "#cdd6f4"; font.pixelSize: 12 }
+ ActionButton { label: page.selectedDevice && page.selectedDevice.blocked ? "Oui" : "Non"; enabled: !!page.selectedDevice; onClicked: { if (page.selectedDevice.blocked) page.selectedDevice.blocked = false; else page.stage = "block"; } }
+ }
+ Text { width: parent.width; wrapMode: Text.Wrap; text: "Un appareil bloqué ne peut pas se connecter."; color: "#a6adc8"; font.pixelSize: 11 }
+ Row {
+ visible: !!page.selectedDevice && page.selectedDevice.paired
+ && page.adapter && page.adapter.enabled
+ && page.selectedDevice.wakeAllowed !== undefined
+ spacing: 8
+ Text { width: 165; text: "Autoriser le réveil"; color: "#cdd6f4"; font.pixelSize: 12 }
+ ActionButton { label: page.selectedDevice && page.selectedDevice.wakeAllowed ? "Activé" : "Désactivé"; onClicked: page.selectedDevice.wakeAllowed = !page.selectedDevice.wakeAllowed }
+ }
+ Text { text: "Nom local"; color: "#cba6f7"; font.pixelSize: 13; font.bold: true }
+ TextField {
+ id: aliasInput; width: page.width
+ text: page.selectedDevice ? page.selectedDevice.name : ""
+ placeholderText: "Alias local"; selectByMouse: true
+ color: "#cdd6f4"; placeholderTextColor: "#6c7086"
+ background: Rectangle { color: "#313244"; border.color: aliasInput.activeFocus ? "#cba6f7" : "#585b70"; radius: 4 }
+ }
+ Row {
+ spacing: 8
+ ActionButton { label: "Réinitialiser"; enabled: !!page.selectedDevice; onClicked: { page.selectedDevice.name = ""; aliasInput.text = page.selectedDevice.name; } }
+ ActionButton { label: "Enregistrer"; enabled: !!page.selectedDevice; onClicked: page.selectedDevice.name = aliasInput.text.trim() }
+ }
+ Text { text: "Informations techniques : " + (page.selectedDevice ? page.selectedDevice.address : ""); color: "#6c7086"; font.pixelSize: 10 }
+ }
+ }
+ }
+ Component {
+ id: forgetComponent
+ Column {
+ spacing: 10
+ Text { width: page.width; wrapMode: Text.Wrap; text: "Oublier " + page.nameOf(page.selectedDevice) + " ?"; color: "#f38ba8"; font.pixelSize: 13 }
+ Text { width: page.width; wrapMode: Text.Wrap; text: "Cela supprimera les informations de pairage."; color: "#a6adc8"; font.pixelSize: 12 }
+ Row {
+ spacing: 8
+ ActionButton { label: "Annuler"; onClicked: page.stage = "details" }
+ ActionButton { label: "Oublier"; danger: true; onClicked: { if (page.selectedDevice) page.selectedDevice.forget(); page.stage = "main"; } }
+ }
+ }
+ }
+ Component {
+ id: blockComponent
+ Column {
+ spacing: 10
+ Text { width: page.width; wrapMode: Text.Wrap; text: "Bloquer " + page.nameOf(page.selectedDevice) + " ?"; color: "#f38ba8"; font.pixelSize: 13 }
+ Text { width: page.width; wrapMode: Text.Wrap; text: "Un appareil bloqué ne peut pas se connecter."; color: "#a6adc8"; font.pixelSize: 12 }
+ Row {
+ spacing: 8
+ ActionButton { label: "Annuler"; onClicked: page.stage = "details" }
+ ActionButton { label: "Bloquer"; danger: true; onClicked: { if (page.selectedDevice) page.selectedDevice.blocked = true; page.stage = "details"; } }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/BluetoothTile.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/BluetoothTile.qml
new file mode 100644
index 000000000..4d47ba559
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/BluetoothTile.qml
@@ -0,0 +1,22 @@
+import QtQuick
+import Quickshell.Bluetooth
+
+QuickToggle {
+ id: tile
+ required property var adapter
+
+ readonly property var connectedDevices: adapter && adapter.devices
+ ? adapter.devices.values.filter(device => device.connected) : []
+
+ title: "Bluetooth"
+ icon: ""
+ active: adapter !== null && adapter.enabled
+ toggleEnabled: adapter !== null
+ subtitle: !adapter ? "Indisponible" : !adapter.enabled ? "Désactivé"
+ : connectedDevices.length === 1 ? connectedDevices[0].name
+ : connectedDevices.length > 1 ? connectedDevices.length + " appareils" : "Activé"
+
+ onToggleRequested: {
+ if (adapter) adapter.enabled = !adapter.enabled;
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/BrightnessSlider.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/BrightnessSlider.qml
new file mode 100644
index 000000000..279a5f7c7
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/BrightnessSlider.qml
@@ -0,0 +1,111 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell.Io
+import "../components"
+
+Item {
+ id: brightnessControl
+
+ // Ce périphérique est celui du panneau interne ; sysfs exige une écriture non atomique.
+ readonly property string backlightPath: "/sys/class/backlight/amdgpu_bl1"
+ readonly property int maximum: parseInt(maximumFile.text(), 10) || 0
+ readonly property real brightness: parseInt(brightnessFile.text(), 10)
+ readonly property bool available: maximum > 0 && Number.isFinite(brightness)
+ readonly property real fraction: available ? brightness / maximum : 0
+ readonly property int percent: Math.round(fraction * 100)
+
+ implicitHeight: content.implicitHeight
+
+ FileView {
+ id: maximumFile
+ path: brightnessControl.backlightPath + "/max_brightness"
+ }
+
+ FileView {
+ id: brightnessFile
+ path: brightnessControl.backlightPath + "/brightness"
+ watchChanges: true
+ atomicWrites: false
+ onFileChanged: reload()
+ }
+
+ Column {
+ id: content
+ width: parent.width
+ spacing: 12
+
+ Item {
+ width: parent.width
+ height: 28
+
+ NerdIcon {
+ id: icon
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ text: ""
+ font.pixelSize: 18
+ color: "#cdd6f4"
+ }
+
+ Text {
+ anchors.left: icon.right
+ anchors.leftMargin: 10
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Luminosité"
+ color: "#cdd6f4"
+ font.pixelSize: 14
+ }
+
+ Text {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ text: brightnessControl.available ? brightnessControl.percent + "%" : "Indisponible"
+ color: "#a6adc8"
+ font.pixelSize: 14
+ }
+ }
+
+ Slider {
+ id: slider
+ width: parent.width
+ height: 30
+ from: 0.1
+ to: 1
+ enabled: brightnessControl.available
+ value: brightnessControl.available
+ ? Math.max(from, Math.min(to, brightnessControl.fraction)) : from
+
+ onMoved: {
+ if (brightnessControl.available) {
+ const fraction = Math.max(from, Math.min(to, value));
+ brightnessFile.setText(String(Math.round(brightnessControl.maximum * fraction)));
+ }
+ }
+
+ background: Rectangle {
+ x: slider.leftPadding
+ y: slider.topPadding + slider.availableHeight / 2 - height / 2
+ width: slider.availableWidth
+ height: 8
+ radius: 4
+ color: "#313244"
+
+ Rectangle {
+ width: slider.visualPosition * parent.width
+ height: parent.height
+ radius: parent.radius
+ color: "#cba6f7"
+ }
+ }
+
+ handle: Rectangle {
+ x: slider.leftPadding + slider.visualPosition * (slider.availableWidth - width)
+ y: slider.topPadding + slider.availableHeight / 2 - height / 2
+ width: 16
+ height: 16
+ radius: 8
+ color: "#cdd6f4"
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/CONNECTIVITY.md b/quickshell/.config/quickshell/labfy-sway/controlcenter/CONNECTIVITY.md
new file mode 100644
index 000000000..07fc5922a
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/CONNECTIVITY.md
@@ -0,0 +1,40 @@
+# Connectivité du Control Center
+
+Backend : QuickShell 0.3.1, `Quickshell.Networking` sur NetworkManager et
+`Quickshell.Bluetooth` sur BlueZ. Les pages n'exécutent ni `nmcli` ni
+`bluetoothctl` pour leurs opérations courantes.
+
+## Wi-Fi
+
+Le scan est possédé par la page seulement si elle active `scannerEnabled`.
+À la fermeture, elle ne désactive que son propre scan. Un clic sur un réseau
+connu non connecté déclenche `connect()` ; un réseau nouveau compatible
+WPA/WPA2-PSK ou SAE ouvre `connectWithPsk()`. Un profil particulier est utilisé
+avec `connectWithSettings()` et peut être oublié avec `NMSettings.forget()`.
+L'oubli global `Network.forget()` a sa propre confirmation.
+
+La liste « Réseaux enregistrés visibles » est limitée aux réseaux exposés par
+`NetworkDevice.networks` ; l'API locale ne fournit pas de catalogue global des
+profils hors de portée. Le réglage `connection.autoconnect` par profil est
+reporté tant que le format de `NMSettings.read()/write()` n'a pas été confirmé
+sur un profil de test. La propriété `NetworkDevice.autoconnect` est un réglage
+de périphérique et ne remplace pas ce réglage par profil.
+
+Les secrets PSK restent dans le `TextField` jusqu'à l'appel natif, puis le champ
+est vidé. Aucun secret n'est enregistré ou journalisé par QuickShell.
+
+## Bluetooth
+
+`blueman-applet` est actif et conservé pour la prise en charge des demandes
+d'authentification BlueZ (PIN, passkeys et confirmations). Son enregistrement
+effectif comme agent n'a pas été éprouvé par un pairage réel. QuickShell 0.3.1 expose `pair()` et
+`cancelPair()`, sans interface d'agent d'authentification dans les qmltypes
+locaux. Le pairage réel demande un appareil choisi par l'utilisateur.
+
+La découverte est activée uniquement quand la page l'initie et est arrêtée à
+sa sortie. `discoverable` n'est jamais modifié automatiquement. Les opérations
+`forget()`, `blocked`, `trusted`, `wakeAllowed` et l'alias local ne sont
+appliquées qu'après une action explicite. `BluetoothDeviceState` n'expose que
+Disconnected, Connected, Disconnecting et Connecting : les erreurs détaillées
+BlueZ ne sont pas disponibles dans cette API, donc l'interface ne simule pas
+un succès après un clic.
diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/ConfirmAction.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/ConfirmAction.qml
new file mode 100644
index 000000000..f6e06eeca
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/ConfirmAction.qml
@@ -0,0 +1,83 @@
+import QtQuick
+
+Item {
+ id: confirmation
+
+ required property var actionInfo
+ signal cancelled()
+ signal confirmed(string actionId)
+
+ implicitHeight: Math.max(120, content.implicitHeight)
+
+ Column {
+ id: content
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ spacing: 16
+
+ Text {
+ width: parent.width
+ text: confirmation.actionInfo.confirmTitle
+ color: "#cdd6f4"
+ font.pixelSize: 16
+ font.bold: true
+ wrapMode: Text.Wrap
+ }
+
+ Text {
+ width: parent.width
+ text: confirmation.actionInfo.description
+ color: "#a6adc8"
+ font.pixelSize: 13
+ wrapMode: Text.Wrap
+ }
+
+ Row {
+ width: parent.width
+ height: 34
+ spacing: 8
+
+ Rectangle {
+ width: (parent.width - parent.spacing) / 2
+ height: parent.height
+ radius: 4
+ color: cancelPointer.containsMouse ? "#45475a" : "#313244"
+
+ Text {
+ anchors.centerIn: parent
+ text: "Annuler"
+ color: "#cdd6f4"
+ font.pixelSize: 13
+ }
+ MouseArea {
+ id: cancelPointer
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.LeftButton
+ onClicked: confirmation.cancelled()
+ }
+ }
+
+ Rectangle {
+ width: (parent.width - parent.spacing) / 2
+ height: parent.height
+ radius: 4
+ color: confirmation.actionInfo.accent
+
+ Text {
+ anchors.centerIn: parent
+ text: confirmation.actionInfo.label
+ color: "#1e1e2e"
+ font.pixelSize: 13
+ font.bold: true
+ }
+ MouseArea {
+ anchors.fill: parent
+ acceptedButtons: Qt.LeftButton
+ onClicked: confirmation.confirmed(confirmation.actionInfo.id)
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml
new file mode 100644
index 000000000..51210ac39
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenter.qml
@@ -0,0 +1,193 @@
+import QtQuick
+import QtQuick.Layouts
+import Quickshell
+import Quickshell.I3
+import Quickshell.Networking
+import Quickshell.Bluetooth
+import "../components"
+
+PopupWindow {
+ id: popup
+
+ required property var barWindow
+ property int currentPage: 0 // 0 = MAIN, 1 = Wi-Fi, 2 = Bluetooth, 3 = Session, 4 = confirmation, 5 = Batterie.
+ signal thresholdApplied()
+ property var pendingAction: null
+ // Une seule table associe les libellés, confirmations et commandes de session.
+ readonly property var sessionActions: [
+ { id: "lock", label: "Verrouiller", icon: "", accent: "#b4befe", command: ["swaylock"] },
+ { id: "suspend", label: "Veille", icon: "", accent: "#b4befe",
+ confirmTitle: "Mettre l'ordinateur en veille ?",
+ description: "La session sera verrouillée avant la veille.",
+ command: ["systemctl", "suspend"] },
+ { id: "logout", label: "Déconnexion", icon: "", accent: "#fab387",
+ confirmTitle: "Se déconnecter ?",
+ description: "La session SwayFX en cours sera fermée.", command: null },
+ { id: "reboot", label: "Redémarrer", icon: "", accent: "#f9e2af",
+ confirmTitle: "Redémarrer l'ordinateur ?",
+ description: "La session en cours sera fermée.",
+ command: ["systemctl", "reboot"] },
+ { id: "poweroff", label: "Éteindre", icon: "", accent: "#f38ba8",
+ confirmTitle: "Éteindre l'ordinateur ?",
+ description: "La session en cours sera fermée.",
+ command: ["systemctl", "poweroff"] }
+ ]
+ readonly property var wifiDevice: Networking.devices.values.find(device => device.type === DeviceType.Wifi) || null
+ readonly property var adapter: Bluetooth.defaultAdapter
+
+ // Une nouvelle ouverture recommence sur MAIN ; les pages libèrent leurs scans à la fermeture.
+ onVisibleChanged: if (!visible) {
+ currentPage = 0;
+ pendingAction = null;
+ }
+
+ function requestSessionAction(id) {
+ const selected = sessionActions.find(item => item.id === id);
+ if (!selected) return;
+ if (id === "lock") {
+ executeSessionAction(id);
+ return;
+ }
+ pendingAction = selected;
+ currentPage = 4;
+ }
+
+ function executeSessionAction(id) {
+ const selected = sessionActions.find(item => item.id === id);
+ if (!selected) return;
+ // Fermer le popup avant toute commande qui peut verrouiller ou terminer la session.
+ visible = false;
+ if (id === "logout") I3.dispatch("exit");
+ else Quickshell.execDetached(selected.command);
+ }
+
+ anchor.window: barWindow
+ anchor.rect.x: barWindow.width - implicitWidth - 8
+ anchor.rect.y: barWindow.height + 6
+ anchor.edges: Edges.Top | Edges.Left
+ anchor.gravity: Edges.Bottom | Edges.Right
+
+ implicitWidth: 400
+ implicitHeight: currentPage === 0 ? mainPage.implicitHeight + 32
+ : currentPage === 3 ? sessionPage.implicitHeight + 32
+ : currentPage === 4 ? confirmationPage.implicitHeight + 32
+ : currentPage === 5 ? batteryPage.implicitHeight + 32 : 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
+ // pour le mot de passe Wi-Fi et l'alias Bluetooth après navigation interne.
+ grabFocus: true
+ color: "transparent"
+
+ Rectangle {
+ anchors.fill: parent
+ radius: 4
+ color: "#1e1e2e"
+ border.color: "#45475a"
+
+ StackLayout {
+ anchors.fill: parent
+ anchors.margins: 16
+ currentIndex: popup.currentPage
+
+ Column {
+ id: mainPage
+ spacing: 18
+
+ Text {
+ text: "Réglages rapides"
+ color: "#cdd6f4"
+ font.pixelSize: 16
+ font.bold: true
+ }
+
+ Row {
+ width: parent.width
+ spacing: 8
+
+ WifiTile {
+ width: (parent.width - parent.spacing) / 2
+ wifiDevice: popup.wifiDevice
+ onDetailsRequested: popup.currentPage = 1
+ }
+ BluetoothTile {
+ width: (parent.width - parent.spacing) / 2
+ adapter: popup.adapter
+ onDetailsRequested: popup.currentPage = 2
+ }
+ }
+
+ VolumeSlider { width: parent.width }
+ BrightnessSlider { width: parent.width }
+ PowerProfile { width: parent.width }
+
+ Item {
+ width: parent.width
+ height: 30
+
+ Rectangle {
+ anchors.right: parent.right
+ width: 40
+ height: 30
+ radius: 4
+ color: powerPointer.containsMouse ? "#45475a" : "#313244"
+
+ NerdIcon {
+ anchors.centerIn: parent
+ text: ""
+ }
+ MouseArea {
+ id: powerPointer
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.LeftButton
+ onClicked: popup.currentPage = 3
+ }
+ }
+ }
+ }
+
+ WifiPage {
+ id: wifiPage
+ wifiDevice: popup.wifiDevice
+ activePage: popup.visible && popup.currentPage === 1
+ onBackRequested: popup.currentPage = 0
+ }
+
+ BluetoothPage {
+ id: bluetoothPage
+ adapter: popup.adapter
+ activePage: popup.visible && popup.currentPage === 2
+ onBackRequested: popup.currentPage = 0
+ }
+
+ SessionPage {
+ id: sessionPage
+ actions: popup.sessionActions
+ onBackRequested: popup.currentPage = 0
+ onActionRequested: id => popup.requestSessionAction(id)
+ }
+
+ ConfirmAction {
+ id: confirmationPage
+ actionInfo: popup.pendingAction || ({ id: "", label: "", confirmTitle: "",
+ description: "", accent: "#cdd6f4" })
+ onCancelled: {
+ popup.pendingAction = null;
+ popup.currentPage = 3;
+ }
+ onConfirmed: id => {
+ if (popup.pendingAction && popup.pendingAction.id === id)
+ popup.executeSessionAction(id);
+ }
+ }
+
+ BatterySettings {
+ id: batteryPage
+ activePage: popup.visible && popup.currentPage === 5
+ onBackRequested: popup.currentPage = 0
+ onThresholdApplied: popup.thresholdApplied()
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenterButton.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenterButton.qml
new file mode 100644
index 000000000..2292590e8
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/ControlCenterButton.qml
@@ -0,0 +1,30 @@
+import QtQuick
+import "../components"
+
+Rectangle {
+ id: controlButton
+
+ required property bool open
+ signal toggled()
+
+ width: 48
+ height: 26
+ radius: 4
+ color: open ? "#cba6f7" : pointer.containsMouse ? "#45475a" : "#313244"
+
+ NerdIcon {
+ anchors.centerIn: parent
+ text: ""
+ // WHY: garder le même poids visuel que les statuts voisins dans la capsule fixe.
+ font.pixelSize: 20
+ color: controlButton.open ? "#1e1e2e" : "#cdd6f4"
+ }
+
+ MouseArea {
+ id: pointer
+ anchors.fill: parent
+ acceptedButtons: Qt.LeftButton
+ hoverEnabled: true
+ onClicked: controlButton.toggled()
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/PowerProfile.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/PowerProfile.qml
new file mode 100644
index 000000000..7e1e1ef1d
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/PowerProfile.qml
@@ -0,0 +1,81 @@
+import QtQuick
+import Quickshell.Services.UPower
+import "../components"
+
+Item {
+ id: powerControl
+
+ implicitHeight: content.implicitHeight
+
+ Column {
+ id: content
+ width: parent.width
+ spacing: 12
+
+ Item {
+ width: parent.width
+ height: 28
+
+ NerdIcon {
+ id: icon
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ text: ""
+ font.pixelSize: 18
+ color: "#cdd6f4"
+ }
+
+ Text {
+ anchors.left: icon.right
+ anchors.leftMargin: 10
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Profil d'énergie"
+ color: "#cdd6f4"
+ font.pixelSize: 14
+ }
+ }
+
+ Row {
+ id: choices
+ width: parent.width
+ height: 34
+ spacing: 4
+
+ Repeater {
+ model: [
+ { label: "Économie", profile: PowerProfile.PowerSaver },
+ { label: "Équilibré", profile: PowerProfile.Balanced },
+ { label: "Performance", profile: PowerProfile.Performance }
+ ]
+
+ Rectangle {
+ required property var modelData
+ readonly property bool available: modelData.profile !== PowerProfile.Performance
+ || PowerProfiles.hasPerformanceProfile
+ readonly property bool selected: PowerProfiles.profile === modelData.profile
+
+ width: (choices.width - 2 * choices.spacing) / 3
+ height: choices.height
+ radius: 4
+ color: !available ? "#313244" : selected ? "#cba6f7" : hover.hovered ? "#45475a" : "#313244"
+
+ Text {
+ anchors.centerIn: parent
+ text: parent.modelData.label
+ color: !parent.available ? "#6c7086" : parent.selected ? "#1e1e2e" : "#cdd6f4"
+ font.pixelSize: 12
+ }
+
+ HoverHandler { id: hover }
+
+ MouseArea {
+ anchors.fill: parent
+ enabled: parent.available
+ acceptedButtons: Qt.LeftButton
+ onClicked: PowerProfiles.profile = parent.modelData.profile
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/QuickToggle.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/QuickToggle.qml
new file mode 100644
index 000000000..bb6f9d8dd
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/QuickToggle.qml
@@ -0,0 +1,83 @@
+import QtQuick
+import "../components"
+
+Rectangle {
+ id: tile
+
+ required property string title
+ required property string subtitle
+ required property string icon
+ required property bool active
+ property bool toggleEnabled: true
+ signal toggleRequested()
+ signal detailsRequested()
+
+ height: 58
+ radius: 4
+ color: active ? "#cba6f7" : "#313244"
+ readonly property color foreground: active ? "#1e1e2e" : "#cdd6f4"
+
+ NerdIcon {
+ id: symbol
+ anchors.left: parent.left
+ anchors.leftMargin: 11
+ anchors.verticalCenter: parent.verticalCenter
+ text: tile.icon
+ color: tile.foreground
+ font.pixelSize: 19
+ }
+
+ Column {
+ anchors.left: symbol.right
+ anchors.leftMargin: 9
+ anchors.right: chevron.left
+ anchors.rightMargin: 2
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 1
+
+ Text {
+ width: parent.width
+ text: tile.title
+ color: tile.foreground
+ font.pixelSize: 13
+ font.bold: true
+ elide: Text.ElideRight
+ }
+ Text {
+ width: parent.width
+ text: tile.subtitle
+ color: tile.foreground
+ opacity: 0.82
+ font.pixelSize: 11
+ elide: Text.ElideRight
+ }
+ }
+
+ Item {
+ id: chevron
+ anchors.right: parent.right
+ width: 28
+ height: parent.height
+
+ NerdIcon {
+ anchors.centerIn: parent
+ text: ""
+ color: tile.foreground
+ }
+ MouseArea {
+ anchors.fill: parent
+ acceptedButtons: Qt.LeftButton
+ onClicked: tile.detailsRequested()
+ }
+ }
+
+ MouseArea {
+ anchors.left: parent.left
+ anchors.right: chevron.left
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ enabled: tile.toggleEnabled
+ acceptedButtons: Qt.LeftButton
+ onClicked: tile.toggleRequested()
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionAction.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionAction.qml
new file mode 100644
index 000000000..3bacfa9f5
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionAction.qml
@@ -0,0 +1,41 @@
+import QtQuick
+import "../components"
+
+Rectangle {
+ id: actionButton
+
+ required property var actionInfo
+ signal selected(string actionId)
+
+ height: 48
+ radius: 4
+ color: pointer.containsMouse ? "#45475a" : "#313244"
+
+ NerdIcon {
+ id: icon
+ anchors.left: parent.left
+ anchors.leftMargin: 14
+ anchors.verticalCenter: parent.verticalCenter
+ width: 22
+ text: actionButton.actionInfo.icon
+ color: actionButton.actionInfo.accent
+ font.pixelSize: 18
+ }
+
+ Text {
+ anchors.left: icon.right
+ anchors.leftMargin: 12
+ anchors.verticalCenter: parent.verticalCenter
+ text: actionButton.actionInfo.label
+ color: "#cdd6f4"
+ font.pixelSize: 14
+ }
+
+ MouseArea {
+ id: pointer
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.LeftButton
+ onClicked: actionButton.selected(actionButton.actionInfo.id)
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionPage.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionPage.qml
new file mode 100644
index 000000000..14f3da794
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/SessionPage.qml
@@ -0,0 +1,61 @@
+import QtQuick
+import Quickshell
+import "../components"
+
+Item {
+ id: page
+
+ required property var actions
+ signal backRequested()
+ signal actionRequested(string actionId)
+
+ // La taille doit être connue avant la création des delegates pour éviter un popup de hauteur nulle.
+ implicitHeight: 28 + actions.length * 48 + actions.length * 10
+
+ Column {
+ id: content
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ spacing: 10
+
+ Item {
+ width: parent.width
+ height: 28
+
+ NerdIcon {
+ id: backIcon
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ text: ""
+ }
+ Text {
+ anchors.left: backIcon.right
+ anchors.leftMargin: 8
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Session"
+ color: "#cdd6f4"
+ font.pixelSize: 16
+ font.bold: true
+ }
+ MouseArea {
+ anchors.left: parent.left
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ width: 100
+ acceptedButtons: Qt.LeftButton
+ onClicked: page.backRequested()
+ }
+ }
+
+ Repeater {
+ model: page.actions
+ delegate: SessionAction {
+ required property var modelData
+ width: content.width
+ actionInfo: modelData
+ onSelected: actionId => page.actionRequested(actionId)
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/VolumeSlider.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/VolumeSlider.qml
new file mode 100644
index 000000000..73e57f378
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/VolumeSlider.qml
@@ -0,0 +1,105 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell.Services.Pipewire
+import "../components"
+
+Item {
+ id: volumeControl
+
+ readonly property var sink: Pipewire.defaultAudioSink
+ // Le tracker suit le sink par défaut ; attendre ready avant toute lecture ou écriture.
+ readonly property var sinkAudio: sink && sink.ready && sink.audio ? sink.audio : null
+ readonly property bool muted: sinkAudio ? sinkAudio.muted : false
+ readonly property int percent: sinkAudio ? Math.round(sinkAudio.volume * 100) : 0
+
+ PwObjectTracker {
+ objects: [volumeControl.sink]
+ }
+
+ implicitHeight: content.implicitHeight
+
+ Column {
+ id: content
+ width: parent.width
+ spacing: 12
+
+ Item {
+ width: parent.width
+ height: 28
+
+ NerdIcon {
+ id: icon
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ text: volumeControl.sinkAudio ? (volumeControl.muted ? "" : "") : ""
+ font.pixelSize: 18
+ color: volumeControl.muted ? "#f38ba8" : "#cdd6f4"
+
+ MouseArea {
+ anchors.fill: parent
+ enabled: volumeControl.sinkAudio !== null
+ acceptedButtons: Qt.LeftButton
+ onClicked: volumeControl.sinkAudio.muted = !volumeControl.sinkAudio.muted
+ }
+ }
+
+ Text {
+ anchors.left: icon.right
+ anchors.leftMargin: 10
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Volume"
+ color: "#cdd6f4"
+ font.pixelSize: 14
+ }
+
+ Text {
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ text: volumeControl.sinkAudio ? volumeControl.percent + "%" : "Indisponible"
+ color: volumeControl.muted ? "#f38ba8" : "#a6adc8"
+ font.pixelSize: 14
+ }
+ }
+
+ Slider {
+ id: slider
+ width: parent.width
+ height: 30
+ from: 0
+ to: 1
+ enabled: volumeControl.sinkAudio !== null
+ value: volumeControl.sinkAudio
+ ? Math.max(0, Math.min(1, volumeControl.sinkAudio.volume)) : 0
+
+ onMoved: {
+ if (volumeControl.sinkAudio)
+ volumeControl.sinkAudio.volume = Math.max(0, Math.min(1, value));
+ }
+
+ background: Rectangle {
+ x: slider.leftPadding
+ y: slider.topPadding + slider.availableHeight / 2 - height / 2
+ width: slider.availableWidth
+ height: 8
+ radius: 4
+ color: "#313244"
+
+ Rectangle {
+ width: slider.visualPosition * parent.width
+ height: parent.height
+ radius: parent.radius
+ color: "#cba6f7"
+ }
+ }
+
+ handle: Rectangle {
+ x: slider.leftPadding + slider.visualPosition * (slider.availableWidth - width)
+ y: slider.topPadding + slider.availableHeight / 2 - height / 2
+ width: 16
+ height: 16
+ radius: 8
+ color: "#cdd6f4"
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/WifiPage.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/WifiPage.qml
new file mode 100644
index 000000000..0ed77c2d7
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/WifiPage.qml
@@ -0,0 +1,320 @@
+import QtQuick
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Networking
+import "../components"
+
+Item {
+ id: page
+ required property var wifiDevice
+ required property bool activePage
+ signal backRequested()
+ property var scanOwnedDevice: null
+ property string stage: "main" // main, details, password, forget
+ property string selectedName: ""
+ property int selectedProfile: -1
+ property string message: ""
+ readonly property bool passwordOpen: stage === "password" && activePage
+ readonly property bool scanWanted: activePage && Networking.wifiEnabled
+ && Networking.wifiHardwareEnabled && wifiDevice !== null
+ readonly property var sortedNetworks: {
+ if (!wifiDevice || !wifiDevice.networks) return [];
+ const networks = wifiDevice.networks.values.slice();
+ networks.sort((a, b) => {
+ const group = network => network.connected ? 0 : network.known ? 1 : 2;
+ return group(a) - group(b) || b.signalStrength - a.signalStrength
+ || a.name.localeCompare(b.name);
+ });
+ const names = new Set();
+ return networks.filter(network => {
+ if (!network.name || names.has(network.name)) return false;
+ names.add(network.name);
+ return true;
+ });
+ }
+ readonly property var currentNetwork: sortedNetworks.find(n => n.connected) || null
+ readonly property var selectedNetwork: sortedNetworks.find(n => n.name === selectedName) || null
+ readonly property var profiles: selectedNetwork && selectedNetwork.nmSettings
+ ? selectedNetwork.nmSettings : []
+
+ // CONTRACT: ne rendre au repos que le scan que cette page a lancé.
+ function syncScan() {
+ if (scanOwnedDevice && (!scanWanted || scanOwnedDevice !== wifiDevice)) {
+ scanOwnedDevice.scannerEnabled = false;
+ scanOwnedDevice = null;
+ }
+ if (scanWanted && !wifiDevice.scannerEnabled) {
+ wifiDevice.scannerEnabled = true;
+ scanOwnedDevice = wifiDevice;
+ }
+ }
+ onScanWantedChanged: syncScan()
+ onWifiDeviceChanged: syncScan()
+ onActivePageChanged: if (!activePage) { stage = "main"; selectedName = ""; message = ""; }
+ Component.onDestruction: if (scanOwnedDevice) scanOwnedDevice.scannerEnabled = false
+
+ function securityLabel(network) {
+ if (!network) return "";
+ switch (network.security) {
+ case WifiSecurityType.Open: return "Ouvert";
+ case WifiSecurityType.WpaPsk: return "WPA";
+ case WifiSecurityType.Wpa2Psk: return "WPA2";
+ case WifiSecurityType.Sae: return "WPA3 SAE";
+ case WifiSecurityType.WpaEap: case WifiSecurityType.Wpa2Eap: return "Entreprise";
+ default: return WifiSecurityType.toString(network.security);
+ }
+ }
+ function pskSupported(network) {
+ return network && (network.security === WifiSecurityType.WpaPsk
+ || network.security === WifiSecurityType.Wpa2Psk
+ || network.security === WifiSecurityType.Sae);
+ }
+ function stateLabel(network) {
+ return network.stateChanging ? (network.state === ConnectionState.Disconnecting
+ ? "Déconnexion…" : "Connexion…") : network.connected ? "Connecté"
+ : network.known ? "Connu" : "Disponible";
+ }
+ function failureLabel(reason) {
+ switch (reason) {
+ case ConnectionFailReason.NoSecrets: return "Secret absent ou incorrect";
+ case ConnectionFailReason.WifiAuthTimeout: return "Délai d'authentification dépassé";
+ case ConnectionFailReason.WifiNetworkLost: return "Réseau disparu";
+ case ConnectionFailReason.WifiClientDisconnected: return "Connexion annulée";
+ case ConnectionFailReason.WifiClientFailed: return "Connexion échouée";
+ default: return "Échec de connexion";
+ }
+ }
+ function choose(network) { selectedName = network.name; selectedProfile = -1; message = ""; stage = "details"; }
+ function connectSelected() {
+ const network = selectedNetwork;
+ if (!network || network.stateChanging || network.connected) return;
+ message = "";
+ if (!Networking.wifiEnabled || !Networking.wifiHardwareEnabled) {
+ message = "Radio Wi-Fi désactivée ou bloquée"; return;
+ }
+ if (network.known || network.security === WifiSecurityType.Open) network.connect();
+ else if (pskSupported(network)) stage = "password";
+ else message = "Configuration avancée requise";
+ }
+ function back() {
+ if (stage === "main") backRequested();
+ else if (stage === "password" || stage === "forget") {
+ if (stage === "password" && body.item && body.item.passwordInput)
+ body.item.passwordInput.clear();
+ stage = "details";
+ }
+ else stage = "main";
+ message = "";
+ }
+ Connections {
+ target: page.selectedNetwork
+ function onConnectionFailed(reason) { page.message = page.failureLabel(reason); page.stage = "details"; }
+ }
+
+ Column {
+ anchors.fill: parent
+ spacing: 10
+ Row {
+ width: parent.width; height: 30; spacing: 8
+ ActionButton { label: ""; onClicked: page.back() }
+ Text {
+ width: parent.width - 100; height: 30; verticalAlignment: Text.AlignVCenter
+ text: page.stage === "main" ? "Wi-Fi" : page.stage === "password"
+ ? "Se connecter à " + page.selectedName : page.stage === "forget"
+ ? "Oublier un réseau" : page.selectedName
+ color: "#cdd6f4"; font.pixelSize: 16; font.bold: true; elide: Text.ElideRight
+ }
+ }
+ Text {
+ width: parent.width; visible: page.message.length > 0
+ text: page.message; wrapMode: Text.Wrap; color: "#f9e2af"; font.pixelSize: 12
+ }
+ Loader {
+ id: body
+ width: parent.width; height: Math.max(0, parent.height - y)
+ sourceComponent: page.stage === "main" ? mainComponent : page.stage === "details"
+ ? detailsComponent : page.stage === "password" ? passwordComponent : forgetComponent
+ onLoaded: if (page.stage === "password" && item && item.passwordInput)
+ Qt.callLater(() => { if (body.item && body.item.passwordInput) body.item.passwordInput.forceActiveFocus(); })
+ }
+ }
+
+ Component {
+ id: mainComponent
+ ScrollView {
+ clip: true; ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
+ Column {
+ width: page.width; spacing: 9
+ Row {
+ width: parent.width; spacing: 8
+ Text { width: parent.width - 100; text: "Wi-Fi " + (Networking.wifiEnabled ? "activé" : "désactivé"); color: "#cdd6f4"; font.pixelSize: 13 }
+ ActionButton {
+ label: Networking.wifiEnabled ? "Désactiver" : "Activer"
+ enabled: Networking.wifiHardwareEnabled
+ onClicked: Networking.wifiEnabled = !Networking.wifiEnabled
+ }
+ }
+ Text { text: page.scanWanted ? "Recherche de réseaux…" : "Scan arrêté"; color: "#a6adc8"; font.pixelSize: 11 }
+ Text { text: "Réseau actuel"; color: "#cba6f7"; font.bold: true; font.pixelSize: 13 }
+ Rectangle {
+ width: parent.width; height: 66; radius: 4; color: "#313244"
+ Text {
+ anchors.left: parent.left; anchors.leftMargin: 10; anchors.top: parent.top; anchors.topMargin: 8
+ text: page.currentNetwork ? page.currentNetwork.name : "Aucun réseau connecté"
+ color: "#cdd6f4"; font.pixelSize: 13; font.bold: true
+ }
+ Text {
+ anchors.left: parent.left; anchors.leftMargin: 10; anchors.bottom: parent.bottom; anchors.bottomMargin: 8
+ text: page.currentNetwork ? page.securityLabel(page.currentNetwork) + " • "
+ + Math.round(page.currentNetwork.signalStrength * 100) + " %" : ""
+ color: "#a6adc8"; font.pixelSize: 11
+ }
+ ActionButton {
+ anchors.right: parent.right; anchors.rightMargin: 8; anchors.verticalCenter: parent.verticalCenter
+ label: "Détails"; visible: !!page.currentNetwork
+ onClicked: page.choose(page.currentNetwork)
+ }
+ }
+ Text { text: "Réseaux disponibles"; color: "#cba6f7"; font.bold: true; font.pixelSize: 13 }
+ Text {
+ visible: page.sortedNetworks.length === 0
+ text: !Networking.wifiHardwareEnabled ? "Wi-Fi bloqué matériellement" : "Aucun réseau visible"
+ color: "#a6adc8"; font.pixelSize: 12
+ }
+ Repeater {
+ model: ScriptModel { values: page.sortedNetworks; objectProp: "name" }
+ delegate: Rectangle {
+ required property var modelData
+ readonly property var network: modelData
+ width: page.width; height: 44; radius: 4
+ color: pointer.containsMouse ? "#45475a" : "#313244"
+ NerdIcon {
+ anchors.left: parent.left; anchors.leftMargin: 9; anchors.verticalCenter: parent.verticalCenter
+ text: network.connected ? "" : network.security === WifiSecurityType.Open ? "" : ""
+ color: network.connected ? "#a6e3a1" : "#cdd6f4"; font.pixelSize: 16
+ }
+ Text {
+ anchors.left: parent.left; anchors.leftMargin: 37; anchors.right: strength.left
+ anchors.rightMargin: 8; anchors.verticalCenter: parent.verticalCenter
+ text: network.name + " • " + page.stateLabel(network)
+ color: "#cdd6f4"; font.pixelSize: 12; elide: Text.ElideRight
+ }
+ Text {
+ id: strength; anchors.right: parent.right; anchors.rightMargin: 9
+ anchors.verticalCenter: parent.verticalCenter
+ text: Math.round(network.signalStrength * 100) + "%"
+ color: "#a6adc8"; font.pixelSize: 11
+ }
+ MouseArea {
+ id: pointer; anchors.fill: parent; hoverEnabled: true
+ onClicked: {
+ page.choose(network);
+ if (network.known && !network.connected && !network.stateChanging)
+ page.connectSelected();
+ }
+ }
+ }
+ }
+ Text { text: "Réseaux enregistrés visibles"; color: "#cba6f7"; font.bold: true; font.pixelSize: 13 }
+ Repeater {
+ model: ScriptModel { values: page.sortedNetworks.filter(n => n.known); objectProp: "name" }
+ delegate: ActionButton {
+ required property var modelData
+ label: modelData.name + " • " + (modelData.nmSettings ? modelData.nmSettings.length : 0) + " profil(s)"
+ onClicked: page.choose(modelData)
+ }
+ }
+ }
+ }
+ }
+ Component {
+ id: detailsComponent
+ ScrollView {
+ clip: true; ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
+ Column {
+ width: page.width; spacing: 10
+ Text { text: page.selectedNetwork ? page.stateLabel(page.selectedNetwork) + " • " + page.securityLabel(page.selectedNetwork) + " • " + Math.round(page.selectedNetwork.signalStrength * 100) + " %" : "Réseau indisponible"; color: "#cdd6f4"; font.pixelSize: 13 }
+ Row {
+ spacing: 8
+ ActionButton {
+ label: page.selectedNetwork && page.selectedNetwork.connected ? "Déconnecter" : "Se connecter"
+ enabled: !!page.selectedNetwork && !page.selectedNetwork.stateChanging
+ onClicked: {
+ if (page.selectedNetwork.connected) page.selectedNetwork.disconnect();
+ else page.connectSelected();
+ }
+ }
+ ActionButton {
+ label: "Oublier"; danger: true
+ visible: !!page.selectedNetwork && page.selectedNetwork.known
+ onClicked: { page.selectedProfile = -1; page.stage = "forget"; }
+ }
+ }
+ Text { text: "Profils NetworkManager"; color: "#cba6f7"; font.pixelSize: 13; font.bold: true }
+ Text { visible: page.profiles.length === 0; text: "Aucun profil enregistré"; color: "#a6adc8"; font.pixelSize: 12 }
+ Repeater {
+ model: page.profiles
+ delegate: Row {
+ required property var modelData
+ required property int index
+ spacing: 6
+ Text { width: 205; text: modelData.id + "\n" + modelData.uuid; color: "#cdd6f4"; font.pixelSize: 11; wrapMode: Text.WrapAnywhere }
+ ActionButton { label: "Utiliser"; onClicked: { page.message = ""; page.selectedNetwork.connectWithSettings(modelData); } }
+ ActionButton { label: "Oublier"; danger: true; onClicked: { page.selectedProfile = index; page.stage = "forget"; } }
+ }
+ }
+ }
+ }
+ }
+ Component {
+ id: passwordComponent
+ Column {
+ property alias passwordInput: input
+ spacing: 10
+ Text { text: "Mot de passe"; color: "#cdd6f4"; font.pixelSize: 13 }
+ TextField {
+ id: input; width: page.width; echoMode: TextInput.Password
+ placeholderText: "Mot de passe Wi-Fi"; selectByMouse: true
+ color: "#cdd6f4"; placeholderTextColor: "#6c7086"
+ background: Rectangle { color: "#313244"; border.color: input.activeFocus ? "#cba6f7" : "#585b70"; radius: 4 }
+ onAccepted: submit.clicked()
+ }
+ Row {
+ spacing: 8
+ ActionButton { label: "Annuler"; onClicked: { input.clear(); page.stage = "details"; } }
+ ActionButton {
+ id: submit; label: "Se connecter"
+ onClicked: {
+ // CONTRACT: le secret ne quitte ce champ que pour l'appel natif NM ; jamais de log ni de fichier.
+ const secret = input.text;
+ input.clear();
+ if (!(secret.length >= 8 && secret.length <= 63) && !/^[0-9a-fA-F]{64}$/.test(secret)) {
+ page.message = "Mot de passe : 8–63 caractères ou 64 chiffres hexadécimaux";
+ return;
+ }
+ if (page.selectedNetwork) page.selectedNetwork.connectWithPsk(secret);
+ page.stage = "details";
+ }
+ }
+ }
+ }
+ }
+ Component {
+ id: forgetComponent
+ Column {
+ spacing: 10
+ Text { width: page.width; wrapMode: Text.Wrap; text: "Oublier " + page.selectedName + " ?"; color: "#f38ba8"; font.pixelSize: 13 }
+ Text { width: page.width; wrapMode: Text.Wrap; text: page.selectedProfile >= 0 ? "Seul le profil sélectionné sera supprimé." : "Tous les profils de ce réseau seront supprimés."; color: "#a6adc8"; font.pixelSize: 12 }
+ Row {
+ spacing: 8
+ ActionButton { label: "Annuler"; onClicked: page.stage = "details" }
+ ActionButton { label: "Oublier"; danger: true; onClicked: {
+ if (page.selectedProfile >= 0 && page.selectedProfile < page.profiles.length)
+ page.profiles[page.selectedProfile].forget();
+ else if (page.selectedNetwork) page.selectedNetwork.forget();
+ page.stage = "main";
+ } }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/WifiTile.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/WifiTile.qml
new file mode 100644
index 000000000..afe3e5354
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/WifiTile.qml
@@ -0,0 +1,24 @@
+import QtQuick
+import Quickshell.Networking
+
+QuickToggle {
+ id: tile
+ required property var wifiDevice
+
+ readonly property var connectedNetwork: wifiDevice && wifiDevice.networks
+ ? wifiDevice.networks.values.find(network => network.connected) : null
+
+ title: "Wi-Fi"
+ icon: Networking.wifiEnabled && Networking.wifiHardwareEnabled ? "" : ""
+ active: Networking.wifiEnabled && Networking.wifiHardwareEnabled
+ toggleEnabled: wifiDevice !== null && Networking.wifiHardwareEnabled
+ subtitle: !Networking.wifiHardwareEnabled ? "Bloqué matériellement"
+ : !Networking.wifiEnabled ? "Désactivé"
+ : connectedNetwork ? connectedNetwork.name : "Non connecté"
+
+ // Le blocage matériel ne peut pas être levé par le bouton logiciel.
+ onToggleRequested: {
+ if (Networking.wifiHardwareEnabled && wifiDevice)
+ Networking.wifiEnabled = !Networking.wifiEnabled;
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/datecenter/DateCenter.qml b/quickshell/.config/quickshell/labfy-sway/datecenter/DateCenter.qml
new file mode 100644
index 000000000..ba7d1b282
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/datecenter/DateCenter.qml
@@ -0,0 +1,52 @@
+import QtQuick
+import Quickshell
+
+PopupWindow {
+ id: popup
+ required property var barWindow
+ required property var clockItem
+ required property var notificationService
+ property bool opening: false
+
+ // L'horloge reste le point d'ancrage du panneau.
+ anchor.window: barWindow
+ anchor.rect.x: clockItem.x + clockItem.width / 2 - implicitWidth / 2
+ anchor.rect.y: barWindow.height + 6
+ anchor.edges: Edges.Top | Edges.Left
+ anchor.gravity: Edges.Bottom | Edges.Right
+ implicitWidth: 450
+ // WHY: Column.implicitHeight conservait la plus grande hauteur atteinte après
+ // suppression d'items ; childrenRect.height reflète les zones visibles.
+ // INVARIANT: le Player conserve ses 116 px et seule la liste fait défiler ses items.
+ implicitHeight: Math.min(600, content.childrenRect.height + 32)
+ visible: false
+ // CONTRACT: seule la liste effectivement visible vaut consultation.
+ onVisibleChanged: notificationService.setDateCenterVisible(visible)
+ // Laisser les boutons de barre recevoir les clics d'exclusion XOR.
+ grabFocus: false
+ color: "transparent"
+ function requestOpen() { opening = false; visible = true; }
+ function requestClose() { opening = false; visible = false; }
+
+ SystemClock { id: clock; precision: SystemClock.Minutes }
+ Rectangle {
+ anchors.fill: parent
+ radius: 4
+ color: "#1e1e2e"
+ border.color: "#45475a"
+ Column {
+ id: content
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: 16
+ spacing: 14
+ Text {
+ text: clock.date.toLocaleDateString(Qt.locale("fr_FR"), "dddd d MMMM")
+ color: "#cba6f7"; font.pixelSize: 17; font.bold: true
+ }
+ MediaPlayer { width: parent.width }
+ NotificationList { width: parent.width; service: popup.notificationService }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/datecenter/MediaPlayer.qml b/quickshell/.config/quickshell/labfy-sway/datecenter/MediaPlayer.qml
new file mode 100644
index 000000000..ae0463fef
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/datecenter/MediaPlayer.qml
@@ -0,0 +1,118 @@
+import QtQuick
+import QtQuick.Layouts
+import Quickshell.Services.Mpris
+import "../components"
+
+Rectangle {
+ id: card
+
+ // Un proxy MPRIS comme playerctld duplique un lecteur réel ; l'ordre DBus
+ // départage les lecteurs de même état sans dépendre de leur arrivée.
+ readonly property var player: {
+ const candidates = Mpris.players.values.filter(item =>
+ item && item.dbusName !== "org.mpris.MediaPlayer2.playerctld"
+ && item.trackTitle && item.trackTitle.trim().length > 0);
+ candidates.sort((a, b) => a.dbusName.localeCompare(b.dbusName));
+ return candidates.find(item => item.playbackState === MprisPlaybackState.Playing)
+ || candidates.find(item => item.playbackState === MprisPlaybackState.Paused)
+ || null;
+ }
+
+ visible: player !== null
+ implicitHeight: visible ? 116 : 0
+ height: implicitHeight
+ radius: 4
+ color: "#313244"
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.margins: 12
+ spacing: 12
+
+ Rectangle {
+ Layout.preferredWidth: 72
+ Layout.preferredHeight: 72
+ radius: 4
+ color: "#45475a"
+ clip: true
+
+ Image {
+ id: artwork
+ anchors.fill: parent
+ source: card.player ? card.player.trackArtUrl : ""
+ fillMode: Image.PreserveAspectCrop
+ asynchronous: true
+ visible: status === Image.Ready
+ }
+ NerdIcon {
+ anchors.centerIn: parent
+ visible: !artwork.visible
+ text: ""
+ color: "#cba6f7"
+ font.pixelSize: 27
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 5
+
+ Text {
+ Layout.fillWidth: true
+ text: card.player ? card.player.trackTitle : ""
+ color: "#cdd6f4"
+ font.pixelSize: 13
+ font.bold: true
+ elide: Text.ElideRight
+ }
+ Text {
+ Layout.fillWidth: true
+ text: card.player ? card.player.trackArtist : ""
+ visible: text.length > 0
+ color: "#a6adc8"
+ font.pixelSize: 11
+ elide: Text.ElideRight
+ }
+
+ Row {
+ Layout.topMargin: 7
+ spacing: 8
+
+ Repeater {
+ model: [
+ { icon: "", capability: "canGoPrevious", action: "previous" },
+ { icon: card.player && card.player.isPlaying ? "" : "",
+ capability: "canTogglePlaying", action: "togglePlaying" },
+ { icon: "", capability: "canGoNext", action: "next" }
+ ]
+ Rectangle {
+ required property var modelData
+ readonly property bool available: card.player
+ && card.player[modelData.capability] === true
+ width: 32
+ height: 27
+ radius: 4
+ color: controlPointer.containsMouse && available ? "#585b70" : "#45475a"
+
+ NerdIcon {
+ anchors.centerIn: parent
+ text: modelData.icon
+ color: parent.available ? "#cdd6f4" : "#6c7086"
+ font.pixelSize: 14
+ }
+ MouseArea {
+ id: controlPointer
+ anchors.fill: parent
+ hoverEnabled: true
+ enabled: parent.available
+ onClicked: {
+ if (card.player && parent.available)
+ card.player[modelData.action]();
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/datecenter/NotificationItem.qml b/quickshell/.config/quickshell/labfy-sway/datecenter/NotificationItem.qml
new file mode 100644
index 000000000..e977c9939
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/datecenter/NotificationItem.qml
@@ -0,0 +1,63 @@
+import QtQuick
+import Quickshell
+import "../components"
+
+Rectangle {
+ id: card
+ required property var notification
+ required property var service
+ readonly property var live: service.live(notification.internalId)
+ readonly property string iconSource: {
+ const icon = notification.appIcon || "";
+ if (icon.startsWith("/")) return "file://" + icon;
+ if (icon.startsWith("file://") || icon.startsWith("data:")) return icon;
+ if (icon) return Quickshell.iconPath(icon, true);
+ const entry = notification.desktopEntry
+ ? DesktopEntries.heuristicLookup(notification.desktopEntry) : null;
+ return entry && entry.icon ? Quickshell.iconPath(entry.icon, true) : "";
+ }
+ implicitHeight: bodyText.visible || image.visible || actions.visible ? 95 + image.height + actions.height : 64
+ height: implicitHeight
+ radius: 4
+ color: "#313244"
+ Column {
+ anchors.fill: parent; anchors.margins: 10; spacing: 4
+ Row {
+ width: parent.width; height: 16; spacing: 7
+ Item {
+ width: 14; height: 14
+ Image { id: appIcon; anchors.fill: parent; source: card.iconSource; fillMode: Image.PreserveAspectFit; visible: status === Image.Ready }
+ NerdIcon { anchors.centerIn: parent; visible: !appIcon.visible; text: ""; color: "#cba6f7"; font.pixelSize: 13 }
+ }
+ Text {
+ width: parent.width - 48
+ text: card.notification.appName || card.notification.desktopEntry || "Notification"
+ textFormat: Text.PlainText; color: "#a6adc8"; font.pixelSize: 11; elide: Text.ElideRight
+ }
+ Text { visible: !!card.live; text: "●"; color: "#a6e3a1"; font.pixelSize: 10 }
+ NerdIcon {
+ text: ""; color: closePointer.containsMouse ? "#f38ba8" : "#a6adc8"; font.pixelSize: 12
+ MouseArea { id: closePointer; anchors.fill: parent; anchors.margins: -5; hoverEnabled: true; onClicked: card.service.dismiss(card.notification.internalId) }
+ }
+ }
+ Text { width: parent.width; text: card.notification.summary; textFormat: Text.PlainText; color: "#cdd6f4"; font.pixelSize: 12; font.bold: true; elide: Text.ElideRight }
+ Text { id: bodyText; width: parent.width; visible: text.length > 0; text: card.notification.body || ""; textFormat: Text.PlainText; color: "#bac2de"; font.pixelSize: 11; wrapMode: Text.Wrap; maximumLineCount: 2; elide: Text.ElideRight }
+ Image { id: image; width: parent.width; height: status === Image.Ready ? Math.min(110, implicitHeight) : 0; visible: status === Image.Ready; source: card.notification.image || ""; fillMode: Image.PreserveAspectFit }
+ Flow {
+ id: actions
+ width: parent.width
+ visible: !!card.live && card.live.actions.length > 0
+ height: visible ? 26 : 0
+ spacing: 5
+ Repeater {
+ model: card.live ? card.live.actions : []
+ delegate: Rectangle {
+ required property var modelData
+ width: label.implicitWidth + 16; height: 24; radius: 4; color: "#45475a"
+ Text { id: label; anchors.centerIn: parent; text: modelData.text; textFormat: Text.PlainText; color: "#cdd6f4"; font.pixelSize: 11 }
+ MouseArea { anchors.fill: parent; onClicked: modelData.invoke() }
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/datecenter/NotificationList.qml b/quickshell/.config/quickshell/labfy-sway/datecenter/NotificationList.qml
new file mode 100644
index 000000000..07827e6a0
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/datecenter/NotificationList.qml
@@ -0,0 +1,50 @@
+import QtQuick
+
+Item {
+ id: list
+ required property var service
+ property bool confirmClear: false
+ readonly property var rows: service.records
+ // CONTRACT: l'état vide conserve une vraie zone sans réserver la place du player.
+ // La liste seule défile au-delà de 307 px de contenu.
+ implicitHeight: rows.length ? 28 + Math.min(307, rows.length * 94) : 80
+ height: implicitHeight
+ Timer { interval: 5000; running: list.confirmClear; onTriggered: list.confirmClear = false }
+ Text { id: title; text: "Notifications"; color: "#cdd6f4"; font.pixelSize: 14; font.bold: true }
+ Text {
+ anchors.right: parent.right
+ text: list.confirmClear ? "Confirmer : tout effacer" : "Tout effacer"
+ visible: list.rows.length > 0
+ color: confirm.containsMouse ? "#f38ba8" : "#a6adc8"
+ font.pixelSize: 11
+ MouseArea {
+ id: confirm
+ anchors.fill: parent
+ hoverEnabled: true
+ onClicked: {
+ if (list.confirmClear) { list.service.clearAll(); list.confirmClear = false; }
+ else list.confirmClear = true;
+ }
+ }
+ }
+ Text {
+ visible: list.rows.length === 0
+ anchors.top: title.bottom; anchors.topMargin: 8
+ text: "Aucune notification récente"
+ color: "#a6adc8"; font.pixelSize: 12
+ }
+ ListView {
+ anchors.top: title.bottom; anchors.topMargin: 8
+ anchors.left: parent.left; anchors.right: parent.right
+ height: parent.height - title.height - 8
+ visible: list.rows.length > 0
+ clip: true; spacing: 7; boundsBehavior: Flickable.StopAtBounds
+ model: list.rows
+ delegate: NotificationItem {
+ required property var modelData
+ width: ListView.view.width
+ notification: modelData
+ service: list.service
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/modules/Clock.qml b/quickshell/.config/quickshell/labfy-sway/modules/Clock.qml
new file mode 100644
index 000000000..aa216a79b
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/modules/Clock.qml
@@ -0,0 +1,79 @@
+import QtQuick
+import Quickshell
+import "../notifications"
+
+Rectangle {
+ id: clockCapsule
+ property bool open: false
+ required property int unreadCount
+ required property int criticalUnreadCount
+ signal toggled()
+ // INVARIANT: la largeur fixe garde le texte sur le centre de l'écran, même avec "9+".
+ width: 184
+ height: 26
+ radius: 4
+ color: open ? "#cba6f7" : pointer.containsMouse ? "#45475a" : "#313244"
+ Accessible.role: Accessible.Button
+ Accessible.name: unreadCount === 0 ? "Date et heure"
+ : "Date et heure, " + unreadCount + (unreadCount === 1
+ ? " notification non lue" : " notifications non lues")
+
+ SystemClock {
+ id: clock
+ precision: SystemClock.Minutes
+ }
+
+ Text {
+ id: clockText
+ // CONTRACT: le centre du texte coïncide avec celui de la barre, sans dépendre du badge.
+ anchors.centerIn: parent
+ text: Qt.formatDateTime(clock.date, "dd/MM HH:mm")
+ color: clockCapsule.open ? "#1e1e2e" : "#cdd6f4"
+ font.pixelSize: 13
+ font.weight: Font.Bold
+ }
+
+ UnreadIndicator {
+ anchors.right: parent.right
+ anchors.rightMargin: 8
+ anchors.verticalCenter: parent.verticalCenter
+ unreadCount: clockCapsule.unreadCount
+ criticalUnreadCount: clockCapsule.criticalUnreadCount
+ inverted: clockCapsule.open
+ }
+
+ MouseArea {
+ id: pointer
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.LeftButton
+ onClicked: clockCapsule.toggled()
+ }
+
+ PopupWindow {
+ anchor.item: clockCapsule
+ anchor.edges: Edges.Bottom | Edges.Left
+ anchor.gravity: Edges.Bottom | Edges.Right
+ anchor.margins.bottom: -6
+ visible: pointer.containsMouse
+ grabFocus: false
+ color: "transparent"
+ implicitWidth: tooltipText.implicitWidth + 16
+ implicitHeight: 26
+ Rectangle {
+ anchors.fill: parent
+ radius: 4
+ color: "#313244"
+ border.color: "#45475a"
+ Text {
+ id: tooltipText
+ anchors.centerIn: parent
+ text: clockCapsule.unreadCount === 0 ? "Date et notifications"
+ : clockCapsule.unreadCount + (clockCapsule.unreadCount === 1
+ ? " notification non lue" : " notifications non lues")
+ color: "#cdd6f4"
+ font.pixelSize: 11
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/modules/Workspaces.qml b/quickshell/.config/quickshell/labfy-sway/modules/Workspaces.qml
new file mode 100644
index 000000000..8459148d1
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/modules/Workspaces.qml
@@ -0,0 +1,90 @@
+import QtQuick
+import Quickshell
+import Quickshell.I3
+import "../status"
+
+Row {
+ id: workspaces
+
+ required property var screen
+
+ spacing: 4
+ height: 26
+
+ Repeater {
+ model: ScriptModel {
+ objectProp: "key"
+ values: {
+ // CONTRACT: les cinq positions graphiques sont indépendantes de la
+ // durée de vie des workspaces vides dans Sway. Les extras et les
+ // états réels proviennent de l'IPC du moniteur courant.
+ const monitors = I3.monitors.values;
+ const candidates = I3.workspaces.values;
+ const monitor = I3.monitorFor(workspaces.screen);
+ if (monitors.length === 0 || !monitor) return [];
+
+ const real = candidates.filter(workspace => workspace.monitor === monitor);
+ const baseline = [1, 2, 3, 4, 5].map(number => ({
+ key: "number:" + number,
+ number: number,
+ workspace: real.find(workspace => workspace.number === number) || null
+ }));
+ const extras = real
+ .filter(workspace => workspace.number < 1 || workspace.number > 5)
+ .sort((a, b) => a.number - b.number || a.name.localeCompare(b.name))
+ // INVARIANT: les noms sans numéro (number = -1) gardent une clé
+ // distincte ; ScriptModel ne confond pas deux delegates réels.
+ .map(workspace => ({
+ key: workspace.number >= 0 ? "number:" + workspace.number : "name:" + workspace.name,
+ number: workspace.number,
+ workspace: workspace
+ }));
+ return baseline.concat(extras);
+ }
+ }
+
+ delegate: Item {
+ id: button
+ required property var modelData
+ readonly property var workspace: modelData.workspace
+ readonly property bool active: workspace && (workspace.focused || workspace.active)
+ readonly property bool urgent: workspace && workspace.urgent
+
+ width: 24
+ height: 26
+
+ Rectangle {
+ anchors.centerIn: parent
+ width: button.active ? 24 : 16
+ height: button.active ? 16 : 11
+ radius: 4
+ color: button.urgent ? "#fab387"
+ : button.active ? "#cba6f7"
+ : pointer.containsMouse ? "#6c7086" : "#585b70"
+ }
+
+ MouseArea {
+ id: pointer
+ anchors.fill: parent
+ acceptedButtons: Qt.LeftButton
+ hoverEnabled: true
+ onClicked: {
+ // CONTRACT: un emplacement baseline absent crée le workspace
+ // par IPC ; les objets existants gardent leur activation native.
+ if (button.workspace) button.workspace.activate();
+ else I3.dispatch("workspace number " + button.modelData.number);
+ }
+ }
+
+ StatusTooltip {
+ target: button
+ hovered: pointer.containsMouse
+ message: button.workspace && button.modelData.number < 0
+ ? "Espace " + button.workspace.name
+ : button.workspace && button.workspace.name !== String(button.modelData.number)
+ ? "Espace " + button.modelData.number + " — " + button.workspace.name
+ : "Espace " + button.modelData.number
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/notifications/NotificationService.qml b/quickshell/.config/quickshell/labfy-sway/notifications/NotificationService.qml
new file mode 100644
index 000000000..21ed64688
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/notifications/NotificationService.qml
@@ -0,0 +1,143 @@
+import QtQuick
+import Quickshell.Services.Notifications
+
+Item {
+ id: service
+ property NotificationStore store: NotificationStore {}
+ property var active: []
+ property var toasts: []
+ // CONTRACT: compteur de popups visibles, partagé entre les sorties écran.
+ property int visibleDateCenters: 0
+ readonly property var records: store.records
+ readonly property int unreadCount: store.unreadCount
+ readonly property int criticalUnreadCount: store.criticalUnreadCount
+
+ // WHY: les objets live restent suivis pour dismiss, expire et action.invoke.
+ NotificationServer {
+ id: server
+ keepOnReload: true
+ bodySupported: true
+ bodyMarkupSupported: false
+ bodyHyperlinksSupported: false
+ bodyImagesSupported: false
+ actionsSupported: true
+ actionIconsSupported: false
+ imageSupported: true
+ inlineReplySupported: false
+ persistenceSupported: true
+ onNotification: notification => service.receive(notification)
+ }
+
+ Component {
+ id: watcher
+ Connections {
+ required property var watched
+ required property int internalId
+ target: watched
+ function onClosed(reason) { service.closed(watched, internalId); }
+ function onSummaryChanged() { service.update(watched, internalId); }
+ function onBodyChanged() { service.update(watched, internalId); }
+ function onAppNameChanged() { service.update(watched, internalId); }
+ function onAppIconChanged() { service.update(watched, internalId); }
+ function onImageChanged() { service.update(watched, internalId); }
+ function onUrgencyChanged() { service.update(watched, internalId); }
+ function onActionsChanged() { service.pulse(); }
+ }
+ }
+
+ function pulse() {
+ active = active.slice();
+ toasts = toasts.slice();
+ }
+
+ function snapshot(n, internalId, old) {
+ return {
+ internalId: internalId,
+ receivedAt: old ? old.receivedAt : new Date().toISOString(),
+ closedAt: null,
+ sourceNotificationId: n.id,
+ appName: n.appName || "",
+ appIcon: n.appIcon || "",
+ desktopEntry: n.desktopEntry || "",
+ summary: n.summary || "",
+ body: n.body || "",
+ image: n.image || "",
+ urgency: n.urgency,
+ resident: !!n.resident,
+ transient: false,
+ // CONTRACT: une mise à jour conserve le statut de consultation du même record.
+ read: old ? old.read : false,
+ readAt: old ? old.readAt : null
+ };
+ }
+
+ function receive(n) {
+ n.tracked = true;
+ // INVARIANT: un replaces_id garde son identité logique et son record.
+ const previous = active.find(a => a.notification === n || a.notification.id === n.id);
+ const persisted = !n.transient;
+ const existing = !previous && n.lastGeneration
+ ? records.find(r => r.sourceNotificationId === n.id && !r.closedAt) : null;
+ const internalId = previous ? previous.internalId : existing ? existing.internalId : store.nextId++;
+ if (previous && previous.watcher) previous.watcher.destroy();
+ const entry = { notification: n, internalId: internalId, transient: !persisted };
+ entry.watcher = watcher.createObject(service, { watched: n, internalId: internalId });
+ active = active.filter(a => a !== previous).concat([entry]);
+ if (persisted && store.ready) {
+ const old = records.find(r => r.internalId === internalId);
+ store.upsert(snapshot(n, internalId, old));
+ // INVARIANT: un record reçu pendant une consultation visible est lu
+ // avant le prochain rendu, sans délai ni seconde source de vérité.
+ if (visibleDateCenters > 0) store.markAllRead();
+ }
+ if (!n.lastGeneration) toasts = toasts.filter(t => t.internalId !== internalId).concat([entry]);
+ }
+
+ function update(n, internalId) {
+ if (!active.some(a => a.notification === n && a.internalId === internalId)) return;
+ const old = records.find(r => r.internalId === internalId);
+ if (old) store.upsert(snapshot(n, internalId, old));
+ pulse();
+ }
+
+ function closed(n, internalId) {
+ const entry = active.find(a => a.notification === n && a.internalId === internalId);
+ if (!entry) return;
+ active = active.filter(a => a !== entry);
+ toasts = toasts.filter(a => a !== entry);
+ const record = records.find(r => r.internalId === internalId);
+ if (record) store.upsert(Object.assign({}, record, { closedAt: new Date().toISOString() }));
+ if (entry.watcher) entry.watcher.destroy();
+ }
+
+ function expire(internalId) {
+ const entry = active.find(a => a.internalId === internalId);
+ if (entry) entry.notification.expire();
+ }
+
+ function dismiss(internalId) {
+ // Suppression d'abord: le signal closed ne doit pas recréer le record.
+ store.remove(internalId);
+ const entry = active.find(a => a.internalId === internalId);
+ if (entry) entry.notification.dismiss();
+ toasts = toasts.filter(a => a.internalId !== internalId);
+ }
+
+ function clearAll() {
+ store.clear();
+ const living = active.slice();
+ for (const entry of living) entry.notification.dismiss();
+ toasts = [];
+ }
+
+ function setDateCenterVisible(isVisible) {
+ // Une consultation ne dépend ni du toast ni de l'expiration D-Bus.
+ visibleDateCenters = Math.max(0, visibleDateCenters + (isVisible ? 1 : -1));
+ if (isVisible) store.markAllRead();
+ }
+
+ function live(internalId) {
+ const entry = active.find(a => a.internalId === internalId);
+ return entry ? entry.notification : null;
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/notifications/NotificationStore.qml b/quickshell/.config/quickshell/labfy-sway/notifications/NotificationStore.qml
new file mode 100644
index 000000000..3c83843b9
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/notifications/NotificationStore.qml
@@ -0,0 +1,87 @@
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import Quickshell.Services.Notifications
+
+QtObject {
+ id: store
+ // CONTRACT: ce fichier appartient à XDG_STATE_HOME, jamais aux dotfiles.
+ readonly property string path: Quickshell.statePath("notifications.json")
+ readonly property int maxRecords: 200
+ property var records: []
+ // CONTRACT: le compte dérive uniquement des records durables, jamais des toasts.
+ readonly property int unreadCount: records.filter(r => r.read === false).length
+ readonly property int criticalUnreadCount: records.filter(r => r.read === false
+ && r.urgency === NotificationUrgency.Critical).length
+ property int nextId: 1
+ property bool ready: false
+
+ property FileView file: FileView {
+ path: store.path
+ blockWrites: true
+ atomicWrites: true
+ onLoaded: store.load()
+ onLoadFailed: store.load()
+ onSaveFailed: error => console.warn("Historique notifications : écriture échouée", error)
+ }
+
+ function load() {
+ if (ready) return;
+ let value = null;
+ let migrationNeeded = false;
+ try {
+ const raw = file.text();
+ value = raw ? JSON.parse(raw) : null;
+ if (value && (value.version === 1 || value.version === 2) && Array.isArray(value.records)) {
+ migrationNeeded = value.version === 1 || value.records.some(r => r
+ && (typeof r.read !== "boolean" || !("readAt" in r)));
+ records = value.records.filter(r => r && Number.isSafeInteger(r.internalId)
+ && r.internalId > 0 && typeof r.summary === "string").slice(0, maxRecords)
+ .map(r => Object.assign({}, r, {
+ // WHY: les anciens imports et historiques précèdent la notion de consultation.
+ read: typeof r.read === "boolean" ? r.read : true,
+ readAt: typeof r.readAt === "string" ? r.readAt : null
+ }));
+ nextId = Math.max(Number.isSafeInteger(value.nextId) && value.nextId > 0
+ ? value.nextId : 1, 1, ...records.map(r => r.internalId + 1));
+ }
+ } catch (error) {
+ console.warn("Historique notifications illisible : conservation du fichier", error);
+ return;
+ }
+ ready = true;
+ // INVARIANT: la migration conserve chaque champ utilisateur et persiste le schéma v2.
+ if (migrationNeeded) save();
+ }
+
+ // INVARIANT: tout changement visible est écrit atomiquement avant retour à l'UI.
+ function save() {
+ if (!ready) return;
+ file.setText(JSON.stringify({ version: 2, nextId: nextId, records: records }));
+ }
+
+ function upsert(record) {
+ const rest = records.filter(r => r.internalId !== record.internalId);
+ records = [record].concat(rest).slice(0, maxRecords);
+ nextId = Math.max(nextId, record.internalId + 1);
+ save();
+ }
+
+ function remove(internalId) {
+ records = records.filter(r => r.internalId !== internalId);
+ save();
+ }
+
+ function clear() {
+ records = [];
+ save();
+ }
+
+ function markAllRead() {
+ if (unreadCount === 0) return;
+ const now = new Date().toISOString();
+ records = records.map(r => r.read === false
+ ? Object.assign({}, r, { read: true, readAt: now }) : r);
+ save();
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/notifications/NotificationToast.qml b/quickshell/.config/quickshell/labfy-sway/notifications/NotificationToast.qml
new file mode 100644
index 000000000..c1ff4fd1d
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/notifications/NotificationToast.qml
@@ -0,0 +1,90 @@
+import QtQuick
+import Quickshell
+import Quickshell.Services.Notifications
+import "../components"
+
+Rectangle {
+ id: card
+ required property var entry
+ required property var service
+ readonly property var notification: entry.notification
+ readonly property string iconSource: {
+ const icon = notification.appIcon || "";
+ if (icon.startsWith("/")) return "file://" + icon;
+ if (icon.startsWith("file://") || icon.startsWith("data:")) return icon;
+ if (icon) return Quickshell.iconPath(icon, true);
+ const desktop = notification.desktopEntry ? DesktopEntries.heuristicLookup(notification.desktopEntry) : null;
+ return desktop && desktop.icon ? Quickshell.iconPath(desktop.icon, true) : "";
+ }
+ readonly property int serverTimeout: notification.urgency === NotificationUrgency.Low ? 4000
+ : notification.urgency === NotificationUrgency.Critical ? 0 : 6000
+ readonly property int timeout: notification.expireTimeout < 0 ? serverTimeout
+ : notification.expireTimeout
+ width: 370
+ implicitHeight: content.implicitHeight + 20
+ radius: 4
+ color: "#1e1e2e"
+ border.color: notification.urgency === NotificationUrgency.Critical ? "#f38ba8" : "#45475a"
+ // CONTRACT: 0 interdit l'expiration automatique. Les durées sont en ms.
+ Timer {
+ interval: Math.max(1, card.timeout)
+ running: card.timeout > 0
+ onTriggered: card.service.expire(card.entry.internalId)
+ }
+ Column {
+ id: content
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: 10
+ spacing: 5
+ Row {
+ width: parent.width
+ spacing: 7
+ Item {
+ width: 18; height: 18
+ Image {
+ id: icon
+ anchors.fill: parent
+ source: card.iconSource
+ fillMode: Image.PreserveAspectFit
+ visible: status === Image.Ready
+ }
+ NerdIcon { anchors.centerIn: parent; visible: !icon.visible; text: ""; color: "#cba6f7" }
+ }
+ Text {
+ width: parent.width - 48
+ text: card.notification.appName || "Notification"
+ textFormat: Text.PlainText
+ color: "#a6adc8"
+ font.pixelSize: 11
+ elide: Text.ElideRight
+ }
+ NerdIcon {
+ text: ""; color: "#a6adc8"
+ MouseArea { anchors.fill: parent; anchors.margins: -4; onClicked: card.service.dismiss(card.entry.internalId) }
+ }
+ }
+ Text { width: parent.width; text: card.notification.summary; textFormat: Text.PlainText; color: "#cdd6f4"; font.bold: true; wrapMode: Text.Wrap; maximumLineCount: 2 }
+ Text { width: parent.width; visible: text.length > 0; text: card.notification.body; textFormat: Text.PlainText; color: "#bac2de"; font.pixelSize: 12; wrapMode: Text.Wrap; maximumLineCount: 3; elide: Text.ElideRight }
+ Image {
+ width: parent.width; height: status === Image.Ready ? Math.min(120, implicitHeight) : 0
+ visible: status === Image.Ready
+ source: card.notification.image || ""
+ fillMode: Image.PreserveAspectFit
+ }
+ Flow {
+ width: parent.width
+ spacing: 5
+ Repeater {
+ model: card.notification.actions
+ delegate: Rectangle {
+ required property var modelData
+ width: actionLabel.implicitWidth + 16; height: 25; radius: 4; color: "#45475a"
+ Text { id: actionLabel; anchors.centerIn: parent; text: modelData.text; textFormat: Text.PlainText; color: "#cdd6f4"; font.pixelSize: 11 }
+ MouseArea { anchors.fill: parent; onClicked: modelData.invoke() }
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/notifications/NotificationToastArea.qml b/quickshell/.config/quickshell/labfy-sway/notifications/NotificationToastArea.qml
new file mode 100644
index 000000000..326647805
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/notifications/NotificationToastArea.qml
@@ -0,0 +1,39 @@
+import QtQuick
+import Quickshell
+
+PopupWindow {
+ id: area
+ required property var barWindow
+ required property var service
+ property bool monitorOpen: false
+ property int monitorWidth: 400
+ anchor.window: barWindow
+ // Laisser les notifications visibles sans recouvrir le moniteur ouvert.
+ anchor.rect.x: monitorOpen ? barWindow.width - monitorWidth - implicitWidth - 16
+ : barWindow.width - implicitWidth - 8
+ anchor.rect.y: barWindow.height + 8
+ anchor.edges: Edges.Top | Edges.Left
+ anchor.gravity: Edges.Bottom | Edges.Right
+ implicitWidth: 386
+ implicitHeight: Math.min(550, stack.implicitHeight + 16)
+ visible: service.toasts.length > 0
+ grabFocus: false
+ color: "transparent"
+ Column {
+ id: stack
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: 8
+ spacing: 7
+ Repeater {
+ model: area.service.toasts.slice(-4).reverse()
+ delegate: NotificationToast {
+ required property var modelData
+ entry: modelData
+ service: area.service
+ width: stack.width
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/notifications/ROLLBACK.md b/quickshell/.config/quickshell/labfy-sway/notifications/ROLLBACK.md
new file mode 100644
index 000000000..759bfea35
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/notifications/ROLLBACK.md
@@ -0,0 +1,59 @@
+# Serveur de notifications QuickShell : retour à Mako
+
+État normal : `quickshell-labfy-sway.service` est activé par
+`wayland-session@sway.desktop.target`. `mako.service` est masqué dans
+`~/.config/systemd/user/mako.service` pour bloquer aussi son activation D-Bus
+par `/usr/share/dbus-1/services/fr.emersion.mako.service`.
+
+Le stockage QuickShell est dans
+`~/.local/state/quickshell/by-shell/eba3734a789a60d49598797b9c87ae6f/notifications.json`.
+L'import brut Mako est dans `~/.local/state/quickshell/labfy-sway/mako-import.json`.
+Ces fichiers ne sont pas effacés lors du retour à Mako.
+
+Pour revenir à Mako :
+
+```sh
+systemctl --user stop quickshell-labfy-sway.service
+systemctl --user disable quickshell-labfy-sway.service
+systemctl --user unmask mako.service
+systemctl --user start mako.service
+busctl --user status org.freedesktop.Notifications
+```
+
+Le propriétaire D-Bus doit afficher `Comm=mako`. Ne lancez pas la
+configuration `labfy-sway` pendant ce retour à Mako : son serveur de
+notifications réclamerait le même nom. Le retour à Mako est indépendant du
+retour à Swaybar décrit ci-dessous.
+
+Pour reprendre QuickShell :
+
+```sh
+systemctl --user stop mako.service
+systemctl --user mask mako.service
+systemctl --user enable quickshell-labfy-sway.service
+systemctl --user start quickshell-labfy-sway.service
+busctl --user status org.freedesktop.Notifications
+```
+
+## Retour à Swaybar + i3status-rs
+
+Réinstaller d'abord le paquet supprimé :
+
+```sh
+sudo pacman -S i3status-rust
+```
+
+Copier `docs/legacy/swaybar/i3status-config.toml` dans
+`~/.config/i3status-rust/config.toml` et `docs/legacy/swaybar/bar` dans
+`~/.config/sway/bar` depuis le dépôt, puis ajouter temporairement
+`include ~/.config/sway/bar` dans `~/.config/sway/config`. Exécuter :
+
+```sh
+sway --validate
+swaymsg reload
+```
+
+Swaybar et son enfant i3status-rs doivent revenir. Pour arrêter QuickShell,
+appliquer aussi la procédure Mako ci-dessus afin de conserver un serveur de
+notifications. Ce retour est manuel et provisoire ; les fichiers archivés ne
+sont plus des packages Stow actifs.
diff --git a/quickshell/.config/quickshell/labfy-sway/notifications/UnreadIndicator.qml b/quickshell/.config/quickshell/labfy-sway/notifications/UnreadIndicator.qml
new file mode 100644
index 000000000..68878e9d7
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/notifications/UnreadIndicator.qml
@@ -0,0 +1,36 @@
+import QtQuick
+import "../components"
+
+Item {
+ id: indicator
+ required property int unreadCount
+ required property int criticalUnreadCount
+ required property bool inverted
+
+ // CONTRACT: cette zone est réservée même à zéro; Clock gère le fond et l'interaction.
+ visible: unreadCount > 0
+ width: 36
+ height: 26
+
+ Row {
+ anchors.centerIn: parent
+ spacing: 3
+ NerdIcon {
+ id: bell
+ // Le glyphe fa-bell est présent dans JetBrainsMono Nerd Font Mono.
+ text: ""
+ color: indicator.inverted ? "#1e1e2e"
+ : indicator.criticalUnreadCount > 0 ? "#f38ba8" : "#cba6f7"
+ // CONTRACT: la cloche gagne en lisibilité sans agrandir le badge ni déplacer la date.
+ font.pixelSize: 16
+ }
+ Text {
+ id: countLabel
+ text: indicator.unreadCount > 9 ? "9+" : String(indicator.unreadCount)
+ color: indicator.inverted ? "#1e1e2e"
+ : indicator.criticalUnreadCount > 0 ? "#f38ba8" : "#cdd6f4"
+ font.pixelSize: 10
+ font.bold: true
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/shell.qml b/quickshell/.config/quickshell/labfy-sway/shell.qml
new file mode 100644
index 000000000..088f2a4a9
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/shell.qml
@@ -0,0 +1,15 @@
+//@ pragma UseQApplication
+import Quickshell
+import "notifications"
+
+ShellRoot {
+ // Une barre par écran, y compris si les sorties changent pendant la session.
+ // CONTRACT: un seul serveur D-Bus pour toutes les sorties.
+ NotificationService { id: rootNotificationService }
+
+ Variants {
+ model: Quickshell.screens
+
+ Bar { notificationService: rootNotificationService }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/status/BatteryIndicator.qml b/quickshell/.config/quickshell/labfy-sway/status/BatteryIndicator.qml
new file mode 100644
index 000000000..caa12b8ed
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/status/BatteryIndicator.qml
@@ -0,0 +1,82 @@
+import QtQuick
+import Quickshell.Io
+import Quickshell.Services.UPower
+import "../components"
+
+Item {
+ id: indicator
+ signal activated()
+ function refreshThreshold() { thresholdFile.reload() }
+ readonly property var battery: UPower.displayDevice
+ readonly property bool available: battery && battery.ready && battery.isPresent
+ readonly property int percent: available ? Math.round(battery.percentage * 100) : 0
+ readonly property bool charging: available && battery.state === UPowerDeviceState.Charging
+ readonly property bool full: available && battery.state === UPowerDeviceState.FullyCharged
+ readonly property string stateLabel: !available ? "Indisponible"
+ : charging ? "En charge"
+ : full ? "Pleine"
+ : battery.state === UPowerDeviceState.PendingCharge ? "En attente de charge"
+ : battery.state === UPowerDeviceState.PendingDischarge ? "En attente de décharge"
+ : battery.state === UPowerDeviceState.Discharging ? "Décharge"
+ : battery.state === UPowerDeviceState.Empty ? "Vide" : "État inconnu"
+ readonly property real remaining: !available ? 0
+ : charging ? battery.timeToFull
+ : battery.state === UPowerDeviceState.Discharging ? battery.timeToEmpty : 0
+ readonly property int configuredThreshold: {
+ const raw = thresholdFile.text().trim();
+ return /^[0-9]{1,3}$/.test(raw) ? Number(raw) : -1;
+ }
+ readonly property string detail: !available ? "Batterie indisponible"
+ : "Batterie : " + percent + " %\n" + stateLabel
+ + (remaining > 0 ? "\n" + (charging ? "Charge complète dans : " : "Temps restant : ")
+ + Math.floor(remaining / 3600) + " h " + String(Math.floor(remaining % 3600 / 60)).padStart(2, "0") : "")
+ + (battery.healthSupported ? "\nSanté : " + Math.round(battery.healthPercentage * 100) + " %" : "")
+ + (configuredThreshold >= 0 ? "\nLimite configurée : " + configuredThreshold + " %" : "")
+ width: available ? icon.width + value.implicitWidth + 10 : 26
+ height: 26
+
+ FileView {
+ id: thresholdFile
+ path: "/sys/class/power_supply/BAT1/charge_control_end_threshold"
+ watchChanges: true
+ onFileChanged: reload()
+ }
+
+ Rectangle { anchors.fill: parent; radius: 4; color: pointer.containsMouse ? "#45475a" : "transparent" }
+ NerdIcon {
+ id: icon
+ width: 18
+ anchors.left: parent.left
+ anchors.leftMargin: 3
+ anchors.verticalCenter: parent.verticalCenter
+ // Les seuils 30/15 % sont visuels ; ils ne modifient aucune politique UPower.
+ text: !indicator.available ? ""
+ : indicator.charging ? ""
+ : indicator.full ? ""
+ : indicator.percent <= 15 ? ""
+ : indicator.percent <= 30 ? ""
+ : indicator.percent <= 50 ? ""
+ : indicator.percent <= 75 ? "" : ""
+ color: indicator.percent <= 15 ? "#f38ba8"
+ : indicator.percent <= 30 ? "#fab387" : "#cdd6f4"
+ }
+ Text {
+ id: value
+ anchors.left: icon.right
+ anchors.leftMargin: 2
+ anchors.verticalCenter: parent.verticalCenter
+ text: indicator.available ? indicator.percent + "%" : ""
+ color: icon.color
+ font.family: "JetBrainsMono Nerd Font Mono"
+ font.pixelSize: 12
+ }
+ MouseArea {
+ id: pointer
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.LeftButton
+ onContainsMouseChanged: if (containsMouse) indicator.refreshThreshold()
+ onClicked: indicator.activated()
+ }
+ StatusTooltip { target: indicator; hovered: pointer.containsMouse; message: indicator.detail }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/status/BluetoothIndicator.qml b/quickshell/.config/quickshell/labfy-sway/status/BluetoothIndicator.qml
new file mode 100644
index 000000000..863253b74
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/status/BluetoothIndicator.qml
@@ -0,0 +1,36 @@
+import QtQuick
+import "../components"
+
+Item {
+ id: indicator
+ required property var adapter
+ signal activated()
+ width: 26
+ height: 26
+ readonly property var connectedDevices: adapter && adapter.devices
+ ? adapter.devices.values.filter(device => device.connected) : []
+ readonly property string detail: !adapter ? "Bluetooth indisponible"
+ : !adapter.enabled ? "Bluetooth désactivé"
+ : connectedDevices.length === 0 ? "Bluetooth activé\nAucun appareil connecté"
+ : connectedDevices.length === 1 ? "Bluetooth activé\n" + connectedDevices[0].name + " connecté"
+ : "Bluetooth activé\n" + connectedDevices.length + " appareils connectés\n"
+ + connectedDevices.map(device => device.name).join("\n")
+
+ Rectangle { anchors.fill: parent; radius: 4; color: pointer.containsMouse ? "#45475a" : "transparent" }
+ NerdIcon {
+ anchors.centerIn: parent
+ // WHY: la silhouette Bluetooth est étroite ; seul le dessin grandit, pas la hitbox.
+ font.pixelSize: 22
+ text: indicator.adapter && indicator.adapter.enabled ? "" : ""
+ color: !indicator.adapter || !indicator.adapter.enabled ? "#a6adc8"
+ : indicator.connectedDevices.length > 0 ? "#cba6f7" : "#cdd6f4"
+ }
+ MouseArea {
+ id: pointer
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.LeftButton
+ onClicked: indicator.activated()
+ }
+ StatusTooltip { target: indicator; hovered: pointer.containsMouse; message: indicator.detail }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/status/NetworkIndicator.qml b/quickshell/.config/quickshell/labfy-sway/status/NetworkIndicator.qml
new file mode 100644
index 000000000..3b2fa3685
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/status/NetworkIndicator.qml
@@ -0,0 +1,44 @@
+import QtQuick
+import Quickshell.Networking
+import "../components"
+
+Item {
+ id: indicator
+ required property var wifiDevice
+ signal activated()
+ width: 26
+ height: 26
+ readonly property var connectedNetwork: wifiDevice && wifiDevice.networks
+ ? wifiDevice.networks.values.find(network => network.connected) : null
+ readonly property int signalPercent: connectedNetwork
+ ? Math.round(Math.max(0, Math.min(1, connectedNetwork.signalStrength)) * 100) : 0
+ readonly property string detail: !Networking.wifiHardwareEnabled
+ ? "Wi-Fi\nBloqué matériellement"
+ : !Networking.wifiEnabled ? "Wi-Fi\nDésactivé"
+ : !wifiDevice ? "Wi-Fi\nPériphérique indisponible"
+ : !connectedNetwork ? "Wi-Fi\nNon connecté"
+ : "Wi-Fi\n" + connectedNetwork.name + "\nSignal : " + signalPercent + " %"
+
+ Rectangle { anchors.fill: parent; radius: 4; color: pointer.containsMouse ? "#45475a" : "transparent" }
+ NerdIcon {
+ anchors.centerIn: parent
+ // WHY: md-wifi remplit mieux la hauteur visuelle que les quatre glyphes
+ // de niveau ; le pourcentage exact reste disponible dans le tooltip.
+ font.pixelSize: 26
+ // CONTRACT: les glyphes connecté, déconnecté et off existent dans la
+ // Nerd Font installée ; seul l'état connecté est simplifié dans la barre.
+ text: !Networking.wifiHardwareEnabled ? ""
+ : !Networking.wifiEnabled ? ""
+ : !indicator.connectedNetwork ? "" : ""
+ color: !Networking.wifiHardwareEnabled ? "#f38ba8"
+ : !Networking.wifiEnabled ? "#a6adc8" : "#cdd6f4"
+ }
+ MouseArea {
+ id: pointer
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.LeftButton
+ onClicked: indicator.activated()
+ }
+ StatusTooltip { target: indicator; hovered: pointer.containsMouse; message: indicator.detail }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml b/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml
new file mode 100644
index 000000000..a186fc9e3
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml
@@ -0,0 +1,29 @@
+import QtQuick
+import "../controlcenter"
+import "../systemmonitor"
+
+Row {
+ id: area
+ required property var barWindow
+ required property var wifiDevice
+ required property var adapter
+ required property bool controlCenterOpen
+ required property bool systemMonitorOpen
+ signal openPageRequested(int page)
+ signal toggleControlCenterRequested()
+ signal toggleSystemMonitorRequested()
+ function refreshBatteryThreshold() { batteryIndicator.refreshThreshold() }
+ spacing: 5
+ height: 26
+
+ NetworkIndicator { wifiDevice: area.wifiDevice; onActivated: area.openPageRequested(1) }
+ BluetoothIndicator { adapter: area.adapter; onActivated: area.openPageRequested(2) }
+ BatteryIndicator { id: batteryIndicator; onActivated: area.openPageRequested(5) }
+ UpdatesIndicator { }
+ Tray { barWindow: area.barWindow }
+ SystemMonitorButton {
+ open: area.systemMonitorOpen
+ onToggled: area.toggleSystemMonitorRequested()
+ }
+ ControlCenterButton { open: area.controlCenterOpen; onToggled: area.toggleControlCenterRequested() }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/status/StatusTooltip.qml b/quickshell/.config/quickshell/labfy-sway/status/StatusTooltip.qml
new file mode 100644
index 000000000..97ca5bc60
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/status/StatusTooltip.qml
@@ -0,0 +1,40 @@
+import QtQuick
+import Quickshell
+
+PopupWindow {
+ id: tip
+ required property Item target
+ required property string message
+ property bool hovered: false
+ anchor.item: target
+ anchor.edges: Edges.Bottom | Edges.Left
+ anchor.gravity: Edges.Bottom | Edges.Right
+ anchor.margins.bottom: -6
+ visible: delay.running ? false : hovered && message.length > 0
+ grabFocus: false
+ color: "transparent"
+ implicitWidth: Math.min(320, content.implicitWidth + 16)
+ implicitHeight: content.implicitHeight + 12
+
+ // CONTRACT: un seul délai et la même surface sont partagés par les indicateurs.
+ onHoveredChanged: {
+ delay.stop();
+ if (hovered) delay.start();
+ }
+ Timer { id: delay; interval: 450; repeat: false }
+ Rectangle {
+ anchors.fill: parent
+ radius: 4
+ color: "#313244"
+ border.color: "#45475a"
+ Text {
+ id: content
+ anchors.centerIn: parent
+ width: Math.min(304, implicitWidth)
+ text: tip.message
+ wrapMode: Text.Wrap
+ color: "#cdd6f4"
+ font.pixelSize: 11
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/status/Tray.qml b/quickshell/.config/quickshell/labfy-sway/status/Tray.qml
new file mode 100644
index 000000000..3d29cdbbd
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/status/Tray.qml
@@ -0,0 +1,17 @@
+import QtQuick
+import Quickshell.Services.SystemTray
+
+Row {
+ id: tray
+ required property var barWindow
+ spacing: 2
+ // Le modèle natif suit les inscriptions et retraits D-Bus sans scrutation.
+ Repeater {
+ model: SystemTray.items
+ delegate: TrayItem {
+ required property var modelData
+ item: modelData
+ barWindow: tray.barWindow
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/status/TrayItem.qml b/quickshell/.config/quickshell/labfy-sway/status/TrayItem.qml
new file mode 100644
index 000000000..40d559f60
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/status/TrayItem.qml
@@ -0,0 +1,56 @@
+import QtQuick
+import Quickshell
+import Quickshell.Services.SystemTray
+import Quickshell.Widgets
+
+Item {
+ id: entry
+ required property var item
+ required property var barWindow
+ width: 26
+ height: 26
+ readonly property string detail: {
+ const heading = item.tooltipTitle || item.title || "Application";
+ return heading + (item.tooltipDescription ? "\n" + item.tooltipDescription : "");
+ }
+
+ Rectangle { anchors.fill: parent; radius: 4; color: pointer.containsMouse ? "#45475a" : "transparent" }
+ IconImage {
+ anchors.centerIn: parent
+ width: 18
+ height: 18
+ source: entry.item.icon
+ opacity: entry.item.status === Status.Passive ? 0.7 : 1
+ }
+ Rectangle {
+ visible: entry.item.status === Status.NeedsAttention
+ width: 4; height: 4; radius: 2
+ color: "#cba6f7"
+ anchors.right: parent.right
+ anchors.top: parent.top
+ }
+ MouseArea {
+ id: pointer
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
+ onClicked: mouse => {
+ if (mouse.button === Qt.MiddleButton) entry.item.secondaryActivate();
+ else if (mouse.button === Qt.RightButton && entry.item.hasMenu) menuAnchor.open();
+ else if (mouse.button === Qt.LeftButton) {
+ if (entry.item.onlyMenu && entry.item.hasMenu) menuAnchor.open();
+ else entry.item.activate();
+ }
+ }
+ onWheel: wheel => entry.item.scroll(wheel.angleDelta.y, false)
+ }
+ QsMenuAnchor {
+ id: menuAnchor
+ menu: entry.item.menu
+ anchor.item: entry
+ anchor.margins.bottom: -5
+ anchor.edges: Edges.Top | Edges.Left
+ anchor.gravity: Edges.Bottom | Edges.Right
+ }
+ StatusTooltip { target: entry; hovered: pointer.containsMouse; message: entry.detail }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/status/UpdatesIndicator.qml b/quickshell/.config/quickshell/labfy-sway/status/UpdatesIndicator.qml
new file mode 100644
index 000000000..9711a1524
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/status/UpdatesIndicator.qml
@@ -0,0 +1,74 @@
+import QtQuick
+import Quickshell
+import Quickshell.Io
+import "../components"
+
+Item {
+ id: indicator
+ readonly property string statePath: Quickshell.env("XDG_RUNTIME_DIR") + "/labfy-quickshell-updates.json"
+ readonly property var state: {
+ const raw = snapshot.text();
+ if (!raw) return { repoUpdates: 0, aurUpdates: 0, totalUpdates: 0, lastCheck: "", error: "Vérification en attente" };
+ try {
+ const value = JSON.parse(raw);
+ if (!Number.isSafeInteger(value.repoUpdates) || value.repoUpdates < 0
+ || !Number.isSafeInteger(value.aurUpdates) || value.aurUpdates < 0
+ || value.totalUpdates !== value.repoUpdates + value.aurUpdates)
+ throw new Error("État invalide");
+ return value;
+ } catch (error) {
+ return { repoUpdates: 0, aurUpdates: 0, totalUpdates: 0, lastCheck: "", error: "État illisible" };
+ }
+ }
+ readonly property bool hasError: Boolean(state.error)
+ visible: hasError || state.totalUpdates > 0
+ width: visible ? icon.width + (hasError ? 0 : count.implicitWidth + 3) + 6 : 0
+ height: 26
+ readonly property string detail: hasError
+ ? "Impossible de vérifier les mises à jour\n" + state.error
+ : state.totalUpdates + (state.totalUpdates === 1 ? " mise à jour" : " mises à jour")
+ + "\n" + state.repoUpdates + " Arch\n" + state.aurUpdates + " AUR"
+ + (state.lastCheck ? "\nDernière vérification : " + Qt.formatTime(new Date(state.lastCheck), "HH:mm") : "")
+
+ // Le timer systemd publie un fichier atomique ; le renderer ne lance jamais de gestionnaire de paquets.
+ FileView {
+ id: snapshot
+ path: indicator.statePath
+ watchChanges: true
+ printErrors: false
+ onFileChanged: reload()
+ }
+ // Après un redémarrage, /run/user ne contient pas encore le fichier :
+ // QFileSystemWatcher ne voit pas sa création. Réessayer seulement jusqu'au premier chargement.
+ Timer {
+ interval: 10000
+ repeat: true
+ running: !snapshot.loaded
+ onTriggered: snapshot.reload()
+ }
+ Rectangle { anchors.fill: parent; radius: 4; color: pointer.containsMouse ? "#45475a" : "transparent" }
+ NerdIcon {
+ id: icon
+ width: 18
+ // WHY: le trait circulaire paraît plus petit que le glyphe batterie à 18 px.
+ font.pixelSize: 22
+ anchors.left: parent.left
+ anchors.leftMargin: 3
+ anchors.verticalCenter: parent.verticalCenter
+ text: indicator.hasError ? "" : ""
+ color: indicator.hasError ? "#fab387" : "#cba6f7"
+ }
+ Text {
+ id: count
+ anchors.left: icon.right
+ anchors.leftMargin: 3
+ anchors.verticalCenter: parent.verticalCenter
+ visible: !indicator.hasError
+ text: indicator.state.totalUpdates
+ color: "#cba6f7"
+ font.family: "JetBrainsMono Nerd Font Mono"
+ font.pixelSize: 12
+ }
+ MouseArea { id: pointer; anchors.fill: parent; hoverEnabled: true; acceptedButtons: Qt.NoButton }
+ StatusTooltip { target: indicator; hovered: pointer.containsMouse; message: indicator.detail }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/systemmonitor/MetricRow.qml b/quickshell/.config/quickshell/labfy-sway/systemmonitor/MetricRow.qml
new file mode 100644
index 000000000..2b4e5f250
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/systemmonitor/MetricRow.qml
@@ -0,0 +1,30 @@
+import QtQuick
+
+Column {
+ id: row
+ required property string label
+ required property string value
+ required property real percentage
+ property string accent: "#cba6f7"
+ width: parent.width
+ spacing: 6
+
+ Row {
+ width: parent.width
+ Text { id: labelText; text: row.label; color: "#cdd6f4"; font.pixelSize: 12 }
+ Item { width: Math.max(0, parent.width - valueText.width - labelText.width); height: 1 }
+ Text { id: valueText; text: row.value; color: "#bac2de"; font.pixelSize: 12 }
+ }
+ Rectangle {
+ width: parent.width
+ height: 5
+ radius: 3
+ color: "#313244"
+ Rectangle {
+ width: parent.width * Math.max(0, Math.min(100, row.percentage)) / 100
+ height: parent.height
+ radius: 3
+ color: row.accent
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/systemmonitor/SystemMetrics.qml b/quickshell/.config/quickshell/labfy-sway/systemmonitor/SystemMetrics.qml
new file mode 100644
index 000000000..043362a0c
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/systemmonitor/SystemMetrics.qml
@@ -0,0 +1,177 @@
+import QtQuick
+import Quickshell
+import Quickshell.Io
+
+Item {
+ id: metrics
+ property bool active: false
+ property real cpuPercent: -1
+ property real ramPercent: -1
+ property real ramUsedGiB: -1
+ property real ramTotalGiB: -1
+ property real gpuPercent: -1
+ property real cpuTemp: -1
+ property real gpuTemp: -1
+ property real diskPercent: -1
+ property real diskUsedGiB: -1
+ property real diskTotalGiB: -1
+ property string loadAverage: ""
+ property string uptimeText: ""
+ property var previousCpu: null
+ property string gpuPath: ""
+ property string cpuTempPath: ""
+ property string gpuTempPath: ""
+
+ function numberFromFile(view, divisor) {
+ const raw = view.text().trim();
+ if (!/^[0-9]+$/.test(raw)) return -1;
+ const value = Number(raw) / divisor;
+ return Number.isFinite(value) ? value : -1;
+ }
+
+ // CONTRACT: la première lecture CPU constitue seulement la référence ; seul un delta positif produit un pourcentage.
+ function updateCpu() {
+ const line = cpuFile.text().split("\n")[0].trim().split(/\s+/);
+ if (line[0] !== "cpu" || line.length < 6) return;
+ const values = line.slice(1).map(Number);
+ if (values.some(value => !Number.isFinite(value))) return;
+ const total = values.reduce((sum, value) => sum + value, 0);
+ const idle = values[3] + values[4]; // idle + iowait
+ if (previousCpu) {
+ const totalDelta = total - previousCpu.total;
+ const idleDelta = idle - previousCpu.idle;
+ if (totalDelta > 0 && idleDelta >= 0)
+ cpuPercent = Math.max(0, Math.min(100, 100 * (1 - idleDelta / totalDelta)));
+ }
+ previousCpu = { total: total, idle: idle };
+ }
+
+ function updateMemory() {
+ const entries = {};
+ for (const line of memoryFile.text().split("\n")) {
+ const match = line.match(/^(MemTotal|MemAvailable):\s+([0-9]+) kB$/);
+ if (match) entries[match[1]] = Number(match[2]);
+ }
+ if (!(entries.MemTotal > 0) || !(entries.MemAvailable >= 0)) return;
+ const used = Math.max(0, entries.MemTotal - entries.MemAvailable);
+ ramPercent = Math.min(100, used * 100 / entries.MemTotal);
+ ramUsedGiB = used / 1048576;
+ ramTotalGiB = entries.MemTotal / 1048576;
+ }
+
+ function updateLoad() {
+ const fields = loadFile.text().trim().split(/\s+/);
+ if (fields.length >= 3 && fields.slice(0, 3).every(value => /^[0-9]+\.[0-9]+$/.test(value)))
+ loadAverage = fields.slice(0, 3).join(" ");
+ }
+
+ function updateUptime() {
+ const seconds = Number(uptimeFile.text().trim().split(/\s+/)[0]);
+ if (!Number.isFinite(seconds) || seconds < 0) return;
+ const minutes = Math.floor(seconds / 60);
+ const days = Math.floor(minutes / 1440);
+ const hours = Math.floor((minutes % 1440) / 60);
+ uptimeText = days > 0 ? days + " j " + hours + " h" : hours + " h " + minutes % 60 + " min";
+ }
+
+ function sample() {
+ if (!active) return;
+ cpuFile.reload();
+ memoryFile.reload();
+ gpuFile.reload();
+ cpuTempFile.reload();
+ gpuTempFile.reload();
+ loadFile.reload();
+ uptimeFile.reload();
+ }
+
+ function refreshDisk() {
+ if (active && !diskReader.running) diskReader.running = true;
+ }
+
+ onActiveChanged: {
+ if (active) {
+ previousCpu = null;
+ cpuPercent = -1;
+ gpuPercent = -1;
+ cpuTemp = -1;
+ gpuTemp = -1;
+ gpuPath = "";
+ cpuTempPath = "";
+ gpuTempPath = "";
+ // WHY: les numéros cardN et hwmonN changent au redémarrage.
+ if (!hardwareResolver.running) hardwareResolver.running = true;
+ sample();
+ refreshDisk();
+ } else {
+ previousCpu = null;
+ diskReader.running = false;
+ }
+ }
+
+ // INVARIANT: aucun échantillonnage ni Process df périodique quand le panneau est fermé.
+ Timer { interval: 1000; repeat: true; running: metrics.active; onTriggered: metrics.sample() }
+ Timer { interval: 15000; repeat: true; running: metrics.active; onTriggered: metrics.refreshDisk() }
+
+ FileView { id: cpuFile; path: "/proc/stat"; onLoaded: metrics.updateCpu() }
+ FileView { id: memoryFile; path: "/proc/meminfo"; onLoaded: metrics.updateMemory() }
+ FileView {
+ id: gpuFile
+ path: metrics.gpuPath
+ printErrors: false
+ onLoaded: metrics.gpuPercent = metrics.numberFromFile(gpuFile, 1)
+ }
+ FileView {
+ id: cpuTempFile
+ path: metrics.cpuTempPath
+ printErrors: false
+ onLoaded: metrics.cpuTemp = metrics.numberFromFile(cpuTempFile, 1000)
+ }
+ FileView {
+ id: gpuTempFile
+ path: metrics.gpuTempPath
+ printErrors: false
+ onLoaded: metrics.gpuTemp = metrics.numberFromFile(gpuTempFile, 1000)
+ }
+ FileView { id: loadFile; path: "/proc/loadavg"; onLoaded: metrics.updateLoad() }
+ FileView { id: uptimeFile; path: "/proc/uptime"; onLoaded: metrics.updateUptime() }
+
+ Process {
+ id: hardwareResolver
+ command: ["/usr/bin/python3", Quickshell.shellPath("bin/resolve-hardware.py")]
+ stdout: StdioCollector { id: hardwareOutput; waitForEnd: true }
+ onExited: (exitCode, exitStatus) => {
+ if (exitCode !== 0 || exitStatus !== 0) return;
+ const paths = hardwareOutput.text.trimEnd().split("\n");
+ if (paths.length !== 3) return;
+ // CONTRACT: seuls les chemins sysfs résolus par le helper sont chargés.
+ if (paths[0].startsWith("/sys/class/drm/")) metrics.gpuPath = paths[0];
+ if (paths[1].startsWith("/sys/class/hwmon/")) metrics.cpuTempPath = paths[1];
+ if (paths[2].startsWith("/sys/class/hwmon/")) metrics.gpuTempPath = paths[2];
+ if (metrics.active) {
+ gpuFile.reload();
+ cpuTempFile.reload();
+ gpuTempFile.reload();
+ }
+ }
+ }
+
+ Process {
+ id: diskReader
+ command: ["df", "-B1", "--output=size,used,pcent,target", "/"]
+ stdout: StdioCollector { id: diskOutput; waitForEnd: true }
+ onExited: (exitCode, exitStatus) => {
+ if (exitCode !== 0 || exitStatus !== 0) return;
+ const lines = diskOutput.text.trim().split("\n");
+ if (lines.length < 2) return;
+ const fields = lines[lines.length - 1].trim().split(/\s+/);
+ if (fields.length !== 4 || fields[3] !== "/") return;
+ const total = Number(fields[0]);
+ const used = Number(fields[1]);
+ if (!(total > 0) || !(used >= 0)) return;
+ metrics.diskTotalGiB = total / 1073741824;
+ metrics.diskUsedGiB = used / 1073741824;
+ metrics.diskPercent = Math.min(100, used * 100 / total);
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/systemmonitor/SystemMonitor.qml b/quickshell/.config/quickshell/labfy-sway/systemmonitor/SystemMonitor.qml
new file mode 100644
index 000000000..5252e4d63
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/systemmonitor/SystemMonitor.qml
@@ -0,0 +1,92 @@
+import QtQuick
+import Quickshell
+import "../components"
+
+PopupWindow {
+ id: popup
+ required property var barWindow
+
+ anchor.window: barWindow
+ anchor.rect.x: barWindow.width - implicitWidth - 8
+ anchor.rect.y: barWindow.height + 6
+ anchor.edges: Edges.Top | Edges.Left
+ anchor.gravity: Edges.Bottom | Edges.Right
+
+ implicitWidth: 400
+ implicitHeight: content.implicitHeight + 32
+ visible: false
+ // Le panneau est informatif ; laisser les boutons de barre recevoir les clics XOR.
+ grabFocus: false
+ color: "transparent"
+
+ SystemMetrics { id: metrics; active: popup.visible }
+
+ Rectangle {
+ anchors.fill: parent
+ radius: 4
+ color: "#1e1e2e"
+ border.color: "#45475a"
+
+ Column {
+ id: content
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: 16
+ spacing: 16
+
+ Row {
+ spacing: 8
+ NerdIcon { text: ""; font.pixelSize: 18 }
+ Text { text: "Moniteur système"; color: "#cdd6f4"; font.pixelSize: 16; font.bold: true }
+ }
+
+ MetricRow {
+ label: "CPU"
+ percentage: metrics.cpuPercent
+ value: (metrics.cpuPercent >= 0 ? Math.round(metrics.cpuPercent) + " %" : "—")
+ + (metrics.cpuTemp >= 0 ? " " + Math.round(metrics.cpuTemp) + " °C" : "")
+ accent: metrics.cpuPercent >= 90 ? "#f38ba8" : metrics.cpuPercent >= 70 ? "#fab387" : "#cba6f7"
+ }
+
+ MetricRow {
+ label: "RAM"
+ percentage: metrics.ramPercent
+ value: metrics.ramPercent >= 0
+ ? metrics.ramUsedGiB.toFixed(1) + " / " + metrics.ramTotalGiB.toFixed(1)
+ + " GiB " + Math.round(metrics.ramPercent) + " %" : "—"
+ accent: metrics.ramPercent >= 90 ? "#f38ba8" : metrics.ramPercent >= 75 ? "#fab387" : "#cba6f7"
+ }
+
+ MetricRow {
+ visible: metrics.gpuPercent >= 0 || metrics.gpuTemp >= 0
+ label: "GPU"
+ percentage: metrics.gpuPercent
+ value: (metrics.gpuPercent >= 0 ? Math.round(metrics.gpuPercent) + " %" : "")
+ + (metrics.gpuTemp >= 0 ? (metrics.gpuPercent >= 0 ? " " : "")
+ + Math.round(metrics.gpuTemp) + " °C" : "")
+ }
+
+ MetricRow {
+ label: "Disque /"
+ percentage: metrics.diskPercent
+ value: metrics.diskPercent >= 0
+ ? metrics.diskUsedGiB.toFixed(0) + " / " + metrics.diskTotalGiB.toFixed(0)
+ + " GiB " + Math.round(metrics.diskPercent) + " %" : "—"
+ }
+
+ Row {
+ width: parent.width
+ Text { id: loadLabel; text: "Charge (1/5/15 min)"; color: "#cdd6f4"; font.pixelSize: 12 }
+ Item { width: Math.max(0, parent.width - loadValue.width - loadLabel.width); height: 1 }
+ Text { id: loadValue; text: metrics.loadAverage || "—"; color: "#bac2de"; font.pixelSize: 12 }
+ }
+ Row {
+ width: parent.width
+ Text { id: uptimeLabel; text: "En marche"; color: "#cdd6f4"; font.pixelSize: 12 }
+ Item { width: Math.max(0, parent.width - uptimeValue.width - uptimeLabel.width); height: 1 }
+ Text { id: uptimeValue; text: metrics.uptimeText || "—"; color: "#bac2de"; font.pixelSize: 12 }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/systemmonitor/SystemMonitorButton.qml b/quickshell/.config/quickshell/labfy-sway/systemmonitor/SystemMonitorButton.qml
new file mode 100644
index 000000000..da8ce9203
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/systemmonitor/SystemMonitorButton.qml
@@ -0,0 +1,34 @@
+import QtQuick
+import "../components"
+import "../status"
+
+Rectangle {
+ id: button
+ required property bool open
+ signal toggled()
+
+ width: 26
+ height: 26
+ radius: 4
+ color: open ? "#cba6f7" : pointer.containsMouse ? "#45475a" : "#313244"
+
+ NerdIcon {
+ anchors.centerIn: parent
+ text: ""
+ // WHY: compenser la surface plus faible du glyphe sans agrandir le bouton.
+ font.pixelSize: 20
+ color: button.open ? "#1e1e2e" : "#cdd6f4"
+ }
+ MouseArea {
+ id: pointer
+ anchors.fill: parent
+ acceptedButtons: Qt.LeftButton
+ hoverEnabled: true
+ onClicked: button.toggled()
+ }
+ StatusTooltip {
+ target: button
+ hovered: pointer.containsMouse && !button.open
+ message: "Moniteur système"
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/windows/WindowButton.qml b/quickshell/.config/quickshell/labfy-sway/windows/WindowButton.qml
new file mode 100644
index 000000000..71226d8f3
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/windows/WindowButton.qml
@@ -0,0 +1,137 @@
+import QtQuick
+import Quickshell
+import Quickshell.I3
+import "../components"
+
+Rectangle {
+ id: button
+
+ required property var windowInfo
+ required property bool primary
+ required property int maxPrimaryWidth
+ property bool menuOpen: false
+ signal menuRequested(var item, var windowInfo)
+
+ readonly property string appName: windowInfo.appId || "Fenêtre"
+ readonly property string fullTitle: windowInfo.title || appName
+ readonly property string glyph: {
+ const app = windowInfo.appKey || "";
+ if (app.includes("firefox")) return "";
+ if (app.includes("kitty") || app.includes("terminal")
+ || app.includes("foot") || app.includes("alacritty")) return "";
+ return "";
+ }
+
+ width: primary
+ ? Math.min(maxPrimaryWidth, Math.max(26, icon.implicitWidth + title.implicitWidth + 22))
+ : 26
+ height: 26
+ radius: 4
+ color: primary ? "#45475a" : pointer.containsMouse ? "#45475a" : "#313244"
+ border.width: primary ? 1 : 0
+ border.color: "#cba6f7"
+
+ NerdIcon {
+ id: icon
+ anchors.left: parent.left
+ anchors.leftMargin: primary ? 8 : 0
+ anchors.verticalCenter: parent.verticalCenter
+ width: primary ? implicitWidth : parent.width
+ text: button.glyph
+ // CONTRACT: le fallback reste centré dans les boutons secondaires de 26 px.
+ font.pixelSize: 16
+ }
+
+ Text {
+ id: title
+ visible: button.primary
+ anchors.left: icon.right
+ anchors.leftMargin: 6
+ anchors.right: parent.right
+ anchors.rightMargin: 8
+ anchors.verticalCenter: parent.verticalCenter
+ text: button.fullTitle
+ color: "#cdd6f4"
+ font.pixelSize: 13
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ }
+
+ MouseArea {
+ id: pointer
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
+
+ onContainsMouseChanged: {
+ if (containsMouse && !button.primary && !button.menuOpen)
+ tooltipDelay.start();
+ else {
+ tooltipDelay.stop();
+ button.showTooltip = false;
+ }
+ }
+
+ onClicked: mouse => {
+ button.showTooltip = false;
+ if (mouse.button === Qt.RightButton)
+ button.menuRequested(button, button.windowInfo);
+ else
+ I3.dispatch("[con_id=" + button.windowInfo.id + "] focus");
+ }
+ }
+
+ property bool showTooltip: false
+
+ Timer {
+ id: tooltipDelay
+ interval: 400
+ repeat: false
+ onTriggered: button.showTooltip = true
+ }
+
+ PopupWindow {
+ anchor.item: button
+ anchor.edges: Edges.Bottom | Edges.Left
+ anchor.gravity: Edges.Bottom | Edges.Right
+ // Une marge négative place le popup sous le bouton avec 6 px d'espace.
+ anchor.margins.bottom: -6
+ visible: button.showTooltip && !button.menuOpen
+ grabFocus: false
+ color: "transparent"
+ implicitWidth: 300
+ implicitHeight: tooltipContent.implicitHeight + 16
+
+ Rectangle {
+ anchors.fill: parent
+ radius: 4
+ color: "#313244"
+ border.color: "#45475a"
+
+ Column {
+ id: tooltipContent
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: 8
+ spacing: 3
+
+ Text {
+ text: button.appName
+ color: "#cdd6f4"
+ font.pixelSize: 12
+ font.bold: true
+ }
+ Text {
+ width: parent.width
+ text: button.fullTitle
+ color: "#a6adc8"
+ font.pixelSize: 12
+ wrapMode: Text.Wrap
+ maximumLineCount: 3
+ elide: Text.ElideRight
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/windows/WindowMenu.qml b/quickshell/.config/quickshell/labfy-sway/windows/WindowMenu.qml
new file mode 100644
index 000000000..02a87344a
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/windows/WindowMenu.qml
@@ -0,0 +1,136 @@
+import QtQuick
+import Quickshell
+import Quickshell.I3
+
+PopupWindow {
+ id: menu
+
+ property var anchorItem: null
+ property var selectedWindow: null
+ property var windows: []
+ readonly property var appWindows: selectedWindow
+ ? windows.filter(window => window.appKey === selectedWindow.appKey) : []
+ readonly property var entry: selectedWindow
+ ? DesktopEntries.heuristicLookup(selectedWindow.appId) : null
+ readonly property var actions: entry ? entry.actions : []
+
+ anchor.item: anchorItem
+ anchor.edges: Edges.Bottom | Edges.Left
+ anchor.gravity: Edges.Bottom | Edges.Right
+ // Une marge négative place le menu sous le bouton avec 6 px d'espace.
+ anchor.margins.bottom: -6
+
+ implicitWidth: 300
+ implicitHeight: content.implicitHeight + 24
+ visible: false
+ // Laisser les boutons de barre recevoir les clics d'exclusion XOR.
+ grabFocus: false
+ color: "transparent"
+
+ Rectangle {
+ anchors.fill: parent
+ radius: 4
+ color: "#1e1e2e"
+ border.color: "#45475a"
+
+ Column {
+ id: content
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: 12
+ spacing: 7
+
+ Text {
+ width: parent.width
+ text: menu.entry ? menu.entry.name
+ : menu.selectedWindow ? menu.selectedWindow.appId : ""
+ color: "#cdd6f4"
+ font.pixelSize: 15
+ font.bold: true
+ elide: Text.ElideRight
+ }
+
+ Text {
+ text: "Fenêtres ouvertes"
+ color: "#a6adc8"
+ font.pixelSize: 11
+ }
+
+ Repeater {
+ model: menu.appWindows
+
+ Rectangle {
+ required property var modelData
+ width: content.width
+ height: 30
+ radius: 4
+ color: pointer.containsMouse ? "#45475a" : "#313244"
+
+ Text {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.leftMargin: 8
+ anchors.rightMargin: 8
+ anchors.verticalCenter: parent.verticalCenter
+ text: modelData.title
+ color: "#cdd6f4"
+ font.pixelSize: 12
+ elide: Text.ElideRight
+ }
+
+ MouseArea {
+ id: pointer
+ anchors.fill: parent
+ hoverEnabled: true
+ onClicked: {
+ menu.visible = false;
+ I3.dispatch("[con_id=" + modelData.id + "] focus");
+ }
+ }
+ }
+ }
+
+ Text {
+ visible: menu.actions.length > 0
+ text: "Actions"
+ color: "#a6adc8"
+ font.pixelSize: 11
+ }
+
+ Repeater {
+ model: menu.actions
+
+ Rectangle {
+ required property var modelData
+ width: content.width
+ height: 30
+ radius: 4
+ color: actionPointer.containsMouse ? "#45475a" : "#313244"
+
+ Text {
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.leftMargin: 8
+ anchors.rightMargin: 8
+ anchors.verticalCenter: parent.verticalCenter
+ text: modelData.name
+ color: "#cdd6f4"
+ font.pixelSize: 12
+ elide: Text.ElideRight
+ }
+
+ MouseArea {
+ id: actionPointer
+ anchors.fill: parent
+ hoverEnabled: true
+ onClicked: {
+ menu.visible = false;
+ modelData.execute();
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/quickshell/.config/quickshell/labfy-sway/windows/WindowStrip.qml b/quickshell/.config/quickshell/labfy-sway/windows/WindowStrip.qml
new file mode 100644
index 000000000..77f24cd6e
--- /dev/null
+++ b/quickshell/.config/quickshell/labfy-sway/windows/WindowStrip.qml
@@ -0,0 +1,239 @@
+import QtQuick
+import Quickshell
+import Quickshell.I3
+import Quickshell.I3._Ipc
+import Quickshell.Io
+
+Item {
+ id: strip
+
+ required property var screen
+ required property int maxWidth
+ property var tree: null
+ property bool componentReady: false
+ property bool refreshRunning: false
+ property bool refreshPending: false
+ property var menuAnchor: null
+ property var menuWindow: null
+ signal menuOpened()
+
+ // monitorFor(screen) peut rester null dans un binding créé avant l'inventaire IPC.
+ // Le modèle réactif des moniteurs réévalue le workspace dès que Sway l'annonce.
+ readonly property var monitor: I3.monitors.values.find(item => item.name === screen.name) || null
+ readonly property var activeWorkspace: monitor ? monitor.activeWorkspace : null
+ readonly property var windows: windowsForWorkspace(tree, activeWorkspace)
+ readonly property var activeWindow: windows.find(window => window.focused) || null
+ readonly property var secondaryWindows: activeWindow
+ ? windows.filter(window => window.id !== activeWindow.id) : []
+
+ width: maxWidth
+ height: 26
+ clip: true
+
+ // L'arbre Sway est la source du workspace : une sortie seule ne distingue pas ses fenêtres.
+ function windowsForWorkspace(root, workspace) {
+ if (!root || !workspace) return [];
+
+ function findWorkspace(node) {
+ if (node.type === "workspace" && node.id === workspace.id) return node;
+ for (const child of (node.nodes || []).concat(node.floating_nodes || [])) {
+ const found = findWorkspace(child);
+ if (found) return found;
+ }
+ return null;
+ }
+
+ const node = findWorkspace(root);
+ if (!node) return [];
+ const found = [];
+
+ function collect(container) {
+ const appId = container.app_id
+ || (container.window_properties && container.window_properties.class) || "";
+ if (container.type === "con" && appId) {
+ found.push({
+ id: container.id,
+ title: container.name || appId,
+ appId: appId,
+ appKey: appId.toLowerCase(),
+ focused: container.focused === true
+ });
+ }
+ for (const child of (container.nodes || []).concat(container.floating_nodes || []))
+ collect(child);
+ }
+
+ collect(node);
+ const focusedIndex = found.findIndex(window => window.focused);
+ if (focusedIndex > 0)
+ found.unshift(found.splice(focusedIndex, 1)[0]);
+ return found;
+ }
+
+ // Process.running devient vrai après la demande de lancement : ce verrou ferme aussi cet intervalle.
+ // Une rafale IPC garde au plus un relevé supplémentaire en attente.
+ function requestRefresh() {
+ if (!componentReady) return;
+ if (refreshRunning) {
+ refreshPending = true;
+ return;
+ }
+ refreshRunning = true;
+ treeReader.running = true;
+ }
+
+ // Les titres animés peuvent changer plusieurs fois par seconde : l'événement IPC
+ // contient déjà le nouveau titre, sans nécessiter un relevé complet de l'arbre.
+ function applyTitleEvent(event) {
+ if (!tree || refreshRunning) return false;
+ let update;
+ try {
+ update = JSON.parse(event.data);
+ } catch (error) {
+ return false;
+ }
+ if (update.change !== "title" || !update.container) return false;
+
+ function updateNode(node) {
+ if (node.id === update.container.id) {
+ node.name = update.container.name;
+ return true;
+ }
+ for (const child of (node.nodes || []).concat(node.floating_nodes || [])) {
+ if (updateNode(child)) return true;
+ }
+ return false;
+ }
+
+ if (!updateNode(tree)) return false;
+ tree = Object.assign({}, tree);
+ return true;
+ }
+
+ function closeMenu() {
+ windowMenu.visible = false;
+ }
+
+ function showMenu(item, windowInfo) {
+ if (windowMenu.visible && menuWindow && menuWindow.id === windowInfo.id) {
+ windowMenu.visible = false;
+ return;
+ }
+ windowMenu.visible = false;
+ menuAnchor = item;
+ menuWindow = windowInfo;
+ windowMenu.visible = true;
+ menuOpened();
+ }
+
+ // Des changements de workspace arrivent pendant la construction QML : démarrer après celle du Process.
+ Component.onCompleted: {
+ componentReady = true;
+ Qt.callLater(requestRefresh);
+ }
+ onMonitorChanged: requestRefresh()
+ onActiveWorkspaceChanged: {
+ closeMenu();
+ requestRefresh();
+ }
+ onWindowsChanged: {
+ if (menuWindow && !windows.some(window => window.id === menuWindow.id))
+ closeMenu();
+ }
+
+ Connections {
+ target: I3
+ function onConnected() { strip.requestRefresh(); }
+ function onRawEvent(event) {
+ if (event.type === "workspace")
+ strip.requestRefresh();
+ }
+ }
+
+ // QuickShell 0.3.1 n'abonne I3.rawEvent qu'à workspace/output.
+ I3IpcListener {
+ subscriptions: ["window"]
+ onIpcEvent: event => {
+ // Chaque abonnement confirmé, initial ou rétabli, impose un snapshot complet.
+ if (event.type === "subscribe") {
+ try {
+ if (JSON.parse(event.data).success === true)
+ strip.requestRefresh();
+ else
+ console.warn("Abonnement aux fenêtres Sway refusé");
+ } catch (error) {
+ console.warn("Réponse d'abonnement Sway illisible :", error);
+ }
+ return;
+ }
+ if (!strip.applyTitleEvent(event))
+ strip.requestRefresh();
+ }
+ }
+
+ Process {
+ id: treeReader
+ command: ["swaymsg", "-t", "get_tree", "-r"]
+ stdout: StdioCollector { id: treeOutput; waitForEnd: true }
+
+ onExited: (exitCode, exitStatus) => {
+ if (exitCode === 0) {
+ try {
+ strip.tree = JSON.parse(treeOutput.text);
+ } catch (error) {
+ console.warn("Arbre Sway illisible :", error);
+ }
+ } else {
+ console.warn("Relevé de l'arbre Sway échoué :", exitCode);
+ }
+ strip.refreshRunning = false;
+ if (strip.refreshPending) {
+ strip.refreshPending = false;
+ Qt.callLater(strip.requestRefresh);
+ }
+ }
+ }
+
+ Row {
+ id: buttons
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 4
+
+ WindowButton {
+ id: activeButton
+ visible: strip.activeWindow !== null
+ windowInfo: strip.activeWindow || ({ id: 0, title: "", appId: "", appKey: "" })
+ primary: true
+ maxPrimaryWidth: Math.max(26, Math.min(400,
+ strip.width - strip.secondaryWindows.length * 30 - 8))
+ menuOpen: windowMenu.visible && strip.menuWindow
+ && strip.menuWindow.id === windowInfo.id
+ onMenuRequested: (item, windowInfo) => strip.showMenu(item, windowInfo)
+ }
+
+ Repeater {
+ model: ScriptModel {
+ values: strip.secondaryWindows
+ objectProp: "id"
+ }
+
+ delegate: WindowButton {
+ required property var modelData
+ windowInfo: modelData
+ primary: false
+ maxPrimaryWidth: 26
+ menuOpen: windowMenu.visible && strip.menuWindow
+ && strip.menuWindow.id === windowInfo.id
+ onMenuRequested: (item, windowInfo) => strip.showMenu(item, windowInfo)
+ }
+ }
+ }
+
+ WindowMenu {
+ id: windowMenu
+ anchorItem: strip.menuAnchor
+ selectedWindow: strip.menuWindow
+ windows: strip.windows
+ }
+}
diff --git a/sway/.config/sway/README.md b/sway/.config/sway/README.md
index faa1aceb9..ef0deceab 100644
--- a/sway/.config/sway/README.md
+++ b/sway/.config/sway/README.md
@@ -1,170 +1,20 @@
-# sway-config
+# Configuration SwayFX
-
+Le compositeur actif est **SwayFX 0.6** avec `scenefx`. La session UWSM
+sélectionne `sway.desktop` et charge `config` : `bind`, `input`, `rules`,
+`style`, `swayfx` et `autostart`. `eDP-1` est l'unique écran configuré, à
+l'origine logique. La configuration de barre native a été archivée dans
+`docs/legacy/swaybar/` ; la barre et les notifications sont fournies par
+QuickShell, lancé par `quickshell-labfy-sway.service`.
-Personal SwayFX configuration, modular and lightweight, designed for everyday Wayland use.
+`autostart` lance une fois le listener IPC
+`scripts/inactive-windows-transparency.py`, `limusic-app` et `autotiling`.
+Cliphist, swayidle, wlsunset et QuickShell sont gérés par systemd user ;
+aucune commande `qs` n'est lancée par Sway. Le fichier `swayfx` fixe les
+animations à 0, les coins à 4, les ombres validées et le flou uniquement sur
+la couche QuickShell. Les profils TLP restent accessibles par les raccourcis
+`tlpctl`.
-## Layout
-
-```bash
-❯ tree .config/sway.config/sway
-├── README.md
-├── assets
-│ └── screen.jpg
-├── autostart.conf
-├── bar.conf
-├── bind.conf
-├── catppuccin-mocha
-├── config
-├── i3status.toml
-├── input.conf
-├── layout.conf
-├── rules.conf
-├── scripts
-│ └── inactive-windows-transparency.py
-└── swayfx.confbash
-```
-
-## Dependencies
-
-### Compositor and session
-- swayfx
-- swaylock
-- swayidle
-- tlp
-- tlp-pd
-
-### Bar and status
-- i3status-rs
-
-### Base tools
-- kitty
-- firefox
-- google-chrome-stable
-- wl-clipboard
-- grim
-- slurp
-- cliphist
-- wlsunset
-- brightnessctl
-- nm-connection-editor
-- playerctl
-
-### Audio / Bluetooth
-- pipewire
-- wireplumber
-- blueman
-
-### Launching / scripts
-- bash
-
-## Config structure
-
-The configuration is split into several included files to keep it readable and easy to maintain.
-
-- `input.conf`: keyboard, touchpad, French layout.
-- `layout.conf`: wallpaper, theme, borders, gaps.
-- `bind.conf`: keyboard shortcuts.
-- `bar.conf`: Sway bar and i3status-rs.
-- `autostart.conf`: services started on session launch.
-- `rules.conf`: window placement and floating rules.
-- `i3status.toml`: i3status-rs configuration.
-
-## Appearance
-
-The theme is based on Catppuccin Macchiato, with dark colors, a top bar, and thin borders.
-The wallpaper is loaded with `output * bg ... fill`, and the main output is positioned with `position 1920,0` in the current config.
-
-## Keyboard
-
-The keyboard is configured for French with the `oss` variant.
-
-Example:
-- layout: `fr`
-- variant: `oss`
-
-## Status bar
-
-The Sway bar is placed at the top and uses `i3status-rs` as its backend.
-
-The i3status-rust configuration includes:
-- workspaces,
-- focused window,
-- music,
-- Bluetooth,
-- network,
-- sound,
-- brightness,
-- battery,
-- power profile,
-- notification,
-- power menu,
-- tray.
-
-## Autostart
-
-On startup, Sway launches the inactive-window transparency helper, LiMusic and
-`autotiling`. Wlsunset, Cliphist and swayidle are owned by user systemd units.
-
-## Keybinds
-
-Some important shortcuts:
-- `Mod + Return`: open the terminal.
-- `Mod + q`: close the focused window.
-- `Mod + Shift + e`: exit Sway via `swaynag`.
-- `Mod + r`: resize mode.
-- `Mod + Ctrl + r`: reload the config.
-- `Mod + Alt + o`: lock the screen.
-- `Print`: capture a selected area to the clipboard.
-- `Shift + Print`: capture the full screen to the clipboard.
-- `Mod + Alt + l`: launcher Wofi.
-- `Mod + Alt + v`: Cliphist via Wofi.
-- `Mod + F9`, `Mod + F10`, `Mod + F11`: TLP power profiles.
-
-## Power profiles
-
-The bar displays the current TLP and platform profiles. The related keybinds
-use the unprivileged `tlpctl` interface provided by `tlp-pd`:
-- `performance`,
-- `balanced`,
-- `power-saver`.
-
-The related keybinds are:
-- `Mod + F9`: `tlpctl power-saver`.
-- `Mod + F10`: `tlpctl balanced`.
-- `Mod + F11`: `tlpctl performance`.
-
-## Floating windows
-
-Some windows are forced into floating mode and centered:
-- `blueman-manager`
-- `pavucontrol`
-- `nm-connection-editor`
-
-## Audio
-
-Audio shortcuts use `pactl` on the default sink.
-Volume is also handled in i3status-rs with a `sound` block.
-
-## Useful commands
-
-Reload Sway:
-```bash
-swaymsg reload
-```
-
-Exit Sway:
-```bash
-swaymsg exit
-```
-
-Restart the bar:
-```bash
-pkill swaybar
-swaymsg reload
-```
-
-## Notes
-
-This config is meant to stay simple, stable, and easy to modify.
-The setup is clearly Wayland-native, with minimal unnecessary layers and a modular structure.
+Valider avec `sway --validate` puis recharger avec `swaymsg reload`.
+Le rollback manuel de la barre et des notifications est décrit dans
+`quickshell/.config/quickshell/labfy-sway/notifications/ROLLBACK.md`.
diff --git a/sway/.config/sway/autostart b/sway/.config/sway/autostart
index f5930204c..debe8a4e8 100644
--- a/sway/.config/sway/autostart
+++ b/sway/.config/sway/autostart
@@ -6,6 +6,7 @@
# Transparence
# =================================================
+# Listener IPC démarré une fois par session ; exec ne se rejoue pas au reload.
exec python3 ~/.config/sway/scripts/inactive-windows-transparency.py
# =================================================
diff --git a/sway/.config/sway/config b/sway/.config/sway/config
index 020f16284..048e20142 100644
--- a/sway/.config/sway/config
+++ b/sway/.config/sway/config
@@ -6,9 +6,9 @@
#
include /etc/sway/config.d/*
include ~/.config/sway/bind
-include ~/.config/sway/bar
+# Barre historique archivée sous docs/legacy/swaybar ; QuickShell appartient à systemd user.
include ~/.config/sway/input
include ~/.config/sway/rules
include ~/.config/sway/style
-# include ~/.config/sway/swayfx
+include ~/.config/sway/swayfx
include ~/.config/sway/autostart
diff --git a/sway/.config/sway/style b/sway/.config/sway/style
index 15fecdb20..d94beff35 100644
--- a/sway/.config/sway/style
+++ b/sway/.config/sway/style
@@ -4,7 +4,8 @@
# Default wallpaper (more resolutions are available in /usr/share/backgrounds/sway/)
output * bg /usr/share/backgrounds/sway/Sway_Wallpaper_Blue_1920x1080.png fill
- output eDP-1 mode 3200x2000@60Hz pos 3200 0 scale 1.4
+ # 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
#
# Style
diff --git a/sway/.config/sway/swayfx b/sway/.config/sway/swayfx
index 08caccce2..76cae6667 100644
--- a/sway/.config/sway/swayfx
+++ b/sway/.config/sway/swayfx
@@ -1,30 +1,28 @@
-# SwayFX config options
-
-### Appearance
-# window corner radius in px
-corner_radius 2
-
-# Window background blur
-blur enable
-blur_xray off
-blur_passes 4
-blur_radius 2
+# Animations désactivées dès le démarrage pour valider la stabilité.
+animation_duration_ms 0
+corner_radius 4
shadows enable
-shadows_on_csd off
-shadow_blur_radius 2
-shadow_color #0000007F
+shadows_on_csd disable
+shadow_blur_radius 10
+shadow_color #00000055
+shadow_inactive_color #00000035
+shadow_offset 0 2
-# i3status blur
-layer_effects "i3status-rs" blur enable; shadows enable; corner_radius -1
-
-# inactive window fade amount. 0.0 = no dimming, 1.0 = fully dimmed
-default_dim_inactive 0.15
-dim_inactive_colors.unfocused #000000FF
-dim_inactive_colors.urgent #900000FF
-
-# Move minimized windows into Scratchpad (enable|disable)
+default_dim_inactive 0
scratchpad_minimize disable
-exec_always sleep 1
+# Limiter le flou aux surfaces shell pour éviter les grandes surfaces floutées.
+blur disable
+blur_xray disable
+blur_passes 2
+blur_radius 2
+# Namespace observé dans get_outputs ; les autres couches restent intactes.
+layer_effects "labfy-sway-bar" {
+ blur enable
+ blur_xray disable
+ blur_ignore_transparent enable
+ shadows enable
+ corner_radius 4
+}
diff --git a/systemd/.config/systemd/user/labfy-quickshell-updates.service b/systemd/.config/systemd/user/labfy-quickshell-updates.service
new file mode 100644
index 000000000..3d0def4ae
--- /dev/null
+++ b/systemd/.config/systemd/user/labfy-quickshell-updates.service
@@ -0,0 +1,8 @@
+[Unit]
+Description=Vérification Arch et AUR pour la barre QuickShell
+After=network-online.target
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/python3 %h/.config/quickshell/labfy-sway/bin/check-updates.py
+TimeoutStartSec=270
diff --git a/systemd/.config/systemd/user/labfy-quickshell-updates.timer b/systemd/.config/systemd/user/labfy-quickshell-updates.timer
new file mode 100644
index 000000000..74284264d
--- /dev/null
+++ b/systemd/.config/systemd/user/labfy-quickshell-updates.timer
@@ -0,0 +1,11 @@
+[Unit]
+Description=Vérification Arch et AUR toutes les dix minutes
+
+[Timer]
+OnActiveSec=10s
+OnUnitActiveSec=10min
+AccuracySec=5s
+Unit=labfy-quickshell-updates.service
+
+[Install]
+WantedBy=timers.target
diff --git a/systemd/.config/systemd/user/mako.service b/systemd/.config/systemd/user/mako.service
new file mode 120000
index 000000000..dc1dc0cde
--- /dev/null
+++ b/systemd/.config/systemd/user/mako.service
@@ -0,0 +1 @@
+/dev/null
\ No newline at end of file
diff --git a/systemd/.config/systemd/user/quickshell-labfy-sway.service b/systemd/.config/systemd/user/quickshell-labfy-sway.service
new file mode 100644
index 000000000..ff6c7e6bb
--- /dev/null
+++ b/systemd/.config/systemd/user/quickshell-labfy-sway.service
@@ -0,0 +1,13 @@
+[Unit]
+Description=QuickShell labfy-sway et serveur de notifications
+PartOf=wayland-session@sway.desktop.target
+After=wayland-session@sway.desktop.target
+
+[Service]
+Type=simple
+ExecStart=/usr/bin/qs -c labfy-sway --no-color
+Restart=on-failure
+RestartSec=3s
+
+[Install]
+WantedBy=wayland-session@sway.desktop.target
diff --git a/systemd/.config/systemd/user/timers.target.wants/labfy-quickshell-updates.timer b/systemd/.config/systemd/user/timers.target.wants/labfy-quickshell-updates.timer
new file mode 120000
index 000000000..7a57ec7e4
--- /dev/null
+++ b/systemd/.config/systemd/user/timers.target.wants/labfy-quickshell-updates.timer
@@ -0,0 +1 @@
+../labfy-quickshell-updates.timer
\ No newline at end of file
diff --git a/systemd/.config/systemd/user/wayland-session@sway.desktop.target.wants/quickshell-labfy-sway.service b/systemd/.config/systemd/user/wayland-session@sway.desktop.target.wants/quickshell-labfy-sway.service
new file mode 120000
index 000000000..cc63d4f8d
--- /dev/null
+++ b/systemd/.config/systemd/user/wayland-session@sway.desktop.target.wants/quickshell-labfy-sway.service
@@ -0,0 +1 @@
+../quickshell-labfy-sway.service
\ No newline at end of file