feat(desktop): replace swaybar with QuickShell and SwayFX

This commit is contained in:
fy59 2026-10-04 12:28:32 +02:00
parent e1f357e035
commit e1efd1881c
76 changed files with 4746 additions and 304 deletions

View file

@ -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

View file

@ -13,5 +13,6 @@ meshlab-bin
mozjpeg-debug
proton-pass-bin
spicetify-marketplace-bin
swayfx
vmware-workstation
yay

View file

@ -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

View file

@ -10,7 +10,7 @@
<p align="center">
<img alt="Arch Linux" src="https://img.shields.io/badge/Arch_Linux-user_environment-1793d1?style=flat-square&logo=archlinux&logoColor=white">
<img alt="Wayland avec Sway" src="https://img.shields.io/badge/Wayland-Sway-89b4fa?style=flat-square">
<img alt="Wayland avec SwayFX" src="https://img.shields.io/badge/Wayland-SwayFX-89b4fa?style=flat-square">
<img alt="GNU Stow" src="https://img.shields.io/badge/deployment-GNU_Stow-cba6f7?style=flat-square">
<img alt="Stable 0.1.0" src="https://img.shields.io/badge/stable-0.1.0-a6e3a1?style=flat-square">
</p>
@ -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
<p align="center">
<img src="docs/assets/screenshots/archasp.png" alt="Environnement ArchASP réel sous Sway" width="100%">
<img src="docs/assets/screenshots/archasp.png" alt="Capture historique de l'environnement ArchASP sous Sway" width="100%">
</p>
<p align="center"><sub>Environnement ArchASP réel — Sway, applications terminal et palette Catppuccin.</sub></p>
<p align="center"><sub>Capture historique avant la migration SwayFX et QuickShell.</sub></p>
## ✦ 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[@]}"

View file

@ -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")"

View file

@ -1,3 +0,0 @@
#!/bin/sh
pkill i3status-rs
i3status-rs ~/.config/i3status-rust/config.toml &

43
bin/root/README.md Normal file
View file

@ -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`.

View file

@ -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 <value>`` on success; stderr
is exactly ``ERROR <reason>`` 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:]))

View file

@ -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/` |

View file

@ -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).

View file

@ -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).

View file

@ -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`.

View file

@ -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"

View file

@ -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

View file

@ -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.

View file

@ -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
}
}

View file

@ -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 |

View file

@ -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())

View file

@ -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()

View file

@ -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"
}

View file

@ -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()
}
}

View file

@ -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
}
}
}

View file

@ -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"; } }
}
}
}
}

View file

@ -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;
}
}

View file

@ -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"
}
}
}
}

View file

@ -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.

View file

@ -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)
}
}
}
}
}

View file

@ -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()
}
}
}
}

View file

@ -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()
}
}

View file

@ -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
}
}
}
}
}
}

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}
}
}

View file

@ -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"
}
}
}
}

View file

@ -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";
} }
}
}
}
}

View file

@ -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;
}
}

View file

@ -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 }
}
}
}

View file

@ -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]();
}
}
}
}
}
}
}
}

View file

@ -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() }
}
}
}
}
}

View file

@ -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
}
}
}

View file

@ -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
}
}
}
}

View file

@ -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
}
}
}
}

View file

@ -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;
}
}

View file

@ -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();
}
}

View file

@ -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() }
}
}
}
}
}

View file

@ -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
}
}
}
}

View file

@ -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.

View file

@ -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
}
}
}

View file

@ -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 }
}
}

View file

@ -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 }
}

View file

@ -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 }
}

View file

@ -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 }
}

View file

@ -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() }
}

View file

@ -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
}
}
}

View file

@ -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
}
}
}

View file

@ -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 }
}

View file

@ -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 }
}

View file

@ -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
}
}
}

View file

@ -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);
}
}
}

View file

@ -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 }
}
}
}
}

View file

@ -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"
}
}

View file

@ -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
}
}
}
}
}

View file

@ -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();
}
}
}
}
}
}
}

View file

@ -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
}
}

View file

@ -1,170 +1,20 @@
# sway-config
# Configuration SwayFX
![Screenshot](./assets/screen.jpg)
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`.

View file

@ -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
# =================================================

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -0,0 +1 @@
/dev/null

View file

@ -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

View file

@ -0,0 +1 @@
../labfy-quickshell-updates.timer

View file

@ -0,0 +1 @@
../quickshell-labfy-sway.service