feat(desktop): replace swaybar with QuickShell and SwayFX
This commit is contained in:
parent
e1f357e035
commit
e1efd1881c
76 changed files with 4746 additions and 304 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
arch-sentinel-sample.timer
|
arch-sentinel-sample.timer
|
||||||
battery-low-notify.timer
|
battery-low-notify.timer
|
||||||
gnome-keyring-daemon.socket
|
gnome-keyring-daemon.socket
|
||||||
|
labfy-quickshell-updates.timer
|
||||||
p11-kit-server.socket
|
p11-kit-server.socket
|
||||||
pipewire-pulse.socket
|
pipewire-pulse.socket
|
||||||
pipewire.socket
|
pipewire.socket
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,6 @@ meshlab-bin
|
||||||
mozjpeg-debug
|
mozjpeg-debug
|
||||||
proton-pass-bin
|
proton-pass-bin
|
||||||
spicetify-marketplace-bin
|
spicetify-marketplace-bin
|
||||||
|
swayfx
|
||||||
vmware-workstation
|
vmware-workstation
|
||||||
yay
|
yay
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,6 @@ gtklock-userinfo-module
|
||||||
gvfs
|
gvfs
|
||||||
heimdall
|
heimdall
|
||||||
hplip
|
hplip
|
||||||
i3status-rust
|
|
||||||
ifuse
|
ifuse
|
||||||
imagemagick
|
imagemagick
|
||||||
inotify-tools
|
inotify-tools
|
||||||
|
|
@ -150,6 +149,7 @@ qt5-wayland
|
||||||
qt5ct
|
qt5ct
|
||||||
qt6-wayland
|
qt6-wayland
|
||||||
qt6ct
|
qt6ct
|
||||||
|
quickshell
|
||||||
radeontop
|
radeontop
|
||||||
rclone
|
rclone
|
||||||
reflector
|
reflector
|
||||||
|
|
@ -165,6 +165,7 @@ samba
|
||||||
sane
|
sane
|
||||||
sane-airscan
|
sane-airscan
|
||||||
sbctl
|
sbctl
|
||||||
|
scenefx
|
||||||
slurp
|
slurp
|
||||||
smartmontools
|
smartmontools
|
||||||
snap-pac
|
snap-pac
|
||||||
|
|
@ -177,7 +178,6 @@ starship
|
||||||
stow
|
stow
|
||||||
strace
|
strace
|
||||||
sudo
|
sudo
|
||||||
sway
|
|
||||||
swaybg
|
swaybg
|
||||||
swayidle
|
swayidle
|
||||||
swaylock
|
swaylock
|
||||||
|
|
|
||||||
21
README.md
21
README.md
|
|
@ -10,7 +10,7 @@
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img alt="Arch Linux" src="https://img.shields.io/badge/Arch_Linux-user_environment-1793d1?style=flat-square&logo=archlinux&logoColor=white">
|
<img alt="Arch Linux" src="https://img.shields.io/badge/Arch_Linux-user_environment-1793d1?style=flat-square&logo=archlinux&logoColor=white">
|
||||||
<img alt="Wayland avec Sway" src="https://img.shields.io/badge/Wayland-Sway-89b4fa?style=flat-square">
|
<img alt="Wayland avec SwayFX" src="https://img.shields.io/badge/Wayland-SwayFX-89b4fa?style=flat-square">
|
||||||
<img alt="GNU Stow" src="https://img.shields.io/badge/deployment-GNU_Stow-cba6f7?style=flat-square">
|
<img alt="GNU Stow" src="https://img.shields.io/badge/deployment-GNU_Stow-cba6f7?style=flat-square">
|
||||||
<img alt="Stable 0.1.0" src="https://img.shields.io/badge/stable-0.1.0-a6e3a1?style=flat-square">
|
<img alt="Stable 0.1.0" src="https://img.shields.io/badge/stable-0.1.0-a6e3a1?style=flat-square">
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -41,15 +41,14 @@ installateur automatique.
|
||||||
|
|
||||||
| Desktop | Shell |
|
| Desktop | Shell |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `greetd → tuigreet → UWSM → Sway` | `Zsh → Starship → Atuin` |
|
| `greetd → tuigreet → UWSM → SwayFX` | `Zsh → Starship → Atuin` |
|
||||||
| `Swaybar → i3status-rs` | `FZF → Zoxide → Eza → Bat → Yazi` |
|
| `QuickShell → barre et notifications` | `FZF → Zoxide → Eza → Bat → Yazi` |
|
||||||
| Mako · Wofi · Swaylock · Cliphist · Kitty | plugins Zsh et Tmux épinglés par sous-modules |
|
| Wofi · Swaylock · Cliphist · Kitty | plugins Zsh et Tmux épinglés par sous-modules |
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
G[greetd] --> T[tuigreet] --> U[UWSM] --> S[Sway]
|
G[greetd] --> T[tuigreet] --> U[UWSM] --> S[SwayFX]
|
||||||
S --> B[Swaybar] --> I[i3status-rs]
|
S --> B[QuickShell : barre et notifications]
|
||||||
S --> M[Mako]
|
|
||||||
S --> W[Wofi]
|
S --> W[Wofi]
|
||||||
S --> L[Swaylock]
|
S --> L[Swaylock]
|
||||||
S --> C[Cliphist]
|
S --> C[Cliphist]
|
||||||
|
|
@ -62,10 +61,10 @@ Les responsabilités et dépendances sont détaillées dans
|
||||||
## ✦ Preview
|
## ✦ Preview
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/assets/screenshots/archasp.png" alt="Environnement ArchASP réel sous Sway" width="100%">
|
<img src="docs/assets/screenshots/archasp.png" alt="Capture historique de l'environnement ArchASP sous Sway" width="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center"><sub>Environnement ArchASP réel — Sway, applications terminal et palette Catppuccin.</sub></p>
|
<p align="center"><sub>Capture historique avant la migration SwayFX et QuickShell.</sub></p>
|
||||||
|
|
||||||
## ✦ Repository
|
## ✦ Repository
|
||||||
|
|
||||||
|
|
@ -91,8 +90,8 @@ git clone --recurse-submodules \
|
||||||
cd "$HOME/.dotfiles"
|
cd "$HOME/.dotfiles"
|
||||||
|
|
||||||
packages=(atuin bat bin btop cliphist eza fastfetch fzf gtk-3.0 gtklock \
|
packages=(atuin bat bin btop cliphist eza fastfetch fzf gtk-3.0 gtklock \
|
||||||
hyprwhspr i3status-rust icons kitty mako nvim opencode qt5ct qt6ct \
|
hyprwhspr icons kitty mako nvim opencode qt5ct qt6ct \
|
||||||
starship sway swaylock systemd themes tmux uwsm wallpapers wofi yazi \
|
quickshell starship sway swaylock systemd themes tmux uwsm wallpapers wofi yazi \
|
||||||
ytmusic-tui zsh)
|
ytmusic-tui zsh)
|
||||||
|
|
||||||
stow --no --verbose=2 --target="$HOME" "${packages[@]}"
|
stow --no --verbose=2 --target="$HOME" "${packages[@]}"
|
||||||
|
|
|
||||||
|
|
@ -8,32 +8,51 @@ readonly CAPACITY_FILE="/sys/class/power_supply/${BATTERY}/capacity"
|
||||||
readonly STATUS_FILE="/sys/class/power_supply/${BATTERY}/status"
|
readonly STATUS_FILE="/sys/class/power_supply/${BATTERY}/status"
|
||||||
readonly HELPER="/usr/local/sbin/batlimit-set"
|
readonly HELPER="/usr/local/sbin/batlimit-set"
|
||||||
|
|
||||||
|
if (( $# > 1 )); then
|
||||||
|
echo "Usage : batlimit [SEUIL]" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ ! -e "$THRESHOLD_FILE" ]]; then
|
if [[ ! -e "$THRESHOLD_FILE" ]]; then
|
||||||
echo "Erreur : aucun seuil de charge disponible pour ${BATTERY}."
|
echo "Erreur : aucun seuil de charge disponible pour ${BATTERY}."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
if [[ ! "$STOP" =~ ^[1-9][0-9]{1,2}$ ]]; then
|
||||||
echo "Erreur : entre un nombre entier."
|
echo "Erreur : entre un nombre entier entre 40 et 100." >&2
|
||||||
exit 1
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if (( STOP < 40 || STOP > 100 )); then
|
if (( STOP < 40 || STOP > 100 )); then
|
||||||
echo "Erreur : choisis une valeur entre 40 et 100."
|
echo "Erreur : choisis une valeur entre 40 et 100." >&2
|
||||||
exit 1
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! sudo -n "$HELPER" "$STOP"; then
|
if RESULT=$(sudo -n "$HELPER" "$STOP"); then
|
||||||
echo "Erreur : impossible d’appliquer le seuil."
|
if [[ -n "$RESULT" && "$RESULT" != "SUCCESS $STOP" ]]; then
|
||||||
|
echo "Erreur : réponse inattendue du service de charge." >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
result_code=$?
|
||||||
|
echo "Erreur : impossible d’appliquer le seuil." >&2
|
||||||
|
exit "$result_code"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
CURRENT_THRESHOLD=$(<"$THRESHOLD_FILE")
|
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
|
||||||
echo "Batterie : ${BATTERY}"
|
echo "Batterie : ${BATTERY}"
|
||||||
echo "Seuil appliqué : ${CURRENT_THRESHOLD}%"
|
echo "Seuil rapporté : ${CURRENT_THRESHOLD}%"
|
||||||
echo "Charge actuelle : $(<"$CAPACITY_FILE")%"
|
echo "Charge actuelle : $(<"$CAPACITY_FILE")%"
|
||||||
echo "État : $(<"$STATUS_FILE")"
|
echo "État : $(<"$STATUS_FILE")"
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
pkill i3status-rs
|
|
||||||
i3status-rs ~/.config/i3status-rust/config.toml &
|
|
||||||
43
bin/root/README.md
Normal file
43
bin/root/README.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Helper de limite de charge
|
||||||
|
|
||||||
|
La source suivie est `usr/local/sbin/batlimit-set` dans ce répertoire. Le fichier
|
||||||
|
exécuté via la règle sudoers existante est `/usr/local/sbin/batlimit-set`.
|
||||||
|
|
||||||
|
Déploiement administrateur après revue :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo install -o root -g root -m 0755 \
|
||||||
|
/home/fy59/.dotfiles/bin/root/usr/local/sbin/batlimit-set \
|
||||||
|
/usr/local/sbin/batlimit-set
|
||||||
|
```
|
||||||
|
|
||||||
|
Le helper crée et maintient `/etc/tlp.d/90-labfy-charge-limit.conf` en
|
||||||
|
`root:root` et mode `0644`. Ce fichier contient la préférence persistante et
|
||||||
|
n'est pas suivi par Git. La configuration antérieure
|
||||||
|
`/etc/tlp.d/10-archasp.conf` reste intacte avec le seuil de secours à 60 %.
|
||||||
|
|
||||||
|
Le helper accepte exactement un entier de 40 à 100. Il affiche `SUCCESS X`
|
||||||
|
avec le code 0 seulement après relecture du seuil sysfs. Il affiche
|
||||||
|
`ERROR invalid_value` (code 2), `ERROR unavailable` ou
|
||||||
|
`ERROR apply_failed` (code 1), ou `ERROR rollback_failed` (code 3).
|
||||||
|
Une erreur d'application restaure l'ancien fichier et tente de réappliquer
|
||||||
|
l'ancien seuil. `rollback_failed` exige une vérification administrative de
|
||||||
|
la configuration et du seuil sysfs.
|
||||||
|
|
||||||
|
`~/.local/bin/batlimit` est la CLI utilisateur suivie par GNU Stow via
|
||||||
|
`bin/.local/bin/batlimit`. Sans argument elle garde l'invite historique ; avec
|
||||||
|
un entier (`batlimit 80`) elle appelle le même helper privilégié que la page
|
||||||
|
Batterie de QuickShell. La CLI affiche un compte rendu lisible, tandis que
|
||||||
|
QuickShell appelle directement le helper et vérifie sa sortie structurée.
|
||||||
|
Le wrapper ne modifie ni sysfs ni TLP lui-même.
|
||||||
|
|
||||||
|
Rollback administrateur après revue :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo rm /etc/tlp.d/90-labfy-charge-limit.conf
|
||||||
|
sudo tlp start
|
||||||
|
cat /sys/class/power_supply/BAT1/charge_control_end_threshold
|
||||||
|
```
|
||||||
|
|
||||||
|
Après le prochain démarrage, vérifier le seuil avec la même lecture sysfs,
|
||||||
|
ainsi que `sudo tlp-stat -b` et `journalctl -u tlp.service -b`.
|
||||||
166
bin/root/usr/local/sbin/batlimit-set
Executable file
166
bin/root/usr/local/sbin/batlimit-set
Executable file
|
|
@ -0,0 +1,166 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Apply one validated BAT1 charge limit and persist it through TLP.
|
||||||
|
|
||||||
|
Protocol marker: LABFY_TLP_PERSISTENT_V1. QuickShell checks the installed
|
||||||
|
root-owned helper for this marker before allowing a write.
|
||||||
|
|
||||||
|
Deployment: install this source as root:root, mode 0755, at
|
||||||
|
/usr/local/sbin/batlimit-set. The sudoers rule grants only that installed path.
|
||||||
|
|
||||||
|
Exit codes: 0 success, 2 invalid argument, 1 unavailable/apply/readback error,
|
||||||
|
3 rollback failure. stdout is exactly ``SUCCESS <value>`` on success; stderr
|
||||||
|
is exactly ``ERROR <reason>`` on failure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG = Path("/etc/tlp.d/90-labfy-charge-limit.conf")
|
||||||
|
SYSFS = Path("/sys/class/power_supply/BAT1/charge_control_end_threshold")
|
||||||
|
LOCK = Path("/run/lock/labfy-batlimit-set.lock")
|
||||||
|
TLP = "/usr/bin/tlp"
|
||||||
|
|
||||||
|
|
||||||
|
def fail(reason: str, code: int = 1) -> int:
|
||||||
|
print(f"ERROR {reason}", file=sys.stderr)
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def read_threshold() -> int:
|
||||||
|
raw = SYSFS.read_text(encoding="ascii").strip()
|
||||||
|
if not re.fullmatch(r"[0-9]{1,3}", raw, re.ASCII):
|
||||||
|
raise ValueError("invalid kernel threshold")
|
||||||
|
return int(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def prior_config() -> bytes | None:
|
||||||
|
try:
|
||||||
|
metadata = CONFIG.lstat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
# CONTRACT: never follow or replace a link supplied at this privileged path.
|
||||||
|
if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != 0 or metadata.st_size > 4096:
|
||||||
|
raise ValueError("unsafe TLP config")
|
||||||
|
return CONFIG.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def sync_directory() -> None:
|
||||||
|
fd = os.open(CONFIG.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||||
|
try:
|
||||||
|
os.fsync(fd)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
|
||||||
|
def replace_config(content: bytes) -> None:
|
||||||
|
# INVARIANT: TLP must see either the entire old file or the entire new file.
|
||||||
|
fd, temporary = tempfile.mkstemp(prefix=".90-labfy-charge-limit.", dir=CONFIG.parent)
|
||||||
|
try:
|
||||||
|
os.fchmod(fd, 0o644)
|
||||||
|
with os.fdopen(fd, "wb") as output:
|
||||||
|
output.write(content)
|
||||||
|
output.flush()
|
||||||
|
os.fsync(output.fileno())
|
||||||
|
os.replace(temporary, CONFIG)
|
||||||
|
sync_directory()
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temporary):
|
||||||
|
os.unlink(temporary)
|
||||||
|
|
||||||
|
|
||||||
|
def restore_config(previous: bytes | None) -> None:
|
||||||
|
if previous is None:
|
||||||
|
try:
|
||||||
|
CONFIG.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
sync_directory()
|
||||||
|
else:
|
||||||
|
replace_config(previous)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_runtime(value: int) -> bool:
|
||||||
|
# TLP's setcharge command is temporary; the atomically replaced file owns boot persistence.
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[TLP, "setcharge", "0", str(value), "BAT1"],
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
timeout=20,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return False
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def locked_operation(value: int) -> int:
|
||||||
|
try:
|
||||||
|
old_value = read_threshold()
|
||||||
|
old_config = prior_config()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return fail("unavailable")
|
||||||
|
|
||||||
|
try:
|
||||||
|
replace_config(
|
||||||
|
f"START_CHARGE_THRESH_BAT1=0\nSTOP_CHARGE_THRESH_BAT1={value}\n".encode("ascii")
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
# Even a directory fsync failure can happen after rename; restore the old bytes.
|
||||||
|
try:
|
||||||
|
restore_config(old_config)
|
||||||
|
except OSError:
|
||||||
|
return fail("rollback_failed", 3)
|
||||||
|
return fail("unavailable")
|
||||||
|
|
||||||
|
try:
|
||||||
|
applied = apply_runtime(value)
|
||||||
|
if applied and read_threshold() == value:
|
||||||
|
print(f"SUCCESS {value}")
|
||||||
|
return 0
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# CONTRACT: a failed apply must not leave the new preference for the next boot.
|
||||||
|
try:
|
||||||
|
restore_config(old_config)
|
||||||
|
restored = apply_runtime(old_value) and read_threshold() == old_value
|
||||||
|
except (OSError, ValueError):
|
||||||
|
restored = False
|
||||||
|
return fail("apply_failed" if restored else "rollback_failed", 1 if restored else 3)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
if len(argv) != 1 or not re.fullmatch(r"[1-9][0-9]{1,2}", argv[0], re.ASCII):
|
||||||
|
return fail("invalid_value", 2)
|
||||||
|
value = int(argv[0])
|
||||||
|
if not 40 <= value <= 100:
|
||||||
|
return fail("invalid_value", 2)
|
||||||
|
if os.geteuid() != 0:
|
||||||
|
return fail("root_required")
|
||||||
|
|
||||||
|
# One fixed root-owned lock serializes callers; QuickShell also limits itself to one Process.
|
||||||
|
try:
|
||||||
|
lock_fd = os.open(LOCK, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
||||||
|
try:
|
||||||
|
metadata = os.fstat(lock_fd)
|
||||||
|
if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != 0:
|
||||||
|
return fail("unavailable")
|
||||||
|
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||||
|
return locked_operation(value)
|
||||||
|
finally:
|
||||||
|
os.close(lock_fd)
|
||||||
|
except OSError:
|
||||||
|
return fail("unavailable")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv[1:]))
|
||||||
|
|
@ -41,7 +41,7 @@ Exemples de correspondance :
|
||||||
| Source versionnée | Cible utilisateur |
|
| Source versionnée | Cible utilisateur |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `sway/.config/sway/` | `~/.config/sway/` |
|
| `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/` |
|
| `bin/.local/bin/` | `~/.local/bin/` |
|
||||||
| `zsh/.zprofile` | `~/.zprofile` |
|
| `zsh/.zprofile` | `~/.zprofile` |
|
||||||
| `wallpapers/.wallpapers/` | `~/.wallpapers/` |
|
| `wallpapers/.wallpapers/` | `~/.wallpapers/` |
|
||||||
|
|
|
||||||
|
|
@ -1,72 +1,35 @@
|
||||||
# Desktop
|
# Desktop
|
||||||
|
|
||||||
## Stack canonique
|
## Session actuelle
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
G[greetd] --> T[tuigreet]
|
G[greetd] --> T[tuigreet] --> U[UWSM] --> S[SwayFX 0.6]
|
||||||
T --> U[UWSM]
|
S --> Q[QuickShell : barre et notifications]
|
||||||
U --> S[Sway]
|
|
||||||
S --> B[Swaybar]
|
|
||||||
B --> I[i3status-rs]
|
|
||||||
S --> M[Mako]
|
|
||||||
S --> W[Wofi]
|
S --> W[Wofi]
|
||||||
S --> L[Swaylock]
|
S --> L[Swaylock]
|
||||||
S --> C[Cliphist]
|
S --> C[Cliphist]
|
||||||
S --> K[Kitty]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| Composant | Responsabilité | Propriété |
|
`uwsm/.config/uwsm/default-id` sélectionne `sway.desktop` ; SwayFX fournit le
|
||||||
| --- | --- | --- |
|
binaire Sway compatible. `sway/.config/sway/config` inclut `swayfx`, mais
|
||||||
| greetd | gestionnaire de connexion système | installé/configuré par `arch-system` |
|
aucune configuration `bar`. QuickShell démarre une seule fois par
|
||||||
| tuigreet | interface terminal de greetd | paquet système ; commande référencée par greetd |
|
`quickshell-labfy-sway.service`, attaché à
|
||||||
| UWSM | cycle de vie de la session Wayland | package Stow `uwsm` et paquet système |
|
`wayland-session@sway.desktop.target`. Mako reste installé et masqué pour le
|
||||||
| Sway | compositeur et configuration de session | package `sway` |
|
rollback des notifications.
|
||||||
| 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` |
|
|
||||||
|
|
||||||
La configuration `uwsm/.config/uwsm/default-id` sélectionne `sway.desktop`.
|
La sortie interne `eDP-1` est positionnée à l'origine ; les paramètres SwayFX
|
||||||
La référence greetd de `.assets/greetd/config.toml` démarre la chaîne système,
|
sont conservés dans `sway/.config/sway/swayfx` : animations 0, coins 4,
|
||||||
mais sa copie dans `/etc` ne relève pas des dotfiles.
|
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,
|
`labfy-quickshell-updates.timer` déclenche la vérification Arch/AUR sans
|
||||||
le style, la barre, l'autostart et les scripts. Swaybar exécute :
|
installer de paquet. `cliphist-text.service`, `cliphist-image.service`,
|
||||||
|
`swayidle.service`, `wlsunset.service`, les agents SSH et keyring restent des
|
||||||
```text
|
unités utilisateur distinctes. Les raccourcis Sway utilisent notamment
|
||||||
i3status-rs ~/.config/i3status-rust/config.toml
|
`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).
|
||||||
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.
|
|
||||||
|
|
|
||||||
|
|
@ -70,15 +70,22 @@ Les commandes suivantes sont destinées à Bash :
|
||||||
```bash
|
```bash
|
||||||
packages=(
|
packages=(
|
||||||
atuin bat bin btop cliphist eza fastfetch fzf gtk-3.0 gtklock
|
atuin bat bin btop cliphist eza fastfetch fzf gtk-3.0 gtklock
|
||||||
hyprwhspr i3status-rust icons kitty mako nvim opencode qt5ct qt6ct
|
hyprwhspr icons kitty mako nvim opencode qt5ct qt6ct
|
||||||
starship sway swaylock systemd themes tmux uwsm wallpapers wofi yazi
|
quickshell starship sway swaylock systemd themes tmux uwsm wallpapers wofi yazi
|
||||||
ytmusic-tui zsh
|
ytmusic-tui zsh
|
||||||
)
|
)
|
||||||
|
|
||||||
printf '%s\n' "${packages[@]}"
|
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
|
## 6. Effectuer le dry-run
|
||||||
|
|
||||||
|
|
@ -114,8 +121,17 @@ systemd-analyze --user verify \
|
||||||
"$HOME"/.config/systemd/user/*.service \
|
"$HOME"/.config/systemd/user/*.service \
|
||||||
"$HOME"/.config/systemd/user/*.timer
|
"$HOME"/.config/systemd/user/*.timer
|
||||||
systemctl --user --failed
|
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
|
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).
|
leurs chemins et configurations locales existent ; voir [`systemd.md`](systemd.md).
|
||||||
|
|
||||||
|
|
|
||||||
8
docs/legacy/swaybar/README.md
Normal file
8
docs/legacy/swaybar/README.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Ancienne barre Sway
|
||||||
|
|
||||||
|
`bar` et `i3status-config.toml` conservent la configuration de retour manuel
|
||||||
|
pré-QuickShell. Les blocs Bluetooth qui contenaient des adresses matérielles
|
||||||
|
personnelles ont été retirés de l'archive. Ces fichiers ne sont plus déployés
|
||||||
|
par Stow. La procédure complète,
|
||||||
|
y compris la réinstallation d'`i3status-rust`, se trouve dans
|
||||||
|
`quickshell/.config/quickshell/labfy-sway/notifications/ROLLBACK.md`.
|
||||||
|
|
@ -32,24 +32,6 @@ button = "middle"
|
||||||
widget = "."
|
widget = "."
|
||||||
action = "toggle_format"
|
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]]
|
||||||
block = "net"
|
block = "net"
|
||||||
device = "wlp1s0"
|
device = "wlp1s0"
|
||||||
|
|
@ -94,8 +94,8 @@ systemctl --user --failed
|
||||||
sway --validate --config "$HOME/.config/sway/config"
|
sway --validate --config "$HOME/.config/sway/config"
|
||||||
```
|
```
|
||||||
|
|
||||||
Contrôler ensuite le login greetd/tuigreet, le démarrage UWSM/Sway, Swaybar,
|
Contrôler ensuite le login greetd/tuigreet, le démarrage UWSM/SwayFX,
|
||||||
i3status-rs, les notifications, le verrouillage, le presse-papiers, Kitty et
|
QuickShell et ses notifications, le verrouillage, le presse-papiers, Kitty et
|
||||||
un nouveau shell Zsh.
|
un nouveau shell Zsh.
|
||||||
|
|
||||||
## Limites actuelles
|
## Limites actuelles
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ mais il ne l'active pas. Les unités effectivement versionnées sous
|
||||||
| Domaine | Unités | Fonction |
|
| Domaine | Unités | Fonction |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| session | `gnome-keyring-daemon.service`, `ssh-agent.service`, `wlsunset.service` | agents et services de session |
|
| 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 |
|
| clipboard | `cliphist-text.service`, `cliphist-image.service` | collecte texte et images Wayland |
|
||||||
| idle | `swayidle.service` | verrouillage, alimentation des sorties et pause média |
|
| idle | `swayidle.service` | verrouillage, alimentation des sorties et pause média |
|
||||||
| batterie | `battery-low-notify.service/.timer` | contrôle et notification périodiques |
|
| 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 |
|
| autres | `hyprwhspr.service`, `protonmail-bridge.service` | intégrations déclenchées selon l'usage |
|
||||||
|
|
||||||
Les liens versionnés dans `default.target.wants/`,
|
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
|
activations utilisateur retenues. `.assets/enabled-user-aux-units.txt` capture
|
||||||
en complément les timers et sockets activés observés sur la machine.
|
en complément les timers et sockets activés observés sur la machine.
|
||||||
|
|
||||||
|
|
|
||||||
161
quickshell/.config/quickshell/labfy-sway/Bar.qml
Normal file
161
quickshell/.config/quickshell/labfy-sway/Bar.qml
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Wayland
|
||||||
|
import "modules"
|
||||||
|
import "controlcenter"
|
||||||
|
import "windows"
|
||||||
|
import "datecenter"
|
||||||
|
import "notifications"
|
||||||
|
import "status"
|
||||||
|
import "systemmonitor"
|
||||||
|
|
||||||
|
PanelWindow {
|
||||||
|
id: bar
|
||||||
|
// Identité layer-shell stable pour cibler seulement cette barre dans SwayFX.
|
||||||
|
WlrLayershell.namespace: "labfy-sway-bar"
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
required property var notificationService
|
||||||
|
screen: modelData
|
||||||
|
|
||||||
|
anchors {
|
||||||
|
left: true
|
||||||
|
right: true
|
||||||
|
top: true
|
||||||
|
}
|
||||||
|
|
||||||
|
margins {
|
||||||
|
top: 6
|
||||||
|
left: 8
|
||||||
|
right: 8
|
||||||
|
}
|
||||||
|
|
||||||
|
implicitHeight: 34
|
||||||
|
// La couche basse laisse les fenêtres plein écran conserver leur priorité.
|
||||||
|
aboveWindows: false
|
||||||
|
// CONTRACT: le PopupWindow reçoit les frappes seulement si son parent
|
||||||
|
// layer-shell est focusable dès son ouverture. OnDemand n'accapare pas
|
||||||
|
// le clavier hors interaction avec la barre ou son popup.
|
||||||
|
focusable: true
|
||||||
|
color: "#eb1e1e2e"
|
||||||
|
|
||||||
|
// CONTRACT: toute la capsule centrale partage le même popup et la même exclusion XOR.
|
||||||
|
function toggleDateCenter() {
|
||||||
|
const opening = !dateCenter.visible && !dateCenter.opening;
|
||||||
|
controlCenter.visible = false;
|
||||||
|
systemMonitor.visible = false;
|
||||||
|
windowStrip.closeMenu();
|
||||||
|
if (opening) dateCenter.requestOpen();
|
||||||
|
else dateCenter.requestClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: leftArea
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
// Garder les titres longs hors de la capsule centrale.
|
||||||
|
width: Math.max(0, centerClock.x - 8)
|
||||||
|
height: bar.height
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
Workspaces {
|
||||||
|
id: workspaces
|
||||||
|
screen: bar.screen
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 8
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
WindowStrip {
|
||||||
|
id: windowStrip
|
||||||
|
screen: bar.screen
|
||||||
|
anchors.left: workspaces.right
|
||||||
|
anchors.leftMargin: 10
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
maxWidth: Math.max(0, leftArea.width - x - 8)
|
||||||
|
onMenuOpened: {
|
||||||
|
controlCenter.visible = false;
|
||||||
|
systemMonitor.visible = false;
|
||||||
|
dateCenter.requestClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Clock {
|
||||||
|
id: centerClock
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
open: dateCenter.visible || dateCenter.opening
|
||||||
|
unreadCount: bar.notificationService.unreadCount
|
||||||
|
criticalUnreadCount: bar.notificationService.criticalUnreadCount
|
||||||
|
onToggled: bar.toggleDateCenter()
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: rightArea
|
||||||
|
anchors.left: centerClock.right
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
height: bar.height
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
// INVARIANT: la zone droite s'étend depuis le bord sans modifier l'ancrage central.
|
||||||
|
RightStatusArea {
|
||||||
|
id: rightStatusArea
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 8
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
barWindow: bar
|
||||||
|
wifiDevice: controlCenter.wifiDevice
|
||||||
|
adapter: controlCenter.adapter
|
||||||
|
controlCenterOpen: controlCenter.visible
|
||||||
|
systemMonitorOpen: systemMonitor.visible
|
||||||
|
onOpenPageRequested: page => {
|
||||||
|
systemMonitor.visible = false;
|
||||||
|
dateCenter.requestClose();
|
||||||
|
windowStrip.closeMenu();
|
||||||
|
controlCenter.currentPage = page;
|
||||||
|
controlCenter.visible = true;
|
||||||
|
}
|
||||||
|
onToggleControlCenterRequested: {
|
||||||
|
systemMonitor.visible = false;
|
||||||
|
const opening = !controlCenter.visible;
|
||||||
|
dateCenter.requestClose();
|
||||||
|
windowStrip.closeMenu();
|
||||||
|
controlCenter.currentPage = 0;
|
||||||
|
controlCenter.visible = opening;
|
||||||
|
}
|
||||||
|
onToggleSystemMonitorRequested: {
|
||||||
|
const opening = !systemMonitor.visible;
|
||||||
|
controlCenter.visible = false;
|
||||||
|
dateCenter.requestClose();
|
||||||
|
windowStrip.closeMenu();
|
||||||
|
systemMonitor.visible = opening;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ControlCenter {
|
||||||
|
id: controlCenter
|
||||||
|
barWindow: bar
|
||||||
|
onThresholdApplied: rightStatusArea.refreshBatteryThreshold()
|
||||||
|
}
|
||||||
|
|
||||||
|
SystemMonitor {
|
||||||
|
id: systemMonitor
|
||||||
|
barWindow: bar
|
||||||
|
}
|
||||||
|
|
||||||
|
DateCenter {
|
||||||
|
id: dateCenter
|
||||||
|
barWindow: bar
|
||||||
|
clockItem: centerClock
|
||||||
|
notificationService: bar.notificationService
|
||||||
|
}
|
||||||
|
NotificationToastArea {
|
||||||
|
barWindow: bar
|
||||||
|
service: bar.notificationService
|
||||||
|
monitorOpen: systemMonitor.visible
|
||||||
|
monitorWidth: systemMonitor.implicitWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
65
quickshell/.config/quickshell/labfy-sway/README.md
Normal file
65
quickshell/.config/quickshell/labfy-sway/README.md
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
# QuickShell labfy-sway
|
||||||
|
|
||||||
|
Barre principale et serveur de notifications de la session SwayFX.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Bar
|
||||||
|
├── Workspaces
|
||||||
|
├── Window/task switcher
|
||||||
|
├── Date Center
|
||||||
|
│ ├── MPRIS
|
||||||
|
│ └── Notifications
|
||||||
|
├── Right Status
|
||||||
|
│ ├── Network
|
||||||
|
│ ├── Bluetooth
|
||||||
|
│ ├── Battery
|
||||||
|
│ ├── Updates
|
||||||
|
│ ├── Tray
|
||||||
|
│ ├── System Monitor
|
||||||
|
│ └── Control Center
|
||||||
|
└── Control Center
|
||||||
|
├── Wi-Fi
|
||||||
|
├── Bluetooth
|
||||||
|
├── Volume
|
||||||
|
├── Brightness
|
||||||
|
├── Power Profile
|
||||||
|
├── Battery limit
|
||||||
|
└── Session
|
||||||
|
```
|
||||||
|
|
||||||
|
`quickshell-labfy-sway.service` est activé par
|
||||||
|
`wayland-session@sway.desktop.target`, sans autostart Sway parallèle.
|
||||||
|
`NotificationService.qml` possède `org.freedesktop.Notifications` ; l'historique
|
||||||
|
est écrit hors Git via `Quickshell.statePath("notifications.json")` dans
|
||||||
|
`~/.local/state/quickshell/`. Mako demeure installé, inactif et masqué ; voir
|
||||||
|
[ROLLBACK.md](notifications/ROLLBACK.md).
|
||||||
|
|
||||||
|
`labfy-quickshell-updates.timer` exécute `bin/check-updates.py`, qui publie un
|
||||||
|
état JSON dans `$XDG_RUNTIME_DIR`. `batlimit` (CLI) et Réglages rapides →
|
||||||
|
Batterie (GUI) passent par `/usr/local/sbin/batlimit-set`. Le seuil courant
|
||||||
|
est 60 % ; le helper root est documenté dans `bin/root/README.md`.
|
||||||
|
|
||||||
|
Dépendances essentielles : `quickshell`, SwayFX 0.6, `scenefx`, une Nerd Font,
|
||||||
|
NetworkManager, BlueZ, PipeWire, TLP/`tlpctl`, `checkupdates`, `yay`, `df`,
|
||||||
|
`python3`, `swaylock` et le helper batterie pour le réglage du seuil. Le
|
||||||
|
backlight utilise le noeud machine `amdgpu_bl1` dans `BrightnessSlider.qml` ;
|
||||||
|
le System Monitor résout dynamiquement les noeuds DRM AMD et hwmon
|
||||||
|
`k10temp`/`amdgpu` par identité, noms et labels. Les scans Wi-Fi et Bluetooth
|
||||||
|
ne sont possédés que par les pages ouvertes.
|
||||||
|
|
||||||
|
## Parité avec i3status-rs
|
||||||
|
|
||||||
|
| Ancien bloc | QuickShell |
|
||||||
|
| --- | --- |
|
||||||
|
| scratchpad indicator | volontairement non repris |
|
||||||
|
| focused_window | WindowStrip |
|
||||||
|
| music | Date Center MPRIS |
|
||||||
|
| bluetooth | status + Control Center |
|
||||||
|
| net | status + Control Center |
|
||||||
|
| sound | Control Center |
|
||||||
|
| backlight | Control Center |
|
||||||
|
| battery | status + page Batterie |
|
||||||
|
| TLP | PowerProfile |
|
||||||
|
| packages | Updates |
|
||||||
|
| time | Clock / Date Center |
|
||||||
|
| menu | page Session |
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"; } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
quickshell/.config/quickshell/labfy-sway/modules/Clock.qml
Normal file
79
quickshell/.config/quickshell/labfy-sway/modules/Clock.qml
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import "../notifications"
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: clockCapsule
|
||||||
|
property bool open: false
|
||||||
|
required property int unreadCount
|
||||||
|
required property int criticalUnreadCount
|
||||||
|
signal toggled()
|
||||||
|
// INVARIANT: la largeur fixe garde le texte sur le centre de l'écran, même avec "9+".
|
||||||
|
width: 184
|
||||||
|
height: 26
|
||||||
|
radius: 4
|
||||||
|
color: open ? "#cba6f7" : pointer.containsMouse ? "#45475a" : "#313244"
|
||||||
|
Accessible.role: Accessible.Button
|
||||||
|
Accessible.name: unreadCount === 0 ? "Date et heure"
|
||||||
|
: "Date et heure, " + unreadCount + (unreadCount === 1
|
||||||
|
? " notification non lue" : " notifications non lues")
|
||||||
|
|
||||||
|
SystemClock {
|
||||||
|
id: clock
|
||||||
|
precision: SystemClock.Minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: clockText
|
||||||
|
// CONTRACT: le centre du texte coïncide avec celui de la barre, sans dépendre du badge.
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: Qt.formatDateTime(clock.date, "dd/MM HH:mm")
|
||||||
|
color: clockCapsule.open ? "#1e1e2e" : "#cdd6f4"
|
||||||
|
font.pixelSize: 13
|
||||||
|
font.weight: Font.Bold
|
||||||
|
}
|
||||||
|
|
||||||
|
UnreadIndicator {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 8
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
unreadCount: clockCapsule.unreadCount
|
||||||
|
criticalUnreadCount: clockCapsule.criticalUnreadCount
|
||||||
|
inverted: clockCapsule.open
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: pointer
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
acceptedButtons: Qt.LeftButton
|
||||||
|
onClicked: clockCapsule.toggled()
|
||||||
|
}
|
||||||
|
|
||||||
|
PopupWindow {
|
||||||
|
anchor.item: clockCapsule
|
||||||
|
anchor.edges: Edges.Bottom | Edges.Left
|
||||||
|
anchor.gravity: Edges.Bottom | Edges.Right
|
||||||
|
anchor.margins.bottom: -6
|
||||||
|
visible: pointer.containsMouse
|
||||||
|
grabFocus: false
|
||||||
|
color: "transparent"
|
||||||
|
implicitWidth: tooltipText.implicitWidth + 16
|
||||||
|
implicitHeight: 26
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: 4
|
||||||
|
color: "#313244"
|
||||||
|
border.color: "#45475a"
|
||||||
|
Text {
|
||||||
|
id: tooltipText
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: clockCapsule.unreadCount === 0 ? "Date et notifications"
|
||||||
|
: clockCapsule.unreadCount + (clockCapsule.unreadCount === 1
|
||||||
|
? " notification non lue" : " notifications non lues")
|
||||||
|
color: "#cdd6f4"
|
||||||
|
font.pixelSize: 11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
quickshell/.config/quickshell/labfy-sway/shell.qml
Normal file
15
quickshell/.config/quickshell/labfy-sway/shell.qml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
//@ pragma UseQApplication
|
||||||
|
import Quickshell
|
||||||
|
import "notifications"
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
// Une barre par écran, y compris si les sorties changent pendant la session.
|
||||||
|
// CONTRACT: un seul serveur D-Bus pour toutes les sorties.
|
||||||
|
NotificationService { id: rootNotificationService }
|
||||||
|
|
||||||
|
Variants {
|
||||||
|
model: Quickshell.screens
|
||||||
|
|
||||||
|
Bar { notificationService: rootNotificationService }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -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() }
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
quickshell/.config/quickshell/labfy-sway/status/Tray.qml
Normal file
17
quickshell/.config/quickshell/labfy-sway/status/Tray.qml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell.Services.SystemTray
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: tray
|
||||||
|
required property var barWindow
|
||||||
|
spacing: 2
|
||||||
|
// Le modèle natif suit les inscriptions et retraits D-Bus sans scrutation.
|
||||||
|
Repeater {
|
||||||
|
model: SystemTray.items
|
||||||
|
delegate: TrayItem {
|
||||||
|
required property var modelData
|
||||||
|
item: modelData
|
||||||
|
barWindow: tray.barWindow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
quickshell/.config/quickshell/labfy-sway/status/TrayItem.qml
Normal file
56
quickshell/.config/quickshell/labfy-sway/status/TrayItem.qml
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Services.SystemTray
|
||||||
|
import Quickshell.Widgets
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: entry
|
||||||
|
required property var item
|
||||||
|
required property var barWindow
|
||||||
|
width: 26
|
||||||
|
height: 26
|
||||||
|
readonly property string detail: {
|
||||||
|
const heading = item.tooltipTitle || item.title || "Application";
|
||||||
|
return heading + (item.tooltipDescription ? "\n" + item.tooltipDescription : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { anchors.fill: parent; radius: 4; color: pointer.containsMouse ? "#45475a" : "transparent" }
|
||||||
|
IconImage {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: 18
|
||||||
|
height: 18
|
||||||
|
source: entry.item.icon
|
||||||
|
opacity: entry.item.status === Status.Passive ? 0.7 : 1
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
visible: entry.item.status === Status.NeedsAttention
|
||||||
|
width: 4; height: 4; radius: 2
|
||||||
|
color: "#cba6f7"
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
}
|
||||||
|
MouseArea {
|
||||||
|
id: pointer
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
|
||||||
|
onClicked: mouse => {
|
||||||
|
if (mouse.button === Qt.MiddleButton) entry.item.secondaryActivate();
|
||||||
|
else if (mouse.button === Qt.RightButton && entry.item.hasMenu) menuAnchor.open();
|
||||||
|
else if (mouse.button === Qt.LeftButton) {
|
||||||
|
if (entry.item.onlyMenu && entry.item.hasMenu) menuAnchor.open();
|
||||||
|
else entry.item.activate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onWheel: wheel => entry.item.scroll(wheel.angleDelta.y, false)
|
||||||
|
}
|
||||||
|
QsMenuAnchor {
|
||||||
|
id: menuAnchor
|
||||||
|
menu: entry.item.menu
|
||||||
|
anchor.item: entry
|
||||||
|
anchor.margins.bottom: -5
|
||||||
|
anchor.edges: Edges.Top | Edges.Left
|
||||||
|
anchor.gravity: Edges.Bottom | Edges.Right
|
||||||
|
}
|
||||||
|
StatusTooltip { target: entry; hovered: pointer.containsMouse; message: entry.detail }
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
136
quickshell/.config/quickshell/labfy-sway/windows/WindowMenu.qml
Normal file
136
quickshell/.config/quickshell/labfy-sway/windows/WindowMenu.qml
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.I3
|
||||||
|
|
||||||
|
PopupWindow {
|
||||||
|
id: menu
|
||||||
|
|
||||||
|
property var anchorItem: null
|
||||||
|
property var selectedWindow: null
|
||||||
|
property var windows: []
|
||||||
|
readonly property var appWindows: selectedWindow
|
||||||
|
? windows.filter(window => window.appKey === selectedWindow.appKey) : []
|
||||||
|
readonly property var entry: selectedWindow
|
||||||
|
? DesktopEntries.heuristicLookup(selectedWindow.appId) : null
|
||||||
|
readonly property var actions: entry ? entry.actions : []
|
||||||
|
|
||||||
|
anchor.item: anchorItem
|
||||||
|
anchor.edges: Edges.Bottom | Edges.Left
|
||||||
|
anchor.gravity: Edges.Bottom | Edges.Right
|
||||||
|
// Une marge négative place le menu sous le bouton avec 6 px d'espace.
|
||||||
|
anchor.margins.bottom: -6
|
||||||
|
|
||||||
|
implicitWidth: 300
|
||||||
|
implicitHeight: content.implicitHeight + 24
|
||||||
|
visible: false
|
||||||
|
// Laisser les boutons de barre recevoir les clics d'exclusion XOR.
|
||||||
|
grabFocus: false
|
||||||
|
color: "transparent"
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: 4
|
||||||
|
color: "#1e1e2e"
|
||||||
|
border.color: "#45475a"
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: content
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.margins: 12
|
||||||
|
spacing: 7
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: menu.entry ? menu.entry.name
|
||||||
|
: menu.selectedWindow ? menu.selectedWindow.appId : ""
|
||||||
|
color: "#cdd6f4"
|
||||||
|
font.pixelSize: 15
|
||||||
|
font.bold: true
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Fenêtres ouvertes"
|
||||||
|
color: "#a6adc8"
|
||||||
|
font.pixelSize: 11
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: menu.appWindows
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
required property var modelData
|
||||||
|
width: content.width
|
||||||
|
height: 30
|
||||||
|
radius: 4
|
||||||
|
color: pointer.containsMouse ? "#45475a" : "#313244"
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.leftMargin: 8
|
||||||
|
anchors.rightMargin: 8
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: modelData.title
|
||||||
|
color: "#cdd6f4"
|
||||||
|
font.pixelSize: 12
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: pointer
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
onClicked: {
|
||||||
|
menu.visible = false;
|
||||||
|
I3.dispatch("[con_id=" + modelData.id + "] focus");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
visible: menu.actions.length > 0
|
||||||
|
text: "Actions"
|
||||||
|
color: "#a6adc8"
|
||||||
|
font.pixelSize: 11
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: menu.actions
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
required property var modelData
|
||||||
|
width: content.width
|
||||||
|
height: 30
|
||||||
|
radius: 4
|
||||||
|
color: actionPointer.containsMouse ? "#45475a" : "#313244"
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.leftMargin: 8
|
||||||
|
anchors.rightMargin: 8
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: modelData.name
|
||||||
|
color: "#cdd6f4"
|
||||||
|
font.pixelSize: 12
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: actionPointer
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
onClicked: {
|
||||||
|
menu.visible = false;
|
||||||
|
modelData.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
239
quickshell/.config/quickshell/labfy-sway/windows/WindowStrip.qml
Normal file
239
quickshell/.config/quickshell/labfy-sway/windows/WindowStrip.qml
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.I3
|
||||||
|
import Quickshell.I3._Ipc
|
||||||
|
import Quickshell.Io
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: strip
|
||||||
|
|
||||||
|
required property var screen
|
||||||
|
required property int maxWidth
|
||||||
|
property var tree: null
|
||||||
|
property bool componentReady: false
|
||||||
|
property bool refreshRunning: false
|
||||||
|
property bool refreshPending: false
|
||||||
|
property var menuAnchor: null
|
||||||
|
property var menuWindow: null
|
||||||
|
signal menuOpened()
|
||||||
|
|
||||||
|
// monitorFor(screen) peut rester null dans un binding créé avant l'inventaire IPC.
|
||||||
|
// Le modèle réactif des moniteurs réévalue le workspace dès que Sway l'annonce.
|
||||||
|
readonly property var monitor: I3.monitors.values.find(item => item.name === screen.name) || null
|
||||||
|
readonly property var activeWorkspace: monitor ? monitor.activeWorkspace : null
|
||||||
|
readonly property var windows: windowsForWorkspace(tree, activeWorkspace)
|
||||||
|
readonly property var activeWindow: windows.find(window => window.focused) || null
|
||||||
|
readonly property var secondaryWindows: activeWindow
|
||||||
|
? windows.filter(window => window.id !== activeWindow.id) : []
|
||||||
|
|
||||||
|
width: maxWidth
|
||||||
|
height: 26
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
// L'arbre Sway est la source du workspace : une sortie seule ne distingue pas ses fenêtres.
|
||||||
|
function windowsForWorkspace(root, workspace) {
|
||||||
|
if (!root || !workspace) return [];
|
||||||
|
|
||||||
|
function findWorkspace(node) {
|
||||||
|
if (node.type === "workspace" && node.id === workspace.id) return node;
|
||||||
|
for (const child of (node.nodes || []).concat(node.floating_nodes || [])) {
|
||||||
|
const found = findWorkspace(child);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = findWorkspace(root);
|
||||||
|
if (!node) return [];
|
||||||
|
const found = [];
|
||||||
|
|
||||||
|
function collect(container) {
|
||||||
|
const appId = container.app_id
|
||||||
|
|| (container.window_properties && container.window_properties.class) || "";
|
||||||
|
if (container.type === "con" && appId) {
|
||||||
|
found.push({
|
||||||
|
id: container.id,
|
||||||
|
title: container.name || appId,
|
||||||
|
appId: appId,
|
||||||
|
appKey: appId.toLowerCase(),
|
||||||
|
focused: container.focused === true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const child of (container.nodes || []).concat(container.floating_nodes || []))
|
||||||
|
collect(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
collect(node);
|
||||||
|
const focusedIndex = found.findIndex(window => window.focused);
|
||||||
|
if (focusedIndex > 0)
|
||||||
|
found.unshift(found.splice(focusedIndex, 1)[0]);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process.running devient vrai après la demande de lancement : ce verrou ferme aussi cet intervalle.
|
||||||
|
// Une rafale IPC garde au plus un relevé supplémentaire en attente.
|
||||||
|
function requestRefresh() {
|
||||||
|
if (!componentReady) return;
|
||||||
|
if (refreshRunning) {
|
||||||
|
refreshPending = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
refreshRunning = true;
|
||||||
|
treeReader.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les titres animés peuvent changer plusieurs fois par seconde : l'événement IPC
|
||||||
|
// contient déjà le nouveau titre, sans nécessiter un relevé complet de l'arbre.
|
||||||
|
function applyTitleEvent(event) {
|
||||||
|
if (!tree || refreshRunning) return false;
|
||||||
|
let update;
|
||||||
|
try {
|
||||||
|
update = JSON.parse(event.data);
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (update.change !== "title" || !update.container) return false;
|
||||||
|
|
||||||
|
function updateNode(node) {
|
||||||
|
if (node.id === update.container.id) {
|
||||||
|
node.name = update.container.name;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (const child of (node.nodes || []).concat(node.floating_nodes || [])) {
|
||||||
|
if (updateNode(child)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updateNode(tree)) return false;
|
||||||
|
tree = Object.assign({}, tree);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
windowMenu.visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMenu(item, windowInfo) {
|
||||||
|
if (windowMenu.visible && menuWindow && menuWindow.id === windowInfo.id) {
|
||||||
|
windowMenu.visible = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
windowMenu.visible = false;
|
||||||
|
menuAnchor = item;
|
||||||
|
menuWindow = windowInfo;
|
||||||
|
windowMenu.visible = true;
|
||||||
|
menuOpened();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Des changements de workspace arrivent pendant la construction QML : démarrer après celle du Process.
|
||||||
|
Component.onCompleted: {
|
||||||
|
componentReady = true;
|
||||||
|
Qt.callLater(requestRefresh);
|
||||||
|
}
|
||||||
|
onMonitorChanged: requestRefresh()
|
||||||
|
onActiveWorkspaceChanged: {
|
||||||
|
closeMenu();
|
||||||
|
requestRefresh();
|
||||||
|
}
|
||||||
|
onWindowsChanged: {
|
||||||
|
if (menuWindow && !windows.some(window => window.id === menuWindow.id))
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: I3
|
||||||
|
function onConnected() { strip.requestRefresh(); }
|
||||||
|
function onRawEvent(event) {
|
||||||
|
if (event.type === "workspace")
|
||||||
|
strip.requestRefresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuickShell 0.3.1 n'abonne I3.rawEvent qu'à workspace/output.
|
||||||
|
I3IpcListener {
|
||||||
|
subscriptions: ["window"]
|
||||||
|
onIpcEvent: event => {
|
||||||
|
// Chaque abonnement confirmé, initial ou rétabli, impose un snapshot complet.
|
||||||
|
if (event.type === "subscribe") {
|
||||||
|
try {
|
||||||
|
if (JSON.parse(event.data).success === true)
|
||||||
|
strip.requestRefresh();
|
||||||
|
else
|
||||||
|
console.warn("Abonnement aux fenêtres Sway refusé");
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Réponse d'abonnement Sway illisible :", error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!strip.applyTitleEvent(event))
|
||||||
|
strip.requestRefresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: treeReader
|
||||||
|
command: ["swaymsg", "-t", "get_tree", "-r"]
|
||||||
|
stdout: StdioCollector { id: treeOutput; waitForEnd: true }
|
||||||
|
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode === 0) {
|
||||||
|
try {
|
||||||
|
strip.tree = JSON.parse(treeOutput.text);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Arbre Sway illisible :", error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("Relevé de l'arbre Sway échoué :", exitCode);
|
||||||
|
}
|
||||||
|
strip.refreshRunning = false;
|
||||||
|
if (strip.refreshPending) {
|
||||||
|
strip.refreshPending = false;
|
||||||
|
Qt.callLater(strip.requestRefresh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: buttons
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
WindowButton {
|
||||||
|
id: activeButton
|
||||||
|
visible: strip.activeWindow !== null
|
||||||
|
windowInfo: strip.activeWindow || ({ id: 0, title: "", appId: "", appKey: "" })
|
||||||
|
primary: true
|
||||||
|
maxPrimaryWidth: Math.max(26, Math.min(400,
|
||||||
|
strip.width - strip.secondaryWindows.length * 30 - 8))
|
||||||
|
menuOpen: windowMenu.visible && strip.menuWindow
|
||||||
|
&& strip.menuWindow.id === windowInfo.id
|
||||||
|
onMenuRequested: (item, windowInfo) => strip.showMenu(item, windowInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: ScriptModel {
|
||||||
|
values: strip.secondaryWindows
|
||||||
|
objectProp: "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate: WindowButton {
|
||||||
|
required property var modelData
|
||||||
|
windowInfo: modelData
|
||||||
|
primary: false
|
||||||
|
maxPrimaryWidth: 26
|
||||||
|
menuOpen: windowMenu.visible && strip.menuWindow
|
||||||
|
&& strip.menuWindow.id === windowInfo.id
|
||||||
|
onMenuRequested: (item, windowInfo) => strip.showMenu(item, windowInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WindowMenu {
|
||||||
|
id: windowMenu
|
||||||
|
anchorItem: strip.menuAnchor
|
||||||
|
selectedWindow: strip.menuWindow
|
||||||
|
windows: strip.windows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,170 +1,20 @@
|
||||||
# sway-config
|
# Configuration SwayFX
|
||||||
|
|
||||||

|
Le compositeur actif est **SwayFX 0.6** avec `scenefx`. La session UWSM
|
||||||
|
sélectionne `sway.desktop` et charge `config` : `bind`, `input`, `rules`,
|
||||||
|
`style`, `swayfx` et `autostart`. `eDP-1` est l'unique écran configuré, à
|
||||||
|
l'origine logique. La configuration de barre native a été archivée dans
|
||||||
|
`docs/legacy/swaybar/` ; la barre et les notifications sont fournies par
|
||||||
|
QuickShell, lancé par `quickshell-labfy-sway.service`.
|
||||||
|
|
||||||
Personal SwayFX configuration, modular and lightweight, designed for everyday Wayland use.
|
`autostart` lance une fois le listener IPC
|
||||||
|
`scripts/inactive-windows-transparency.py`, `limusic-app` et `autotiling`.
|
||||||
|
Cliphist, swayidle, wlsunset et QuickShell sont gérés par systemd user ;
|
||||||
|
aucune commande `qs` n'est lancée par Sway. Le fichier `swayfx` fixe les
|
||||||
|
animations à 0, les coins à 4, les ombres validées et le flou uniquement sur
|
||||||
|
la couche QuickShell. Les profils TLP restent accessibles par les raccourcis
|
||||||
|
`tlpctl`.
|
||||||
|
|
||||||
## Layout
|
Valider avec `sway --validate` puis recharger avec `swaymsg reload`.
|
||||||
|
Le rollback manuel de la barre et des notifications est décrit dans
|
||||||
```bash
|
`quickshell/.config/quickshell/labfy-sway/notifications/ROLLBACK.md`.
|
||||||
❯ 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.
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
# Transparence
|
# 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
|
exec python3 ~/.config/sway/scripts/inactive-windows-transparency.py
|
||||||
|
|
||||||
# =================================================
|
# =================================================
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@
|
||||||
#
|
#
|
||||||
include /etc/sway/config.d/*
|
include /etc/sway/config.d/*
|
||||||
include ~/.config/sway/bind
|
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/input
|
||||||
include ~/.config/sway/rules
|
include ~/.config/sway/rules
|
||||||
include ~/.config/sway/style
|
include ~/.config/sway/style
|
||||||
# include ~/.config/sway/swayfx
|
include ~/.config/sway/swayfx
|
||||||
include ~/.config/sway/autostart
|
include ~/.config/sway/autostart
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@
|
||||||
|
|
||||||
# Default wallpaper (more resolutions are available in /usr/share/backgrounds/sway/)
|
# Default wallpaper (more resolutions are available in /usr/share/backgrounds/sway/)
|
||||||
output * bg /usr/share/backgrounds/sway/Sway_Wallpaper_Blue_1920x1080.png fill
|
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
|
# Style
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,28 @@
|
||||||
# SwayFX config options
|
# Animations désactivées dès le démarrage pour valider la stabilité.
|
||||||
|
animation_duration_ms 0
|
||||||
### Appearance
|
|
||||||
# window corner radius in px
|
|
||||||
corner_radius 2
|
|
||||||
|
|
||||||
# Window background blur
|
|
||||||
blur enable
|
|
||||||
blur_xray off
|
|
||||||
blur_passes 4
|
|
||||||
blur_radius 2
|
|
||||||
|
|
||||||
|
corner_radius 4
|
||||||
shadows enable
|
shadows enable
|
||||||
shadows_on_csd off
|
shadows_on_csd disable
|
||||||
shadow_blur_radius 2
|
shadow_blur_radius 10
|
||||||
shadow_color #0000007F
|
shadow_color #00000055
|
||||||
|
shadow_inactive_color #00000035
|
||||||
|
shadow_offset 0 2
|
||||||
|
|
||||||
# i3status blur
|
default_dim_inactive 0
|
||||||
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)
|
|
||||||
scratchpad_minimize disable
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
11
systemd/.config/systemd/user/labfy-quickshell-updates.timer
Normal file
11
systemd/.config/systemd/user/labfy-quickshell-updates.timer
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Vérification Arch et AUR toutes les dix minutes
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnActiveSec=10s
|
||||||
|
OnUnitActiveSec=10min
|
||||||
|
AccuracySec=5s
|
||||||
|
Unit=labfy-quickshell-updates.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
1
systemd/.config/systemd/user/mako.service
Symbolic link
1
systemd/.config/systemd/user/mako.service
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
/dev/null
|
||||||
13
systemd/.config/systemd/user/quickshell-labfy-sway.service
Normal file
13
systemd/.config/systemd/user/quickshell-labfy-sway.service
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
[Unit]
|
||||||
|
Description=QuickShell labfy-sway et serveur de notifications
|
||||||
|
PartOf=wayland-session@sway.desktop.target
|
||||||
|
After=wayland-session@sway.desktop.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/qs -c labfy-sway --no-color
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=wayland-session@sway.desktop.target
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
../labfy-quickshell-updates.timer
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
../quickshell-labfy-sway.service
|
||||||
Loading…
Reference in a new issue