diff --git a/.assets/enabled-user-aux-units.txt b/.assets/enabled-user-aux-units.txt index ed3c0576b..31b3ecc4a 100644 --- a/.assets/enabled-user-aux-units.txt +++ b/.assets/enabled-user-aux-units.txt @@ -1,6 +1,7 @@ arch-sentinel-sample.timer battery-low-notify.timer gnome-keyring-daemon.socket +labfy-quickshell-updates.timer p11-kit-server.socket pipewire-pulse.socket pipewire.socket diff --git a/.assets/pkglist-aur.txt b/.assets/pkglist-aur.txt index 0cfeb681c..4d7ef6341 100644 --- a/.assets/pkglist-aur.txt +++ b/.assets/pkglist-aur.txt @@ -13,5 +13,6 @@ meshlab-bin mozjpeg-debug proton-pass-bin spicetify-marketplace-bin +swayfx vmware-workstation yay diff --git a/.assets/pkglist-pacman.txt b/.assets/pkglist-pacman.txt index e628925de..72f2f0414 100644 --- a/.assets/pkglist-pacman.txt +++ b/.assets/pkglist-pacman.txt @@ -75,7 +75,6 @@ gtklock-userinfo-module gvfs heimdall hplip -i3status-rust ifuse imagemagick inotify-tools @@ -150,6 +149,7 @@ qt5-wayland qt5ct qt6-wayland qt6ct +quickshell radeontop rclone reflector @@ -165,6 +165,7 @@ samba sane sane-airscan sbctl +scenefx slurp smartmontools snap-pac @@ -177,7 +178,6 @@ starship stow strace sudo -sway swaybg swayidle swaylock diff --git a/README.md b/README.md index eb58c8c86..4f8625f19 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

Arch Linux - Wayland avec Sway + Wayland avec SwayFX GNU Stow Stable 0.1.0

@@ -41,15 +41,14 @@ installateur automatique. | Desktop | Shell | | --- | --- | -| `greetd → tuigreet → UWSM → Sway` | `Zsh → Starship → Atuin` | -| `Swaybar → i3status-rs` | `FZF → Zoxide → Eza → Bat → Yazi` | -| Mako · Wofi · Swaylock · Cliphist · Kitty | plugins Zsh et Tmux épinglés par sous-modules | +| `greetd → tuigreet → UWSM → SwayFX` | `Zsh → Starship → Atuin` | +| `QuickShell → barre et notifications` | `FZF → Zoxide → Eza → Bat → Yazi` | +| Wofi · Swaylock · Cliphist · Kitty | plugins Zsh et Tmux épinglés par sous-modules | ```mermaid flowchart LR - G[greetd] --> T[tuigreet] --> U[UWSM] --> S[Sway] - S --> B[Swaybar] --> I[i3status-rs] - S --> M[Mako] + G[greetd] --> T[tuigreet] --> U[UWSM] --> S[SwayFX] + S --> B[QuickShell : barre et notifications] S --> W[Wofi] S --> L[Swaylock] S --> C[Cliphist] @@ -62,10 +61,10 @@ Les responsabilités et dépendances sont détaillées dans ## ✦ Preview

- Environnement ArchASP réel sous Sway + Capture historique de l'environnement ArchASP sous Sway

-

Environnement ArchASP réel — Sway, applications terminal et palette Catppuccin.

+

Capture historique avant la migration SwayFX et QuickShell.

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