Compare commits

...

3 commits

71 changed files with 5147 additions and 172 deletions

3
.gitattributes vendored Normal file
View file

@ -0,0 +1,3 @@
# These upstream unified diffs preserve source whitespace needed by patch.
tools/slurp-keymap-guard.patch whitespace=-blank-at-eol,-space-before-tab
tools/quickshell-qt-compat-v1/qt612-metatypes-v031.patch whitespace=-blank-at-eol,-space-before-tab

View file

@ -74,7 +74,7 @@ ArchASP
└── Dotfiles_ArchAP utilisateur · $HOME · Stow · systemd --user └── Dotfiles_ArchAP utilisateur · $HOME · Stow · systemd --user
``` ```
Les 31 packages Stow couvrent le desktop, le shell, le développement, les Les 32 packages Stow couvrent le desktop, le shell, le développement, les
services utilisateur et l'apparence. `.assets/` contient les inventaires de services utilisateur et l'apparence. `.assets/` contient les inventaires de
reconstruction ; `docs/` contient la documentation et n'est jamais un package reconstruction ; `docs/` contient la documentation et n'est jamais un package
Stow. Voir le [contrat d'architecture](docs/architecture.md). Stow. Voir le [contrat d'architecture](docs/architecture.md).
@ -90,7 +90,7 @@ 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 icons kitty mako nvim opencode qt5ct qt6ct \ hyprwhspr icons kitty mako nvim opencode portal qt5ct qt6ct \
quickshell 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)
@ -110,6 +110,7 @@ inventaires et validations sont décrits dans le
| [Architecture](docs/architecture.md) | Frontières ArchASP, Stow et données locales | | [Architecture](docs/architecture.md) | Frontières ArchASP, Stow et données locales |
| [Installation](docs/installation.md) | Clone neuf, sous-modules, Stow et validations | | [Installation](docs/installation.md) | Clone neuf, sous-modules, Stow et validations |
| [Desktop](docs/desktop.md) | Chaîne greetd/UWSM/Sway et composants Wayland | | [Desktop](docs/desktop.md) | Chaîne greetd/UWSM/Sway et composants Wayland |
| [Sélecteur de fichiers](portal/README.md) | Portail FileChooser, Yazi, Kitty et raccourcis |
| [Shell](docs/shell.md) | Zsh, outils interactifs, plugins et scripts | | [Shell](docs/shell.md) | Zsh, outils interactifs, plugins et scripts |
| [systemd](docs/systemd.md) | Services système et unités utilisateur | | [systemd](docs/systemd.md) | Services système et unités utilisateur |
| [Packages](docs/packages.md) | Inventaires Pacman, AUR, services et outils externes | | [Packages](docs/packages.md) | Inventaires Pacman, AUR, services et outils externes |

View file

@ -1,8 +1,9 @@
#!/usr/bin/python3 #!/usr/bin/python3
"""Daemon d'accès conservateur aux supports amovibles via UDisks2.""" """Daemon d'accès aux supports bloc UDisks2 et aux appareils MTP Gio."""
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
import logging import logging
import os import os
@ -12,16 +13,18 @@ import sys
import tempfile import tempfile
import threading import threading
import time import time
import uuid
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
SCHEMA = "labfy.removable-media" SCHEMA = "labfy.removable-media"
VERSION = 1 VERSION = 2
MAX_REQUEST = 16 * 1024 MAX_REQUEST = 16 * 1024
MAX_ERROR = 240 MAX_ERROR = 240
MAX_CLIENTS = 8 MAX_CLIENTS = 8
READ_TIMEOUT = 2.0 READ_TIMEOUT = 2.0
OPERATION_TIMEOUT_SECONDS = 30
SOCKET_NAME = "labfy-removable-media.sock" SOCKET_NAME = "labfy-removable-media.sock"
STATE_NAME = "labfy-removable-media.json" STATE_NAME = "labfy-removable-media.json"
ALLOWED_OPERATIONS = {"list", "mount", "unmount", "open", "safe-remove"} ALLOWED_OPERATIONS = {"list", "mount", "unmount", "open", "safe-remove"}
@ -55,6 +58,17 @@ def public_operation_error(error: BaseException, operation: str) -> str:
return bounded_error(error) return bounded_error(error)
def public_mtp_operation_error(error: BaseException, operation: str) -> str:
"""Ne publie jamais le détail Gio, qui peut contenir l'URI MTP."""
if isinstance(error, RequestError):
return bounded_error(error)
return {
"mount": "Impossible de monter l'appareil MTP.",
"unmount": "Impossible de démonter l'appareil MTP.",
"safe-remove": "Impossible de fermer la session MTP.",
}.get(operation, "Impossible d'effectuer l'opération sur l'appareil MTP.")
def runtime_dir() -> Path: def runtime_dir() -> Path:
value = os.environ.get("XDG_RUNTIME_DIR") value = os.environ.get("XDG_RUNTIME_DIR")
if not value or not os.path.isabs(value): if not value or not os.path.isabs(value):
@ -93,17 +107,35 @@ class FilesystemRecord:
removable: bool removable: bool
ejectable: bool ejectable: bool
power_off_capable: bool power_off_capable: bool
kind: str = "block"
mounted_state: bool | None = None
@property
def mounted(self) -> bool:
return bool(self.mount_point) if self.mounted_state is None else self.mounted_state
@property
def safe_remove_runtime_id(self) -> str:
return self.drive_path
def actions(self) -> list[str]:
actions = ["unmount", "open"] if self.mount_point else ["mount", "open"]
if self.power_off_capable or self.ejectable:
actions.append("safe-remove")
return actions
def public(self, busy: bool = False, error: str = "") -> dict[str, Any]: def public(self, busy: bool = False, error: str = "") -> dict[str, Any]:
return { return {
"runtime_id": self.path, "runtime_id": self.path,
"drive_runtime_id": self.drive_path, "safe_remove_runtime_id": self.safe_remove_runtime_id,
"kind": self.kind,
"actions": self.actions(),
"display_name": self.display_name, "display_name": self.display_name,
"label": self.label, "label": self.label,
"filesystem": self.filesystem, "filesystem": self.filesystem,
"size_bytes": self.size_bytes, "size_bytes": self.size_bytes,
"mount_point": self.mount_point, "mount_point": self.mount_point,
"mounted": bool(self.mount_point), "mounted": self.mounted,
"removable": self.removable, "removable": self.removable,
"ejectable": self.ejectable, "ejectable": self.ejectable,
"power_off_capable": self.power_off_capable, "power_off_capable": self.power_off_capable,
@ -112,6 +144,22 @@ class FilesystemRecord:
} }
@dataclass(frozen=True)
class MTPRecord(FilesystemRecord):
"""Vue publique d'un volume MTP, sans URI ni identité matérielle."""
kind: str = "mtp"
def actions(self) -> list[str]:
if not self.mounted:
return ["mount"]
actions = ["unmount"]
if self.mount_point:
actions.append("open")
actions.append("safe-remove")
return actions
class UDisksBackend: class UDisksBackend:
"""Adaptateur Gio minimal ; l'ObjectManager reste l'unique source de vérité.""" """Adaptateur Gio minimal ; l'ObjectManager reste l'unique source de vérité."""
@ -325,6 +373,266 @@ class UDisksBackend:
return sorted(result) return sorted(result)
class MTPBackend:
"""Adaptateur des volumes MTP exposés par GVFS via Gio.VolumeMonitor."""
def __init__(self, Gio: Any, GLib: Any, monitor: Any | None = None) -> None:
self.Gio = Gio
self.GLib = GLib
self.monitor = monitor or Gio.VolumeMonitor.get()
self._nonce = uuid.uuid4().hex
self._counter = 0
self._ids_by_identity: dict[str, str] = {}
self._identity_by_id: dict[str, str] = {}
self._volumes_by_id: dict[str, Any] = {}
@staticmethod
def _is_mtp(volume: Any) -> bool:
try:
root = volume.get_activation_root()
return root is not None and str(root.get_uri_scheme()).lower() == "mtp"
except (AttributeError, TypeError):
return False
def _live_volumes(self) -> list[Any]:
return [volume for volume in self.monitor.get_volumes() if self._is_mtp(volume)]
@staticmethod
def _identity(volume: Any) -> str:
"""Identifie une présence sans conserver ni publier son URI MTP."""
root = volume.get_activation_root()
uri = str(root.get_uri())
return hashlib.sha256(uri.encode("utf-8")).hexdigest()
def _runtime_id(self, volume: Any) -> str:
identity = self._identity(volume)
runtime_id = self._ids_by_identity.get(identity)
if runtime_id is None:
self._counter += 1
runtime_id = f"mtp:{self._nonce}:{self._counter}"
self._ids_by_identity[identity] = runtime_id
self._identity_by_id[runtime_id] = identity
self._volumes_by_id[runtime_id] = volume
return runtime_id
def _forget_volume(self, volume: Any) -> None:
if not self._is_mtp(volume):
return
identity = self._identity(volume)
runtime_id = self._ids_by_identity.pop(identity, None)
if runtime_id is not None:
self._identity_by_id.pop(runtime_id, None)
self._volumes_by_id.pop(runtime_id, None)
@staticmethod
def _mount_path(volume: Any) -> str:
mount = volume.get_mount()
if mount is None:
return ""
root = mount.get_root()
if root is None:
return ""
# CONTRACT: ce chemin local est fourni par GVFS. Une URI MTP ne doit
# jamais servir à reconstruire à la main un chemin sous /run/user.
return str(root.get_path() or "")
@staticmethod
def _size(volume: Any) -> int:
mount = volume.get_mount()
if mount is None:
return 0
try:
info = mount.get_root().query_filesystem_info("filesystem::size", None)
return int(info.get_attribute_uint64("filesystem::size"))
except Exception:
return 0
def records(self) -> list[FilesystemRecord]:
volumes = self._live_volumes()
live_identities = {self._identity(volume) for volume in volumes}
for identity, runtime_id in list(self._ids_by_identity.items()):
if identity not in live_identities:
self._ids_by_identity.pop(identity, None)
self._identity_by_id.pop(runtime_id, None)
self._volumes_by_id.pop(runtime_id, None)
records: list[FilesystemRecord] = []
for volume in volumes:
runtime_id = self._runtime_id(volume)
name = str(volume.get_name() or "Appareil Android")
mount = volume.get_mount()
display_name = name
if mount is not None:
try:
display_name = str(mount.get_name() or name)
except AttributeError:
pass
records.append(
MTPRecord(
path=runtime_id,
drive_path=runtime_id,
display_name=display_name,
label=name,
filesystem="mtp",
size_bytes=self._size(volume),
mount_point=self._mount_path(volume),
removable=True,
ejectable=False,
power_off_capable=False,
mounted_state=mount is not None,
)
)
return sorted(records, key=lambda item: item.path)
def connect_changed(self, callback: Callable[[], None]) -> None:
def volume_removed(_monitor: Any, volume: Any, *_args: Any) -> None:
self._forget_volume(volume)
callback()
self.monitor.connect("volume-removed", volume_removed)
for signal in (
"volume-added",
"volume-changed",
"mount-added",
"mount-changed",
"mount-removed",
):
self.monitor.connect(signal, lambda *_args: callback())
def _volume(self, runtime_id: str) -> Any:
# INVARIANT: relire le moniteur avant toute action et utiliser le wrapper
# vivant correspondant à la présence ayant reçu cet identifiant opaque.
identity = self._identity_by_id.get(runtime_id)
volume = next(
(
item
for item in self._live_volumes()
if identity is not None and self._identity(item) == identity
),
None,
)
if volume is None:
raise RequestError("Appareil MTP introuvable ou retiré")
self._volumes_by_id[runtime_id] = volume
return volume
def operate_async(
self,
runtime_id: str,
operation: str,
callback: Callable[[str | None, BaseException | None], None],
) -> Callable[[], None]:
volume = self._volume(runtime_id)
cancellable = self.Gio.Cancellable.new()
finished = False
timeout_id: int | None = None
settle_id: int | None = None
def complete(path: str | None = None, error: BaseException | None = None) -> None:
nonlocal finished
if finished:
return
finished = True
if timeout_id is not None:
self.GLib.source_remove(timeout_id)
if settle_id is not None:
self.GLib.source_remove(settle_id)
callback(path, error)
def timed_out() -> bool:
cancellable.cancel()
complete(error=RequestError("Délai d'opération MTP dépassé"))
return False
timeout_id = self.GLib.timeout_add_seconds(OPERATION_TIMEOUT_SECONDS, timed_out)
def verify_postcondition(expect_mounted: bool) -> bool:
try:
current = self._volume(runtime_id)
mounted = current.get_mount() is not None
if mounted != expect_mounted:
return True
complete(self._mount_path(current) if mounted else "")
except BaseException as error:
complete(error=error)
return False
def settle(expect_mounted: bool) -> None:
nonlocal settle_id
if not verify_postcondition(expect_mounted):
return
# WHY: GVFS peut publier GMount quelques instants après mount_finish.
# Le polling rend la main entre chaque lecture et reste couvert par
# le timeout/cancellable global de l'opération.
settle_id = self.GLib.timeout_add(100, verify_postcondition, expect_mounted)
def mount_done(source: Any, result: Any, *_args: Any) -> None:
try:
source.mount_finish(result)
settle(True)
except BaseException as error:
complete(error=error)
def unmount_done(source: Any, result: Any, *_args: Any) -> None:
try:
source.unmount_with_operation_finish(result)
settle(False)
except BaseException as error:
complete(error=error)
try:
if operation == "mount":
volume.mount(
self.Gio.MountMountFlags.NONE, None, cancellable, mount_done
)
elif operation in {"unmount", "safe-remove"}:
mount = volume.get_mount()
if mount is None:
if operation == "safe-remove":
raise RequestError("L'appareil MTP n'a pas de session à fermer")
complete("")
else:
# CONTRACT: le retrait MTP ferme uniquement la session GVFS ;
# il ne déclenche jamais UDisks PowerOff/Eject.
mount.unmount_with_operation(
self.Gio.MountUnmountFlags.NONE,
None,
cancellable,
unmount_done,
)
else:
raise RequestError("Opération MTP non autorisée")
except BaseException as error:
complete(error=error)
return cancellable.cancel
class RemovableMediaBackend:
"""Agrège les deux sources sans modifier la politique UDisks existante."""
def __init__(self, block: UDisksBackend, mtp: MTPBackend) -> None:
self.block = block
self.mtp = mtp
def records(self) -> list[FilesystemRecord]:
return self.block.records() + self.mtp.records()
def connect_changed(self, callback: Callable[[], None]) -> None:
self.block.connect_changed(callback)
self.mtp.connect_changed(callback)
def __getattr__(self, name: str) -> Any:
return getattr(self.block, name)
def operate_mtp_async(
self,
runtime_id: str,
operation: str,
callback: Callable[[str | None, BaseException | None], None],
) -> Callable[[], None]:
return self.mtp.operate_async(runtime_id, operation, callback)
class MediaDaemon: class MediaDaemon:
"""Modèle métier injectable et indépendant de Gio.""" """Modèle métier injectable et indépendant de Gio."""
@ -391,7 +699,7 @@ class MediaDaemon:
self._write_state() self._write_state()
def start(self) -> None: def start(self) -> None:
"""Effectue le scan initial après l'abonnement aux événements UDisks.""" """Effectue le scan initial après l'abonnement aux deux sources."""
self.devices_changed() self.devices_changed()
def devices_changed(self) -> None: def devices_changed(self) -> None:
@ -406,7 +714,10 @@ class MediaDaemon:
confirmed: dict[str, str] = {} confirmed: dict[str, str] = {}
for item in newcomers: for item in newcomers:
if item.mount_point: if item.mounted:
continue
if item.kind == "mtp":
self._start_mtp_operation(item, "mount", None)
continue continue
try: try:
self.backend.mount(item.path) self.backend.mount(item.path)
@ -430,6 +741,35 @@ class MediaDaemon:
] ]
self._write_state() self._write_state()
def _start_mtp_operation(
self,
record: FilesystemRecord,
operation: str,
done: Callable[[dict[str, Any] | BaseException], None] | None,
) -> Callable[[], None]:
self._set_busy(record.safe_remove_runtime_id, True)
def completed(path: str | None, error: BaseException | None) -> None:
with self._lock:
result_error: BaseException | None = None
if error is None:
self._errors.pop(record.path, None)
else:
message = public_mtp_operation_error(error, operation)
self._errors[record.path] = message
result_error = RequestError(message)
LOGGER.warning("Échec Gio pendant une opération MTP : %s", error)
self._busy_drives.discard(record.safe_remove_runtime_id)
self.refresh()
if done is not None:
done(result_error or {"ok": True, "state": self.state()})
try:
return self.backend.operate_mtp_async(record.path, operation, completed)
except BaseException as error:
completed(None, error)
return lambda: None
def _record(self, runtime_id: str) -> FilesystemRecord: def _record(self, runtime_id: str) -> FilesystemRecord:
# CONTRACT: on relit UDisks avant toute mutation ; un identifiant d'objet # CONTRACT: on relit UDisks avant toute mutation ; un identifiant d'objet
# disparu ou réutilisé ne doit jamais viser un ancien support en cache. # disparu ou réutilisé ne doit jamais viser un ancien support en cache.
@ -524,8 +864,19 @@ class MediaDaemon:
self.refresh() self.refresh()
return {"ok": True, "state": self.state()} return {"ok": True, "state": self.state()}
assert runtime_id is not None assert runtime_id is not None
if operation == "safe-remove":
self.refresh() self.refresh()
mtp_record = next(
(item for item in self._records if item.path == runtime_id and item.kind == "mtp"),
None,
)
if mtp_record is not None:
if operation == "open":
if "open" not in mtp_record.actions() or not mtp_record.mount_point:
raise RequestError("L'appareil MTP n'a pas fourni de chemin local")
self.launcher(mtp_record.mount_point)
return {"ok": True, "state": self.state()}
raise RequestError("Cette opération MTP exige une complétion asynchrone")
if operation == "safe-remove":
records = self._drive_records(runtime_id) records = self._drive_records(runtime_id)
if not records: if not records:
raise RequestError("Lecteur introuvable ou retiré") raise RequestError("Lecteur introuvable ou retiré")
@ -552,6 +903,44 @@ class MediaDaemon:
self.launcher(mount_point) self.launcher(mount_point)
return {"ok": True, "state": self.state()} return {"ok": True, "state": self.state()}
def handle_async(
self,
request: dict[str, Any],
done: Callable[[dict[str, Any] | BaseException], None],
) -> Callable[[], None]:
"""Lance une requête depuis GLib et remet sa réponse au callback."""
try:
operation, runtime_id = validate_request(request)
if operation == "list":
done(self.handle(request))
return lambda: None
assert runtime_id is not None
self.refresh()
record = next(
(
item
for item in self._records
if item.kind == "mtp"
and runtime_id in {item.path, item.safe_remove_runtime_id}
),
None,
)
if record is None:
done(self.handle(request))
return lambda: None
if operation not in record.actions():
raise RequestError("Opération indisponible pour cet appareil MTP")
if operation == "open":
if not record.mount_point:
raise RequestError("L'appareil MTP n'a pas fourni de chemin local")
self.launcher(record.mount_point)
done({"ok": True, "state": self.state()})
return lambda: None
return self._start_mtp_operation(record, operation, done)
except BaseException as error:
done(error)
return lambda: None
@staticmethod @staticmethod
def _launch_yazi(mount_point: str) -> None: def _launch_yazi(mount_point: str) -> None:
subprocess.Popen( subprocess.Popen(
@ -580,9 +969,11 @@ def validate_request(request: Any) -> tuple[str, str | None]:
if runtime_id is not None: if runtime_id is not None:
raise RequestError("runtime_id est interdit pour list") raise RequestError("runtime_id est interdit pour list")
return operation, None return operation, None
if not isinstance(runtime_id, str) or not runtime_id.startswith( valid_prefix = isinstance(runtime_id, str) and (
"/org/freedesktop/UDisks2/" runtime_id.startswith("/org/freedesktop/UDisks2/")
) or len(runtime_id) > 512: or runtime_id.startswith("mtp:")
)
if not valid_prefix or len(runtime_id) > 512:
raise RequestError("runtime_id invalide") raise RequestError("runtime_id invalide")
return operation, runtime_id return operation, runtime_id
@ -663,25 +1054,38 @@ class JsonSocketServer:
class GLibDispatcher: class GLibDispatcher:
"""Fait exécuter les mutations Gio dans le thread de la boucle GLib.""" """Fait exécuter les mutations Gio dans le thread de la boucle GLib."""
def __init__(self, GLib: Any, handler: Callable[[dict[str, Any]], dict[str, Any]]) -> None: def __init__(
self,
GLib: Any,
handler: Callable[
[dict[str, Any], Callable[[dict[str, Any] | BaseException], None]],
Callable[[], None],
],
) -> None:
self.GLib = GLib self.GLib = GLib
self.handler = handler self.handler = handler
def __call__(self, request: dict[str, Any]) -> dict[str, Any]: def __call__(self, request: dict[str, Any]) -> dict[str, Any]:
completed = threading.Event() completed = threading.Event()
result: list[dict[str, Any] | BaseException] = [] result: list[dict[str, Any] | BaseException] = []
cancellation: list[Callable[[], None]] = []
def complete(value: dict[str, Any] | BaseException) -> None:
if not completed.is_set():
result.append(value)
completed.set()
def invoke() -> bool: def invoke() -> bool:
try: try:
result.append(self.handler(request)) cancellation.append(self.handler(request, complete))
except BaseException as error: except BaseException as error:
result.append(error) complete(error)
finally:
completed.set()
return False return False
self.GLib.idle_add(invoke) self.GLib.idle_add(invoke)
if not completed.wait(30): if not completed.wait(OPERATION_TIMEOUT_SECONDS + 2):
if cancellation:
self.GLib.idle_add(cancellation[0])
raise RequestError("Délai d'opération dépassé") raise RequestError("Délai d'opération dépassé")
value = result[0] value = result[0]
if isinstance(value, BaseException): if isinstance(value, BaseException):
@ -697,13 +1101,17 @@ def main() -> int:
from gi.repository import Gio, GLib from gi.repository import Gio, GLib
base = runtime_dir() base = runtime_dir()
backend = UDisksBackend(Gio, GLib) backend = RemovableMediaBackend(
UDisksBackend(Gio, GLib), MTPBackend(Gio, GLib)
)
daemon = MediaDaemon(backend, base / STATE_NAME) daemon = MediaDaemon(backend, base / STATE_NAME)
# CONTRACT: s'abonner avant le scan ferme la fenêtre où un objet ajouté # CONTRACT: s'abonner avant le scan ferme la fenêtre où un objet ajouté
# entre ces deux opérations aurait échappé à l'automount. # entre ces deux opérations aurait échappé à l'automount.
backend.connect_changed(daemon.devices_changed) backend.connect_changed(daemon.devices_changed)
daemon.start() daemon.start()
server = JsonSocketServer(base / SOCKET_NAME, GLibDispatcher(GLib, daemon.handle)) server = JsonSocketServer(
base / SOCKET_NAME, GLibDispatcher(GLib, daemon.handle_async)
)
server.start() server.start()
threading.Thread(target=server.serve_forever, daemon=True).start() threading.Thread(target=server.serve_forever, daemon=True).start()
GLib.MainLoop().run() GLib.MainLoop().run()

View file

@ -22,7 +22,7 @@ def usage() -> int:
" labfy-removable-mediactl mount <runtime-id>\n" " labfy-removable-mediactl mount <runtime-id>\n"
" labfy-removable-mediactl unmount <runtime-id>\n" " labfy-removable-mediactl unmount <runtime-id>\n"
" labfy-removable-mediactl open <runtime-id>\n" " labfy-removable-mediactl open <runtime-id>\n"
" labfy-removable-mediactl safe-remove <drive-id>", " labfy-removable-mediactl safe-remove <safe-remove-id>",
file=sys.stderr, file=sys.stderr,
) )
return 2 return 2

View file

@ -24,6 +24,34 @@ ombres validées, flou global désactivé, flou réservé à la
couche QuickShell. Le script d'opacité inactive écoute l'IPC Sway au lancement couche QuickShell. Le script d'opacité inactive écoute l'IPC Sway au lancement
de la session. de la session.
## Décoration des fenêtres flottantes
Les fenêtres flottantes classiques utilisent une bordure serveur `pixel 2`,
sans barre de titre Sway. `sway/.config/sway/style` fixe les valeurs par défaut
pour les nouvelles fenêtres et les couleurs `client.*` : Lavender `#b4befe`
avec le focus, Surface2 `#6c7086` sans focus. Ces couleurs sont communes aux
fenêtres flottantes et en mosaïque ; SwayFX 0.6 ne fournit pas ici une palette
distincte selon le mode. La règle `[floating]` de `sway/.config/sway/rules`
couvre les fenêtres créées flottantes, y compris les dialogues. La règle
`labfy-yazi-filechooser` conserve son centrage et force cette même bordure
pour le sélecteur Yazi. L'abonné IPC existant
`scripts/inactive-windows-transparency.py` écoute aussi `window::floating`.
Il cible l'ID du conteneur devenu flottant, attend 50 ms que son éventuel mode
`csd` se stabilise, puis applique `border pixel 2` si nécessaire. La bordure
antérieure est rétablie lorsqu'il revient en mosaïque. Cela couvre aussi bien
le raccourci `$mod+Ctrl+space` qu'une commande IPC directe `floating enable`,
sans nouveau processus et sans modifier les surfaces layer-shell.
`corner_radius 4` dans `sway/.config/sway/swayfx` est un réglage global SwayFX :
les fenêtres en mosaïque avaient déjà ce rayon, qui n'est pas modifié par cette
politique. Les valeurs par défaut des fenêtres en mosaïque restent inchangées.
SwayFX 0.6 ne propose pas de règle native réévaluée à chaque transition ;
`for_window` ne traite que l'apparition de la fenêtre. L'abonné IPC mémorise
uniquement les bordures remplacées jusqu'au retour en mosaïque ou à la fermeture
du conteneur. Les surfaces layer-shell de QuickShell, ses notifications et les
menus système ne sont pas des fenêtres classiques ciblées par `[floating]` ;
leurs règles existantes restent en vigueur.
## Services et outils ## Services et outils
`labfy-quickshell-updates.timer` déclenche la vérification Arch/AUR sans `labfy-quickshell-updates.timer` déclenche la vérification Arch/AUR sans

View file

@ -63,14 +63,14 @@ installer. Comparer leur contenu à la machine cible et lire
[`packages.md`](packages.md) ainsi que [`packages.md`](packages.md) ainsi que
[`external-tools.md`](../.assets/external-tools.md) avant toute installation. [`external-tools.md`](../.assets/external-tools.md) avant toute installation.
## 5. Définir les 31 packages Stow ## 5. Définir les 32 packages Stow
Les commandes suivantes sont destinées à Bash : 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 icons kitty mako nvim opencode qt5ct qt6ct hyprwhspr icons kitty mako nvim opencode portal qt5ct qt6ct
quickshell 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
) )
@ -79,6 +79,9 @@ printf '%s\n' "${packages[@]}"
``` ```
`docs/` et `.assets/` ne doivent pas être ajoutés à ce tableau. Le package `docs/` et `.assets/` ne doivent pas être ajoutés à ce tableau. Le package
`portal` demande l'installation du backend décrite dans
[`portal/README.md`](../portal/README.md).
Le package
`quickshell` déploie `~/.config/quickshell/labfy-sway/` ; une Nerd Font `quickshell` déploie `~/.config/quickshell/labfy-sway/` ; une Nerd Font
(`ttf-jetbrains-mono-nerd` sur cette machine) fournit les icônes. (`ttf-jetbrains-mono-nerd` sur cette machine) fournit les icônes.
SwayFX 0.6 est un paquet foreign/local à reconstruire depuis sa source SwayFX 0.6 est un paquet foreign/local à reconstruire depuis sa source

View file

@ -50,7 +50,7 @@ exigent une décision spécifique ; aucun helper AUR n'est supposé magique.
## 6. Déployer Stow ## 6. Déployer Stow
Suivre le [guide d'installation](installation.md) : définir explicitement les Suivre le [guide d'installation](installation.md) : définir explicitement les
31 packages, exécuter le dry-run, résoudre les conflits, puis seulement lancer 32 packages, exécuter le dry-run, résoudre les conflits, puis seulement lancer
le déploiement réel. `docs/` et `.assets/` ne sont pas déployés. le déploiement réel. `docs/` et `.assets/` ne sont pas déployés.
## 7. Restaurer les services ## 7. Restaurer les services

View file

@ -33,7 +33,7 @@ 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.
## Supports amovibles USB/SD ## Supports amovibles USB/SD et MTP
`labfy-removable-media.service` appartient au package Stow `systemd` et son `labfy-removable-media.service` appartient au package Stow `systemd` et son
exécutable `labfy-removable-media`, ainsi que le client exécutable `labfy-removable-media`, ainsi que le client
@ -43,25 +43,39 @@ de ces deux packages, le lien versionné dans
Sway. L'unité est rattachée à `wayland-session@sway.desktop.target`, redémarre Sway. L'unité est rattachée à `wayland-session@sway.desktop.target`, redémarre
sur échec et n'est pas un service système. sur échec et n'est pas un service système.
Le daemon observe l'ObjectManager UDisks du bus système et publie son état Un seul daemon de session assure le cycle de vie, le socket et le snapshot de
runtime JSON V1 dans `$XDG_RUNTIME_DIR/labfy-removable-media.json`. Son socket `labfy-removable-media`. Il observe l'ObjectManager UDisks du bus système pour
Unix `$XDG_RUNTIME_DIR/labfy-removable-media.sock` et le snapshot sont créés les lecteurs USB et SD/MMC, et `Gio.VolumeMonitor`/GVFS-MTP pour les
avec le mode `0600`. L'IPC n'accepte qu'une requête JSON téléphones. Il publie le snapshot JSON `labfy.removable-media` version `2`
terminée par LF pour `list`, `mount`, `unmount`, `open` ou `safe-remove` ; les dans `$XDG_RUNTIME_DIR/labfy-removable-media.json`. Son socket Unix
identifiants runtime sont relus dans UDisks avant toute mutation. `$XDG_RUNTIME_DIR/labfy-removable-media.sock` et le snapshot sont créés avec le
mode `0600`. L'IPC n'accepte qu'une requête JSON terminée par LF pour `list`,
`mount`, `unmount`, `open` ou `safe-remove` ; les identifiants runtime opaques
sont relus dans leur backend avant toute mutation.
Seuls les systèmes de fichiers des lecteurs USB ou SD/MMC sont présentés. Les Seuls les systèmes de fichiers des lecteurs USB ou SD/MMC éligibles et les
blocs `HintIgnore` et `HintSystem`, ainsi que les volumes `crypto_LUKS`, sont volumes MTP détectés par GVFS sont présentés. Pour UDisks, les blocs
exclus ; aucun déverrouillage LUKS n'est tenté. MTP ne relève pas de ce contrat `HintIgnore` et `HintSystem`, ainsi que les volumes `crypto_LUKS`, sont exclus ;
UDisks `Block`/`Filesystem` et n'est pas pris en charge. Un objet UDisks aucun déverrouillage LUKS n'est tenté. Pendant la vie du daemon, chaque support
nouvellement présent est monté au plus une fois : un échec est isolé des autres présent est monté automatiquement au plus une fois : un échec est isolé des
volumes, et une nouvelle tentative automatique demande une disparition puis une autres supports, et une nouvelle tentative automatique demande une disparition
réinsertion de l'objet. Un démontage manuel ne provoque donc pas de remontage puis une réinsertion. Pendant cette même vie, un démontage manuel ne provoque
sur un événement de propriétés ultérieur. donc pas de remontage sur un événement de propriétés ultérieur. Au démarrage ou
au redémarrage, le cold-start V1 effectue un nouveau scan : un téléphone déjà
présent peut alors être monté. Pour MTP, le montage et cette règle de présence
reposent sur GVFS.
La commande `safe-remove` porte sur le lecteur : elle démonte d'abord tous ses Le schéma V2 publie des identifiants MTP opaques, `kind` et `actions`. Chaque
systèmes de fichiers, y compris ceux masqués par l'interface, puis relit leur appareil publie aussi `safe_remove_runtime_id`, y compris lorsque `safe-remove`
état avant d'appeler `PowerOff` ou `Eject`. n'est pas disponible. Il ne publie pas d'URI MTP ni de champ d'identité
matérielle. Pour ouvrir un support, UDisks fournit son véritable point de
montage ; le chemin du `GMount` est réservé à MTP. Ce chemin, conservé dans le
snapshot privé (`0600`), peut contenir un identifiant intégré par GVFS. La
commande `safe-remove` UDisks porte sur le lecteur : elle
démonte d'abord tous ses systèmes de fichiers, y compris ceux masqués par
l'interface, puis relit leur état avant d'appeler `PowerOff` ou `Eject`. Pour
MTP, `safe-remove` ferme logiquement le montage GVFS ; il n'appelle ni
`PowerOff` ni `Eject`.
Après Stow et le rechargement de systemd user, vérifier l'unité et exécuter les Après Stow et le rechargement de systemd user, vérifier l'unité et exécuter les
tests synthétiques sans périphérique réel : tests synthétiques sans périphérique réel :
@ -74,10 +88,14 @@ systemctl --user is-enabled labfy-removable-media.service
python3 -B -m unittest discover -s tests/removable_media -p 'test_*.py' python3 -B -m unittest discover -s tests/removable_media -p 'test_*.py'
``` ```
Les tests utilisent des backends et sockets synthétiques. Ils couvrent le Les 29 tests `unittest` utilisent des backends et sockets synthétiques. Ils
filtrage UDisks, l'automontage borné, les opérations par volume ou lecteur, le couvrent le filtrage UDisks, le cycle de vie MTP/GVFS, l'automontage borné, les
contrat JSON et les limites de l'IPC, mais ne constituent pas une validation sur opérations par volume ou lecteur, le contrat JSON V2 et les limites de l'IPC.
matériel USB/SD réel. Cette validation n'a pas encore été effectuée. Ils sont passants. Le `qmllint` est également passant. Ces validations
logicielles ne constituent pas une recette UI sur matériel MTP : celle-ci reste
à effectuer. Un Samsung réel a été reconnu et les opérations ciblées de montage
et de retrait en sécurité ont été vérifiées dans le code isolé. La validation
matérielle USB Ventoy V1 avait été effectuée avant le commit précédent.
La gestion des supports amovibles est indépendante de Session Restore : elle ne La gestion des supports amovibles est indépendante de Session Restore : elle ne
participe ni aux checkpoints, ni au choix de restauration au démarrage. participe ni aux checkpoints, ni au choix de restauration au démarrage.

View file

@ -0,0 +1,8 @@
[filechooser]
cmd=yazi-wrapper.sh
default_dir=$HOME/Downloads
# WHY: le fichier provisoire permet à Yazi de choisir un nouveau nom de sauvegarde.
# CONTRACT: le backend supprime ce fichier si la sélection est annulée.
create_help_file=1
open_mode = suggested
save_mode = last

View file

@ -0,0 +1,66 @@
#!/bin/sh
# Adaptation du wrapper Yazi de hunkyburrito/xdg-desktop-portal-termfilechooser 1.4.3.
# CONTRACT: arguments multiple, directory, save, path, out, debug dans cet ordre.
set -eu
if [ "${1:-}" != '--session' ]; then
# WHY: kitty reçoit des arguments distincts ; aucun chemin n'est réinterprété par un shell.
# INVARIANT: l'app_id est réservé à ce sélecteur, jamais aux terminaux ordinaires.
# CONTRACT: dimension initiale 1000x650, bornée à la sortie SwayFX active.
width=1000
height=650
if command -v swaymsg >/dev/null 2>&1 && command -v jq >/dev/null 2>&1; then
geometry=$(swaymsg -t get_outputs -r 2>/dev/null | jq -r '[.[] | select(.focused == true and .active == true)] | first | if . then "\(.rect.width) \(.rect.height)" else empty end' 2>/dev/null) || geometry=''
if [ -n "$geometry" ]; then
output_width=${geometry%% *}
output_height=${geometry#* }
case "$output_width:$output_height" in
*[!0-9:]* ) ;;
* )
[ "$output_width" -le 1040 ] && width=$(( output_width > 80 ? output_width - 40 : 40 ))
[ "$output_height" -le 690 ] && height=$(( output_height > 80 ? output_height - 40 : 40 ))
;;
esac
fi
fi
exec kitty --class labfy-yazi-filechooser --title 'Yazi · Sélecteur de fichiers' \
--override remember_window_size=no \
--override "initial_window_width=${width}" \
--override "initial_window_height=${height}" \
-- "$0" --session "$@"
fi
shift
multiple=${1:?}
directory=${2:?}
save=${3:?}
path=${4:?}
out=${5:?}
debug=${6:-0}
case "$multiple:$directory:$save:$debug" in
*[!01:]* ) exit 2 ;;
esac
# CONTRACT: Yazi utilise sa configuration XDG normale ; chooser-file contient
# les chemins sélectionnés, tandis que cwd-file sert au choix du dossier courant.
if [ "$directory" = 1 ]; then
yazi --chooser-file="$out" --cwd-file="$out.1" "$path" || :
if [ ! -s "$out" ] && [ -s "$out.1" ]; then
cat "$out.1" > "$out"
fi
rm -f -- "$out.1"
else
yazi --chooser-file="$out" "$path" || :
fi
if [ "$save" = 1 ] && [ -s "$out" ]; then
# WHY: le backend peut proposer un fichier existant. La confirmation garde
# l'écrasement potentiel explicite avant de publier le chemin à l'appelant.
printf '\nDestination sélectionnée : %s\nConfirmer ? L’application peut écraser son contenu. [o/N] ' "$(head -n 1 -- "$out")"
IFS= read -r reply </dev/tty || reply=''
case "$reply" in
o|O|oui|Oui|OUI) ;;
*) : > "$out" ;;
esac
fi

View file

@ -0,0 +1,8 @@
[preferred]
# WHY: la configuration utilisateur est choisie avant sway-portals.conf ;
# CONTRACT: conserver le routage Sway pour toutes les autres interfaces.
default=gtk
org.freedesktop.impl.portal.FileChooser=termfilechooser
org.freedesktop.impl.portal.ScreenCast=wlr
org.freedesktop.impl.portal.Screenshot=wlr
org.freedesktop.impl.portal.Secret=gnome-keyring

84
portal/README.md Normal file
View file

@ -0,0 +1,84 @@
# Yazi comme FileChooser du portail
Package GNU Stow : `stow -d ~/.dotfiles -t ~ portal`. Les fichiers actifs dans
`~/.config/xdg-desktop-portal/` et
`~/.config/xdg-desktop-portal-termfilechooser/` sont des liens Stow. La règle
SwayFX est dans le package `sway`.
## Dépendances
Arch Linux : `xdg-desktop-portal`, `xdg-desktop-portal-gtk`,
`xdg-desktop-portal-wlr`, `kitty`, `yazi`, `jq`, `swayfx`, `libinih`,
`libsystemd`, `meson`, `ninja` et `scdoc` pour une compilation complète. Le
backend retenu est
[`hunkyburrito/xdg-desktop-portal-termfilechooser`](https://github.com/hunkyburrito/xdg-desktop-portal-termfilechooser),
version 1.4.3, commit `bcb2387949e4eb38a35390ebf1693f92869e619d` dans
l'installation testée. Le paquet AUR
`xdg-desktop-portal-termfilechooser-hunkyburrito-git` existe aussi.
Cette machine utilise une installation Meson utilisateur sous `~/.local`, sans
changer les backends système. Les fichiers nécessaires sont
`~/.local/libexec/xdg-desktop-portal-termfilechooser`,
`~/.local/share/dbus-1/services/org.freedesktop.impl.portal.desktop.termfilechooser.service`
et `~/.local/share/xdg-desktop-portal/portals/termfilechooser.portal`.
La procédure de reproduction, conservée dans ce dépôt indépendamment du cache
de construction, est la suivante (sans l'exécuter pendant une simple publication) :
```sh
git clone https://github.com/hunkyburrito/xdg-desktop-portal-termfilechooser.git
cd xdg-desktop-portal-termfilechooser
git checkout --detach bcb2387949e4eb38a35390ebf1693f92869e619d
meson setup build --prefix="$HOME/.local"
meson compile -C build
meson install -C build
```
Contrôler ensuite les trois chemins ci-dessus et la configuration Stow de ce
dossier. Le dépôt source et le répertoire `build` sont temporaires et ne font
pas partie des dotfiles. Cette procédure installe uniquement dans le préfixe
utilisateur ; elle ne remplace aucun paquet système.
Après installation ou mise à jour du backend, recharger seulement
`xdg-desktop-portal.service` après avoir vérifié qu'aucune session de partage
d'écran ou requête portal n'est en cours.
## Routage et fenêtre
`FileChooser` utilise `termfilechooser`, `ScreenCast` et `Screenshot` utilisent
`wlr`, `Secret` utilise `gnome-keyring`, et les autres interfaces disponibles
gardent `gtk`. `XDG_CURRENT_DESKTOP=sway:wlroots:swayfx` sélectionne la
configuration utilisateur `portals.conf` avant les fichiers système propres à
Sway. Le wrapper lance Kitty avec `--class labfy-yazi-filechooser` ; SwayFX
flotte et centre uniquement cet `app_id`. Kitty calcule au lancement une taille
de 1000 × 650, bornée à la sortie SwayFX active avec une marge de 40 pixels.
La fenêtre reste redimensionnable et sur le workspace courant. Le thème Kitty
et le thème Yazi Catppuccin Mocha sont ceux de la configuration habituelle.
Firefox 157 utilise le portail FileChooser en mode automatique quand il est
disponible. Aucun `GTK_USE_PORTAL` global ni préférence de profil n'est
nécessaire dans cette installation. Pour diagnostiquer un profil différent,
vérifier `widget.use-xdg-desktop-portal.file-picker` dans `about:config` : `1`
force le portail, `0` le désactive, `2` suit le mode automatique.
## Utilisation
Dans le sélecteur, `o` ou `Entrée` valide le ou les fichiers survolés ou
sélectionnés. `Espace` sélectionne chaque fichier en mode multiple. `q` annule
un choix de fichier ; `Q` annule aussi et ne transmet pas le dossier courant.
Pour un dossier, entrer dedans puis appuyer sur `q` ; `Q` annule. `M` ouvre le
menu des supports, `g` puis `Espace` permet de saisir un chemin. Ouvrir un
fichier via Yazi sans mode chooser n'est pas la même opération : ici, le
wrapper utilise `--chooser-file` pour remettre les chemins à l'application.
Pour « Enregistrer sous », le backend crée un fichier provisoire annoté. Le
déplacer avec `x`, naviguer vers la destination, le déposer avec `p`, puis le
renommer avec `r` si nécessaire. Valider le fichier avec `o` ou `Entrée`, puis
répondre `o` au prompt de confirmation du wrapper. Le prompt avertit que
l'application peut écraser une destination existante ; répondre autrement
annule. `Q` dans Yazi annule également et le backend supprime le provisoire
resté à son emplacement d'origine. Si le provisoire a été déplacé avant une
annulation, supprimer manuellement ce fichier déplacé. Les essais doivent
utiliser un dossier temporaire et éviter tout écrasement de données réelles.
Les applications GTK/Qt qui utilisent leurs dialogues natifs plutôt que le
portail conservent leur comportement. Ce réglage ne change aucune association
MIME et ne remplace pas les portails d'autres interfaces.

View file

@ -1,5 +1,7 @@
import QtQuick import QtQuick
import Quickshell import Quickshell
import Quickshell.Io
import Quickshell.I3
import Quickshell.Wayland import Quickshell.Wayland
import "modules" import "modules"
import "controlcenter" import "controlcenter"
@ -8,6 +10,8 @@ import "datecenter"
import "notifications" import "notifications"
import "status" import "status"
import "theme" import "theme"
import "clipboard"
import "overview"
PanelWindow { PanelWindow {
id: bar id: bar
@ -17,6 +21,7 @@ PanelWindow {
required property var modelData required property var modelData
required property var notificationService required property var notificationService
required property bool startupHost required property bool startupHost
required property bool resizeMode
screen: modelData screen: modelData
anchors { anchors {
@ -40,9 +45,160 @@ PanelWindow {
focusable: true focusable: true
color: Theme.panelBackground color: Theme.panelBackground
function closeClipboard() {
if (clipboardLoader.item && !clipboardLoader.item.closing)
clipboardLoader.item.dismiss(true);
}
// CONTRACT: clic et IPC suivent la même exclusion que les panneaux de barre.
function toggleClipboard() {
if (clipboardLoader.active) {
closeClipboard();
} else {
closeOverview();
controlCenter.visible = false;
dateCenter.requestClose();
windowStrip.closeMenu();
rightStatusArea.closeRemovableMedia();
clipboardLoader.active = true;
}
return true;
}
// CONTRACT: un seul écran reçoit l'Overview ; une capture sans overlay
// précède l'ouverture afin de montrer le workspace actif tel qu'affiché.
function closeOverview() {
overviewRequested = false;
if (overviewLoader.item) overviewLoader.item.dismiss();
}
function toggleOverview() {
if (overviewRequested || overviewLoader.active) { closeOverview(); return true; }
closeClipboard();
controlCenter.visible = false;
dateCenter.requestClose();
windowStrip.closeMenu();
rightStatusArea.closeRemovableMedia();
overviewRequested = true;
if (!overviewCapture.running) overviewCapture.running = true;
return true;
}
property bool overviewRequested: false
readonly property string overviewBackend: (Quickshell.env("XDG_CONFIG_HOME") ||
(Quickshell.env("HOME") + "/.config")) + "/quickshell/labfy-sway/overview/backend.py"
IpcHandler {
target: "overviewUi-" + bar.screen.name
function toggle(): bool { return bar.toggleOverview(); }
function state(): string {
return JSON.stringify({ loaded: overviewLoader.active,
visible: !!overviewLoader.item && overviewLoader.item.visible,
requested: bar.overviewRequested,
cards: overviewLoader.item ? overviewLoader.item.cards.map(item => ({
number: item.number, windows: item.windows.length,
snapshot: item.snapshot !== "", output: item.output
})) : [],
bounds: overviewLoader.item ? {
x: overviewLoader.item.cardX, y: overviewLoader.item.cardY,
width: overviewLoader.item.cardWidth, height: overviewLoader.item.cardHeight
} : null });
}
}
Process {
id: overviewCapture
command: ["python3", "-B", bar.overviewBackend, "capture", "--output", bar.screen.name]
onExited: (code, status) => {
if (bar.overviewRequested) overviewLoader.active = true;
else if (code !== 0) console.warn("Capture du workspace impossible");
}
}
// CONTRACT: un workspace nouvellement visible reçoit une capture après
// que Sway a présenté sa première frame, sans attendre le cycle de 15 s.
Connections {
target: I3
function onRawEvent(event) {
if (event.type === "workspace") overviewCaptureRefresh.restart();
}
}
Timer {
id: overviewCaptureRefresh
interval: 350; repeat: false
onTriggered: {
if (!overviewCapture.running && !overviewLoader.active && !clipboardLoader.active
&& !controlCenter.visible && !dateCenter.visible)
overviewCapture.running = true;
}
}
// Un seul snapshot toutes les 15 secondes pour le workspace actuellement
// visible ; aucune boucle de capture pendant l'Overview ou un autre panneau.
Timer {
interval: 15000; repeat: true; running: true; triggeredOnStart: true
onTriggered: {
if (!overviewCapture.running && !overviewLoader.active && !clipboardLoader.active
&& !controlCenter.visible && !dateCenter.visible)
overviewCapture.running = true;
}
}
LazyLoader {
id: overviewLoader
active: false
WorkspaceOverview {
barWindow: bar
onOverviewDismissed: { bar.overviewRequested = false; overviewLoader.active = false; }
}
}
// CONTRACT: contrôle événementiel par écran pour ouvrir les pages depuis
// les raccourcis et les vérifications visuelles ; aucun état n'est dupliqué.
IpcHandler {
target: "appearanceUi-" + bar.screen.name
function openPage(page: string): bool {
if (controlCenter.pages[page] === undefined) return false;
bar.closeClipboard();
dateCenter.requestClose();
windowStrip.closeMenu();
controlCenter.openPage(page);
controlCenter.visible = true;
return true;
}
function close(): bool { controlCenter.visible = false; return true; }
function openDateCenter(): bool { bar.toggleDateCenter(); return true; }
function openWindowMenu(): bool {
bar.closeClipboard();
controlCenter.visible = false;
dateCenter.requestClose();
return windowStrip.openActiveMenu();
}
}
// CONTRACT: un seul écran reçoit l'appel IPC ; le composant et ses données
// n'existent que pendant l'ouverture ou le nettoyage de fermeture.
IpcHandler {
target: "clipboardUi-" + bar.screen.name
function toggle(): bool { return bar.toggleClipboard(); }
function state(): string {
// Diagnostic sans nombre, identifiant ni aperçu de presse-papiers.
return JSON.stringify({ loaded: clipboardLoader.active,
visible: !!clipboardLoader.item && clipboardLoader.item.visible,
searchFocused: !!clipboardLoader.item && clipboardLoader.item.searchFocused,
listLoaded: !!clipboardLoader.item && clipboardLoader.item.listLoaded,
bounds: clipboardLoader.item ? {
x: clipboardLoader.item.cardX, y: clipboardLoader.item.cardY,
width: clipboardLoader.item.cardWidth, height: clipboardLoader.item.cardHeight
} : null });
}
}
LazyLoader {
id: clipboardLoader
active: false
ClipboardPanel {
barWindow: bar
onCleanupFinished: clipboardLoader.active = false
}
}
// CONTRACT: toute la capsule centrale partage le même popup et la même exclusion XOR. // CONTRACT: toute la capsule centrale partage le même popup et la même exclusion XOR.
function toggleDateCenter() { function toggleDateCenter() {
const opening = !dateCenter.visible && !dateCenter.opening; const opening = !dateCenter.visible && !dateCenter.opening;
closeOverview();
closeClipboard();
controlCenter.visible = false; controlCenter.visible = false;
windowStrip.closeMenu(); windowStrip.closeMenu();
rightStatusArea.closeRemovableMedia(); rightStatusArea.closeRemovableMedia();
@ -59,11 +215,31 @@ PanelWindow {
height: bar.height height: bar.height
clip: true clip: true
Rectangle {
id: resizeIndicator
visible: bar.resizeMode
anchors.left: parent.left
anchors.leftMargin: 8
anchors.verticalCenter: parent.verticalCenter
width: visible ? resizeLabel.implicitWidth + 12 : 0
height: 24
radius: 4
color: Theme.buttonBackground
Text {
id: resizeLabel
anchors.centerIn: parent
text: "RESIZE"
font.pixelSize: 11
font.bold: true
color: Theme.peach
}
}
Workspaces { Workspaces {
id: workspaces id: workspaces
screen: bar.screen screen: bar.screen
anchors.left: parent.left anchors.left: resizeIndicator.right
anchors.leftMargin: 8 anchors.leftMargin: resizeIndicator.visible ? 6 : 0
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
} }
@ -75,6 +251,8 @@ PanelWindow {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
maxWidth: Math.max(0, leftArea.width - x - 8) maxWidth: Math.max(0, leftArea.width - x - 8)
onMenuOpened: { onMenuOpened: {
bar.closeOverview();
bar.closeClipboard();
controlCenter.visible = false; controlCenter.visible = false;
dateCenter.requestClose(); dateCenter.requestClose();
rightStatusArea.closeRemovableMedia(); rightStatusArea.closeRemovableMedia();
@ -110,7 +288,11 @@ PanelWindow {
wifiDevice: controlCenter.wifiDevice wifiDevice: controlCenter.wifiDevice
adapter: controlCenter.adapter adapter: controlCenter.adapter
controlCenterOpen: controlCenter.visible controlCenterOpen: controlCenter.visible
audioBrightnessController: controlCenter
clipboardOpen: clipboardLoader.active && !!clipboardLoader.item && !clipboardLoader.item.closing
onOpenPageRequested: page => { onOpenPageRequested: page => {
bar.closeOverview();
bar.closeClipboard();
dateCenter.requestClose(); dateCenter.requestClose();
windowStrip.closeMenu(); windowStrip.closeMenu();
rightStatusArea.closeRemovableMedia(); rightStatusArea.closeRemovableMedia();
@ -118,7 +300,9 @@ PanelWindow {
controlCenter.visible = true; controlCenter.visible = true;
} }
onToggleControlCenterRequested: { onToggleControlCenterRequested: {
bar.closeOverview();
const opening = !controlCenter.visible; const opening = !controlCenter.visible;
bar.closeClipboard();
dateCenter.requestClose(); dateCenter.requestClose();
windowStrip.closeMenu(); windowStrip.closeMenu();
rightStatusArea.closeRemovableMedia(); rightStatusArea.closeRemovableMedia();
@ -126,12 +310,15 @@ PanelWindow {
controlCenter.visible = opening; controlCenter.visible = opening;
} }
onToggleRemovableMediaRequested: { onToggleRemovableMediaRequested: {
bar.closeOverview();
const opening = !rightStatusArea.removableMediaOpen; const opening = !rightStatusArea.removableMediaOpen;
bar.closeClipboard();
controlCenter.visible = false; controlCenter.visible = false;
dateCenter.requestClose(); dateCenter.requestClose();
windowStrip.closeMenu(); windowStrip.closeMenu();
rightStatusArea.setRemovableMediaOpen(opening); rightStatusArea.setRemovableMediaOpen(opening);
} }
onToggleClipboardRequested: bar.toggleClipboard()
} }
} }

View file

@ -8,6 +8,7 @@ Barre principale et serveur de notifications de la session SwayFX.
```text ```text
Bar Bar
├── Workspaces ├── Workspaces
├── Workspace Overview
├── Window/task switcher ├── Window/task switcher
├── Date Center ├── Date Center
│ ├── MPRIS │ ├── MPRIS
@ -18,6 +19,7 @@ Bar
│ ├── Battery │ ├── Battery
│ ├── Updates │ ├── Updates
│ ├── Supports amovibles │ ├── Supports amovibles
│ ├── Presse-papiers
│ ├── Tray │ ├── Tray
│ └── Control Center │ └── Control Center
└── Control Center └── Control Center
@ -32,6 +34,14 @@ Bar
└── Session └── Session
``` ```
L'icône **Presse-papiers** ouvre le panneau d'historique sous la zone droite de
la barre ; `Mod+Alt+V` conserve le même basculement sur l'écran focalisé.
L'architecture, les raccourcis, les tests isolés et le retour vers Wofi sont
décrits dans [clipboard/README.md](clipboard/README.md).
`Super+W` ouvre la [vue d'ensemble visuelle](overview/README.md) sur la sortie
focalisée. `Super+Shift+W` conserve le layout Sway `tabbed`.
`quickshell-labfy-sway.service` est activé par `quickshell-labfy-sway.service` est activé par
`wayland-session@sway.desktop.target`, sans autostart Sway parallèle. `wayland-session@sway.desktop.target`, sans autostart Sway parallèle.
`NotificationService.qml` possède `org.freedesktop.Notifications` ; l'historique `NotificationService.qml` possède `org.freedesktop.Notifications` ; l'historique
@ -58,41 +68,199 @@ service permanent ni changement automatique du thème.
## Supports amovibles ## Supports amovibles
`RemovableMediaIndicator.qml` affiche dans la zone d'état un indicateur `RemovableMediaIndicator.qml` affiche dans la zone d'état un indicateur
lorsqu'au moins un système de fichiers éligible est présent. Son `PopupWindow` lorsqu'au moins un support éligible est présent. Son `PopupWindow` natif liste
natif liste les volumes, leur état et les erreurs publiées, puis offre les les volumes, leur état et les erreurs publiées, puis offre les actions
actions **Ouvrir**, **Monter** ou **Démonter**, et **Retirer en sécurité** si **Ouvrir**, **Monter** ou **Démonter**, et **Retirer en sécurité** lorsque le
le lecteur le permet. Ouvrir lance Yazi dans Kitty via UWSM. Le popup participe contrat le permet. Ouvrir lance Yazi dans Kitty via UWSM en utilisant le
à la même exclusion mutuelle que le Date Center, le menu des fenêtres et le véritable point de montage UDisks pour USB/SD ou le chemin du `GMount` pour
Control Center. MTP. Le popup participe à la même exclusion mutuelle que le Date Center, le
menu des fenêtres et le Control Center.
QuickShell lit exclusivement le snapshot JSON V1 QuickShell lit exclusivement le snapshot JSON V2
`$XDG_RUNTIME_DIR/labfy-removable-media.json`. Il refuse intégralement un `$XDG_RUNTIME_DIR/labfy-removable-media.json`. Il refuse intégralement un
snapshot dont `schema` n'est pas `labfy.removable-media`, dont `version` n'est snapshot dont `schema` n'est pas `labfy.removable-media`, dont `version` n'est
pas `1`, ou dont les appareils ne respectent pas le contrat attendu. Les pas `2`, ou dont les appareils ne respectent pas le contrat attendu. Le schéma
commandes passent sans shell à `labfy-removable-mediactl`, qui est l'unique publie `kind` et `actions`. Chaque appareil publie aussi
frontière de commande vers le daemon ; le socket Unix associé est privé (`0600`). `safe_remove_runtime_id`, y compris lorsque `safe-remove` n'est pas disponible.
Les identifiants MTP restent opaques et aucune URI MTP n'est publiée. Le chemin
de montage exact fourni par GVFS peut néanmoins contenir un identifiant de
l'appareil ; le snapshot est privé (`0600`). Les commandes passent sans shell à `labfy-removable-mediactl`, qui est
l'unique frontière de commande vers le daemon ; le socket Unix associé est
privé (`0600`).
Le service utilisateur repose sur l'ObjectManager UDisks et ne présente que Le service utilisateur est unique : il utilise l'ObjectManager UDisks pour les
les systèmes de fichiers USB ou SD/MMC. Les blocs marqués `HintIgnore` ou systèmes de fichiers USB ou SD/MMC et `Gio.VolumeMonitor`/GVFS-MTP pour les
`HintSystem` sont exclus. Un volume `crypto_LUKS` n'est ni présenté ni téléphones. Les blocs UDisks marqués `HintIgnore` ou `HintSystem` sont exclus.
déverrouillé automatiquement. Les périphériques MTP ne fournissent pas ce Un volume `crypto_LUKS` n'est ni présenté ni déverrouillé automatiquement.
modèle UDisks de système de fichiers et ne sont donc pas pris en charge. Chaque Pendant la vie du daemon, chaque support présent peut être monté
objet UDisks présent peut être monté automatiquement une seule fois ; après un automatiquement une seule fois ; après un démontage manuel, il reste démonté
démontage manuel, il reste démonté jusqu'à sa disparition puis sa réinsertion. jusqu'à sa disparition puis sa réinsertion. Au démarrage ou au redémarrage, le
Le retrait en sécurité agit au niveau du lecteur et vérifie que ses systèmes de cold-start V1 effectue un nouveau scan : un téléphone déjà présent peut alors
fichiers sont démontés avant l'éjection ou la mise hors tension. être monté. Pour USB/SD, le retrait en sécurité agit au niveau du lecteur et
vérifie que ses systèmes de fichiers sont démontés avant l'éjection ou la mise
hors tension. Pour MTP, il ferme logiquement le montage GVFS sans appeler
`PowerOff`.
Le contrôle visuel synthétique peut être activé avec Le contrôle visuel synthétique peut être activé avec
`LABFY_REMOVABLE_MEDIA_TEST_STATE` (`0`, `mounted`, `unmounted` ou `LABFY_REMOVABLE_MEDIA_TEST_STATE` (`0`, `USB`, `MTP`, `USB+MTP`, `busy` ou
`busy-error`) dans le processus QuickShell. Ces états ne lancent aucune `error`) dans le processus QuickShell. Ces états ne lancent aucune commande.
commande. Les tests Python synthétiques du daemon et de son IPC sont décrits Les tests Python synthétiques du daemon et de son IPC sont décrits dans
dans [systemd.md](../../../../docs/systemd.md#supports-amovibles-usbsd). [systemd.md](../../../../docs/systemd.md#supports-amovibles-usbsd-et-mtp).
Cette fonction est indépendante de Session Restore : elle ne lit ni n'écrit de Cette fonction est indépendante de Session Restore : elle ne lit ni n'écrit de
checkpoint de session et n'intervient pas dans son démarrage. checkpoint de session et n'intervient pas dans son démarrage.
La validation sur matériel USB/SD réel n'a pas encore été effectuée ; l'état Les 29 tests `unittest` synthétiques et `qmllint` sont passants. Un Samsung réel
actuel est couvert par les tests synthétiques seulement. a été reconnu et les opérations ciblées de montage et de retrait en sécurité
ont été vérifiées dans le code isolé. La recette UI sur matériel MTP reste à
effectuer. La validation matérielle USB Ventoy V1 avait été effectuée avant le
commit précédent.
## Défilement sur les icônes et mode Resize
Les deux indicateurs du groupe système à droite de la barre règlent séparément le volume et la
luminosité par défilement vertical à deux doigts ou avec une molette. Un pas
vaut **un point de pourcentage**. Le geste physique vers le haut augmente la
valeur. Le clic sur le bouton Réglages rapides conserve son rôle.
`WheelHandler` accepte explicitement `Mouse` et `TouchPad`, et ne traite que
les 66 px de chaque zone « icône + pourcentage ». Pour un événement, `pixelDelta.y` est choisi s'il
est non nul, sinon `angleDelta.y` ; les deux ne sont jamais additionnés. Les
seuils initiaux sont **32 pixels** pour le touchpad et **120 unités angulaires**
pour un cran de molette. Le reliquat est conservé entre événements, remis à
zéro à la fin du geste si Qt fournit `ScrollEnd`, au changement de direction
ou au changement de source. Aucun délai ne jette les petits mouvements.
`LABFY_SCROLL_DEBUG=1` permet de diagnostiquer les deltas et phases reçus.
### Contrôles de barre
Le retour matériel a établi que le geste vers le haut agissait à l'envers sur
le volume. `ScrollSteps.js` applique la correction de signe **une seule fois**
aux événements de touchpad. La molette classique garde son sens et son seuil
de 120 unités. Les événements à `pixelDelta` non nul, ceux dont le périphérique
Qt est `TouchPad` et les petits deltas angulaires continus sont traités comme
gestes tactiles.
La luminosité pouvait rester bloquée après la première écriture : `onSaved`
plaçait la file en attente d'un nouveau `onLoaded` qui n'arrivait pas dans le
cas reproduit. Le watcher `FileView` peut charger la valeur avant `onSaved` ;
attendre ensuite un autre chargement n'est pas fiable. La variable
`awaitingReadback` restait alors vraie ; les demandes suivantes étaient
accumulées sans nouvelle écriture. Le diagnostic a reproduit ce cas avec le
Control Center fermé : le composant recevait `−1`, tandis que sysfs restait à
376000/400000. La correction libère la file depuis `saved`, relit la valeur matérielle
après le callback `FileView`, puis lance la dernière consigne en attente. La
relecture de ce petit fichier sysfs est bornée ; `saveFailed`, `loadFailed` et
les données invalides sont signalés. Aucun pourcentage optimiste n'est affiché.
La présentation reprend celle des indicateurs Wi-Fi, Bluetooth et batterie :
icône Nerd Font, pourcentage réel à largeur fixe, fond transparent hors survol
et infobulle. Le soleil `󰖨` remplace le glyphe ambigu ``. La disposition
finale, avec le tray tout à droite, est décrite ci-dessous.
Validation des contrôles : tests unitaires de `ScrollSteps`, `qmllint` et injection
synthétique dans `ScrollIcon.applyWheel` via un IPC temporaire retiré après
essai. Avec le Control Center fermé, le volume a fait 67 → 68 → 67 % ; la
luminosité a fait 372000 → 376000 → 372000 sur 400000, puis trois pas
rapprochés 93 → 96 % → 93 %. L'essai panneau ouvert puis refermé a conservé
la synchronisation à 93 %. L'appel direct au contrôle a également fonctionné
panneau fermé. L'injection était synthétique ; les essais tactiles et visuels
de la série ont été validés séparément et ne sont pas rejoués pour cette
publication.
Les bornes 0/100 % pour le volume et 10/100 % pour la luminosité sont testées
avec `node tests/quickshell/test_percent_math.js`, sans pousser le matériel
aux extrêmes.
Le volume utilise `Pipewire.defaultAudioSink` et son `PwObjectTracker`, comme
le curseur du Control Center. Il est borné entre 0 et 100 %, sans changer
`muted`. La luminosité utilise le `FileView` sysfs déjà employé par le
Control Center, avec un minimum sûr de 10 % et un maximum de 100 %. Ses
écritures sont sérialisées ; la valeur du matériel est relue après chaque
écriture, y compris si le pilote arrondit. L'absence de sortie audio ou de
rétroéclairage désactive l'icône concernée.
Le libellé `RESIZE` en Catppuccin Peach apparaît à gauche des workspaces
quand l'événement IPC Sway `mode` annonce `resize`. Un seul abonnement
`I3IpcListener` partage cet état entre toutes les sorties. Une requête
ponctuelle `get_binding_state` récupère le mode courant au démarrage et à la
reconnexion. L'indicateur n'intervient dans aucun raccourci Sway.
Validation : `qmllint`, `node tests/quickshell/test_scroll_steps.js`,
`sway -C`, `git diff --check`, transition IPC `default → resize → default`,
raccourci `Super+R` puis `Escape` injectés, rechargement QuickShell pendant
`resize`, et inspection visuelle de la barre. Les fonctions des contrôles ont été
appelées temporairement par IPC : PipeWire est passé de 60 à 61 %, et sysfs
de 374815/400000 à 380000/400000 (95 %) ; les niveaux initiaux ont ensuite
été restaurés et le point IPC de test retiré. Aucun geste multitouch physique
n'a été généré par l'automatisation.
### Présentation de la zone droite
La batterie, le volume et la luminosité partagent `PercentIndicatorContent` :
une zone d'icône de 22 px, un écart fixe de 4 px et une valeur alignée à gauche
dans une réserve de 38 px. Le composant mesure 66 × 26 px. La police mono à
12 px affiche partout un espace avant `%` (`9 %`, `10 %`, `99 %`, `100 %`) ;
une donnée indisponible affiche `—`. La réserve empêche le changement de
largeur sans éloigner le nombre de son icône. Les silhouettes des glyphes sont
ajustées individuellement : batterie 21 px, volume 22 px, soleil 23 px.
Le groupe système garde l'ordre Wi-Fi, Bluetooth, batterie, volume,
luminosité, avec 6 px entre indicateurs. Les deux séparateurs utilisent le
même token `Theme.separator`, mesurent 1 × 14 px et disposent chacun de 8 px
de marge latérale. Le tray garde les icônes et menus des applications, avec
des images centrées de 20 × 20 px dans les cibles existantes de 26 × 26 px et
4 px entre cibles. Les actions QuickShell gardent 5 px entre elles ; le bouton
Control Center mesure 30 × 26 px, sans fond au repos et avec un fond discret
au survol ou lorsque le panneau est ouvert.
La zone de défilement du volume et de la luminosité couvre les 66 × 26 px de
chaque indicateur, texte compris.
Validation de la présentation : `qmllint` sur les composants modifiés, tests Node de
`PercentPresentation`, `PercentMath` et `ScrollSteps`, `git diff --check`,
chargement de la configuration par QuickShell et inspection d'une capture de
la barre réelle à l'échelle de la sortie. Les formats 9/10/99/100 % ont été
testés sans modifier les niveaux matériels. Les essais tactiles et visuels de
la série ont également couvert le tray.
### Disposition finale de la zone droite
L'ordre visuel est désormais : Wi-Fi, Bluetooth, batterie, volume,
luminosité ; séparateur ; actions QuickShell, bouton Control Center compris ;
séparateur ; tray applicatif tout à droite. Le second séparateur disparaît
avec un tray vide. Les deux traits visibles utilisent `Theme.separator`,
mesurent 1 × 14 px et sont séparés des groupes par 8 px. Les cibles des
statuts, actions et icônes du tray gardent leur hauteur de 26 px ; les groupes
conservent respectivement 6, 5 et 4 px d'espacement interne.
La police commune des glyphes ne leur donne pas une hauteur de dessin commune :
les formes Wi-Fi, haut-parleur, soleil et réglages occupent moins de hauteur
que les formes Bluetooth et batterie. Les tailles de police sont donc ajustées
sur la barre réelle : Wi-Fi 30 px, Bluetooth 18 px, batterie 18 px, volume
28 px, luminosité 26 px, mises à jour 26 px, presse-papiers 24 px et bouton
Control Center 28 px. L'icône de support amovible conserve ses 18 px et les
images du tray leurs 20 × 20 px. Ces ajustements ne changent ni les zones
interactives, ni les callbacks, ni les contrôleurs du volume et de la
luminosité.
### Accents Catppuccin de la barre
Les pictogrammes utilisent directement les propriétés de `Theme`, alimentées
par l'unique `catppuccin.json` : Wi-Fi connecté `sky`, Bluetooth actif `blue`,
batterie normale `green`, volume non muet `mauve`, luminosité disponible
`yellow`, mises à jour disponibles `peach`, support amovible `teal`,
presse-papiers `pink` et menu système `lavender`. Les nombres et pourcentages
restent en `foreground`. `PercentIndicatorContent` sépare désormais la
couleur du glyphe de celle de la valeur, sans changer ses dimensions.
`StatusColorRoles.js` choisit les rôles selon les états réels, sans contenir
de valeur hexadécimale : Wi-Fi bloqué ou Bluetooth inactif atténués, volume
muet atténué, batterie sous 30 % en avertissement et à 15 % ou moins en
`danger`, erreur de mise à jour ou de support en `danger`, opération de
support en `warningForeground`. Une vérification de mises à jour encore en
attente reste neutre. Les couleurs du tray et les fonds existants ne changent
pas. Les états rares sont vérifiés avec des données isolées par
`tests/quickshell/test_status_color_roles.js`, sans agir sur le matériel.
## Parité avec i3status-rs ## Parité avec i3status-rs
@ -103,8 +271,8 @@ actuel est couvert par les tests synthétiques seulement.
| music | Date Center MPRIS | | music | Date Center MPRIS |
| bluetooth | status + Control Center | | bluetooth | status + Control Center |
| net | status + Control Center | | net | status + Control Center |
| sound | Control Center | | sound | icône à défilement + Control Center |
| backlight | Control Center | | backlight | icône à défilement + Control Center |
| battery | status + page Batterie | | battery | status + page Batterie |
| TLP | PowerProfile | | TLP | PowerProfile |
| packages | Updates | | packages | Updates |

View file

@ -0,0 +1,264 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import "../theme"
PanelWindow {
id: panel
required property var barWindow
signal cleanupFinished()
property var items: []
property var results: []
property int selected: 0
property string operation: ""
property string activeId: ""
property string thumbnail: ""
property bool confirming: false
property bool closing: false
property var queuedAction: null
property string message: ""
property bool listLoaded: false
readonly property bool searchFocused: search.activeFocus
readonly property real cardX: card.x
readonly property real cardY: card.y
readonly property real cardWidth: card.width
readonly property real cardHeight: card.height
// Le test QtTest cible la vraie hiérarchie d'items, jamais le backend réel.
readonly property Item interactionRoot: contentRoot
readonly property Item clearActionItem: clearLabel
readonly property Item confirmActionItem: confirmLabel
readonly property Item cancelActionItem: cancelLabel
readonly property Item removeActionItem: removeLabel
readonly property Item resultList: list
readonly property string backend: (Quickshell.env("XDG_CONFIG_HOME") ||
(Quickshell.env("HOME") + "/.config")) + "/quickshell/labfy-sway/clipboard/backend.py"
readonly property string isolatedDb: Quickshell.env("LABFY_CLIPHIST_TEST_DB") || ""
readonly property string isolatedWlCopy: Quickshell.env("LABFY_CLIPHIST_TEST_WL_COPY") || ""
// WHY: l'appel IPC ne satisfait pas la contrainte de parent du grab PopupWindow.
// CONTRACT: le focus exclusif existe seulement durant cette surface éphémère.
screen: barWindow.screen
anchors { top: true; bottom: true; left: true; right: true }
exclusionMode: ExclusionMode.Ignore
exclusiveZone: 0
aboveWindows: true
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
WlrLayershell.namespace: "labfy-clipboard-history"
color: "transparent"
function run(action, ident) {
if (helper.running) return false;
operation = action;
activeId = ident || "";
helper.command = ["python3", "-B", backend, action]
.concat(ident ? [ident] : [])
.concat(isolatedDb ? ["--db-path", isolatedDb] : [])
.concat(isolatedWlCopy ? ["--wl-copy-path", isolatedWlCopy] : []);
helper.running = true;
return true;
}
function refresh() { run("list", ""); }
function request(action, ident) {
// Une miniature en cours ne doit pas faire perdre une commande clavier.
if (helper.running) { queuedAction = { action: action, ident: ident || "" }; return; }
run(action, ident);
}
function filter() {
const query = search.text.toLocaleLowerCase();
results = items.filter(item => !query || (!item.image && item.preview.toLocaleLowerCase().includes(query)));
selected = 0;
updateThumbnail();
}
function currentItem() { return selected >= 0 && selected < results.length ? results[selected] : null; }
function updateThumbnail() {
thumbnail = "";
const item = currentItem();
if (item && item.image && !helper.running && !queuedAction) run("thumb", item.id);
}
function choose() {
const item = currentItem();
if (item && !confirming) request("select", item.id);
}
function remove() {
const item = currentItem();
if (item && !confirming) request("delete", item.id);
}
function dismiss(force) {
if (confirming && !force) { confirming = false; return; }
if (closing) return;
closing = true;
queuedAction = null;
visible = false;
items = []; results = []; thumbnail = "";
if (!helper.running) run("clean", "");
}
Process {
id: helper
running: false
stdout: StdioCollector { id: output; waitForEnd: true }
stderr: StdioCollector { waitForEnd: true }
onExited: (code, status) => {
const action = panel.operation;
const requestedId = panel.activeId;
let data = {};
try { data = JSON.parse(output.text); } catch (_) {}
panel.operation = "";
panel.activeId = "";
if (panel.closing) {
if (action === "clean") panel.cleanupFinished();
else panel.run("clean", "");
return;
}
if (code !== 0 || status !== 0 || data.error) {
panel.message = "Opération impossible. Réessayez.";
if (panel.queuedAction) {
const next = panel.queuedAction;
panel.queuedAction = null;
panel.run(next.action, next.ident);
}
return;
}
if (action === "list") {
panel.items = data.items || [];
panel.listLoaded = true;
panel.filter();
} else if (action === "thumb") {
const current = panel.currentItem();
if (current && current.id === requestedId) panel.thumbnail = data.path || "";
else panel.updateThumbnail();
} else if (action === "select") panel.dismiss();
else if (action === "delete" || action === "wipe") {
panel.confirming = false;
panel.refresh();
}
if (panel.queuedAction && !helper.running) {
const next = panel.queuedAction;
panel.queuedAction = null;
panel.run(next.action, next.ident);
}
}
}
// La surface Wayland est créée après Component.onCompleted : demander le
// focus au tour suivant, une fois le layer-shell visible et focusable.
Timer { interval: 120; running: true; onTriggered: search.forceActiveFocus() }
// WHY: TextField consomme Return avant Keys.onPressed sur cette version de Qt.
// CONTRACT: Entrée active l'entrée sélectionnée même pendant la recherche.
Shortcut { sequence: "Return"; context: Qt.WindowShortcut; enabled: panel.visible && !panel.confirming; onActivated: panel.choose() }
Shortcut { sequence: "Enter"; context: Qt.WindowShortcut; enabled: panel.visible && !panel.confirming; onActivated: panel.choose() }
Component.onCompleted: refresh()
Item {
id: contentRoot
anchors.fill: parent
Keys.onPressed: event => { if (event.key === Qt.Key_Escape) { panel.dismiss(); event.accepted = true; } }
MouseArea { anchors.fill: parent; onClicked: panel.dismiss(true) }
Rectangle {
id: card
// CONTRACT: même marge droite et même départ vertical que le
// Control Center ; la surface complète conserve son focus clavier.
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: panel.barWindow.height + 12
anchors.rightMargin: 16
width: Math.max(0, Math.min(540, parent.width - 32))
height: Math.max(0, Math.min(500, parent.height - anchors.topMargin - 16))
radius: 4
color: "#1e1e2e"
border.color: "#45475a"
border.width: 1
MouseArea { anchors.fill: parent; onClicked: {} }
Column {
anchors.fill: parent
anchors.margins: 16
spacing: 12
Row {
width: parent.width; height: 30
Text { text: "󰅇 Presse-papiers"; width: Math.max(0, parent.width - countLabel.implicitWidth - clearLabel.implicitWidth - 8); elide: Text.ElideRight
color: "#cdd6f4"; font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 18; font.bold: true }
Item { width: 8; height: 1 }
Text { id: countLabel; text: panel.items.length + " / 50"; color: "#a6adc8"; font.pixelSize: 12; anchors.verticalCenter: parent.verticalCenter }
Text { id: clearLabel; text: " Tout effacer"; color: "#f38ba8"; font.pixelSize: 12; anchors.verticalCenter: parent.verticalCenter
MouseArea { anchors.fill: parent; onClicked: panel.confirming = true } }
}
Rectangle {
width: parent.width; height: 40; radius: 4; color: "#313244"; border.color: search.activeFocus ? "#b4befe" : "#45475a"
TextField {
id: search
anchors.fill: parent; anchors.margins: 2
placeholderText: "Rechercher dans les textes…"
color: "#cdd6f4"; placeholderTextColor: "#a6adc8"
font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 13
background: Item {}
onTextChanged: panel.filter()
Keys.onPressed: event => {
if (event.key === Qt.Key_Down) { panel.selected = Math.min(panel.results.length - 1, panel.selected + 1); list.positionViewAtIndex(panel.selected, ListView.Contain); panel.updateThumbnail(); event.accepted = true; }
else if (event.key === Qt.Key_Up) { panel.selected = Math.max(0, panel.selected - 1); list.positionViewAtIndex(panel.selected, ListView.Contain); panel.updateThumbnail(); event.accepted = true; }
else if (event.key === Qt.Key_Escape) { panel.dismiss(); event.accepted = true; }
else if (event.key === Qt.Key_Delete && (event.modifiers & Qt.ControlModifier)) { panel.remove(); event.accepted = true; }
}
}
}
Text { visible: panel.message !== ""; text: panel.message; color: "#f38ba8"; font.pixelSize: 12 }
Text { visible: panel.confirming; text: "Effacer tout l’historique ? Cette action est irréversible."; color: "#f9e2af"; font.pixelSize: 12 }
Row {
visible: panel.confirming; spacing: 16
Text { id: confirmLabel; text: "Confirmer la purge"; color: "#f38ba8"; font.bold: true
MouseArea { anchors.fill: parent; onClicked: panel.request("wipe", "") } }
Text { id: cancelLabel; text: "Annuler"; color: "#b4befe"; MouseArea { anchors.fill: parent; onClicked: panel.confirming = false } }
}
Item {
width: parent.width
height: Math.max(0, parent.height - y - 36)
Text { anchors.centerIn: parent; visible: panel.results.length === 0; text: panel.items.length === 0 ? "Historique vide" : "Aucun résultat textuel"; color: "#a6adc8" }
ListView {
id: list
anchors.fill: parent
visible: panel.results.length > 0
clip: true; spacing: 6
model: panel.results
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
delegate: Rectangle {
required property var modelData
required property int index
width: list.width - 12; height: modelData.image && panel.selected === index ? 122 : 66
radius: 4
color: panel.selected === index ? "#45475a" : "#313244"
border.color: panel.selected === index ? "#b4befe" : "transparent"
Row {
anchors.fill: parent; anchors.margins: 10; spacing: 10
Text { text: modelData.image ? "" : "󰦨"; color: "#b4befe"; font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 19 }
Column {
width: parent.width - 35; spacing: 5
Text { text: modelData.image ? "Image" : "Texte"; color: "#b4befe"; font.bold: true; font.pixelSize: 12 }
Text { width: parent.width; text: modelData.preview; color: "#cdd6f4"; font.pixelSize: 12; wrapMode: Text.Wrap; maximumLineCount: 2; elide: Text.ElideRight }
Image { visible: modelData.image && panel.selected === index && panel.thumbnail !== ""; source: visible ? panel.thumbnail : ""; width: 180; height: 75; fillMode: Image.PreserveAspectFit; asynchronous: true; cache: false }
}
}
MouseArea { anchors.fill: parent; acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: mouse => {
panel.selected = index;
if (mouse.button === Qt.RightButton) panel.remove();
else panel.choose();
}
}
}
}
}
Row {
width: parent.width; height: 20
Text { text: parent.width < 410 ? "↑ ↓ Entrée Échap" : "↑ ↓ parcourir Entrée copier Ctrl+Suppr retirer Échap fermer";
width: Math.max(0, parent.width - removeLabel.implicitWidth - 8); elide: Text.ElideRight
color: "#a6adc8"; font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 11 }
Item { width: 8; height: 1 }
Text { id: removeLabel; text: "Supprimer"; color: "#f38ba8"; font.pixelSize: 12
MouseArea { anchors.fill: parent; onClicked: panel.remove() } }
}
}
}
}
}

View file

@ -0,0 +1,42 @@
# Historique du presse-papiers
L'icône presse-papiers de la zone droite et `Mod+Alt+V` appellent la même fonction de la barre. Le raccourci passe par `~/.config/sway/scripts/clipboard-quickshell` et cible par IPC l'écran SwayFX focalisé. La barre crée `ClipboardPanel.qml` avec `LazyLoader` et le détruit à la fermeture. Le panneau reste une surface layer-shell à focus clavier exclusif, sans zone réservée ; sa carte est alignée sur la marge droite du Control Center, juste sous la barre. Elle mesure au plus 540 × 500 px et se réduit sur les petits écrans. Le focus revient à la fenêtre précédente quand la surface disparaît. Aucun démon supplémentaire ni interrogation permanente de `cliphist`.
Les collecteurs `cliphist-text.service` et `cliphist-image.service`, la base `cliphist` et la limite de **50 entrées affichées** restent inchangés. `backend.py` exécute directement `cliphist` et `wl-copy` sans shell. QML reçoit uniquement les aperçus de `cliphist list`, les identifiants et le type MIME. Avant une sélection ou une suppression, le helper vérifie que l'identifiant est toujours dans les 50 entrées courantes. `decode` doit terminer avec succès avant que `wl-copy` ne reçoive son flux binaire ; les images gardent leur type MIME.
La version installée de `cliphist` (0.7.0) ne fournit ni `list -fields` ni `max-store-size` : son code ignore sans erreur les entrées de plus de 5 000 000 octets. Un PNG plus grand peut donc être proposé par Wayland sans entrer dans l'historique. La limite n'est pas relevée implicitement. Une version ultérieure de `cliphist` expose ces options, mais une migration et son budget de stockage doivent être décidés séparément.
## Utilisation
- `↑` / `↓` : changer la sélection ; `Entrée` : restaurer l'entrée sélectionnée.
- Saisie directe : filtrer les textes ; les images restent visibles lorsque la recherche est vide.
- Clic gauche sur une entrée : restaurer et fermer ; défilement : parcourir.
- `Ctrl+Suppr` ou « Supprimer » : retirer l'entrée sélectionnée.
- « Tout effacer » demande une confirmation explicite ; « Annuler » et `Échap` l'annulent.
- `Échap`, un clic extérieur ou un second clic sur l'icône ferment le panneau. `Mod+Alt+V` le referme aussi.
L'ouverture du presse-papiers ferme Date Center, Control Center, le menu des fenêtres et le panneau des supports amovibles. L'ouverture de ces panneaux ferme réciproquement le presse-papiers. Les notifications ne sont pas modifiées.
## Images et confidentialité
Une seule image sélectionnée peut recevoir une miniature. Elle est produite par ImageMagick dans un répertoire `0700` sous `XDG_RUNTIME_DIR`, avec limites locales de mémoire et de surface. Le panneau nettoie ses miniatures à la fermeture. Les anciens fichiers de `cliphist/thumbs` ne sont pas réutilisés : leurs permissions et leur provenance ne sont pas garanties. Le décodage utilise un fichier temporaire privé, sans placer les octets binaires dans une propriété QML ou dans les arguments d'une commande.
Les journaux ne contiennent ni aperçu ni contenu décodé. L'interface affiche les aperçus seulement quand l'utilisateur l'ouvre. Elle ne crée pas de base supplémentaire ni de nouvelle politique de rétention. Les deux collecteurs actuels ne distinguent pas de manière fiable les secrets des autres contenus ; l'exclusion automatique des mots de passe n'est donc pas annoncée. Pour éviter la collecte d'un secret, il faut agir à la source ou définir ultérieurement une politique explicite.
## Dépendances et retour arrière
Dépendances : QuickShell 0.3.1, Qt 6, SwayFX, `jq`, Python 3, `cliphist`, `wl-clipboard`, ImageMagick et une Nerd Font. Le script Wofi `copypast` reste présent. Pour revenir à Wofi, remplacer uniquement la ligne `Mod+Alt+V` de `sway/.config/sway/bind` par `bindsym $mod+Alt+v exec ~/.config/sway/scripts/copypast`, puis recharger la configuration Sway. Les autres usages de Wofi, dont le lanceur `Mod+Alt+L`, restent indépendants.
## Vérification isolée
`python3 -B -m unittest discover -s tests/clipboard -v` utilise une base `cliphist` temporaire et le chemin absolu d'un faux `wl-copy`. Le test couvre texte, image PNG, octets, type MIME, suppression, purge, cache et historique vide. Il ne touche jamais l'historique personnel. Le panneau live est inspectable sans contenu via `quickshell ipc -c labfy-sway call clipboardUi-eDP-1 state` (adapter le nom de sortie).
`python3 -B tests/clipboard/ui_quickshell_smoke.py` exige une session Wayland et exerce les vrais `MouseArea` du panneau avec QtTest et la touche Entrée avec `ydotool` : icône, liste, texte, PNG, miniature, défilement, suppression, annulation et confirmation de purge, clic extérieur et focus. La base `cliphist`, le cache de miniatures et le faux `wl-copy` sont créés dans un répertoire temporaire privé. `CLIPBOARD_UI_CAPTURE=/run/user/$UID/clipboard-card.png` ajoute une capture intérieure de la carte factice pour vérifier le rendu.
`Print` passe par `screenshot-region` : il ferme d'abord le panneau exclusif s'il est ouvert, lance le `slurp` corrigé, puis confie la géométrie à `grim`. Échap arrête la commande avant toute écriture dans le presse-papiers ; seul un fichier PNG vérifié est remis à `wl-copy --type image/png`. Le binaire utilisateur `$HOME/.local/libexec/labfy-slurp` est construit par `tools/build-slurp-keymap-guard` depuis `slurp` 1.5.0, avec une garde sur les événements clavier reçus avant la keymap. La construction ne remplace pas `/usr/bin/slurp` et ne nécessite pas de privilèges administrateur.
Lorsque le paquet officiel inclura cette garde, remplacer le chemin utilisateur
dans `screenshot-region` par le binaire officiel et retirer la construction locale.
Pour une instance graphique de test, `LABFY_CLIPHIST_TEST_DB` désigne la base isolée et `LABFY_CLIPHIST_TEST_WL_COPY` le chemin absolu du faux `wl-copy`. Les deux variables doivent être fournies ensemble afin de ne toucher ni la base ni le presse-papiers de la session.
Limites : le filtre porte sur les aperçus textuels, pas sur le texte intégral ; les images ne sont pas recherchées. Une miniature ImageMagick peut être refusée par les limites de ressources sans empêcher la sélection de l'image. La surface prend le focus clavier seulement après sa création Wayland, environ 120 ms après ouverture.

View file

@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""Pont éphémère cliphist : seules les métadonnées et miniatures passent à QML."""
import argparse
import json
import os
from pathlib import Path
import re
import selectors
import subprocess
import sys
import tempfile
import time
IMAGE = re.compile(r"^\[\[ binary data .*?\b(png|jpeg|jpg|bmp|webp)\b", re.I)
MIMES = {"png": "image/png", "jpeg": "image/jpeg", "jpg": "image/jpeg", "bmp": "image/bmp", "webp": "image/webp"}
MAX_DECODE_BYTES = 256 * 1024 * 1024
def command(db, action):
return ["cliphist"] + (["-db-path", db] if db else []) + [action]
def entries(db):
# CONTRACT: les 50 premières lignes sont les plus récentes ; le champ avant TAB est l'identité stable.
result = subprocess.run(command(db, "list"), capture_output=True, timeout=10)
if result.returncode:
if result.returncode == 1 and b"please store something first" in result.stderr:
return []
raise RuntimeError("Historique indisponible")
items = []
for raw in result.stdout.splitlines()[:50]:
ident, separator, preview = raw.partition(b"\t")
if not separator or not ident.isdigit():
continue
label = preview.decode("utf-8", "replace")
image = IMAGE.match(label)
items.append({"id": ident.decode("ascii"), "image": bool(image),
"mime": MIMES.get(image.group(1).lower()) if image else "text/plain;charset=utf-8",
"preview": "Image · " + label[3:-3].strip() if image else label[:400]})
return items
def exact(db, ident):
if not re.fullmatch(r"[0-9]+", ident):
raise ValueError("Identifiant invalide")
for item in entries(db):
if item["id"] == ident:
return item
raise ValueError("Entrée introuvable parmi les 50 éléments affichés")
def decode(db, ident, output):
# WHY: le flux binaire privé est limité en espace et en temps ; aucune capture mémoire QML.
process = subprocess.Popen(command(db, "decode"), stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL)
try:
process.stdin.write((ident + "\t").encode("ascii"))
process.stdin.close()
size = 0
deadline = time.monotonic() + 20
with selectors.DefaultSelector() as selector:
selector.register(process.stdout, selectors.EVENT_READ)
while True:
if time.monotonic() >= deadline:
raise RuntimeError("Décodage trop lent")
ready = selector.select(max(0, deadline - time.monotonic()))
if not ready:
raise RuntimeError("Décodage trop lent")
chunk = os.read(process.stdout.fileno(), 65536)
if not chunk:
break
size += len(chunk)
if size > MAX_DECODE_BYTES:
raise RuntimeError("Entrée trop volumineuse")
output.write(chunk)
if process.wait(timeout=2):
raise RuntimeError("Décodage impossible")
finally:
if not process.stdin.closed:
process.stdin.close()
if process.poll() is None:
process.kill()
process.wait()
process.stdout.close()
output.flush()
output.seek(0)
def cache_dir():
# CONTRACT: le test graphique peut isoler les miniatures sans changer le
# socket Wayland fourni par XDG_RUNTIME_DIR.
runtime = Path(os.environ.get("LABFY_CLIPHIST_TEST_CACHE_DIR") or
os.environ.get("XDG_RUNTIME_DIR", tempfile.gettempdir()))
path = runtime / ("labfy-cliphist-panel-" + str(os.getuid()))
path.mkdir(mode=0o700, exist_ok=True)
if path.is_symlink() or path.stat().st_uid != os.getuid() or path.stat().st_mode & 0o077:
raise RuntimeError("Répertoire temporaire non privé")
return path
def main():
parser = argparse.ArgumentParser()
parser.add_argument("action", choices=("list", "select", "delete", "wipe", "thumb", "clean"))
parser.add_argument("id", nargs="?")
parser.add_argument("--db-path", default=None, help=argparse.SUPPRESS)
parser.add_argument("--wl-copy-path", default="wl-copy", help=argparse.SUPPRESS)
args = parser.parse_args()
if args.action == "list":
print(json.dumps({"items": entries(args.db_path)}, ensure_ascii=False))
elif args.action == "clean":
path = cache_dir()
for file in path.glob("*.png"):
if file.is_file() and not file.is_symlink():
file.unlink()
print('{"ok":true}')
elif args.action == "wipe":
# CONTRACT: la confirmation est dans QML ; cette commande n'est appelée qu'après confirmation explicite.
subprocess.run(command(args.db_path, "wipe"), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, timeout=10)
print('{"ok":true}')
else:
item = exact(args.db_path, args.id or "")
if args.action == "delete":
result = subprocess.run(command(args.db_path, "delete"), input=(item["id"] + "\t").encode("ascii"),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
if result.returncode:
raise RuntimeError("Suppression impossible")
print('{"ok":true}')
return
if args.action == "thumb" and not item["image"]:
raise ValueError("Miniature réservée aux images")
with tempfile.TemporaryFile() as decoded:
decode(args.db_path, item["id"], decoded)
if args.action == "select":
# INVARIANT: wl-copy reçoit les octets décodés par stdin avec le MIME exact.
result = subprocess.run([args.wl_copy_path, "--type", item["mime"]], stdin=decoded,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
if result.returncode:
raise RuntimeError("Restauration du presse-papiers impossible")
print('{"ok":true}')
else:
path = cache_dir() / (item["id"] + ".png")
if not path.exists():
with tempfile.NamedTemporaryFile(dir=path.parent, prefix="thumb-", suffix=".png", delete=False) as out:
temporary = Path(out.name)
try:
result = subprocess.run(["magick", "-limit", "memory", "64MiB", "-limit", "map", "128MiB",
"-limit", "disk", "256MiB", "-limit", "time", "8",
"-limit", "area", "40MP", "-", "-thumbnail", "180x110>", "png:-"],
stdin=decoded, stdout=out, stderr=subprocess.DEVNULL, timeout=10)
if result.returncode:
raise RuntimeError("Miniature indisponible")
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
print(json.dumps({"path": path.as_uri()}))
if __name__ == "__main__":
try:
main()
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired, RuntimeError, ValueError) as error:
# CONTRACT: aucun aperçu ni contenu décodé n'est écrit dans les journaux.
print(json.dumps({"error": str(error)}))
sys.exit(1)

View file

@ -3,6 +3,7 @@ import QtQuick.Controls
import Quickshell.Io import Quickshell.Io
import "../components" import "../components"
import "../theme" import "../theme"
import "PercentMath.js" as PercentMath
Item { Item {
id: brightnessControl id: brightnessControl
@ -15,6 +16,46 @@ Item {
readonly property real fraction: available ? brightness / maximum : 0 readonly property real fraction: available ? brightness / maximum : 0
readonly property int percent: Math.round(fraction * 100) readonly property int percent: Math.round(fraction * 100)
// INVARIANT: une seule écriture sysfs à la fois ; les gestes rapides
// mettent à jour la consigne, puis chaque résultat est relu du matériel.
property int requestedPercent: -1
property int writtenPercent: -1
property bool writeInFlight: false
property bool readbackFailed: false
function requestPercent(value) {
if (!available) return;
requestedPercent = PercentMath.bounded(value, 10);
if (!writeInFlight) writeRequested();
}
function adjustBy(steps) {
if (!available || !steps) return;
requestPercent((requestedPercent >= 0 ? requestedPercent : percent) + steps);
}
function writeRequested() {
if (!available || requestedPercent < 0) return;
const raw = Math.max(1, Math.round(maximum * requestedPercent / 100));
writtenPercent = requestedPercent;
writeInFlight = true;
brightnessFile.setText(String(raw));
}
function finishWrite() {
// Exécuter après le signal saved évite de réentrer dans les callbacks
// FileView pendant l'attente bornée de lecture des quelques octets sysfs.
readbackFailed = false;
brightnessFile.reload();
brightnessFile.waitForJob();
const raw = brightnessFile.text().trim();
const actual = /^[0-9]+$/.test(raw) ? Number(raw) : NaN;
writeInFlight = false;
if (readbackFailed || !Number.isSafeInteger(actual) || actual < 0 || actual > maximum) {
requestedPercent = -1;
console.warn("Relecture du rétroéclairage impossible après écriture");
return;
}
if (requestedPercent !== writtenPercent) writeRequested();
else requestedPercent = -1;
}
implicitHeight: content.implicitHeight implicitHeight: content.implicitHeight
FileView { FileView {
@ -28,6 +69,25 @@ Item {
watchChanges: true watchChanges: true
atomicWrites: false atomicWrites: false
onFileChanged: reload() onFileChanged: reload()
onLoadFailed: error => {
brightnessControl.readbackFailed = true;
console.warn("Lecture du rétroéclairage impossible", error);
}
onSaved: {
// WHY: FileView peut déjà avoir émis loaded() depuis le watcher
// avant saved(). Attendre un second loaded() bloquait la file pour
// toujours. Relire ici les quelques octets de sysfs, puis libérer
// l'écriture avant de lancer la dernière consigne en attente.
// Le pilote peut arrondir ; une nouvelle consigne est la seule
// raison de poursuivre, jamais un écart entre cible et matériel.
Qt.callLater(brightnessControl.finishWrite);
}
onSaveFailed: error => {
console.warn("Écriture du rétroéclairage impossible", error);
brightnessControl.writeInFlight = false;
brightnessControl.requestedPercent = -1;
reload();
}
} }
Column { Column {
@ -79,7 +139,7 @@ Item {
onMoved: { onMoved: {
if (brightnessControl.available) { if (brightnessControl.available) {
const fraction = Math.max(from, Math.min(to, value)); const fraction = Math.max(from, Math.min(to, value));
brightnessFile.setText(String(Math.round(brightnessControl.maximum * fraction))); brightnessControl.requestPercent(fraction * 100);
} }
} }

View file

@ -21,6 +21,15 @@ PopupWindow {
property int currentPage: pages.main property int currentPage: pages.main
function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; } function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; }
signal thresholdApplied() signal thresholdApplied()
// CONTRACT: la barre appelle les mêmes instances que les curseurs du
// panneau ; les valeurs affichées restent celles des backends observés.
readonly property bool volumeAvailable: volumeSlider.sinkAudio !== null
readonly property bool volumeMuted: volumeSlider.muted
readonly property int volumePercent: volumeSlider.percent
readonly property bool brightnessAvailable: brightnessSlider.available
readonly property int brightnessPercent: brightnessSlider.percent
function adjustVolume(steps) { volumeSlider.adjustBy(steps); }
function adjustBrightness(steps) { brightnessSlider.adjustBy(steps); }
readonly property var pendingAction: sessionExitGate.pendingAction readonly property var pendingAction: sessionExitGate.pendingAction
readonly property string checkpointError: sessionExitGate.errorMessage readonly property string checkpointError: sessionExitGate.errorMessage
readonly property string configHome: Quickshell.env("XDG_CONFIG_HOME") readonly property string configHome: Quickshell.env("XDG_CONFIG_HOME")
@ -162,8 +171,8 @@ PopupWindow {
} }
} }
VolumeSlider { width: parent.width } VolumeSlider { id: volumeSlider; width: parent.width }
BrightnessSlider { width: parent.width } BrightnessSlider { id: brightnessSlider; width: parent.width }
PowerProfile { width: parent.width } PowerProfile { width: parent.width }
Rectangle { Rectangle {

View file

@ -8,17 +8,19 @@ Rectangle {
required property bool open required property bool open
signal toggled() signal toggled()
width: 48 // Même hauteur que les actions voisines, avec une cible légèrement plus
// large pour l'accès au panneau sans capsule permanente.
width: 30
height: 26 height: 26
radius: 4 radius: 4
color: open ? Theme.accent : pointer.containsMouse ? Theme.border : Theme.buttonBackground color: open ? Theme.emphasisBackground : pointer.containsMouse ? Theme.border : "transparent"
NerdIcon { NerdIcon {
anchors.centerIn: parent anchors.centerIn: parent
text: "" text: ""
// WHY: garder le même poids visuel que les statuts voisins dans la capsule fixe. // WHY: garder le même poids visuel que les actions voisines sans capsule permanente.
font.pixelSize: 20 font.pixelSize: 28
color: controlButton.open ? Theme.onAccent : Theme.foreground color: controlButton.open ? Theme.accentForeground : Theme.lavender
} }
MouseArea { MouseArea {

View file

@ -0,0 +1,5 @@
// CONTRACT: les deux contrôles appliquent des points de pourcentage absolus ;
// leur plancher diffère, mais aucun ne peut dépasser 100 %.
function bounded(value, minimum) {
return Math.max(minimum, Math.min(100, Math.round(value)));
}

View file

@ -3,6 +3,7 @@ import QtQuick.Controls
import Quickshell.Services.Pipewire import Quickshell.Services.Pipewire
import "../components" import "../components"
import "../theme" import "../theme"
import "PercentMath.js" as PercentMath
Item { Item {
id: volumeControl id: volumeControl
@ -13,6 +14,28 @@ Item {
readonly property bool muted: sinkAudio ? sinkAudio.muted : false readonly property bool muted: sinkAudio ? sinkAudio.muted : false
readonly property int percent: sinkAudio ? Math.round(sinkAudio.volume * 100) : 0 readonly property int percent: sinkAudio ? Math.round(sinkAudio.volume * 100) : 0
// CONTRACT: un pas est un point absolu, calculé depuis la dernière
// consigne locale tant que PipeWire n'a pas encore publié son écho.
property int requestedPercent: -1
property var sentPercents: []
function adjustBy(steps) {
if (!sinkAudio || !steps) return;
const base = requestedPercent >= 0 ? requestedPercent : percent;
const next = PercentMath.bounded(base + steps, 0);
requestedPercent = next;
sentPercents = sentPercents.concat([next]).slice(-100);
sinkAudio.volume = next / 100;
}
onSinkAudioChanged: { requestedPercent = -1; sentPercents = []; }
onPercentChanged: {
// Un changement extérieur annule la consigne locale ; les échos de
// commandes rapprochées déjà envoyées ne doivent pas l'annuler.
if (requestedPercent === percent || sentPercents.indexOf(percent) < 0) {
requestedPercent = -1;
sentPercents = [];
}
}
PwObjectTracker { PwObjectTracker {
objects: [volumeControl.sink] objects: [volumeControl.sink]
} }

View file

@ -0,0 +1,98 @@
# Vue d'ensemble des workspaces SwayFX
`Super+W` ouvre et ferme la surface `PanelWindow` centrée sur la sortie
focalisée. Elle affiche toujours les workspaces numérotés 1 à 10 ; un workspace
absent de l'arbre Sway est indiqué vide. Le panneau atteint au plus 1140 × 730
pixels logiques, avec 16 pixels de marge minimale. Avec cinq colonnes, sa
hauteur est de 566 pixels : la grille de 430 pixels tient dans 438 pixels
utiles sur `eDP-1`, sans défilement. Sur les petits écrans, le défilement reste
disponible si la grille dépasse réellement la hauteur utile. La surface
layer-shell `Overlay` prend le focus
clavier exclusivement pendant l'ouverture et libère ce focus en se fermant.
## Provenance des miniatures
`grim -o <sortie> -s 0.25 -t jpeg -q 72` prend un **snapshot réel de la sortie
visible**. Une capture a lieu à l'ouverture, puis lors du changement de
workspace après 350 ms et au plus toutes les 15 s pendant que l'Overview est
fermée. Les images sont remplacées atomiquement dans
`$XDG_RUNTIME_DIR/labfy-workspace-overview/`. Le cache est éphémère et privé.
Chaque JPEG possède un manifeste JSON avec l'identité du workspace, sa sortie,
une empreinte de la hiérarchie et de la géométrie Sway, l'horodatage et le
`mtime` du fichier. `state()` ne fournit l'image que si ces données correspondent
à l'arbre courant. Les titres et le focus ne font pas partie de l'empreinte.
Une modification de taille, disposition, flottement ou composition de fenêtres
masque immédiatement l'ancien snapshot complet. Les images individuelles
disponibles restent affichées dans les nouveaux rectangles des fenêtres.
Un workspace devenu vide efface son ancienne image et son manifeste. Les
workspaces inchangés conservent leur capture.
La sortie qui porte l'Overview ne peut pas être capturée tant que sa surface
overlay est visible. Après un changement structurel, elle montre donc la
reconstruction jusqu'à la fermeture du panneau ; une capture différée de
300 ms est alors tentée. Le helper vérifie avant et après `grim` la présence
de l'overlay, l'identité et l'empreinte du workspace. Une sortie visible
différente de celle du panneau peut être recapturée pendant l'ouverture,
après 350 ms, si elle ne porte pas l'overlay. Ces captures multi-écrans n'ont
pas été vérifiées sur matériel. Une capture peut contenir une autre application
superposée à la sortie entre deux vérifications ; elle reste désignée comme
dernière capture.
`grim -T <identifiant>` capture réellement une fenêtre de cette session
SwayFX 0.6, même lorsque son workspace est caché. Le backend lit
`foreign_toplevel_identifier` sur la fenêtre dans `get_tree`, puis associe
l'image obtenue au vrai `con_id`. Passer le `con_id` à `grim -T` échoue : ce
sont deux identités distinctes. Chaque image individuelle a un manifeste
contenant les deux identifiants, son horodatage et le `mtime` du JPEG. Elle
reste utilisable après un déplacement ou un redimensionnement, et disparaît
du cache à la fermeture de la fenêtre. L'ouverture de l'Overview rafraîchit
la sortie visible et les fenêtres sans capture ; un changement structurel
recapture les fenêtres des workspaces concernés, par lots de 16 au plus.
Cette capture par toplevel n'inclut pas l'overlay de l'Overview.
La capture d'une fenêtre cachée peut refléter sa dernière frame si
l'application cesse de dessiner hors écran. `grim -T` ne fournit pas le rendu
composé d'un workspace caché. **Aucune carte n'est présentée comme live.**
Un workspace dont la capture complète est encore compatible montre ce
snapshot. Après un changement, la carte place les dernières images des
fenêtres encore présentes selon `get_tree`. Si une fenêtre n'a pas de capture
individuelle valide, son rectangle reste une reconstruction géométrique.
Les rectangles reprennent la géométrie réelle,
le focus, la position flottante et le titre de l'application. Les onglets et
les piles sont représentés par les rectangles et titres des feuilles ; leur
ornement Sway exact n'est pas reproduit.
Les sorties sont obtenues par `get_outputs`, les workspaces par
`get_workspaces`, les fenêtres par `get_tree`. Les changements `window`,
`workspace` et `output` déclenchent une relecture regroupée à 220 ms lorsque
la vue est ouverte. Chaque barre capture uniquement sa propre sortie. La
sélection du panneau passe par la sortie focalisée de Sway. Une seule sortie a
été vérifiée réellement ; les déplacements et captures entre plusieurs
sorties demandent un contrôle sur matériel multi-écrans.
La commande de déplacement valide le `con_id`, attend la réponse de Sway, puis
compare les arbres avant et après. Les numéros source et destination, ainsi que
les workspaces dont l'empreinte a changé, sont renvoyés à l'interface. Les
événements IPC provoquent aussi une relecture regroupée pour les changements
externes. La sélection clavier initiale suit le workspace réellement focalisé.
Le cache éphémère garde au plus une paire JPEG/manifeste par fenêtre encore
présente dans les workspaces 1 à 10, plus les captures complètes compatibles
des sorties ; les fichiers temporaires interrompus sont retirés après une
période de grâce. Aucun service supplémentaire n'est lancé.
## Interactions
- Clic sur une carte : activer le workspace et fermer.
- Clic sur une fenêtre : activer son workspace, focaliser son `con_id`, fermer.
- Glisser une fenêtre sur une carte : la déplacer et garder l'Overview ouverte.
- Clic droit sur une fenêtre : sélectionner « déplacer vers ».
- Flèches : choisir la carte ; `Tab` : choisir une fenêtre ; `M` : choisir une
destination ; chiffres `1` à `9`, `0` pour `10`, puis `Entrée` : déplacer.
- `Entrée` sans mode déplacement : activer la carte ou la fenêtre choisie.
- `Échap` ou clic hors du panneau : fermer.
Le helper `backend.py` vérifie les bornes de workspace et l'existence du vrai
`con_id` dans l'arbre juste avant toute commande. Les arguments sont passés
directement à `swaymsg`, sans shell ni titre de fenêtre. Le déplacement utilise
`[con_id=N] move container to workspace number W` et ne change pas le workspace
actif. Une réponse Sway en échec laisse le panneau ouvert et relit l'arbre.

View file

@ -0,0 +1,681 @@
import QtQuick
import Quickshell
import Quickshell.I3
import Quickshell.I3._Ipc
import Quickshell.Io
import Quickshell.Wayland
import "../theme"
PanelWindow {
id: overview
required property var barWindow
signal overviewDismissed()
property var cards: []
property int selectedWorkspace: 1
property bool initialSelectionSet: false
property int selectedWindow: 0
property int moveTarget: 0
property int dropTarget: 0
property string message: ""
property bool refreshPending: false
property string actionName: ""
property var dirtyCaptures: []
property var pendingWindowNumbers: []
property var activeWindowNumbers: []
property int windowCaptureCursor: 0
readonly property string backend: (Quickshell.env("XDG_CONFIG_HOME") ||
(Quickshell.env("HOME") + "/.config")) + "/quickshell/labfy-sway/overview/backend.py"
readonly property real cardX: card.x
readonly property real cardY: card.y
readonly property real cardWidth: card.width
readonly property real cardHeight: card.height
readonly property Item interactionRoot: keyboardRoot
readonly property real gridContentHeight: grid.height
readonly property real gridViewportHeight: scroll.height
// WHY: la surface layer-shell prend le clavier, comme ClipboardPanel.
// INVARIANT: sa disparition rend le focus au client Sway précédent sans touche injectée.
screen: barWindow.screen
anchors { top: true; bottom: true; left: true; right: true }
exclusionMode: ExclusionMode.Ignore
exclusiveZone: 0
aboveWindows: true
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
WlrLayershell.namespace: "labfy-workspace-overview"
color: "transparent"
function dismiss() {
visible = false;
// WHY: grim inclurait cette surface overlay. Après sa disparition,
// seules les sorties visibles dont l'arbre a changé sont recapturées.
for (const card of dirtyCaptures) {
Quickshell.execDetached(["python3", "-B", backend, "capture", "--output", card.output,
"--number", String(card.number), "--workspace-id", String(card.workspaceId),
"--revision", card.revision,
"--settle-ms", "300"]);
}
overviewDismissed();
}
function applyState(data) {
const next = data.cards || [];
const changedWindows = [];
if (!cards.length) {
for (const current of next)
if (current.windows.length && (current.visible
|| current.windows.some(window => !window.preview)))
changedWindows.push(current.number);
}
if (cards.length) {
for (const current of next) {
const old = cards.find(value => value.number === current.number);
if (old && current.windows.length && (old.revision !== current.revision
|| (!old.visible && current.visible))) changedWindows.push(current.number);
if (!old || old.revision === current.revision || !current.visible
|| !current.output) continue;
if (current.windows.length === 0) {
dirtyCaptures = dirtyCaptures.filter(value => value.output !== current.output);
continue;
}
// CONTRACT: comparer les arbres avant/après repère aussi bien
// la source que la destination d'un déplacement confirmé IPC.
dirtyCaptures = dirtyCaptures.filter(value => value.output !== current.output)
.concat([{ output: current.output, number: current.number,
workspaceId: current.workspaceId, revision: current.revision }]);
if (current.output !== screen.name && !captureDebounce.running)
captureDebounce.start();
}
}
if (changedWindows.length) queueWindowCaptures(changedWindows);
if (JSON.stringify(cards) !== JSON.stringify(next)) cards = next;
if (!initialSelectionSet || !cards.some(value => value.number === selectedWorkspace && value.exists)) {
const focused = cards.find(value => value.focused);
if (focused) selectedWorkspace = focused.number;
initialSelectionSet = true;
}
selectedWindow = Math.min(selectedWindow, currentWindows().length);
if (selectedWindow === 0) moveTarget = 0;
}
function queueWindowCaptures(numbers) {
for (const number of numbers)
if (!pendingWindowNumbers.includes(number)) pendingWindowNumbers.push(number);
if (!windowCapture.running && !windowCaptureDebounce.running) windowCaptureDebounce.start();
}
function startWindowCapture() {
if (windowCapture.running) return;
if (windowCaptureCursor === 0) {
if (!pendingWindowNumbers.length) return;
activeWindowNumbers = pendingWindowNumbers;
pendingWindowNumbers = [];
}
windowCapture.command = ["python3", "-B", backend, "capture-windows", "--numbers",
activeWindowNumbers.join(","), "--cursor", String(windowCaptureCursor)];
windowCapture.running = true;
}
function refresh() {
if (reader.running) { refreshPending = true; return; }
reader.running = true;
}
function scheduleRefresh() {
// INVARIANT: un flux de titres animés ne repousse jamais indéfiniment
// la lecture d'une fermeture ou d'un déplacement de fenêtre.
if (!debounce.running) debounce.start();
}
function applyTitleEvent(event) {
let data;
try { data = JSON.parse(event.data); } catch (_) { return false; }
if (data.change !== "title" || !data.container) return false;
let found = false;
const updated = cards.map(card => Object.assign({}, card, { windows: card.windows.map(window => {
if (window.id !== data.container.id) return window;
found = true;
return Object.assign({}, window, { title: data.container.name || window.app });
}) }));
if (found) cards = updated;
return found;
}
function execute(action, number, ident) {
if (actor.running || number < 1 || number > 10) return;
actionName = action;
const args = ["python3", "-B", backend, action, "--number", String(number)];
if (ident > 0) args.push("--con-id", String(ident));
actor.command = args;
actor.running = true;
}
function activateWorkspace(number) { execute("workspace", number, 0); }
function focusWindow(number, ident) { execute("focus", number, ident); }
function moveWindow(ident, number) {
if (!ident || number < 1 || number > 10) return;
execute("move", number, ident);
}
function currentWindows() {
const item = cards.find(value => value.number === selectedWorkspace);
return item ? item.windows : [];
}
function navigate(delta) {
selectedWorkspace = Math.max(1, Math.min(10, selectedWorkspace + delta));
selectedWindow = 0;
}
function workspaceAt(x, y) {
// CONTRACT: le dépôt exige un point dans une carte réelle, même si
// Qt rogne l'item glissé à la limite de sa miniature.
for (let index = 0; index < workspaceRepeater.count; index++) {
const item = workspaceRepeater.itemAt(index);
if (!item) continue;
const point = item.mapFromItem(keyboardRoot, x, y);
if (point.x >= 0 && point.y >= 0 && point.x < item.width && point.y < item.height)
return item.number;
}
return 0;
}
Component.onCompleted: refresh()
Timer { id: debounce; interval: 220; repeat: false; onTriggered: overview.refresh() }
Timer {
id: captureDebounce
interval: 350; repeat: false
onTriggered: overview.captureNextOtherOutput()
}
Timer {
id: windowCaptureDebounce
interval: 250; repeat: false
onTriggered: overview.startWindowCapture()
}
function captureNextOtherOutput() {
if (otherCapture.running) return;
const card = dirtyCaptures.find(value => value.output !== screen.name);
if (!card) return;
otherCapture.command = ["python3", "-B", backend, "capture", "--output", card.output,
"--number", String(card.number), "--workspace-id", String(card.workspaceId),
"--revision", card.revision];
otherCapture.running = true;
}
Timer { interval: 120; running: true; onTriggered: keyboardRoot.forceActiveFocus() }
I3IpcListener {
subscriptions: ["window"]
onIpcEvent: event => {
if (event.type === "subscribe") {
overview.scheduleRefresh();
} else if (!overview.applyTitleEvent(event)) overview.scheduleRefresh();
}
}
Connections {
target: I3
function onRawEvent(event) {
if (event.type === "workspace" || event.type === "output") overview.scheduleRefresh();
}
function onConnected() { overview.scheduleRefresh(); }
}
Process {
id: reader
command: ["python3", "-B", overview.backend, "state"]
stdout: StdioCollector { id: readerOutput; waitForEnd: true }
onExited: (code, status) => {
if (code === 0) {
try {
const data = JSON.parse(readerOutput.text);
overview.applyState(data);
} catch (error) { overview.message = "Lecture des workspaces impossible."; }
} else overview.message = "Lecture des workspaces impossible.";
if (overview.refreshPending) {
overview.refreshPending = false;
Qt.callLater(overview.refresh);
}
}
}
Process {
id: otherCapture
stdout: StdioCollector { id: otherCaptureOutput; waitForEnd: true }
onExited: (code, status) => {
let result = {};
try { result = JSON.parse(otherCaptureOutput.text); } catch (_) {}
const args = otherCapture.command;
const output = args[args.indexOf("--output") + 1];
if (code === 0 && result.captured)
overview.dirtyCaptures = overview.dirtyCaptures.filter(value =>
value.output !== output || value.revision !== result.revision);
overview.refresh();
if (result.captured && overview.dirtyCaptures.some(value => value.output !== overview.screen.name))
captureDebounce.restart();
}
}
Process {
id: windowCapture
stdout: StdioCollector { id: windowCaptureOutput; waitForEnd: true }
onExited: (code, status) => {
let result = {};
try { result = JSON.parse(windowCaptureOutput.text); } catch (_) {}
overview.refresh();
if (code === 0 && result.nextCursor > 0) {
overview.windowCaptureCursor = result.nextCursor;
} else {
overview.windowCaptureCursor = 0;
overview.activeWindowNumbers = [];
}
if (overview.windowCaptureCursor > 0 || overview.pendingWindowNumbers.length)
windowCaptureDebounce.start();
}
}
Process {
id: actor
stdout: StdioCollector { id: actorOutput; waitForEnd: true }
onExited: (code, status) => {
let result = {};
try { result = JSON.parse(actorOutput.text); } catch (_) {}
if (code !== 0 || !result.ok) {
overview.message = "Action Sway impossible ; la vue a été actualisée.";
overview.refresh();
return;
}
overview.applyState(result.state);
overview.message = "";
if (overview.actionName === "move") {
overview.selectedWindow = 0;
overview.moveTarget = 0;
overview.refresh();
} else overview.dismiss();
}
}
Item {
id: keyboardRoot
anchors.fill: parent
focus: true
Keys.onPressed: event => {
if (event.key === Qt.Key_Escape) { overview.dismiss(); event.accepted = true; }
else if (event.key === Qt.Key_Left) { overview.navigate(-1); event.accepted = true; }
else if (event.key === Qt.Key_Right) { overview.navigate(1); event.accepted = true; }
else if (event.key === Qt.Key_Up) { overview.navigate(-columns); event.accepted = true; }
else if (event.key === Qt.Key_Down) { overview.navigate(columns); event.accepted = true; }
else if (event.key === Qt.Key_Tab) {
const windows = overview.currentWindows();
if (windows.length) overview.selectedWindow = (overview.selectedWindow + 1) % (windows.length + 1);
event.accepted = true;
} else if (event.key === Qt.Key_M && overview.selectedWindow > 0) {
overview.moveTarget = overview.selectedWorkspace === 10 ? 1 : overview.selectedWorkspace + 1;
event.accepted = true;
} else if (event.key >= Qt.Key_0 && event.key <= Qt.Key_9 && overview.moveTarget) {
overview.moveTarget = event.key === Qt.Key_0 ? 10 : event.key - Qt.Key_0;
event.accepted = true;
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
const windows = overview.currentWindows();
if (overview.moveTarget && overview.selectedWindow > 0)
overview.moveWindow(windows[overview.selectedWindow - 1].id, overview.moveTarget);
else if (overview.selectedWindow > 0)
overview.focusWindow(overview.selectedWorkspace, windows[overview.selectedWindow - 1].id);
else overview.activateWorkspace(overview.selectedWorkspace);
event.accepted = true;
}
}
MouseArea { anchors.fill: parent; onClicked: overview.dismiss() }
readonly property int columns: card.width >= 1030 ? 5 : card.width >= 750 ? 4 : card.width >= 560 ? 3 : 2
Rectangle {
id: card
anchors.centerIn: parent
width: Math.max(0, Math.min(1140, parent.width - 32))
// WHY: deux rangées de cinq cartes tenaient à 30 px près ; ce
// profil garde 16 px de marge d'écran et supprime le scroll normal.
height: Math.max(0, Math.min(keyboardRoot.columns === 5 ? 566 : 730, parent.height - 32))
radius: 4
color: Theme.popupBackground
border.color: Theme.outline
border.width: 1
MouseArea { anchors.fill: parent; onClicked: {} }
Column {
id: content
anchors.fill: parent
anchors.margins: 16
spacing: 10
Item {
id: header
width: parent.width; height: 44
Rectangle {
id: headerAccent
anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter
width: 3; height: 32; radius: 1
color: Theme.lavender
}
Column {
anchors.left: headerAccent.right; anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text { text: "Vue d’ensemble"; color: Theme.foreground; font.pixelSize: 20; font.bold: true }
Text { text: "Espaces de travail · captures et disposition actuelle";
color: Theme.secondaryForeground; font.pixelSize: 11 }
}
Rectangle {
anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter
width: 94; height: 24; radius: 3
color: Theme.surface0; border.color: Theme.separator
visible: header.width > 520
Text { anchors.centerIn: parent; text: "10 ESPACES";
color: Theme.accentForeground; font.pixelSize: 10; font.bold: true }
}
}
Text {
id: messageLabel
visible: overview.message !== ""; height: visible ? 18 : 0
text: overview.message; color: Theme.warningForeground; font.pixelSize: 12
}
Flickable {
id: scroll
width: parent.width
// CONTRACT: la grille utilise toute la hauteur réellement
// restante ; le footer et l'espacement ne sont comptés qu'une fois.
height: Math.max(0, parent.height - y - footer.height - content.spacing)
contentWidth: width
contentHeight: grid.height
clip: true
interactive: contentHeight > height + 1
boundsBehavior: Flickable.StopAtBounds
Grid {
id: grid
width: scroll.width
columns: keyboardRoot.columns
spacing: 10
Repeater {
id: workspaceRepeater
model: overview.cards
delegate: Rectangle {
id: workspaceCard
required property var modelData
readonly property int number: modelData.number
readonly property bool active: modelData.focused
readonly property bool occupied: modelData.windows.length > 0
readonly property bool hovered: cardHover.hovered || cardPointer.containsMouse
readonly property bool dropHighlighted: overview.dropTarget === number || dropArea.containsDrag
width: (grid.width - (grid.columns - 1) * grid.spacing) / grid.columns
height: grid.columns === 5 ? 210 : grid.columns === 4 ? 190
: grid.columns === 3 ? 140 : 175
radius: 4
color: dropHighlighted ? Theme.surface2
: active ? Theme.surface1
: hovered ? Theme.buttonHover
: occupied ? Theme.surface0 : Theme.mantle
border.color: dropHighlighted ? Theme.lavender
: overview.selectedWorkspace === number ? Theme.lavender
: hovered ? Theme.overlay1
: occupied ? Theme.strongBorder : Theme.separator
border.width: dropHighlighted || overview.selectedWorkspace === number ? 2 : 1
Behavior on color { ColorAnimation { duration: 110 } }
HoverHandler { id: cardHover }
Rectangle {
anchors.top: parent.top; anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - 16; height: 2; radius: 1
color: workspaceCard.active || workspaceCard.dropHighlighted
? Theme.lavender : "transparent"
}
DropArea {
id: dropArea
anchors.fill: parent
keys: ["labfy-workspace-window"]
onDropped: drop => {
if (drop.source && drop.source.conId > 0
&& drop.source.sourceWorkspace !== workspaceCard.number) {
overview.moveWindow(drop.source.conId, workspaceCard.number);
drop.acceptProposedAction();
}
}
}
MouseArea {
id: cardPointer
anchors.fill: parent
hoverEnabled: true
onClicked: overview.activateWorkspace(workspaceCard.number)
}
Column {
anchors.fill: parent; anchors.margins: 8; spacing: 5
Item {
id: cardHeader
width: parent.width; height: 24
Rectangle {
id: numberBadge
anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter
width: 30; height: 22; radius: 3
color: workspaceCard.active || workspaceCard.dropHighlighted
? Theme.lavender : Theme.surface1
Text {
anchors.centerIn: parent
text: String(workspaceCard.number).padStart(2, "0")
color: workspaceCard.active || workspaceCard.dropHighlighted
? Theme.onAccent : Theme.foreground
font.bold: true; font.pixelSize: 12
}
}
Text {
anchors.left: numberBadge.right; anchors.leftMargin: 7
anchors.verticalCenter: parent.verticalCenter
width: Math.max(0, parent.width - 78)
text: workspaceCard.modelData.output || "Disponible"
color: workspaceCard.occupied ? Theme.foreground : Theme.secondaryForeground
font.pixelSize: 11; elide: Text.ElideRight
}
Text {
anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter
text: workspaceCard.occupied ? String(workspaceCard.modelData.windows.length) : "—"
color: workspaceCard.occupied ? Theme.accentForeground : Theme.disabledForeground
font.pixelSize: 11; font.bold: workspaceCard.occupied
}
}
Item {
id: preview
width: parent.width
height: Math.max(0, parent.height - cardHeader.height
- provenance.implicitHeight - parent.spacing * 2)
clip: true
Rectangle {
anchors.fill: parent; radius: 3
color: Theme.crust; border.color: Theme.separator
}
Image {
anchors.fill: parent
source: workspaceCard.modelData.snapshot ? workspaceCard.modelData.snapshot
+ "?t=" + workspaceCard.modelData.snapshotTime : ""
visible: workspaceCard.modelData.snapshot !== ""
&& workspaceCard.modelData.windows.length > 0
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: false
}
Column {
anchors.centerIn: parent
visible: workspaceCard.modelData.windows.length === 0
&& !workspaceCard.dropHighlighted
spacing: 7
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
width: 30; height: 21; radius: 2
color: "transparent"; border.color: Theme.overlay0
Rectangle {
anchors.left: parent.left; anchors.right: parent.right
anchors.top: parent.top; anchors.margins: 3
height: 2; radius: 1; color: Theme.overlay0
}
}
Text { anchors.horizontalCenter: parent.horizontalCenter
text: "Espace vide"; color: Theme.secondaryForeground;
font.pixelSize: 12; font.bold: true }
}
Repeater {
model: workspaceCard.modelData.windows
delegate: Rectangle {
id: windowVisual
required property var modelData
required property int index
property int conId: modelData.id
property int sourceWorkspace: workspaceCard.number
readonly property var outputRect: workspaceCard.modelData.outputRect || workspaceCard.modelData.rect
readonly property real sx: outputRect && outputRect.width ? preview.width / outputRect.width : 1
readonly property real sy: outputRect && outputRect.height ? preview.height / outputRect.height : 1
x: Math.max(0, (modelData.rect.x - (outputRect ? outputRect.x : 0)) * sx)
y: Math.max(0, (modelData.rect.y - (outputRect ? outputRect.y : 0)) * sy)
width: Math.max(15, Math.min(preview.width - x, modelData.rect.width * sx))
height: Math.max(15, Math.min(preview.height - y, modelData.rect.height * sy))
radius: 2
clip: true
color: workspaceCard.modelData.snapshot ? "#1e1e2e35" : Theme.surface1
border.color: modelData.focused || (overview.selectedWorkspace === workspaceCard.number
&& overview.selectedWindow === index + 1) ? Theme.lavender : Theme.overlay0
border.width: modelData.focused ? 2 : 1
Image {
anchors.fill: parent
source: !workspaceCard.modelData.snapshot && windowVisual.modelData.preview
? windowVisual.modelData.preview + "?t=" + windowVisual.modelData.previewTime : ""
visible: !workspaceCard.modelData.snapshot
&& windowVisual.modelData.preview !== ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: false
}
// WHY: déplacer la zone source hors du preview rogné
// perdait les positions de la souris et l'état de dépôt.
// Le proxy vit dans la surface, la miniature reste en place.
Rectangle {
id: dragProxy
parent: keyboardRoot
z: 1000
width: 138; height: 34; radius: 3
opacity: windowPointer.drag.active ? 1 : 0
color: Theme.surface2
border.color: Theme.lavender; border.width: 1
Drag.active: windowPointer.drag.active
Drag.source: windowVisual
Drag.keys: ["labfy-workspace-window"]
Drag.hotSpot.x: width / 2
Drag.hotSpot.y: height / 2
Text {
anchors.fill: parent; anchors.margins: 6
text: windowVisual.modelData.app
color: Theme.foreground; font.pixelSize: 11
font.bold: true; elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
}
}
Text {
anchors.left: parent.left; anchors.right: parent.right; anchors.bottom: parent.bottom
height: Math.min(28, parent.height)
leftPadding: 3
text: windowVisual.modelData.app + " · " + windowVisual.modelData.title
color: Theme.foreground; font.pixelSize: 9; elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
Rectangle { anchors.fill: parent; z: -1; color: "#11111bc9" }
}
MouseArea {
id: windowPointer
anchors.fill: parent
drag.target: dragProxy
drag.threshold: 6
acceptedButtons: Qt.LeftButton | Qt.RightButton
onPressed: {
const point = windowVisual.mapToItem(keyboardRoot, 0, 0);
dragProxy.x = point.x;
dragProxy.y = point.y;
overview.selectedWorkspace = workspaceCard.number;
}
onClicked: mouse => {
overview.selectedWindow = windowVisual.index + 1;
if (mouse.button === Qt.RightButton) {
overview.moveTarget = workspaceCard.number === 10 ? 1 : workspaceCard.number + 1;
keyboardRoot.forceActiveFocus();
} else if (!drag.active)
overview.focusWindow(workspaceCard.number, windowVisual.conId);
}
onPositionChanged: mouse => {
if (!drag.active) return;
const point = windowPointer.mapToItem(keyboardRoot, mouse.x, mouse.y);
const target = overview.workspaceAt(point.x, point.y);
overview.dropTarget = target !== workspaceCard.number ? target : 0;
}
onReleased: mouse => {
if (drag.active) {
const point = windowPointer.mapToItem(keyboardRoot, mouse.x, mouse.y);
const target = overview.workspaceAt(point.x, point.y);
if (target && target !== workspaceCard.number)
overview.moveWindow(windowVisual.conId, target);
}
overview.dropTarget = 0;
}
onCanceled: overview.dropTarget = 0
}
}
}
Rectangle {
anchors.fill: parent; z: 2; radius: 3
color: "transparent"; border.color: Theme.overlay0
border.width: 1
}
Rectangle {
anchors.fill: parent; z: 3; radius: 3
visible: workspaceCard.dropHighlighted
// Qt lit #AARRGGBB : alpha faible, accent Lavender.
color: "#32b4befe"
border.color: Theme.lavender; border.width: 2
Text { anchors.centerIn: parent; text: "Déposer ici";
color: Theme.foreground; font.pixelSize: 12; font.bold: true }
}
}
Text {
id: provenance
text: workspaceCard.modelData.windows.length === 0
? "Aucune fenêtre"
: workspaceCard.modelData.snapshot
? (workspaceCard.modelData.visible ? "Capture · sortie visible" : "Dernière capture · fenêtre actualisée")
: workspaceCard.modelData.windows.some(window => window.preview)
? "Dernières images · fenêtres"
: "Géométrie · arbre Sway"
color: Theme.secondaryForeground; font.pixelSize: 10; elide: Text.ElideRight
width: parent.width
}
}
}
}
}
}
Rectangle {
id: footer
width: parent.width; height: 32; radius: 3
color: Theme.mantle; border.color: Theme.separator
Row {
anchors.left: parent.left; anchors.leftMargin: 11
anchors.verticalCenter: parent.verticalCenter
spacing: 13
visible: overview.moveTarget === 0
Row { spacing: 5
Text { text: "← ↑ ↓ →"; color: Theme.lavender; font.pixelSize: 11; font.bold: true }
Text { text: "naviguer"; color: Theme.secondaryForeground; font.pixelSize: 11 }
}
Row { spacing: 5
Text { text: "Tab"; color: Theme.lavender; font.pixelSize: 11; font.bold: true }
Text { text: "fenêtre"; color: Theme.secondaryForeground; font.pixelSize: 11 }
}
Row { spacing: 5
Text { text: "M"; color: Theme.lavender; font.pixelSize: 11; font.bold: true }
Text { text: "déplacer"; color: Theme.secondaryForeground; font.pixelSize: 11 }
}
Row { spacing: 5
Text { text: "Entrée"; color: Theme.lavender; font.pixelSize: 11; font.bold: true }
Text { text: "activer"; color: Theme.secondaryForeground; font.pixelSize: 11 }
visible: footer.width > 620
}
Text { text: "Clic droit ou glisser : déplacer"; color: Theme.secondaryForeground;
font.pixelSize: 11; visible: footer.width > 820 }
}
Text {
anchors.left: parent.left; anchors.leftMargin: 11
anchors.verticalCenter: parent.verticalCenter
visible: overview.moveTarget !== 0
text: "Déplacer vers " + overview.moveTarget + " · 1–9 ou 0=10 · Entrée confirmer"
color: Theme.accentForeground; font.pixelSize: 11; font.bold: true
}
Text {
anchors.right: parent.right; anchors.rightMargin: 11
anchors.verticalCenter: parent.verticalCenter
text: "Échap fermer"; color: Theme.secondaryForeground; font.pixelSize: 11
}
}
}
}
}
}

View file

@ -0,0 +1,390 @@
#!/usr/bin/env python3
"""Source bornée des cartes Sway et des captures de sorties visibles."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
import time
def sway(kind):
result = subprocess.run(["swaymsg", "-t", kind, "-r"], capture_output=True,
text=True, timeout=5, check=True)
return json.loads(result.stdout)
def cache_dir():
# CONTRACT: cache éphémère et privé ; aucune capture de bureau sur disque durable.
base = Path(os.environ.get("XDG_RUNTIME_DIR", tempfile.gettempdir()))
path = base / "labfy-workspace-overview"
path.mkdir(mode=0o700, exist_ok=True)
return path
def snapshot_path(output, number):
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", output)
return cache_dir() / f"{safe}-workspace-{number}.jpg"
def snapshot_metadata_path(output, number):
return snapshot_path(output, number).with_suffix(".json")
def window_preview_path(con_id):
return cache_dir() / f"window-{con_id}.jpg"
def window_preview_metadata_path(con_id):
return window_preview_path(con_id).with_suffix(".json")
def valid_window_preview(con_id, foreign_id):
# CONTRACT: le con_id donne l'identité Sway, l'identifiant foreign toplevel
# est celui attendu par grim -T. Leur égalité n'est jamais supposée.
if not isinstance(foreign_id, str) or not re.fullmatch(r"[0-9a-f]{32}", foreign_id):
return None
path = window_preview_path(con_id)
metadata_path = window_preview_metadata_path(con_id)
try:
image_stat = path.stat()
if image_stat.st_size == 0 or metadata_path.stat().st_size > 2048:
return None
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
if (metadata.get("version") != 1 or metadata.get("conId") != con_id
or metadata.get("foreignId") != foreign_id
or metadata.get("imageMtimeNs") != image_stat.st_mtime_ns
or not isinstance(metadata.get("capturedNs"), int)):
return None
return path.as_uri(), str(metadata["capturedNs"])
except (OSError, ValueError, AttributeError, TypeError, json.JSONDecodeError):
return None
def prune_window_previews(live_ids):
# INVARIANT: un fichier de fenêtre fermée ne peut ni devenir une fenêtre
# fantôme ni croître indéfiniment dans le cache de la session.
for path in cache_dir().glob("window-*.*"):
match = re.fullmatch(r"window-([0-9]+)\.(?:jpg|json)", path.name)
if match and int(match.group(1)) not in live_ids:
path.unlink(missing_ok=True)
# WHY: une interruption brutale peut laisser un JPEG temporaire. Une
# période de grâce protège les captures encore en cours dans un autre helper.
cutoff = time.time() - 600
for path in cache_dir().glob("tmp*.*"):
try:
if path.suffix in (".jpg", ".json") and path.stat().st_mtime < cutoff:
path.unlink(missing_ok=True)
except OSError:
pass
def structure_revision(node, workspace, output_rect):
# CONTRACT: seuls identité, hiérarchie, ordre, disposition et géométrie
# rendent un JPEG incompatible. Un titre ou un focus ne le font pas.
def geometry(item):
rect = item.get("rect") or {}
return [rect.get(key) for key in ("x", "y", "width", "height")]
def structure(item):
return [item.get("type"), item.get("id"), item.get("layout"),
item.get("floating"), geometry(item),
[structure(child) for child in item.get("nodes") or []],
[structure(child) for child in item.get("floating_nodes") or []]]
value = [workspace["id"], workspace["num"], workspace.get("output"),
geometry({"rect": output_rect}), structure(node)]
encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def forget_snapshot(output, number):
# INVARIANT: un workspace vide ne garde pas une ancienne image susceptible
# de réapparaître après une autre transition.
snapshot_metadata_path(output, number).unlink(missing_ok=True)
snapshot_path(output, number).unlink(missing_ok=True)
def forget_other_outputs(number, output):
# WHY: un workspace supprimé ou déplacé de sortie n'est plus atteignable
# par son ancien chemin de cache ; retirer uniquement ses anciens fichiers.
keep = {snapshot_path(output, number), snapshot_metadata_path(output, number)} if output else set()
for suffix in ("jpg", "json"):
for path in cache_dir().glob(f"*-workspace-{number}.{suffix}"):
if path not in keep:
path.unlink(missing_ok=True)
def valid_snapshot(output, number, workspace_id, revision):
path = snapshot_path(output, number)
metadata_path = snapshot_metadata_path(output, number)
try:
image_stat = path.stat()
if image_stat.st_size == 0 or metadata_path.stat().st_size > 2048:
return None
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
if (metadata.get("version") != 1 or metadata.get("output") != output
or metadata.get("number") != number
or metadata.get("workspaceId") != workspace_id
or metadata.get("revision") != revision
or metadata.get("imageMtimeNs") != image_stat.st_mtime_ns
or not isinstance(metadata.get("capturedNs"), int)):
return None
return path.as_uri(), str(metadata["capturedNs"])
except (OSError, ValueError, AttributeError, TypeError, json.JSONDecodeError):
return None
def windows_in(node):
found = []
def visit(item, floating=False):
app = item.get("app_id") or (item.get("window_properties") or {}).get("class")
# SwayFX publie une fenêtre flottante directement comme floating_con.
# Son wrapper possède le vrai con_id, app_id et rect ; l'ignorer ferait
# disparaître précisément les fenêtres flottantes de l'Overview.
if item.get("type") in ("con", "floating_con") and app:
rect = item.get("rect") or {}
if all(isinstance(rect.get(key), int) for key in ("x", "y", "width", "height")):
found.append({"id": item["id"], "foreignId": item.get("foreign_toplevel_identifier") or "",
"title": item.get("name") or app,
"app": app, "rect": rect, "focused": item.get("focused") is True,
"floating": floating or item.get("floating") in ("user_on", "auto_on"),
"layout": item.get("layout") or ""})
for child in item.get("nodes") or []:
visit(child, floating)
for child in item.get("floating_nodes") or []:
visit(child, True)
visit(node)
return found
def state():
tree = sway("get_tree")
workspaces = sway("get_workspaces")
outputs = sway("get_outputs")
by_number = {item["num"]: item for item in workspaces if item.get("num") in range(1, 11)}
output_info = {item["name"]: item for item in outputs if item.get("active")}
overlay_outputs = [item["name"] for item in outputs if item.get("active")
and any(surface.get("namespace") == "labfy-workspace-overview"
for surface in item.get("layer_shell_surfaces") or [])]
workspace_nodes = {}
def visit(item):
if item.get("type") == "workspace":
workspace_nodes[item.get("id")] = item
for child in (item.get("nodes") or []) + (item.get("floating_nodes") or []):
visit(child)
visit(tree)
cards = []
for number in range(1, 11):
workspace = by_number.get(number)
node = workspace_nodes.get(workspace["id"]) if workspace else None
output = workspace.get("output") if workspace else ""
forget_other_outputs(number, output)
windows = windows_in(node) if node else []
for window in windows:
preview = valid_window_preview(window["id"], window["foreignId"])
window["preview"] = preview[0] if preview else ""
window["previewTime"] = preview[1] if preview else ""
revision = (structure_revision(node, workspace, output_info.get(output, {}).get("rect"))
if node and workspace else "")
snapshot = valid_snapshot(output, number, workspace["id"], revision) if revision else None
if not windows:
snapshot = None
if output:
forget_snapshot(output, number)
cards.append({"number": number, "exists": bool(workspace),
"workspaceId": workspace["id"] if workspace else 0,
"revision": revision,
"visible": bool(workspace and workspace.get("visible")),
"focused": bool(workspace and workspace.get("focused")),
"output": output, "outputRect": output_info.get(output, {}).get("rect"),
"rect": node.get("rect") if node else None,
"layout": node.get("layout") if node else "",
"windows": windows,
"snapshot": snapshot[0] if snapshot else "",
# INVARIANT: l'URL QML change à chaque remplacement, même
# si deux captures surviennent pendant la même seconde.
"snapshotTime": snapshot[1] if snapshot else ""})
prune_window_previews({window["id"] for card in cards for window in card["windows"]})
return {"cards": cards, "outputs": [item["name"] for item in outputs if item.get("active")],
"focusedOutput": next((item["name"] for item in outputs if item.get("focused")), ""),
"overlayOutputs": overlay_outputs}
def capture(output, expected_number=None, expected_workspace_id=None,
expected_revision=None, settle_ms=0):
if settle_ms < 0 or settle_ms > 1000:
raise ValueError("Délai de capture invalide")
if settle_ms:
time.sleep(settle_ms / 1000)
before = state()
current = next((card for card in before["cards"] if card["visible"] and card["output"] == output), None)
if (output in before["overlayOutputs"] or not current or not current["windows"]
or (expected_number is not None and current["number"] != expected_number)
or (expected_workspace_id is not None and current["workspaceId"] != expected_workspace_id)
or (expected_revision is not None and current["revision"] != expected_revision)):
return {"captured": False}
path = snapshot_path(output, current["number"])
metadata_path = snapshot_metadata_path(output, current["number"])
# WHY: grim capture uniquement une sortie effectivement affichée. Écriture
# atomique pour qu'un Image QML ne voie jamais un JPEG partiel.
with tempfile.NamedTemporaryFile(dir=path.parent, suffix=".jpg", delete=False) as temp:
temporary = Path(temp.name)
try:
subprocess.run(["grim", "-o", output, "-s", "0.25", "-t", "jpeg", "-q", "72",
str(temporary)], check=True, timeout=8, capture_output=True)
if not temporary.is_file() or temporary.stat().st_size == 0:
raise RuntimeError("Capture vide")
after = state()
updated = next((card for card in after["cards"] if card["number"] == current["number"]), None)
# INVARIANT: un changement pendant grim ou la présence de l'Overview
# interdit de publier une image associée au mauvais arbre.
if (output in after["overlayOutputs"] or not updated or not updated["visible"]
or updated["output"] != output or updated["workspaceId"] != current["workspaceId"]
or updated["revision"] != current["revision"]):
return {"captured": False}
temporary.replace(path)
metadata = {"version": 1, "output": output, "number": current["number"],
"workspaceId": current["workspaceId"], "revision": current["revision"],
"capturedNs": time.time_ns(), "imageMtimeNs": path.stat().st_mtime_ns}
# WHY: le manifeste est publié après le JPEG ; un lecteur concurrent
# n'associe jamais l'ancienne révision aux nouveaux pixels.
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent,
suffix=".json", delete=False) as temp_meta:
json.dump(metadata, temp_meta, separators=(",", ":"))
temporary_meta = Path(temp_meta.name)
try:
temporary_meta.replace(metadata_path)
finally:
temporary_meta.unlink(missing_ok=True)
finally:
temporary.unlink(missing_ok=True)
return {"captured": True, "number": current["number"], "path": path.as_uri(),
"revision": current["revision"]}
def capture_windows(numbers, cursor=0):
if cursor < 0:
raise ValueError("Curseur de capture invalide")
before = state()
candidates = [window for card in before["cards"] if card["number"] in numbers
for window in card["windows"]
if re.fullmatch(r"[0-9a-f]{32}", window["foreignId"])]
# CONTRACT: une invocation capture au plus 16 fenêtres ; l'interface peut
# reprendre au curseur suivant sans créer de service ou de pool permanent.
batch = candidates[cursor:cursor + 16]
captures = []
try:
for window in batch:
with tempfile.NamedTemporaryFile(dir=cache_dir(), suffix=".jpg", delete=False) as temp:
temporary = Path(temp.name)
captures.append((window, temporary))
try:
result = subprocess.run(["grim", "-T", window["foreignId"], "-s", "0.25",
"-t", "jpeg", "-q", "72", str(temporary)],
capture_output=True, timeout=5, check=False)
if result.returncode != 0 or temporary.stat().st_size == 0:
temporary.unlink(missing_ok=True)
except (OSError, subprocess.TimeoutExpired):
temporary.unlink(missing_ok=True)
after = state()
current = {window["id"]: window for card in after["cards"] for window in card["windows"]}
published = []
for window, temporary in captures:
if not temporary.is_file() or current.get(window["id"], {}).get("foreignId") != window["foreignId"]:
continue
path = window_preview_path(window["id"])
metadata_path = window_preview_metadata_path(window["id"])
temporary.replace(path)
metadata = {"version": 1, "conId": window["id"], "foreignId": window["foreignId"],
"capturedNs": time.time_ns(), "imageMtimeNs": path.stat().st_mtime_ns}
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=cache_dir(),
suffix=".json", delete=False) as temp_meta:
json.dump(metadata, temp_meta, separators=(",", ":"))
temporary_meta = Path(temp_meta.name)
try:
temporary_meta.replace(metadata_path)
finally:
temporary_meta.unlink(missing_ok=True)
published.append(window["id"])
finally:
for _, temporary in captures:
temporary.unlink(missing_ok=True)
next_cursor = cursor + len(batch) if cursor + len(batch) < len(candidates) else 0
return {"captured": published, "nextCursor": next_cursor}
def command(action, number=None, con_id=None):
if number not in range(1, 11):
raise ValueError("Workspace invalide")
before = state() if action in ("move", "focus") else None
if action in ("move", "focus"):
# CONTRACT: seul un con_id d'une vraie fenêtre de l'arbre courant peut
# commander Sway. Aucun titre ni texte libre n'entre dans le critère.
known = {window["id"] for card in before["cards"] for window in card["windows"]}
if con_id not in known:
raise ValueError("Fenêtre absente de l'arbre Sway")
source_number = next((card["number"] for card in before["cards"]
if any(window["id"] == con_id for window in card["windows"])), 0) if before else 0
if action == "move":
args = [f"[con_id={con_id}]", "move", "container", "to", "workspace", "number", str(number)]
elif action == "focus":
args = ["workspace", "number", str(number), ";", f"[con_id={con_id}]", "focus"]
else:
args = ["workspace", "number", str(number)]
result = subprocess.run(["swaymsg", "-r", *args], capture_output=True, text=True,
timeout=5, check=True)
responses = json.loads(result.stdout)
if not all(item.get("success") for item in responses):
raise RuntimeError(result.stdout)
after = state()
changed = ([card["number"] for old, card in zip(before["cards"], after["cards"])
if old.get("revision") != card.get("revision")] if before else [])
return {"ok": True, "state": after, "source": source_number if action == "move" else 0,
"destination": number if action == "move" else 0, "changedWorkspaces": changed}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("action", choices=("state", "capture", "capture-windows", "move", "focus", "workspace"))
parser.add_argument("--output")
parser.add_argument("--number", type=int)
parser.add_argument("--con-id", type=int)
parser.add_argument("--workspace-id", type=int)
parser.add_argument("--revision")
parser.add_argument("--settle-ms", type=int, default=0)
parser.add_argument("--numbers")
parser.add_argument("--cursor", type=int, default=0)
args = parser.parse_args()
if args.action == "state":
result = state()
elif args.action == "capture":
if not args.output or args.output not in state()["outputs"]:
raise ValueError("Sortie inactive")
if args.revision is not None and not re.fullmatch(r"[0-9a-f]{64}", args.revision):
raise ValueError("Révision de capture invalide")
result = capture(args.output, args.number, args.workspace_id, args.revision, args.settle_ms)
elif args.action == "capture-windows":
if not args.numbers or not re.fullmatch(r"(?:10|[1-9])(?:,(?:10|[1-9]))*", args.numbers):
raise ValueError("Liste de workspaces invalide")
result = capture_windows(set(map(int, args.numbers.split(","))), args.cursor)
else:
result = command(args.action, args.number, args.con_id)
print(json.dumps(result, ensure_ascii=False, separators=(",", ":")))
if __name__ == "__main__":
try:
main()
except (ValueError, RuntimeError, subprocess.SubprocessError, OSError, json.JSONDecodeError) as error:
print(json.dumps({"error": str(error)}, ensure_ascii=False))
sys.exit(1)

View file

@ -1,10 +1,45 @@
//@ pragma UseQApplication //@ pragma UseQApplication
import QtQuick
import Quickshell import Quickshell
import Quickshell.Io import Quickshell.Io
import Quickshell.I3
import "notifications" import "notifications"
import "theme" import "theme"
ShellRoot { ShellRoot {
id: shell
property bool resizeMode: false
IpcHandler {
target: "barMode"
function active(): bool { return shell.resizeMode; }
}
// CONTRACT: un seul abonnement mode pour toutes les barres ; les événements
// IPC reflètent aussi les changements déclenchés hors du clavier.
I3IpcListener {
subscriptions: ["mode"]
onIpcEvent: event => {
if (event.type !== "mode") return;
try { shell.resizeMode = JSON.parse(event.data).change === "resize"; }
catch (error) { console.warn("Événement mode Sway invalide", error); }
}
}
// WHY: l'abonnement ne rejoue pas le mode courant après un redémarrage.
// Une requête ponctuelle au démarrage et à la reconnexion réconcilie l'état.
Process {
id: modeSnapshot
command: ["swaymsg", "-t", "get_binding_state"]
stdout: StdioCollector { id: modeSnapshotOutput; waitForEnd: true }
onExited: (code, status) => {
if (code !== 0) return;
try { shell.resizeMode = JSON.parse(modeSnapshotOutput.text).name === "resize"; }
catch (error) { console.warn("État mode Sway invalide", error); }
}
}
Component.onCompleted: modeSnapshot.running = true
Connections {
target: I3
function onConnected() { if (!modeSnapshot.running) modeSnapshot.running = true; }
}
// CONTRACT: seul ce point d'entrée reçoit les changements live du backend. // CONTRACT: seul ce point d'entrée reçoit les changements live du backend.
// Aucun état de thème n'est écrit par Theme.qml. // Aucun état de thème n'est écrit par Theme.qml.
IpcHandler { IpcHandler {
@ -33,6 +68,7 @@ ShellRoot {
model: Quickshell.screens model: Quickshell.screens
Bar { Bar {
resizeMode: shell.resizeMode
notificationService: rootNotificationService notificationService: rootNotificationService
// Une seule barre possède le chooser, même en configuration multi-écran. // Une seule barre possède le chooser, même en configuration multi-écran.
startupHost: Quickshell.screens.length > 0 startupHost: Quickshell.screens.length > 0

View file

@ -3,6 +3,7 @@ import Quickshell.Io
import Quickshell.Services.UPower import Quickshell.Services.UPower
import "../components" import "../components"
import "../theme" import "../theme"
import "StatusColorRoles.js" as StatusColorRoles
Item { Item {
id: indicator id: indicator
@ -33,7 +34,7 @@ Item {
+ Math.floor(remaining / 3600) + " h " + String(Math.floor(remaining % 3600 / 60)).padStart(2, "0") : "") + Math.floor(remaining / 3600) + " h " + String(Math.floor(remaining % 3600 / 60)).padStart(2, "0") : "")
+ (battery.healthSupported ? "\nSanté : " + Math.round(battery.healthPercentage * 100) + " %" : "") + (battery.healthSupported ? "\nSanté : " + Math.round(battery.healthPercentage * 100) + " %" : "")
+ (configuredThreshold >= 0 ? "\nLimite configurée : " + configuredThreshold + " %" : "") + (configuredThreshold >= 0 ? "\nLimite configurée : " + configuredThreshold + " %" : "")
width: available ? icon.width + value.implicitWidth + 10 : 26 width: content.width
height: 26 height: 26
FileView { FileView {
@ -44,32 +45,21 @@ Item {
} }
Rectangle { anchors.fill: parent; radius: 4; color: pointer.containsMouse ? Theme.border : "transparent" } Rectangle { anchors.fill: parent; radius: 4; color: pointer.containsMouse ? Theme.border : "transparent" }
NerdIcon { PercentIndicatorContent {
id: icon id: content
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. // Les seuils 30/15 % sont visuels ; ils ne modifient aucune politique UPower.
text: !indicator.available ? "󰂑" glyph: !indicator.available ? "󰂑"
: indicator.charging ? "󰂄" : indicator.charging ? "󰂄"
: indicator.full ? "󰁹" : indicator.full ? "󰁹"
: indicator.percent <= 15 ? "󰁺" : indicator.percent <= 15 ? "󰁺"
: indicator.percent <= 30 ? "󰁻" : indicator.percent <= 30 ? "󰁻"
: indicator.percent <= 50 ? "󰁽" : indicator.percent <= 50 ? "󰁽"
: indicator.percent <= 75 ? "󰁿" : "󰁹" : indicator.percent <= 75 ? "󰁿" : "󰁹"
color: indicator.percent <= 15 ? Theme.danger available: indicator.available
: indicator.percent <= 30 ? Theme.urgentForeground : Theme.foreground percent: indicator.percent
} tint: Theme[StatusColorRoles.battery(indicator.available, indicator.percent)]
Text { valueTint: Theme[StatusColorRoles.batteryValue(indicator.available, indicator.percent)]
id: value glyphPixelSize: 18
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 { MouseArea {
id: pointer id: pointer

View file

@ -1,6 +1,7 @@
import QtQuick import QtQuick
import "../components" import "../components"
import "../theme" import "../theme"
import "StatusColorRoles.js" as StatusColorRoles
Item { Item {
id: indicator id: indicator
@ -20,11 +21,12 @@ Item {
Rectangle { anchors.fill: parent; radius: 4; color: pointer.containsMouse ? Theme.border : "transparent" } Rectangle { anchors.fill: parent; radius: 4; color: pointer.containsMouse ? Theme.border : "transparent" }
NerdIcon { NerdIcon {
anchors.centerIn: parent anchors.centerIn: parent
// WHY: la silhouette Bluetooth est étroite ; seul le dessin grandit, pas la hitbox. // WHY: le glyphe Bluetooth remplit toute la hauteur de sa police ;
font.pixelSize: 22 // 18 px l'aligne visuellement sur les autres statuts sans changer sa cible.
font.pixelSize: 18
text: indicator.adapter && indicator.adapter.enabled ? "" : "󰂲" text: indicator.adapter && indicator.adapter.enabled ? "" : "󰂲"
color: !indicator.adapter || !indicator.adapter.enabled ? Theme.secondaryForeground // L'infobulle conserve la distinction appareil connecté / radio active.
: indicator.connectedDevices.length > 0 ? Theme.accent : Theme.foreground color: Theme[StatusColorRoles.bluetooth(!!indicator.adapter && indicator.adapter.enabled)]
} }
MouseArea { MouseArea {
id: pointer id: pointer

View file

@ -0,0 +1,38 @@
import QtQuick
import "../components"
import "../theme"
Item {
id: indicator
required property bool open
signal toggled()
width: 26
height: 26
// CONTRACT: ce témoin ne possède aucun état d'historique ; la barre reste
// l'unique propriétaire du LazyLoader et de l'ouverture par IPC ou clic.
Rectangle {
anchors.fill: parent
radius: 4
color: indicator.open ? Theme.accent
: pointer.containsMouse ? Theme.border : "transparent"
}
NerdIcon {
anchors.centerIn: parent
text: "󰅇"
font.pixelSize: 24
color: indicator.open ? Theme.onAccent : Theme.pink
}
MouseArea {
id: pointer
anchors.fill: parent
acceptedButtons: Qt.LeftButton
hoverEnabled: true
onClicked: indicator.toggled()
}
StatusTooltip {
target: indicator
hovered: pointer.containsMouse && !indicator.open
message: "Presse-papiers"
}
}

View file

@ -2,6 +2,7 @@ import QtQuick
import Quickshell.Networking import Quickshell.Networking
import "../components" import "../components"
import "../theme" import "../theme"
import "StatusColorRoles.js" as StatusColorRoles
Item { Item {
id: indicator id: indicator
@ -25,14 +26,14 @@ Item {
anchors.centerIn: parent anchors.centerIn: parent
// WHY: md-wifi remplit mieux la hauteur visuelle que les quatre glyphes // WHY: md-wifi remplit mieux la hauteur visuelle que les quatre glyphes
// de niveau ; le pourcentage exact reste disponible dans le tooltip. // de niveau ; le pourcentage exact reste disponible dans le tooltip.
font.pixelSize: 26 font.pixelSize: 30
// CONTRACT: les glyphes connecté, déconnecté et off existent dans la // CONTRACT: les glyphes connecté, déconnecté et off existent dans la
// Nerd Font installée ; seul l'état connecté est simplifié dans la barre. // Nerd Font installée ; seul l'état connecté est simplifié dans la barre.
text: !Networking.wifiHardwareEnabled ? "󰖪" text: !Networking.wifiHardwareEnabled ? "󰖪"
: !Networking.wifiEnabled ? "󰖪" : !Networking.wifiEnabled ? "󰖪"
: !indicator.connectedNetwork ? "󰤯" : "󰖩" : !indicator.connectedNetwork ? "󰤯" : "󰖩"
color: !Networking.wifiHardwareEnabled ? Theme.danger color: Theme[StatusColorRoles.wifi(Networking.wifiHardwareEnabled,
: !Networking.wifiEnabled ? Theme.secondaryForeground : Theme.foreground Networking.wifiEnabled, !!indicator.connectedNetwork)]
} }
MouseArea { MouseArea {
id: pointer id: pointer

View file

@ -0,0 +1,45 @@
import QtQuick
import "../components"
import "../theme"
import "PercentPresentation.js" as PercentPresentation
Item {
id: content
required property string glyph
required property bool available
required property int percent
property color tint: Theme.foreground
// CONTRACT: l'accent colore le pictogramme ; la valeur reste du texte,
// sauf lorsqu'un indicateur transmet déjà une alerte par sa valeur.
property color valueTint: Theme.foreground
// Les glyphes Nerd Font ont des silhouettes différentes à taille égale.
property int glyphPixelSize: 22
readonly property int iconSlotWidth: 22
// Cinq caractères monospace à 12 px (« 100 % ») tiennent dans 38 px.
readonly property int valueSlotWidth: 38
readonly property int iconValueGap: 4
width: 2 + iconSlotWidth + iconValueGap + valueSlotWidth
height: 26
NerdIcon {
width: content.iconSlotWidth
height: parent.height
anchors.left: parent.left
anchors.leftMargin: 2
anchors.verticalCenter: parent.verticalCenter
text: content.glyph
font.pixelSize: content.glyphPixelSize
color: content.tint
}
Text {
anchors.left: parent.left
anchors.leftMargin: 2 + content.iconSlotWidth + content.iconValueGap
anchors.verticalCenter: parent.verticalCenter
width: content.valueSlotWidth
horizontalAlignment: Text.AlignLeft
text: PercentPresentation.label(content.available, content.percent)
color: content.valueTint
font.family: "JetBrainsMono Nerd Font Mono"
font.pixelSize: 12
}
}

View file

@ -0,0 +1,5 @@
// CONTRACT: les indicateurs système utilisent le même espace avant « % » et
// signalent explicitement l'absence de valeur sans afficher un faux zéro.
function label(available, percent) {
return available ? percent + " %" : "—";
}

View file

@ -4,6 +4,7 @@ import Quickshell.Io
import "../components" import "../components"
import "../controlcenter" import "../controlcenter"
import "../theme" import "../theme"
import "StatusColorRoles.js" as StatusColorRoles
Item { Item {
id: indicator id: indicator
@ -20,6 +21,13 @@ Item {
readonly property var devices: testState ? fakeDevices(testState) : readDevices() readonly property var devices: testState ? fakeDevices(testState) : readDevices()
readonly property int deviceCount: devices.length readonly property int deviceCount: devices.length
readonly property bool hasBusyDevice: devices.some(device => device.busy) readonly property bool hasBusyDevice: devices.some(device => device.busy)
readonly property bool hasErrorDevice: devices.some(device => Boolean(device.error))
readonly property var publicDeviceKeys: [
"actions", "busy", "display_name", "ejectable", "error", "filesystem",
"kind", "label", "mount_point", "mounted", "power_off_capable",
"removable", "runtime_id", "safe_remove_runtime_id", "size_bytes"
]
readonly property var allowedActions: ["mount", "unmount", "open", "safe-remove"]
visible: deviceCount > 0 visible: deviceCount > 0
width: visible ? 26 : 0 width: visible ? 26 : 0
@ -27,10 +35,26 @@ Item {
onDeviceCountChanged: if (deviceCount === 0) popup.visible = false onDeviceCountChanged: if (deviceCount === 0) popup.visible = false
function hasExactKeys(device, expectedKeys) {
const keys = Object.keys(device).sort();
if (keys.length !== expectedKeys.length) return false;
return keys.every((key, index) => key === expectedKeys[index]);
}
function validDevice(device) { function validDevice(device) {
return device !== null && typeof device === "object" return device !== null && typeof device === "object"
// CONTRACT: la vue ne tolère aucune extension implicite du protocole
// public, notamment une URI ou une identité matérielle MTP.
&& hasExactKeys(device, publicDeviceKeys)
&& typeof device.runtime_id === "string" && device.runtime_id.length > 0 && typeof device.runtime_id === "string" && device.runtime_id.length > 0
&& typeof device.drive_runtime_id === "string" && device.drive_runtime_id.length > 0 && typeof device.safe_remove_runtime_id === "string"
&& device.safe_remove_runtime_id.length > 0
&& (device.kind === "block" || device.kind === "mtp")
&& Array.isArray(device.actions)
&& device.actions.every((action, index) =>
typeof action === "string"
&& allowedActions.indexOf(action) !== -1
&& device.actions.indexOf(action) === index)
&& typeof device.display_name === "string" && typeof device.display_name === "string"
&& (device.label === null || typeof device.label === "string") && (device.label === null || typeof device.label === "string")
&& (device.filesystem === null || typeof device.filesystem === "string") && (device.filesystem === null || typeof device.filesystem === "string")
@ -51,7 +75,9 @@ Item {
const value = JSON.parse(raw); const value = JSON.parse(raw);
// CONTRACT: ignorer intégralement un snapshot d'un autre protocole ou // CONTRACT: ignorer intégralement un snapshot d'un autre protocole ou
// partiellement écrit ; aucune commande ne doit viser ses identifiants. // partiellement écrit ; aucune commande ne doit viser ses identifiants.
if (value.schema !== "labfy.removable-media" || value.version !== 1 if (value === null || typeof value !== "object"
|| !hasExactKeys(value, ["devices", "schema", "updated_at", "version"])
|| value.schema !== "labfy.removable-media" || value.version !== 2
|| !Array.isArray(value.devices) || !Number.isSafeInteger(value.updated_at) || !Array.isArray(value.devices) || !Number.isSafeInteger(value.updated_at)
|| value.updated_at < 0 || value.updated_at < 0
|| !value.devices.every(validDevice)) || !value.devices.every(validDevice))
@ -63,19 +89,41 @@ Item {
} }
} }
function fakeDevice(id, mounted, busy, error) { function fakeBlockDevice(busy, error) {
return { return {
runtime_id: "test-volume-" + id, runtime_id: "test-volume-usb",
drive_runtime_id: "test-drive-" + id, safe_remove_runtime_id: "test-drive-usb",
display_name: id === 1 ? "Clé USB de test" : "Disque de sauvegarde", kind: "block",
label: id === 1 ? "LABFY" : "ARCHIVES", actions: ["unmount", "open", "safe-remove"],
filesystem: id === 1 ? "exfat" : "ext4", display_name: "Clé USB de test",
size_bytes: id === 1 ? 64000000000 : 1000000000000, label: "LABFY",
mount_point: mounted ? "/run/media/test/" + (id === 1 ? "LABFY" : "ARCHIVES") : "", filesystem: "exfat",
mounted: mounted, size_bytes: 64000000000,
mount_point: "/run/media/test/LABFY",
mounted: true,
removable: true, removable: true,
ejectable: id === 1, ejectable: true,
power_off_capable: id === 2, power_off_capable: false,
busy: busy,
error: error
};
}
function fakeMtpDevice(busy, error) {
return {
runtime_id: "test-mtp-session",
safe_remove_runtime_id: "test-mtp-session",
kind: "mtp",
actions: ["unmount", "open", "safe-remove"],
display_name: "Stockage du téléphone",
label: "Téléphone Android",
filesystem: "mtp",
size_bytes: 128000000000,
mount_point: "/run/user/1000/gvfs/mtp:host=test",
mounted: true,
removable: true,
ejectable: false,
power_off_capable: false,
busy: busy, busy: busy,
error: error error: error
}; };
@ -83,18 +131,21 @@ Item {
function fakeDevices(mode) { function fakeDevices(mode) {
if (mode === "0") return []; if (mode === "0") return [];
if (mode === "1" || mode === "mounted" || mode === "1-mounted") if (mode === "USB") return [fakeBlockDevice(false, "")];
return [fakeDevice(1, true, false, "")]; if (mode === "MTP") return [fakeMtpDevice(false, "")];
if (mode === "unmounted" || mode === "1-unmounted") if (mode === "USB+MTP")
return [fakeDevice(1, false, false, "")]; return [fakeBlockDevice(false, ""), fakeMtpDevice(false, "")];
if (mode === "2" || mode === "busy-error" || mode === "2-busy-error") return [ if (mode === "busy") return [fakeMtpDevice(true, "")];
fakeDevice(1, true, true, ""), if (mode === "error")
fakeDevice(2, false, false, "Le support nécessite une vérification.") return [fakeBlockDevice(false, "Le support nécessite une vérification.")];
];
console.warn("LABFY_REMOVABLE_MEDIA_TEST_STATE inconnu :", mode); console.warn("LABFY_REMOVABLE_MEDIA_TEST_STATE inconnu :", mode);
return []; return [];
} }
function hasAction(device, action) {
return device.actions.indexOf(action) !== -1;
}
function formatSize(bytes) { function formatSize(bytes) {
if (bytes <= 0) return "Taille inconnue"; if (bytes <= 0) return "Taille inconnue";
const units = ["o", "Kio", "Mio", "Gio", "Tio"]; const units = ["o", "Kio", "Mio", "Gio", "Tio"];
@ -110,9 +161,10 @@ Item {
function runAction(action, device) { function runAction(action, device) {
// INVARIANT: les identifiants TEST_ONLY ne quittent jamais le renderer. // INVARIANT: les identifiants TEST_ONLY ne quittent jamais le renderer.
if (testState || actionProcess.running || device.busy) return; if (testState || actionProcess.running || device.busy || !hasAction(device, action))
return;
let target = device.runtime_id; let target = device.runtime_id;
if (action === "safe-remove") target = device.drive_runtime_id; if (action === "safe-remove") target = device.safe_remove_runtime_id;
// CONTRACT: le client est l'unique frontière de commande et reçoit un // CONTRACT: le client est l'unique frontière de commande et reçoit un
// argv direct. Les identifiants opaques ne traversent jamais un shell. // argv direct. Les identifiants opaques ne traversent jamais un shell.
actionProcess.command = [clientPath, action, target]; actionProcess.command = [clientPath, action, target];
@ -162,8 +214,8 @@ Item {
NerdIcon { NerdIcon {
anchors.centerIn: parent anchors.centerIn: parent
text: indicator.hasBusyDevice || actionProcess.running ? "󰔟" : "󰕓" text: indicator.hasBusyDevice || actionProcess.running ? "󰔟" : "󰕓"
color: indicator.hasBusyDevice || actionProcess.running color: Theme[StatusColorRoles.removable(indicator.hasErrorDevice,
? Theme.warningForeground : Theme.foreground indicator.hasBusyDevice || actionProcess.running)]
font.pixelSize: 18 font.pixelSize: 18
} }
@ -251,8 +303,11 @@ Item {
Text { Text {
width: parent.width width: parent.width
text: (modelData.label ? modelData.label + " • " : "") text: modelData.kind === "mtp"
+ (modelData.filesystem || "Système de fichiers inconnu") ? "Appareil MTP • " + indicator.formatSize(modelData.size_bytes)
: (modelData.label ? modelData.label + " • " : "")
+ (modelData.filesystem
|| "Système de fichiers inconnu")
+ " • " + indicator.formatSize(modelData.size_bytes) + " • " + indicator.formatSize(modelData.size_bytes)
color: Theme.secondaryForeground color: Theme.secondaryForeground
font.pixelSize: 11 font.pixelSize: 11
@ -262,6 +317,11 @@ Item {
Text { Text {
width: parent.width width: parent.width
text: modelData.busy ? "Opération en cours…" text: modelData.busy ? "Opération en cours…"
: modelData.kind === "mtp" && modelData.mounted
? (modelData.mount_point
? "Connexion ouverte sur " + modelData.mount_point
: "Connexion ouverte")
: modelData.kind === "mtp" ? "Connexion fermée"
: modelData.mounted ? "Monté sur " + modelData.mount_point : modelData.mounted ? "Monté sur " + modelData.mount_point
: "Non monté" : "Non monté"
color: modelData.busy ? Theme.warningForeground color: modelData.busy ? Theme.warningForeground
@ -271,6 +331,16 @@ Item {
elide: Text.ElideMiddle elide: Text.ElideMiddle
} }
Text {
visible: modelData.kind === "mtp"
&& indicator.hasAction(modelData, "safe-remove")
width: parent.width
text: "Fermer la connexion déconnecte proprement l’appareil de cette session."
color: Theme.secondaryForeground
font.pixelSize: 11
wrapMode: Text.Wrap
}
Text { Text {
visible: Boolean(modelData.error) visible: Boolean(modelData.error)
width: parent.width width: parent.width
@ -285,22 +355,30 @@ Item {
spacing: 6 spacing: 6
ActionButton { ActionButton {
visible: modelData.mounted visible: indicator.hasAction(modelData, "open")
label: "Ouvrir" label: "Ouvrir"
enabled: !indicator.testState && !modelData.busy enabled: !indicator.testState && !modelData.busy
&& !actionProcess.running && !actionProcess.running
onClicked: indicator.runAction("open", modelData) onClicked: indicator.runAction("open", modelData)
} }
ActionButton { ActionButton {
label: modelData.mounted ? "Démonter" : "Monter" visible: indicator.hasAction(modelData, "mount")
label: "Monter"
enabled: !indicator.testState && !modelData.busy enabled: !indicator.testState && !modelData.busy
&& !actionProcess.running && !actionProcess.running
onClicked: indicator.runAction( onClicked: indicator.runAction("mount", modelData)
modelData.mounted ? "unmount" : "mount", modelData)
} }
ActionButton { ActionButton {
visible: modelData.ejectable || modelData.power_off_capable visible: indicator.hasAction(modelData, "unmount")
label: "Retirer en sécurité" label: "Démonter"
enabled: !indicator.testState && !modelData.busy
&& !actionProcess.running
onClicked: indicator.runAction("unmount", modelData)
}
ActionButton {
visible: indicator.hasAction(modelData, "safe-remove")
label: modelData.kind === "mtp"
? "Fermer la connexion" : "Retirer en sécurité"
enabled: !indicator.testState && !modelData.busy enabled: !indicator.testState && !modelData.busy
&& !actionProcess.running && !actionProcess.running
onClicked: indicator.runAction("safe-remove", modelData) onClicked: indicator.runAction("safe-remove", modelData)

View file

@ -1,5 +1,7 @@
import QtQuick import QtQuick
import "../controlcenter" import "../controlcenter"
import "../theme"
import "StatusColorRoles.js" as StatusColorRoles
Row { Row {
id: area id: area
@ -7,24 +9,69 @@ Row {
required property var wifiDevice required property var wifiDevice
required property var adapter required property var adapter
required property bool controlCenterOpen required property bool controlCenterOpen
required property bool clipboardOpen
required property var audioBrightnessController
signal openPageRequested(int page) signal openPageRequested(int page)
signal toggleControlCenterRequested() signal toggleControlCenterRequested()
signal toggleRemovableMediaRequested() signal toggleRemovableMediaRequested()
signal toggleClipboardRequested()
function refreshBatteryThreshold() { batteryIndicator.refreshThreshold() } function refreshBatteryThreshold() { batteryIndicator.refreshThreshold() }
function closeRemovableMedia() { removableMediaIndicator.popupOpen = false } function closeRemovableMedia() { removableMediaIndicator.popupOpen = false }
function setRemovableMediaOpen(open) { removableMediaIndicator.popupOpen = open } function setRemovableMediaOpen(open) { removableMediaIndicator.popupOpen = open }
readonly property bool removableMediaOpen: removableMediaIndicator.popupOpen readonly property bool removableMediaOpen: removableMediaIndicator.popupOpen
spacing: 5 // INVARIANT: huit pixels de part et d'autre des deux séparateurs identiques.
spacing: 8
height: 26 height: 26
// CONTRACT: statuts, actions, puis tray en dernier ; callbacks inchangés.
Row {
spacing: 6
height: 26
NetworkIndicator { wifiDevice: area.wifiDevice; onActivated: area.openPageRequested(1) } NetworkIndicator { wifiDevice: area.wifiDevice; onActivated: area.openPageRequested(1) }
BluetoothIndicator { adapter: area.adapter; onActivated: area.openPageRequested(2) } BluetoothIndicator { adapter: area.adapter; onActivated: area.openPageRequested(2) }
BatteryIndicator { id: batteryIndicator; onActivated: area.openPageRequested(5) } BatteryIndicator { id: batteryIndicator; onActivated: area.openPageRequested(5) }
ScrollIcon {
glyph: area.audioBrightnessController.volumeMuted ? "󰝟" : "󰕾"
glyphColor: Theme[StatusColorRoles.volume(
area.audioBrightnessController.volumeAvailable,
area.audioBrightnessController.volumeMuted)]
glyphPixelSize: 28
label: "Volume"
percent: area.audioBrightnessController.volumePercent
available: area.audioBrightnessController.volumeAvailable
onAdjusted: steps => area.audioBrightnessController.adjustVolume(steps)
}
ScrollIcon {
glyph: "󰖨"
glyphColor: Theme.yellow
glyphPixelSize: 26
label: "Luminosité"
percent: area.audioBrightnessController.brightnessPercent
available: area.audioBrightnessController.brightnessAvailable
onAdjusted: steps => area.audioBrightnessController.adjustBrightness(steps)
}
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 1; height: 14
color: Theme.separator
}
Row {
spacing: 5
height: 26
UpdatesIndicator { } UpdatesIndicator { }
RemovableMediaIndicator { RemovableMediaIndicator {
id: removableMediaIndicator id: removableMediaIndicator
onToggleRequested: area.toggleRemovableMediaRequested() onToggleRequested: area.toggleRemovableMediaRequested()
} }
Tray { barWindow: area.barWindow } ClipboardIndicator { open: area.clipboardOpen; onToggled: area.toggleClipboardRequested() }
ControlCenterButton { open: area.controlCenterOpen; onToggled: area.toggleControlCenterRequested() } ControlCenterButton { open: area.controlCenterOpen; onToggled: area.toggleControlCenterRequested() }
} }
Rectangle {
visible: tray.implicitWidth > 0
anchors.verticalCenter: parent.verticalCenter
width: 1; height: 14
color: Theme.separator
}
Tray { id: tray; barWindow: area.barWindow }
}

View file

@ -0,0 +1,81 @@
import QtQuick
import Quickshell
import "../theme"
import "ScrollSteps.js" as ScrollSteps
Item {
id: iconButton
required property string glyph
required property string label
required property int percent
property int glyphPixelSize: 22
property color glyphColor: Theme.foreground
property bool available: false
signal adjusted(int steps)
// CONTRACT: la hitbox et la valeur partagent la même largeur stable que
// la batterie ; le texte débute à 4 px de l'icône, sans vide variable.
width: content.width
height: 26
property var scrollState: ({ remainder: 0, source: "" })
// CONTRACT: le chemin Qt et le diagnostic injecté traversent exactement
// la même accumulation et le même signal vers le contrôle existant.
function applyWheel(pixel, angle, inverted, phase, touchpad) {
if (!available) return;
const result = ScrollSteps.advance(scrollState,
pixel, angle, inverted, phase, touchpad);
scrollState = result;
if (result.steps) adjusted(result.steps);
return result;
}
Accessible.role: Accessible.Button
Accessible.name: label
Rectangle {
anchors.fill: parent
radius: 4
color: pointer.containsMouse ? Theme.border : "transparent"
}
PercentIndicatorContent {
id: content
glyph: iconButton.glyph
available: iconButton.available
percent: iconButton.percent
tint: iconButton.available ? iconButton.glyphColor : Theme.disabledForeground
valueTint: iconButton.available ? Theme.foreground : Theme.disabledForeground
glyphPixelSize: iconButton.glyphPixelSize
}
MouseArea {
id: pointer
anchors.fill: parent
acceptedButtons: Qt.NoButton
hoverEnabled: true
}
StatusTooltip {
target: iconButton
hovered: pointer.containsMouse
message: iconButton.available ? iconButton.label + " : " + iconButton.percent
+ " %\nDéfiler pour régler par pas de 1 %" : iconButton.label + " indisponible"
}
WheelHandler {
target: null
invertible: false
// Qt n'accepte que Mouse par défaut : activer explicitement TouchPad.
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
// WHY: le handler est limité à la largeur de cet indicateur ; aucun autre
// élément de la barre ne transforme un mouvement en réglage.
onWheel: event => {
// Qt Wayland peut exposer un geste à deux doigts sans pixelDelta ;
// les petits angleDelta restent donc classés comme touchpad.
const touchpad = event.pixelDelta.y !== 0
|| (event.device && event.device.type === PointerDevice.TouchPad)
|| (event.angleDelta.y !== 0 && Math.abs(event.angleDelta.y) < 120);
const result = iconButton.applyWheel(event.pixelDelta.y,
event.angleDelta.y, event.inverted, event.phase, touchpad);
if (Quickshell.env("LABFY_SCROLL_DEBUG") === "1")
console.log("Scroll", iconButton.label, "pixel", event.pixelDelta.y,
"angle", event.angleDelta.y, "phase", event.phase,
"inverted", event.inverted, "touchpad", touchpad,
"steps", result ? result.steps : 0);
}
}
}

View file

@ -0,0 +1,21 @@
// CONTRACT: pixelDelta et angleDelta décrivent le même événement : choisir
// une seule source. 120 unités angulaires valent un cran de souris ; le seuil
// pixel est une valeur initiale à calibrer sur le touchpad réel.
function advance(state, pixel, angle, inverted, phase, touchpad) {
if (phase === 3) return { remainder: 0, source: "", steps: 0 };
const source = pixel !== 0 ? "pixel" : "angle";
// WHY: sur le touchpad ASUS, le signe normalisé de V2 produisait
// l'action opposée au mouvement physique observé. Une seule correction
// s'applique ici, après la normalisation de QWheelEvent.inverted.
const delta = (source === "pixel" ? pixel : angle)
* (inverted ? -1 : 1) * (touchpad ? -1 : 1);
if (!delta) return { remainder: state.remainder, source: state.source, steps: 0 };
const threshold = source === "pixel" ? 32 : 120;
let remainder = state.source === source ? state.remainder : 0;
if (phase === 1 || (remainder !== 0 && Math.sign(remainder) !== Math.sign(delta)))
remainder = 0;
remainder += delta;
const count = Math.trunc(remainder / threshold);
const steps = count === 0 ? 0 : count;
return { remainder: remainder - steps * threshold, source: source, steps: steps };
}

View file

@ -0,0 +1,33 @@
// CONTRACT: ces fonctions choisissent des rôles de Theme, jamais des couleurs
// fixes ; les états matériels priment toujours sur l'accent décoratif.
function wifi(hardwareEnabled, enabled, connected) {
if (!hardwareEnabled) return "disabledForeground";
return enabled && connected ? "sky" : "secondaryForeground";
}
function bluetooth(active) {
return active ? "blue" : "disabledForeground";
}
function battery(available, percent) {
if (!available) return "disabledForeground";
if (percent <= 15) return "danger";
if (percent <= 30) return "urgentForeground";
return "green";
}
function batteryValue(available, percent) {
return available && percent > 30 ? "foreground" : battery(available, percent);
}
function volume(available, muted) {
return !available || muted ? "disabledForeground" : "mauve";
}
function updates(hasError, pending) {
return pending ? "secondaryForeground" : hasError ? "danger" : "peach";
}
function removable(hasError, busy) {
return hasError ? "danger" : busy ? "warningForeground" : "teal";
}

View file

@ -4,7 +4,7 @@ import Quickshell.Services.SystemTray
Row { Row {
id: tray id: tray
required property var barWindow required property var barWindow
spacing: 2 spacing: 4
// Le modèle natif suit les inscriptions et retraits D-Bus sans scrutation. // Le modèle natif suit les inscriptions et retraits D-Bus sans scrutation.
Repeater { Repeater {
model: SystemTray.items model: SystemTray.items

View file

@ -18,8 +18,10 @@ Item {
Rectangle { anchors.fill: parent; radius: 4; color: pointer.containsMouse ? Theme.border : "transparent" } Rectangle { anchors.fill: parent; radius: 4; color: pointer.containsMouse ? Theme.border : "transparent" }
IconImage { IconImage {
anchors.centerIn: parent anchors.centerIn: parent
width: 18 // CONTRACT: l'image fournie par l'application reste inchangée ; seul
height: 18 // son emplacement dans le slot de 26 px est harmonisé.
width: 20
height: 20
source: entry.item.icon source: entry.item.icon
opacity: entry.item.status === Status.Passive ? 0.7 : 1 opacity: entry.item.status === Status.Passive ? 0.7 : 1
} }

View file

@ -3,6 +3,7 @@ import Quickshell
import Quickshell.Io import Quickshell.Io
import "../components" import "../components"
import "../theme" import "../theme"
import "StatusColorRoles.js" as StatusColorRoles
Item { Item {
id: indicator id: indicator
@ -22,6 +23,7 @@ Item {
} }
} }
readonly property bool hasError: Boolean(state.error) readonly property bool hasError: Boolean(state.error)
readonly property bool pending: state.error === "Vérification en attente"
visible: hasError || state.totalUpdates > 0 visible: hasError || state.totalUpdates > 0
width: visible ? icon.width + (hasError ? 0 : count.implicitWidth + 3) + 6 : 0 width: visible ? icon.width + (hasError ? 0 : count.implicitWidth + 3) + 6 : 0
height: 26 height: 26
@ -52,12 +54,12 @@ Item {
id: icon id: icon
width: 18 width: 18
// WHY: le trait circulaire paraît plus petit que le glyphe batterie à 18 px. // WHY: le trait circulaire paraît plus petit que le glyphe batterie à 18 px.
font.pixelSize: 22 font.pixelSize: 26
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: 3 anchors.leftMargin: 3
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: indicator.hasError ? "" : "󰚰" text: indicator.hasError ? "" : "󰚰"
color: indicator.hasError ? Theme.urgentForeground : Theme.accent color: Theme[StatusColorRoles.updates(indicator.hasError, indicator.pending)]
} }
Text { Text {
id: count id: count
@ -66,7 +68,7 @@ Item {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: !indicator.hasError visible: !indicator.hasError
text: indicator.state.totalUpdates text: indicator.state.totalUpdates
color: Theme.accent color: Theme.foreground
font.family: "JetBrainsMono Nerd Font Mono" font.family: "JetBrainsMono Nerd Font Mono"
font.pixelSize: 12 font.pixelSize: 12
} }

View file

@ -113,6 +113,11 @@ Item {
function closeMenu() { function closeMenu() {
windowMenu.visible = false; windowMenu.visible = false;
} }
function openActiveMenu() {
if (!activeWindow) return false;
showMenu(activeButton, activeWindow);
return true;
}
function showMenu(item, windowInfo) { function showMenu(item, windowInfo) {
if (windowMenu.visible && menuWindow && menuWindow.id === windowInfo.id) { if (windowMenu.visible && menuWindow && menuWindow.id === windowInfo.id) {

View file

@ -23,6 +23,9 @@ QuickShell, lancé par `quickshell-labfy-sway.service`.
`autostart` lance une fois le listener IPC `autostart` lance une fois le listener IPC
`scripts/inactive-windows-transparency.py`, `limusic-app` et `autotiling`. `scripts/inactive-windows-transparency.py`, `limusic-app` et `autotiling`.
Ce listener réutilise les événements `window::floating` pour appliquer la
bordure serveur de 2 px lors des bascules IPC, puis restaure la bordure
antérieure au retour en mosaïque. Aucun second abonné permanent n'est lancé.
Cliphist, swayidle, wlsunset et QuickShell sont gérés par systemd user ; 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 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 animations à 0, les coins à 4, les ombres validées et le flou uniquement sur

View file

@ -107,7 +107,9 @@ bindsym $mod+v splitv
# Switch the current container between different layout styles # Switch the current container between different layout styles
bindsym $mod+s layout stacking bindsym $mod+s layout stacking
bindsym $mod+w layout tabbed # Super+W appartient à l'Overview ; conserver tabbed sur Super+Shift+W.
bindsym $mod+Shift+w layout tabbed
bindsym $mod+w exec ~/.config/sway/scripts/overview-quickshell
bindsym $mod+e layout toggle split bindsym $mod+e layout toggle split
# Make the current focus fullscreen # Make the current focus fullscreen
@ -212,7 +214,7 @@ bindsym $mod+Alt+p exec proton-pass
bindsym $mod+Alt+t exec ~/.local/bin/thunderbird-proton bindsym $mod+Alt+t exec ~/.local/bin/thunderbird-proton
bindsym $mod+Alt+y exec kitty -- sh -c "yazi" bindsym $mod+Alt+y exec kitty -- sh -c "yazi"
bindsym $mod+Alt+w exec gtk-launch chrome-hnpfjngllnobngcgfapefoaidbinmjnm-Default bindsym $mod+Alt+w exec gtk-launch chrome-hnpfjngllnobngcgfapefoaidbinmjnm-Default
bindsym $mod+Alt+v exec ~/.config/sway/scripts/copypast bindsym $mod+Alt+v exec ~/.config/sway/scripts/clipboard-quickshell
bindsym $mod+Alt+c exec --no-startup-id kitty --app-id=lardon-freecad-log --title="Lardon · FreeCAD autopush" --directory=/home/fy59/Documents/Lardon /home/fy59/Documents/Lardon/tools/lardon-freecad-launcher bindsym $mod+Alt+c exec --no-startup-id kitty --app-id=lardon-freecad-log --title="Lardon · FreeCAD autopush" --directory=/home/fy59/Documents/Lardon /home/fy59/Documents/Lardon/tools/lardon-freecad-launcher
bindsym $mod+Alt+s exec sqlitebrowser bindsym $mod+Alt+s exec sqlitebrowser
bindsym $mod+Alt+e exec sh -c 'cd /home/fy59/Documents/Investigations/Template/labfy-investigation && exec ./labfy-investigation' bindsym $mod+Alt+e exec sh -c 'cd /home/fy59/Documents/Investigations/Template/labfy-investigation && exec ./labfy-investigation'
@ -231,7 +233,7 @@ bindsym $mod+c exec codex-desktop
bindsym Shift+Print exec grim - | wl-copy -t image/png bindsym Shift+Print exec grim - | wl-copy -t image/png
# Print = zone -> presse-papier # Print = zone -> presse-papier
bindsym Print exec grim -g "$(slurp)" - | wl-copy -t image/png bindsym Print exec ~/.config/sway/scripts/screenshot-region
############################################### ###############################################

View file

@ -9,6 +9,11 @@
for_window [app_id="blueman-manager"] floating enable, resize set 40ppt 40ppt, move position center for_window [app_id="blueman-manager"] floating enable, resize set 40ppt 40ppt, move position center
for_window [app_id="Proton Pass"] floating enable, resize set 40ppt 40ppt, move position right top for_window [app_id="Proton Pass"] floating enable, resize set 40ppt 40ppt, move position right top
# WHY: le sélecteur portal doit flotter et rester centré.
# CONTRACT: seul l'app_id dédié au wrapper Yazi reçoit ce placement ;
# la bordure serveur garantit la même politique que les autres flottantes.
for_window [app_id="labfy-yazi-filechooser"] floating enable, border pixel 2, move position center
#================================================= #=================================================
# Lardon # Lardon
#================================================= #=================================================
@ -17,3 +22,9 @@ for_window [app_id="lardon-freecad-log"] floating enable
for_window [app_id="lardon-freecad-log"] resize set width 850 px height 360 px for_window [app_id="lardon-freecad-log"] resize set width 850 px height 360 px
for_window [app_id="lardon-freecad-log"] move position center for_window [app_id="lardon-freecad-log"] move position center
for_window [app_id="lardon-freecad-log"] border pixel 2 for_window [app_id="lardon-freecad-log"] border pixel 2
# WHY: les fenêtres CSD peuvent ignorer default_floating_border.
# CONTRACT: toute fenêtre créée flottante utilise une bordure serveur de 2 px,
# sans barre de titre ; client.* fournit Lavender au focus et Mocha hors focus.
# Les transitions ultérieures sont traitées par l'abonné IPC d'opacité existant.
for_window [floating] border pixel 2

View file

@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
# CONTRACT: seul l'écran focalisé reçoit toggle ; aucun contenu de presse-papiers ne traverse IPC.
output="$(swaymsg -t get_outputs -r | jq -r '.[] | select(.focused == true) | .name' | head -n 1)"
[[ -n "$output" ]] || exit 1
# Une relance ciblée de QuickShell laisse une courte fenêtre sans socket IPC.
for attempt in 1 2 3 4 5; do
if quickshell ipc -c labfy-sway call "clipboardUi-$output" toggle >/dev/null 2>&1; then
exit 0
fi
sleep 0.1
done
exit 1

View file

@ -2,14 +2,15 @@
# This script requires i3ipc-python package (install it from a system package manager # This script requires i3ipc-python package (install it from a system package manager
# or pip). # or pip).
# It makes inactive windows transparent. Use `transparency_val` variable to control # It makes inactive windows transparent and corrects borders on floating events.
# transparency strength in range of 0…1 or use the command line argument -o. # Use the command line argument -o to control inactive opacity in range 0…1.
import argparse import argparse
import json import json
import os import os
import signal import signal
import sys import sys
import time
from functools import partial from functools import partial
from pathlib import Path from pathlib import Path
@ -17,6 +18,10 @@ import i3ipc
STATE = Path(os.environ.get('XDG_STATE_HOME', Path.home() / '.local/state')) / 'labfy-appearance/effective.json' STATE = Path(os.environ.get('XDG_STATE_HOME', Path.home() / '.local/state')) / 'labfy-appearance/effective.json'
# INVARIANT: seules les bordures de mosaïque remplacées en flottant sont
# mémorisées, puis oubliées au retour en mosaïque ou à la fermeture.
previous_tiled_borders = {}
def inactive_opacity(): def inactive_opacity():
# CONTRACT: lecture uniquement sur événement IPC ou SIGUSR1 de la # CONTRACT: lecture uniquement sur événement IPC ou SIGUSR1 de la
@ -38,10 +43,38 @@ def apply_all(ipc):
def on_window(args, ipc, event): def on_window(args, ipc, event):
global focused_set global focused_set
# To get the workspace for a container, we need to have received its # WHY: Sway émet window::floating avant de finir le passage à csd de
# parents, so fetch the whole tree # certaines fenêtres. Attendre uniquement cette transition évite qu'une
# bordure corrigée aussitôt soit écrasée dans la même opération.
if event.change == 'floating' and event.container.floating in ('user_on', 'auto_on'):
time.sleep(0.05)
# L'arbre courant remplace l'instantané trop précoce de l'événement.
tree = ipc.get_tree() tree = ipc.get_tree()
# WHY: for_window ne se rejoue pas sur une transition IPC vers floating.
# CONTRACT: la bordure serveur ne s'applique qu'au conteneur devenu
# flottant ; sa décoration précédente revient lors du retour en mosaïque.
# INVARIANT: une bordure déjà correcte ne déclenche aucune commande, donc
# aucune réaction répétée à un éventuel événement causé par border.
if event.change == 'floating':
window = tree.find_by_id(event.container.id)
if window is not None and window.floating in ('user_on', 'auto_on'):
if window.border != 'pixel' or window.current_border_width != 2:
# L'instantané de l'événement garde la bordure d'entrée,
# même si l'arbre courant montre déjà csd en flottant.
previous_tiled_borders[window.id] = (
event.container.border, event.container.current_border_width)
window.command('border pixel 2')
elif event.container.id in previous_tiled_borders:
border, width = previous_tiled_borders.pop(event.container.id)
if border in ('normal', 'pixel') and isinstance(width, int) and 0 <= width <= 1000:
event.container.command(f'border {border} {width}')
elif border in ('none', 'csd'):
event.container.command(f'border {border}')
elif event.change == 'close':
previous_tiled_borders.pop(event.container.id, None)
args.opacity = inactive_opacity() args.opacity = inactive_opacity()
focused = tree.find_focused() focused = tree.find_focused()
if focused is None: if focused is None:

View file

@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
# CONTRACT: le raccourci vise la sortie focalisée ; aucun workspace n'est
# activé avant le clic de l'utilisateur dans l'Overview.
output="$(swaymsg -t get_outputs -r | jq -r '.[] | select(.focused == true) | .name' | head -n 1)"
[[ -n "$output" ]] || exit 1
for attempt in 1 2 3 4 5; do
if quickshell ipc -c labfy-sway call "overviewUi-$output" toggle >/dev/null 2>&1; then
exit 0
fi
sleep 0.1
done
exit 1

View file

@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
# WHY: la surface plein écran du presse-papiers prend le focus exclusif et
# peut faire échouer slurp sur SwayFX. La détruire avant de créer le sélecteur.
output="$(swaymsg -t get_outputs -r | jq -r '.[] | select(.focused == true) | .name' | head -n 1)"
if [[ -n "$output" ]]; then
target="clipboardUi-$output"
state="$(quickshell ipc -c labfy-sway call "$target" state 2>/dev/null || true)"
if [[ -n "$state" ]] && jq -e '.loaded == true' >/dev/null 2>&1 <<<"$state"; then
quickshell ipc -c labfy-sway call "$target" toggle >/dev/null
# CONTRACT: slurp ne démarre qu'une fois le layer-shell exclusif détruit.
for ((attempt = 0; attempt < 40; attempt++)); do
state="$(quickshell ipc -c labfy-sway call "$target" state 2>/dev/null || true)"
if [[ -n "$state" ]] && jq -e '.loaded == false' >/dev/null 2>&1 <<<"$state"; then
break
fi
sleep 0.05
done
[[ -n "$state" ]] && jq -e '.loaded == false' >/dev/null 2>&1 <<<"$state" || exit 1
fi
fi
# INVARIANT: Échap ou une géométrie vide ne lance ni grim ni wl-copy.
selector="$HOME/.local/libexec/labfy-slurp"
[[ -x "$selector" ]] || { echo "Sélecteur slurp corrigé absent : exécuter tools/build-slurp-keymap-guard" >&2; exit 1; }
geometry="$("$selector")" || exit 0
[[ -n "$geometry" ]] || exit 0
runtime="${XDG_RUNTIME_DIR:?}"
image="$(mktemp "$runtime/labfy-screenshot-XXXXXXXX.png")"
chmod 600 "$image"
trap 'rm -f -- "$image"' EXIT
grim -g "$geometry" "$image"
# CONTRACT: seul un PNG complet et non vide peut remplacer le presse-papiers.
[[ -s "$image" ]] || exit 1
python3 - "$image" <<'PY'
import sys
with open(sys.argv[1], 'rb') as image:
if image.read(8) != b'\x89PNG\r\n\x1a\n':
raise SystemExit(1)
PY
wl-copy --type image/png < "$image"

View file

@ -1,5 +1,5 @@
[Unit] [Unit]
Description=Gestion des supports amovibles USB et SD Description=Gestion des supports amovibles USB, SD et Android MTP
PartOf=wayland-session@sway.desktop.target PartOf=wayland-session@sway.desktop.target
After=wayland-session@sway.desktop.target After=wayland-session@sway.desktop.target

View file

@ -0,0 +1,71 @@
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
BACKEND = Path(__file__).resolve().parents[2] / "quickshell/.config/quickshell/labfy-sway/clipboard/backend.py"
PNG = subprocess.run(["magick", "-size", "1x1", "xc:red", "png:-"], capture_output=True, check=True).stdout
class BackendTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.db = str(self.root / "db")
self.bin = self.root / "bin"
self.bin.mkdir()
fake = self.bin / "wl-copy"
fake.write_text("#!/usr/bin/env python3\nimport os,sys,pathlib\n"
"pathlib.Path(os.environ['TEST_CLIP_BYTES']).write_bytes(sys.stdin.buffer.read())\n"
"pathlib.Path(os.environ['TEST_CLIP_MIME']).write_text(sys.argv[2])\n")
fake.chmod(0o700)
self.env = dict(os.environ, PATH=str(self.bin) + os.pathsep + os.environ["PATH"],
XDG_RUNTIME_DIR=str(self.root), TEST_CLIP_BYTES=str(self.root / "copied"),
TEST_CLIP_MIME=str(self.root / "mime"))
def store(self, data):
subprocess.run(["cliphist", "-db-path", self.db, "store"], input=data, check=True)
def action(self, action, ident=None, success=True):
argv = [sys.executable, str(BACKEND), action]
if ident is not None:
argv.append(str(ident))
argv.extend(["--db-path", self.db])
argv.extend(["--wl-copy-path", str(self.bin / "wl-copy")])
result = subprocess.run(argv, env=self.env, capture_output=True, text=True)
self.assertEqual(result.returncode == 0, success)
return json.loads(result.stdout)
def test_text_image_exact_delete_wipe_and_cache(self):
self.assertEqual(self.action("list")["items"], [])
payloads = [b"first", "accent é\nline\t$`".encode(), PNG]
for payload in payloads:
self.store(payload)
items = self.action("list")["items"]
self.assertEqual(len(items), 3)
self.assertTrue(items[0]["image"])
self.assertFalse(items[1]["image"])
self.action("select", items[1]["id"])
self.assertEqual((self.root / "copied").read_bytes(), payloads[1])
self.assertEqual((self.root / "mime").read_text(), "text/plain;charset=utf-8")
self.action("select", items[0]["id"])
self.assertEqual((self.root / "copied").read_bytes(), PNG)
self.assertEqual((self.root / "mime").read_text(), "image/png")
self.assertTrue(self.action("thumb", items[0]["id"])["path"].startswith("file:"))
self.action("clean")
self.assertEqual(list(self.root.glob("labfy-cliphist-panel-*/*.png")), [])
self.action("delete", items[1]["id"])
remaining = self.action("list")["items"]
self.assertEqual([x["id"] for x in remaining], [items[0]["id"], items[2]["id"]])
self.action("delete", "999999", success=False)
self.assertEqual(len(self.action("list")["items"]), 2)
self.action("wipe")
self.assertEqual(self.action("list")["items"], [])
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,204 @@
import json
import os
from pathlib import Path
import subprocess
import tempfile
import time
# Exécuter dans une session Wayland ; base et wl-copy restent privés au test.
REPO = Path(__file__).resolve().parents[2]
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
db = root / "db"
output = root / "copied"
mime = root / "mime"
fake = root / "wl-copy"
png = subprocess.run(["magick", "-size", "2x2", "xc:blue", "png:-"],
capture_output=True, check=True).stdout
for index in range(14):
subprocess.run(["cliphist", "-db-path", str(db), "store"],
input=("fixture-%02d" % index).encode(), check=True)
fake.write_text("#!/usr/bin/env python3\nimport os,sys,pathlib\n"
"pathlib.Path(os.environ['TEST_OUT']).write_bytes(sys.stdin.buffer.read())\n"
"pathlib.Path(os.environ['TEST_MIME']).write_text(sys.argv[2])\n")
fake.chmod(0o700)
qml = root / "shell.qml"
qml.write_text('''import QtQuick
import QtTest
import Quickshell
import Quickshell.Io
import "file:/home/fy59/.dotfiles/quickshell/.config/quickshell/labfy-sway/status" as S
import "file:/home/fy59/.dotfiles/quickshell/.config/quickshell/labfy-sway/clipboard" as C
ShellRoot {
id: root
PanelWindow {
id: bar
screen: Quickshell.screens[0]
anchors { top: true; right: true }
implicitWidth: 100
implicitHeight: 34
S.ClipboardIndicator { id: icon; open: loader.active; anchors.centerIn: parent;
onToggled: { if (loader.active) loader.item.dismiss(true); else loader.active = true; } }
}
LazyLoader { id: loader; active: false; C.ClipboardPanel { barWindow: bar; onCleanupFinished: loader.active = false } }
TestCase { id: tester; name: "Fixture"; when: false }
IpcHandler {
target: "clipboardMouseFixture"
function state(): string {
const p = loader.item;
return JSON.stringify({loaded: loader.active, visible: !!p && p.visible, focused: !!p && p.searchFocused,
results: p ? p.results.length : 0, images: p ? p.results.filter(x => x.image).length : 0,
thumbnail: p ? p.thumbnail : "", operation: p ? p.operation : "",
message: p ? p.message : "", selected: p ? p.selected : -1,
confirming: !!p && p.confirming, listY: p ? p.resultList.contentY : 0,
contentHeight: p ? p.resultList.contentHeight : 0, listHeight: p ? p.resultList.height : 0,
bounds: p ? {x:p.cardX,y:p.cardY,w:p.cardWidth,h:p.cardHeight} : null});
}
function clickIcon(): bool { tester.mouseClick(icon, 13, 13); return true; }
function clickItem(index: int): bool {
const item = loader.item.resultList.itemAtIndex(index);
if (!item) return false;
tester.mouseClick(item, 30, item.height / 2);
return true;
}
function clickClear(): bool {
const item = loader.item.clearActionItem;
tester.mouseClick(item, item.width / 2, item.height / 2);
return true;
}
function clickConfirm(): bool {
const item = loader.item.confirmActionItem;
tester.mouseClick(item, item.width / 2, item.height / 2);
return true;
}
function clickCancel(): bool {
const item = loader.item.cancelActionItem;
tester.mouseClick(item, item.width / 2, item.height / 2);
return true;
}
function clickRemove(): bool {
const item = loader.item.removeActionItem;
tester.mouseClick(item, item.width / 2, item.height / 2);
return true;
}
function wheel(): bool {
const item = loader.item.resultList;
tester.mouseWheel(item, item.width / 2, item.height / 2, 0, -120);
return true;
}
function clickOutside(): bool {
tester.mouseClick(loader.item.interactionRoot, 2, 2);
return true;
}
function refresh(): bool { loader.item.refresh(); return true; }
}
}
'''.replace("file:/home/fy59/.dotfiles", REPO.as_uri()))
environment = dict(os.environ, LABFY_CLIPHIST_TEST_DB=str(db),
LABFY_CLIPHIST_TEST_CACHE_DIR=str(root),
LABFY_CLIPHIST_TEST_WL_COPY=str(fake), TEST_OUT=str(output),
TEST_MIME=str(mime))
# CONTRACT: le chemin absolu injecté dans QML doit viser le collecteur factice,
# car un vrai wl-copy dans la session écraserait les données personnelles.
assert Path(environment["LABFY_CLIPHIST_TEST_WL_COPY"]).resolve() == fake.resolve()
assert fake.is_file() and os.access(fake, os.X_OK)
process = subprocess.Popen(["quickshell", "-p", str(qml)], env=environment,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
def call(name, *args):
result = subprocess.run(["quickshell", "ipc", "-p", str(qml), "call",
"clipboardMouseFixture", name, *map(str, args)],
capture_output=True, text=True)
if result.returncode:
raise AssertionError((name, result.stdout[:200], result.stderr[:200]))
return json.loads(result.stdout)
def wait_for(predicate, seconds=3):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
value = call("state")
if predicate(value):
return value
time.sleep(0.04)
raise AssertionError(("state timeout", call("state")))
try:
time.sleep(0.3)
assert call("clickIcon")
opened = wait_for(lambda state: state["loaded"] and state["focused"] and state["results"] == 14)
assert opened["bounds"]["w"] == 540 and opened["bounds"]["y"] == 46
if os.environ.get("CLIPBOARD_UI_CAPTURE"):
box = opened["bounds"]
# Le recadrage intérieur ne montre que la carte alimentée par les données factices.
geometry = f"{box['x'] + 2},{box['y'] + 2} {box['w'] - 4}x{box['h'] - 4}"
subprocess.run(["grim", "-g", geometry, os.environ["CLIPBOARD_UI_CAPTURE"]], check=True)
assert call("clickIcon")
wait_for(lambda state: not state["loaded"])
assert call("clickIcon")
wait_for(lambda state: state["results"] == 14)
assert call("wheel")
scrolled = wait_for(lambda state: state["listY"] > 0)
assert scrolled["contentHeight"] > scrolled["listHeight"]
time.sleep(0.4)
assert call("clickItem", 5)
wait_for(lambda state: not state["loaded"])
assert output.read_bytes() == b"fixture-08"
assert call("clickIcon")
wait_for(lambda state: state["results"] == 14)
assert call("clickRemove")
wait_for(lambda state: state["results"] == 13)
current = subprocess.run(["cliphist", "-db-path", str(db), "list"],
capture_output=True, check=True).stdout.splitlines()
assert len(current) == 13
assert call("clickClear")
assert call("state")["confirming"]
assert call("clickCancel")
assert not call("state")["confirming"]
assert len(subprocess.run(["cliphist", "-db-path", str(db), "list"],
capture_output=True, check=True).stdout.splitlines()) == 13
assert call("clickClear")
subprocess.run(["wtype", "-k", "Escape"], check=True)
wait_for(lambda state: not state["confirming"] and state["visible"])
assert call("clickClear")
assert call("clickIcon")
wait_for(lambda state: not state["loaded"])
assert call("clickIcon")
wait_for(lambda state: state["results"] == 13)
assert call("clickClear")
assert call("clickConfirm")
wait_for(lambda state: state["results"] == 0)
print("icon_click_toggle=true geometry=true focus=true mouse_selection=true click_restore=true"
" scroll=true delete=true purge_confirm=true purge_cancel=true escape_cancel=true"
" purge_forced_close=true empty=true")
subprocess.run(["cliphist", "-db-path", str(db), "store"], input=png, check=True)
assert call("refresh")
state = wait_for(lambda state: state["results"] == 1 and state["images"] == 1
and state["thumbnail"].startswith("file:"))
assert state["thumbnail"]
assert call("clickItem", 0)
wait_for(lambda state: not state["loaded"])
assert output.read_bytes() == png and mime.read_text() == "image/png"
print("image_mouse_restore=true image_mime=true")
assert call("clickIcon")
wait_for(lambda state: state["results"] == 1)
subprocess.run(["ydotool", "key", "28:1", "28:0"], check=True)
wait_for(lambda state: not state["loaded"])
assert output.read_bytes() == png and mime.read_text() == "image/png"
print("image_visible=true thumbnail=true image_keyboard_restore=true")
assert call("clickIcon")
wait_for(lambda state: state["results"] == 1)
assert call("clickOutside")
wait_for(lambda state: not state["loaded"])
assert not list(root.glob("labfy-cliphist-panel-*/*.png"))
print("outside_click_close=true")
finally:
process.terminate()
try:
log = process.communicate(timeout=4)[0]
except subprocess.TimeoutExpired:
process.kill()
log = process.communicate()[0]
errors = [line for line in log.splitlines() if "ERROR" in line]
if errors:
raise AssertionError("QuickShell reported errors: " + str(errors[-2:]))

View file

@ -0,0 +1,245 @@
import importlib.util
import json
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
BACKEND = Path(__file__).resolve().parents[2] / "quickshell/.config/quickshell/labfy-sway/overview/backend.py"
spec = importlib.util.spec_from_file_location("overview_backend", BACKEND)
backend = importlib.util.module_from_spec(spec)
spec.loader.exec_module(backend)
class OverviewBackendTests(unittest.TestCase):
def test_state_keeps_floating_and_excludes_layer_shell(self):
rect = {"x": 0, "y": 0, "width": 1000, "height": 700}
tiled = {"type": "con", "id": 10, "app_id": "native-app", "name": "Native",
"foreign_toplevel_identifier": "a" * 32,
"rect": rect, "nodes": [], "floating_nodes": []}
floating = {"type": "floating_con", "id": 11, "app_id": "xwayland-app",
"window_properties": {"class": "xwayland-app"}, "name": "Floating",
"rect": rect, "floating": "user_on", "nodes": [], "floating_nodes": []}
workspace = {"type": "workspace", "id": 4, "name": "1: travail", "rect": rect,
"layout": "splith", "nodes": [tiled], "floating_nodes": [floating]}
output = {"type": "output", "id": 3, "name": "eDP-1", "nodes": [workspace],
"floating_nodes": []}
tree = {"type": "root", "nodes": [output], "floating_nodes": []}
ws = [{"id": 4, "name": "1: travail", "num": 1, "output": "eDP-1",
"visible": True, "focused": True}]
outputs = [{"name": "eDP-1", "active": True, "focused": True, "rect": rect,
"layer_shell_surfaces": [{"namespace": "labfy-sway-bar"}]}]
with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}):
with patch.object(backend, "sway", side_effect=lambda kind: {
"get_tree": tree, "get_workspaces": ws, "get_outputs": outputs}[kind]):
result = backend.state()
self.assertEqual(len(result["cards"]), 10)
self.assertEqual([window["id"] for window in result["cards"][0]["windows"]], [10, 11])
self.assertTrue(result["cards"][0]["windows"][1]["floating"])
self.assertEqual(result["cards"][0]["windows"][0]["foreignId"], "a" * 32)
self.assertFalse(result["cards"][1]["exists"])
def test_move_uses_only_valid_con_id_and_workspace(self):
cards = [{"number": 1, "revision": "before", "windows": [{"id": 42}]},
{"number": 4, "revision": "destination", "windows": []}]
moved = [{"number": 1, "revision": "after", "windows": []},
{"number": 4, "revision": "after-destination", "windows": [{"id": 42}]}]
fake = subprocess.CompletedProcess([], 0, '[{"success":true}]', "")
with patch.object(backend, "state", side_effect=[{"cards": cards}, {"cards": moved},
{"cards": cards}, {"cards": cards}]), \
patch.object(backend.subprocess, "run", return_value=fake) as run:
result = backend.command("move", 4, 42)
self.assertTrue(result["ok"])
self.assertEqual((result["source"], result["destination"], result["changedWorkspaces"]),
(1, 4, [1, 4]))
self.assertEqual(run.call_args.args[0], ["swaymsg", "-r", "[con_id=42]",
"move", "container", "to", "workspace", "number", "4"])
with self.assertRaises(ValueError):
backend.command("move", 11, 42)
with self.assertRaises(ValueError):
backend.command("move", 4, 43)
self.assertEqual(run.call_count, 1)
def test_revision_changes_for_layout_geometry_and_move_but_not_title_or_focus(self):
rect = {"x": 0, "y": 0, "width": 1000, "height": 700}
window = {"type": "con", "id": 42, "app_id": "test", "name": "Original",
"focused": False, "rect": dict(rect), "nodes": [], "floating_nodes": []}
workspace = {"type": "workspace", "id": 4, "num": 1, "output": "eDP-1",
"layout": "splith", "rect": rect, "nodes": [window], "floating_nodes": []}
first = backend.structure_revision(workspace, workspace, rect)
window["name"] = "Nouveau titre"
window["focused"] = True
self.assertEqual(first, backend.structure_revision(workspace, workspace, rect))
workspace["layout"] = "tabbed"
self.assertNotEqual(first, backend.structure_revision(workspace, workspace, rect))
workspace["layout"] = "splith"
window["rect"] = {**rect, "width": 500}
self.assertNotEqual(first, backend.structure_revision(workspace, workspace, rect))
window["rect"] = rect
workspace["nodes"] = []
self.assertNotEqual(first, backend.structure_revision(workspace, workspace, rect))
def test_changed_workspace_rejects_old_snapshot_and_empty_workspace_removes_it(self):
rect = {"x": 0, "y": 0, "width": 1000, "height": 700}
window = {"type": "con", "id": 42, "app_id": "test", "name": "Test",
"rect": dict(rect), "nodes": [], "floating_nodes": []}
workspace = {"type": "workspace", "id": 4, "num": 1, "output": "eDP-1",
"layout": "splith", "rect": rect, "nodes": [window], "floating_nodes": []}
tree = {"type": "root", "nodes": [{"type": "output", "nodes": [workspace]}]}
workspaces = [{"id": 4, "num": 1, "output": "eDP-1", "visible": True, "focused": True}]
outputs = [{"name": "eDP-1", "active": True, "focused": True, "rect": rect}]
with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}), \
patch.object(backend, "sway", side_effect=lambda kind: {
"get_tree": tree, "get_workspaces": workspaces, "get_outputs": outputs}[kind]):
path = backend.snapshot_path("eDP-1", 1)
path.write_bytes(b"JPEG")
original = backend.structure_revision(workspace, workspaces[0], rect)
backend.snapshot_metadata_path("eDP-1", 1).write_text(json.dumps({
"version": 1, "output": "eDP-1", "number": 1, "workspaceId": 4,
"revision": original, "capturedNs": 123, "imageMtimeNs": path.stat().st_mtime_ns}))
self.assertNotEqual(backend.state()["cards"][0]["snapshot"], "")
window["name"] = "Titre changé"
self.assertNotEqual(backend.state()["cards"][0]["snapshot"], "")
window["rect"] = {**rect, "width": 500}
changed = backend.state()["cards"][0]
self.assertEqual(changed["snapshot"], "")
self.assertEqual([item["id"] for item in changed["windows"]], [42])
workspace["nodes"] = []
empty = backend.state()["cards"][0]
self.assertEqual(empty["snapshot"], "")
self.assertEqual(empty["windows"], [])
self.assertFalse(path.exists())
self.assertFalse(backend.snapshot_metadata_path("eDP-1", 1).exists())
def test_capture_rejects_overlay_and_mid_capture_change(self):
card = {"number": 1, "workspaceId": 4, "revision": "first", "visible": True,
"output": "eDP-1", "windows": [{"id": 42}]}
with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}):
with patch.object(backend, "state", return_value={
"cards": [card], "overlayOutputs": ["eDP-1"]}):
self.assertFalse(backend.capture("eDP-1")["captured"])
def fake_grim(args, **_kwargs):
Path(args[-1]).write_bytes(b"JPEG")
return subprocess.CompletedProcess(args, 0, "", "")
changed = {**card, "revision": "second"}
with patch.object(backend, "state", side_effect=[
{"cards": [card], "overlayOutputs": []},
{"cards": [changed], "overlayOutputs": []}]), \
patch.object(backend.subprocess, "run", side_effect=fake_grim):
self.assertFalse(backend.capture("eDP-1")["captured"])
self.assertFalse(backend.snapshot_path("eDP-1", 1).exists())
def test_capture_publishes_metadata_only_for_stable_visible_workspace(self):
card = {"number": 7, "workspaceId": 164, "revision": "stable", "visible": True,
"output": "eDP-1", "windows": [{"id": 42}]}
def fake_grim(args, **_kwargs):
Path(args[-1]).write_bytes(b"JPEG")
return subprocess.CompletedProcess(args, 0, "", "")
with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}), \
patch.object(backend, "state", return_value={"cards": [card], "overlayOutputs": []}), \
patch.object(backend.subprocess, "run", side_effect=fake_grim):
result = backend.capture("eDP-1", expected_number=7, expected_workspace_id=164)
self.assertTrue(result["captured"])
self.assertIsNotNone(backend.valid_snapshot("eDP-1", 7, 164, "stable"))
self.assertIsNone(backend.valid_snapshot("eDP-1", 7, 164, "changed"))
self.assertIsNone(backend.valid_snapshot("eDP-1", 7, 999, "stable"))
self.assertFalse(backend.capture("eDP-1", expected_number=8)["captured"])
self.assertFalse(backend.capture("eDP-1", expected_revision="changed")["captured"])
def test_removed_workspace_cleans_only_its_cache_entries(self):
tree = {"type": "root", "nodes": [], "floating_nodes": []}
outputs = [{"name": "eDP-1", "active": True, "focused": True}]
with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}), \
patch.object(backend, "sway", side_effect=lambda kind: {
"get_tree": tree, "get_workspaces": [], "get_outputs": outputs}[kind]):
obsolete = backend.snapshot_path("eDP-1", 8)
retained = backend.cache_dir() / "unrelated.txt"
obsolete.write_bytes(b"old")
retained.write_bytes(b"other")
backend.state()
self.assertFalse(obsolete.exists())
self.assertTrue(retained.exists())
def test_window_preview_survives_workspace_move_but_not_window_close(self):
rect = {"x": 0, "y": 0, "width": 1000, "height": 700}
window = {"type": "con", "id": 42, "app_id": "test", "name": "Test",
"foreign_toplevel_identifier": "a" * 32,
"rect": rect, "nodes": [], "floating_nodes": []}
source = {"type": "workspace", "id": 4, "num": 7, "output": "eDP-1",
"layout": "splith", "rect": rect, "nodes": [window], "floating_nodes": []}
destination = {"type": "workspace", "id": 5, "num": 8, "output": "eDP-1",
"layout": "splith", "rect": rect, "nodes": [], "floating_nodes": []}
tree = {"type": "root", "nodes": [source, destination]}
workspaces = [{"id": 4, "num": 7, "output": "eDP-1", "visible": True, "focused": True},
{"id": 5, "num": 8, "output": "eDP-1", "visible": False, "focused": False}]
outputs = [{"name": "eDP-1", "active": True, "focused": True, "rect": rect}]
with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}), \
patch.object(backend, "sway", side_effect=lambda kind: {
"get_tree": tree, "get_workspaces": workspaces, "get_outputs": outputs}[kind]):
path = backend.window_preview_path(42)
path.write_bytes(b"JPEG")
backend.window_preview_metadata_path(42).write_text(json.dumps({
"version": 1, "conId": 42, "foreignId": "a" * 32,
"capturedNs": 123, "imageMtimeNs": path.stat().st_mtime_ns}))
before = backend.state()["cards"]
self.assertNotEqual(before[6]["windows"][0]["preview"], "")
source["nodes"] = []
destination["nodes"] = [window]
moved = backend.state()["cards"]
self.assertEqual(moved[6]["windows"], [])
self.assertEqual(moved[7]["windows"][0]["preview"], path.as_uri())
destination["nodes"] = []
backend.state()
self.assertFalse(path.exists())
def test_individual_capture_uses_foreign_id_and_checks_window_lifetime(self):
foreign_id = "b" * 32
window = {"id": 42, "foreignId": foreign_id, "preview": ""}
before = {"cards": [{"number": 7, "windows": [window]}]}
closed = {"cards": [{"number": 7, "windows": []}]}
def fake_grim(args, **_kwargs):
Path(args[-1]).write_bytes(b"JPEG")
return subprocess.CompletedProcess(args, 0, b"", b"")
with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}), \
patch.object(backend.subprocess, "run", side_effect=fake_grim) as run:
with patch.object(backend, "state", side_effect=[before, before]):
result = backend.capture_windows({7})
self.assertEqual(result["captured"], [42])
self.assertEqual(run.call_args.args[0][0:3], ["grim", "-T", foreign_id])
self.assertIsNotNone(backend.valid_window_preview(42, foreign_id))
self.assertIsNone(backend.valid_window_preview(42, "c" * 32))
with patch.object(backend, "state", side_effect=[before, closed]):
result = backend.capture_windows({7})
self.assertEqual(result["captured"], [])
def test_individual_captures_are_batched(self):
windows = [{"id": ident, "foreignId": f"{ident:032x}", "preview": ""}
for ident in range(1, 18)]
cards = {"cards": [{"number": 7, "windows": windows}]}
def fake_grim(args, **_kwargs):
Path(args[-1]).write_bytes(b"JPEG")
return subprocess.CompletedProcess(args, 0, b"", b"")
with tempfile.TemporaryDirectory() as directory, patch.dict("os.environ", {"XDG_RUNTIME_DIR": directory}), \
patch.object(backend, "state", return_value=cards), \
patch.object(backend.subprocess, "run", side_effect=fake_grim) as run:
first = backend.capture_windows({7})
self.assertEqual(first["captured"], list(range(1, 17)))
self.assertEqual(first["nextCursor"], 16)
second = backend.capture_windows({7}, cursor=first["nextCursor"])
self.assertEqual(second["captured"], [17])
self.assertEqual(second["nextCursor"], 0)
self.assertEqual(run.call_count, 17)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,18 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const source = fs.readFileSync(path.resolve(__dirname,
"../../quickshell/.config/quickshell/labfy-sway/controlcenter/PercentMath.js"), "utf8");
const context = {};
vm.createContext(context);
vm.runInContext(source, context);
const bounded = context.bounded;
assert.equal(bounded(-1, 0), 0);
assert.equal(bounded(35 + 1, 0), 36);
assert.equal(bounded(101, 0), 100);
assert.equal(bounded(9, 10), 10);
assert.equal(bounded(93 + 1, 10), 94);
assert.equal(bounded(101, 10), 100);
console.log("PercentMath: PASS");

View file

@ -0,0 +1,14 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const source = fs.readFileSync(path.resolve(__dirname,
"../../quickshell/.config/quickshell/labfy-sway/status/PercentPresentation.js"), "utf8");
const context = {};
vm.createContext(context);
vm.runInContext(source, context);
for (const value of [9, 10, 99, 100])
assert.equal(context.label(true, value), `${value} %`);
assert.equal(context.label(false, 0), "—");
console.log("PercentPresentation: PASS");

View file

@ -0,0 +1,50 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const source = fs.readFileSync(path.resolve(__dirname,
"../../quickshell/.config/quickshell/labfy-sway/status/ScrollSteps.js"), "utf8");
const context = {};
vm.createContext(context);
vm.runInContext(source, context);
const advance = context.advance;
const empty = () => ({ remainder: 0, source: "" });
let state = empty();
for (const part of [3, 4, 5, 7, 8]) state = advance(state, part, 0, false, 2, false);
assert.equal(state.steps, 0);
state = advance(state, 5, 120, false, 2, false);
assert.equal(state.steps, 1); // pixelDelta a priorité, sans compter angleDelta.
assert.equal(state.remainder, 0);
state = advance(empty(), 0, 120, false, 0, false);
assert.equal(state.steps, 1); // Un cran de souris = un point.
state = advance(empty(), 0, -240, false, 0, false);
assert.equal(state.steps, -2);
state = advance(empty(), -32, 0, false, 2, true);
assert.equal(state.steps, 1); // Deltas ASUS normalisés : geste haut = hausse.
state = advance(empty(), 32, 0, false, 2, true);
assert.equal(state.steps, -1); // Geste bas = baisse.
state = advance(empty(), 32, 0, true, 2, true);
assert.equal(state.steps, 1); // inverted est traité une seule fois.
state = advance(empty(), 0, -120, false, 0, true);
assert.equal(state.steps, 1); // Touchpad à delta angulaire seul.
state = advance({ remainder: 20, source: "pixel" }, -16, 0, false, 2, false);
assert.equal(state.steps, 0);
assert.equal(state.remainder, -16); // Direction inversée : ancien reliquat abandonné.
state = advance({ remainder: 20, source: "pixel" }, 0, 120, false, 0, false);
assert.equal(state.steps, 1); // Changement de source : reliquat incomparable abandonné.
state = advance({ remainder: 20, source: "pixel" }, 0, 0, false, 3, false);
assert.equal(state.remainder, 0); // Fin de geste.
state = empty();
let total = 0;
for (let i = 0; i < 320; i++) {
state = advance(state, 1, 0, false, 2, false);
total += state.steps;
}
assert.equal(total, 10);
assert.equal(state.remainder, 0); // Dix points réguliers, sans accélération.
console.log("ScrollSteps: PASS");

View file

@ -0,0 +1,39 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const source = fs.readFileSync(path.resolve(__dirname,
"../../quickshell/.config/quickshell/labfy-sway/status/StatusColorRoles.js"), "utf8");
const roles = {};
vm.createContext(roles);
vm.runInContext(source, roles);
assert.equal(roles.wifi(true, true, true), "sky");
assert.equal(roles.wifi(true, true, false), "secondaryForeground");
assert.equal(roles.wifi(true, false, true), "secondaryForeground");
assert.equal(roles.wifi(false, true, true), "disabledForeground");
assert.equal(roles.bluetooth(true), "blue");
assert.equal(roles.bluetooth(false), "disabledForeground");
assert.equal(roles.battery(false, 60), "disabledForeground");
assert.equal(roles.battery(true, 15), "danger");
assert.equal(roles.battery(true, 16), "urgentForeground");
assert.equal(roles.battery(true, 30), "urgentForeground");
assert.equal(roles.battery(true, 31), "green");
assert.equal(roles.batteryValue(true, 15), "danger");
assert.equal(roles.batteryValue(true, 30), "urgentForeground");
assert.equal(roles.batteryValue(true, 31), "foreground");
assert.equal(roles.batteryValue(false, 0), "disabledForeground");
assert.equal(roles.volume(true, false), "mauve");
assert.equal(roles.volume(true, true), "disabledForeground");
assert.equal(roles.volume(false, false), "disabledForeground");
assert.equal(roles.updates(false, false), "peach");
assert.equal(roles.updates(true, false), "danger");
assert.equal(roles.updates(true, true), "secondaryForeground");
assert.equal(roles.removable(false, false), "teal");
assert.equal(roles.removable(false, true), "warningForeground");
assert.equal(roles.removable(true, true), "danger");
console.log("StatusColorRoles: PASS");

View file

@ -106,17 +106,23 @@ class MediaDaemonTests(unittest.TestCase):
state = daemon.state() state = daemon.state()
self.assertEqual(state["schema"], "labfy.removable-media") self.assertEqual(state["schema"], "labfy.removable-media")
self.assertEqual(state["version"], 1) self.assertEqual(state["version"], 2)
self.assertEqual(set(state), {"schema", "version", "devices", "updated_at"}) self.assertEqual(set(state), {"schema", "version", "devices", "updated_at"})
self.assertEqual( self.assertEqual(
set(state["devices"][0]), set(state["devices"][0]),
{ {
"runtime_id", "drive_runtime_id", "display_name", "label", "runtime_id", "safe_remove_runtime_id", "kind", "actions",
"display_name", "label",
"filesystem", "size_bytes", "mount_point", "mounted", "filesystem", "size_bytes", "mount_point", "mounted",
"removable", "ejectable", "power_off_capable", "busy", "error", "removable", "ejectable", "power_off_capable", "busy", "error",
}, },
) )
self.assertTrue(state["devices"][0]["mounted"]) self.assertTrue(state["devices"][0]["mounted"])
self.assertEqual(state["devices"][0]["kind"], "block")
self.assertEqual(
state["devices"][0]["safe_remove_runtime_id"],
"/org/freedesktop/UDisks2/drives/usb",
)
self.assertEqual(stat.S_IMODE(self.state_path.stat().st_mode), 0o600) self.assertEqual(stat.S_IMODE(self.state_path.stat().st_mode), 0o600)
self.assertEqual(json.loads(self.state_path.read_text()), state) self.assertEqual(json.loads(self.state_path.read_text()), state)

View file

@ -25,6 +25,10 @@ SPEC.loader.exec_module(media)
class IpcTests(unittest.TestCase): class IpcTests(unittest.TestCase):
def test_request_allowlist_and_shape(self): def test_request_allowlist_and_shape(self):
self.assertEqual(media.validate_request({"operation": "list"}), ("list", None)) self.assertEqual(media.validate_request({"operation": "list"}), ("list", None))
self.assertEqual(
media.validate_request({"operation": "mount", "runtime_id": "mtp:nonce:1"}),
("mount", "mtp:nonce:1"),
)
with self.assertRaises(media.RequestError): with self.assertRaises(media.RequestError):
media.validate_request({"operation": "format", "runtime_id": "/tmp/x"}) media.validate_request({"operation": "format", "runtime_id": "/tmp/x"})
with self.assertRaises(media.RequestError): with self.assertRaises(media.RequestError):
@ -70,6 +74,30 @@ class IpcTests(unittest.TestCase):
self.assertIn("volumineuse", response["error"]) self.assertIn("volumineuse", response["error"])
self.assertEqual(called, []) self.assertEqual(called, [])
def test_dispatcher_waits_for_async_completion(self):
pending = []
class GLib:
@staticmethod
def idle_add(callback):
callback()
dispatcher = media.GLibDispatcher(
GLib, lambda request, done: pending.append((request, done)) or (lambda: None)
)
result = []
thread = threading.Thread(
target=lambda: result.append(dispatcher({"operation": "list"}))
)
thread.start()
while not pending:
threading.Event().wait(0.001)
pending[0][1]({"ok": True})
thread.join(2)
self.assertFalse(thread.is_alive())
self.assertEqual(result, [{"ok": True}])
def test_slow_incomplete_request_times_out_without_calling_handler(self): def test_slow_incomplete_request_times_out_without_calling_handler(self):
called = [] called = []
server = media.JsonSocketServer(Path("unused"), lambda request: called.append(request)) server = media.JsonSocketServer(Path("unused"), lambda request: called.append(request))

View file

@ -0,0 +1,347 @@
import importlib.util
from importlib.machinery import SourceFileLoader
import json
import stat
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).parents[2] / "bin/.local/bin/labfy-removable-media"
SPEC = importlib.util.spec_from_loader(
"labfy_removable_media_mtp", SourceFileLoader("labfy_removable_media_mtp", str(SCRIPT))
)
assert SPEC and SPEC.loader
media = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = media
SPEC.loader.exec_module(media)
class FakeRoot:
def __init__(self, scheme="mtp", path=None, uri="mtp://fake-device/"):
self.scheme = scheme
self.path = path
self.uri = uri
def get_uri_scheme(self):
return self.scheme
def get_path(self):
return self.path
def get_uri(self):
return self.uri
def query_filesystem_info(self, _attributes, _cancellable):
return type("Info", (), {"get_attribute_uint64": lambda self, _name: 1234})()
class FakeMount:
def __init__(self, volume, path):
self.volume = volume
self.root = FakeRoot(path=path)
def get_root(self):
return self.root
def get_name(self):
return "Stockage du téléphone"
def unmount_with_operation(self, _flags, _operation, _cancellable, callback):
self.volume.pending = lambda: setattr(self.volume, "current_mount", None)
callback(self, object())
def unmount_with_operation_finish(self, _result):
self.volume.pending()
class FakeVolume:
def __init__(self, scheme="mtp", name="Téléphone"):
self.activation = FakeRoot(scheme=scheme)
self.name = name
self.current_mount = None
self.pending = None
self.mount_calls = 0
def get_activation_root(self):
return self.activation
def get_name(self):
return self.name
def get_mount(self):
return self.current_mount
def mount(self, _flags, _operation, _cancellable, callback):
self.mount_calls += 1
self.pending = lambda: setattr(
self, "current_mount", FakeMount(self, "/run/user/1000/gvfs/mtp:host=fake")
)
callback(self, object())
def mount_finish(self, _result):
self.pending()
class FakeMonitor:
def __init__(self, volumes):
self.volumes = volumes
self.callbacks = {}
def get_volumes(self):
return list(self.volumes)
def connect(self, signal, callback):
self.callbacks[signal] = callback
class FakeCancellable:
def __init__(self):
self.cancelled = False
def cancel(self):
self.cancelled = True
class FakeGio:
class Cancellable:
@staticmethod
def new():
return FakeCancellable()
class MountMountFlags:
NONE = 0
class MountUnmountFlags:
NONE = 0
class FakeGLib:
def __init__(self):
self.sources = {}
self.next_source = 0
def timeout_add_seconds(self, _delay, callback):
self.next_source += 1
self.sources[self.next_source] = callback
return self.next_source
def timeout_add(self, _delay, callback, *args):
self.next_source += 1
self.sources[self.next_source] = lambda: callback(*args)
return self.next_source
def source_remove(self, source):
self.sources.pop(source, None)
class MTPBackendTests(unittest.TestCase):
def backend(self, volumes):
return media.MTPBackend(FakeGio, FakeGLib(), FakeMonitor(volumes))
def test_classifies_only_activation_root_mtp_and_uses_opaque_ids(self):
mtp = FakeVolume("mtp")
backend = self.backend([mtp, FakeVolume("gphoto2"), FakeVolume(None)])
records = backend.records()
self.assertEqual(len(records), 1)
self.assertEqual(records[0].kind, "mtp")
self.assertTrue(records[0].path.startswith("mtp:"))
self.assertNotIn("Téléphone", records[0].path)
self.assertNotIn("mtp://", records[0].path)
self.assertEqual(records[0].actions(), ["mount"])
def test_mount_and_safe_remove_complete_async_with_real_root_path(self):
volume = FakeVolume()
backend = self.backend([volume])
runtime_id = backend.records()[0].path
completed = []
backend.operate_async(runtime_id, "mount", lambda path, error: completed.append((path, error)))
self.assertEqual(completed, [("/run/user/1000/gvfs/mtp:host=fake", None)])
mounted = backend.records()[0]
self.assertEqual(mounted.mount_point, "/run/user/1000/gvfs/mtp:host=fake")
self.assertEqual(mounted.display_name, "Stockage du téléphone")
self.assertEqual(mounted.actions(), ["unmount", "open", "safe-remove"])
completed.clear()
backend.operate_async(runtime_id, "safe-remove", lambda path, error: completed.append((path, error)))
self.assertEqual(completed, [("", None)])
self.assertIsNone(volume.get_mount())
def test_mounted_without_local_path_omits_only_open(self):
volume = FakeVolume()
volume.current_mount = FakeMount(volume, None)
record = self.backend([volume]).records()[0]
self.assertTrue(record.public()["mounted"])
self.assertEqual(record.mount_point, "")
self.assertEqual(record.actions(), ["unmount", "safe-remove"])
def test_disappearance_rejects_stale_runtime_id(self):
volume = FakeVolume()
backend = self.backend([volume])
runtime_id = backend.records()[0].path
backend.monitor.volumes = []
with self.assertRaisesRegex(media.RequestError, "introuvable"):
backend.operate_async(runtime_id, "mount", lambda *_args: None)
def test_new_wrappers_keep_id_and_live_wrapper_is_used_for_action(self):
shared = {"mount": None}
class RewrappedVolume(FakeVolume):
def get_mount(self):
return shared["mount"]
def mount(self, _flags, _operation, _cancellable, callback):
shared["mount"] = FakeMount(
self, "/run/user/1000/gvfs/mtp:host=fake"
)
callback(self, object())
def mount_finish(self, _result):
pass
class RewrappingMonitor(FakeMonitor):
def __init__(self):
super().__init__([])
self.calls = 0
self.last_wrapper = None
def get_volumes(self):
self.calls += 1
self.last_wrapper = RewrappedVolume(name=f"Wrapper {self.calls}")
return [self.last_wrapper]
monitor = RewrappingMonitor()
backend = media.MTPBackend(FakeGio, FakeGLib(), monitor)
first_id = backend.records()[0].path
second_id = backend.records()[0].path
completed = []
backend.operate_async(
first_id, "mount", lambda path, error: completed.append((path, error))
)
self.assertEqual(second_id, first_id)
self.assertGreaterEqual(monitor.calls, 3)
self.assertIsNotNone(monitor.last_wrapper.get_mount())
self.assertEqual(completed, [("/run/user/1000/gvfs/mtp:host=fake", None)])
def test_observed_removal_invalidates_id_before_same_uri_reinsertion(self):
first = FakeVolume()
monitor = FakeMonitor([first])
backend = media.MTPBackend(FakeGio, FakeGLib(), monitor)
backend.connect_changed(lambda: None)
first_id = backend.records()[0].path
monitor.callbacks["volume-removed"](monitor, first)
monitor.volumes = [FakeVolume()]
self.assertNotEqual(backend.records()[0].path, first_id)
def test_timeout_cancels_pending_mount_and_returns_bounded_french_error(self):
class PendingVolume(FakeVolume):
def mount(self, _flags, _operation, cancellable, _callback):
self.cancellable = cancellable
volume = PendingVolume()
backend = self.backend([volume])
runtime_id = backend.records()[0].path
completed = []
backend.operate_async(
runtime_id, "mount", lambda path, error: completed.append((path, error))
)
timeout = next(iter(backend.GLib.sources.values()))
self.assertFalse(timeout())
self.assertTrue(volume.cancellable.cancelled)
self.assertIsInstance(completed[0][1], media.RequestError)
self.assertIn("Délai d'opération MTP dépassé", str(completed[0][1]))
class CompositeBackend:
def __init__(self, mtp_backend):
self.mtp = mtp_backend
def records(self):
return self.mtp.records()
def operate_mtp_async(self, runtime_id, operation, callback):
return self.mtp.operate_async(runtime_id, operation, callback)
class MTPDaemonTests(unittest.TestCase):
def test_public_v2_snapshot_uses_opaque_id_and_authoritative_mount_path(self):
volume = FakeVolume(name="Téléphone de test")
raw_uri = "mtp://[usb:001,007]/raw-serial-ABC-123/"
volume.activation.uri = raw_uri
# CONTRACT: GVFS peut inclure une identité dans son chemin local ; ce
# chemin exact reste nécessaire à Yazi et le snapshot est privé (0600).
mount_path = "/run/user/1000/gvfs/mtp:host=raw-serial-ABC-123"
volume.current_mount = FakeMount(volume, mount_path)
with tempfile.TemporaryDirectory() as directory:
state_path = Path(directory) / media.STATE_NAME
daemon = media.MediaDaemon(
CompositeBackend(media.MTPBackend(FakeGio, FakeGLib(), FakeMonitor([volume]))),
state_path,
)
state = daemon.state()
device = state["devices"][0]
snapshot = state_path.read_text()
snapshot_mode = stat.S_IMODE(state_path.stat().st_mode)
self.assertEqual(state["version"], 2)
self.assertEqual(
set(device),
{
"runtime_id", "safe_remove_runtime_id", "kind", "actions",
"display_name", "label", "filesystem", "size_bytes",
"mount_point", "mounted", "removable", "ejectable",
"power_off_capable", "busy", "error",
},
)
self.assertEqual(device["kind"], "mtp")
self.assertEqual(device["actions"], ["unmount", "open", "safe-remove"])
self.assertTrue(device["mounted"])
self.assertTrue(device["runtime_id"].startswith("mtp:"))
self.assertEqual(device["runtime_id"], device["safe_remove_runtime_id"])
self.assertEqual(device["mount_point"], mount_path)
self.assertNotIn("mtp://", device["runtime_id"])
self.assertNotIn("raw-serial-ABC-123", device["runtime_id"])
self.assertNotIn(raw_uri, json.dumps(state, ensure_ascii=False))
self.assertIn(mount_path, snapshot)
self.assertEqual(snapshot_mode, 0o600)
def test_manual_unmount_is_not_automounted_again(self):
volume = FakeVolume()
monitor = FakeMonitor([volume])
mtp = media.MTPBackend(FakeGio, FakeGLib(), monitor)
with tempfile.TemporaryDirectory() as directory:
daemon = media.MediaDaemon(
CompositeBackend(mtp), Path(directory) / media.STATE_NAME
)
mtp.connect_changed(daemon.devices_changed)
daemon.start()
runtime_id = daemon.state()["devices"][0]["runtime_id"]
self.assertEqual(volume.mount_calls, 1)
replies = []
daemon.handle_async(
{"operation": "unmount", "runtime_id": runtime_id}, replies.append
)
monitor.callbacks["mount-changed"](monitor, None)
self.assertEqual(len(replies), 1)
self.assertFalse(daemon.state()["devices"][0]["mounted"])
self.assertEqual(volume.mount_calls, 1)
self.assertIn("volume-changed", monitor.callbacks)
self.assertIn("mount-changed", monitor.callbacks)
if __name__ == "__main__":
unittest.main()

22
tools/build-slurp-keymap-guard Executable file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
# CONTRACT: build the pinned upstream release with the narrow keymap guard;
# install only in the user's libexec, never over the system package.
repo="$(cd -- "$(dirname -- "$0")/.." && pwd)"
source_dir="${XDG_CACHE_HOME:-$HOME/.cache}/labfy-slurp-v1.5.0"
binary="$HOME/.local/libexec/labfy-slurp"
if [[ ! -d "$source_dir/.git" ]]; then
git clone --depth 1 --branch v1.5.0 https://github.com/emersion/slurp.git "$source_dir"
fi
[[ "$(git -C "$source_dir" rev-parse HEAD)" == fc921b603ee02afff42aba9eb073e82fab900048 ]] || exit 1
if ! git -C "$source_dir" diff --quiet -- main.c; then
git -C "$source_dir" apply --reverse --check "$repo/tools/slurp-keymap-guard.patch"
else
patch -d "$source_dir" -p1 < "$repo/tools/slurp-keymap-guard.patch"
fi
meson setup "$source_dir/build" "$source_dir" -Dman-pages=disabled --reconfigure 2>/dev/null || \
meson setup "$source_dir/build" "$source_dir" -Dman-pages=disabled
ninja -C "$source_dir/build"
install -Dm755 "$source_dir/build/slurp" "$binary"
printf '%s\n' "$binary"

View file

@ -0,0 +1,64 @@
# Maintainer: Caleb Maclennan <caleb@alerque.com>
pkgname=quickshell
pkgver=0.3.1
pkgrel=1.1
pkgdesc='Flexible toolkit for making desktop shells with QtQuick'
arch=(x86_64 aarch64)
url="https://git.outfoxxed.me/$pkgname/$pkgname"
license=(LGPL-3.0-only)
depends=(cpptrace libcpptrace.so
glibc # libc.so libm.so
hicolor-icon-theme
jemalloc libjemalloc.so
libdrm # libdrm.so
libgcc libgcc_s.so
libglvnd libEGL.so libOpenGL.so
libpipewire libpipewire-0.3.so
libstdc++ libstdc++.so
libxcb # libxcb.so
mesa # libgbm.so
pam libpam.so
polkit
qt6-base # libQt6Core.so libQt6DBus.so libQt6Gui.so libQt6Network.so libQt6Widgets.so
qt6-declarative # libQt6Qml.so libQt6Quick.so
qt6-svg
qt6-wayland # libQt6WaylandClient.so
wayland libwayland-client.so)
makedepends=(cli11
cmake
ninja
qt6-shadertools
spirv-tools
vulkan-headers
wayland-protocols)
_archive="$pkgname-$pkgver"
source=("$_archive.tar.gz::$url/archive/v$pkgver.tar.gz"
qt612-metatypes-v031.patch)
sha256sums=('d60592622f1aa1cbb853d4814f605dfde827bc692befbfecffaccf4c90e352d8'
'e254e199a8f6b1147fa4cba5786ea08d5473279c187feeedb52f02908a3a397f')
prepare() {
cd "$pkgname"
patch --batch -Np1 -i "$srcdir/qt612-metatypes-v031.patch"
}
build() {
cd "$pkgname"
local cmake_options=(
-D CMAKE_BUILD_TYPE=RelWithDebInfo
-D DISTRIBUTOR='Arch Linux'
-D DISTRIBUTOR_DEBUGINFO_AVAILABLE=Yes
-D CRASH_REPORTER=Off
-D CMAKE_INSTALL_PREFIX=/usr
-D INSTALL_QML_PREFIX=lib/qt6/qml
)
cmake -G Ninja -B build -W no-dev "${cmake_options[@]}"
cmake --build build
}
package() {
cd "$pkgname"
DESTDIR="$pkgdir" cmake --install build
install -Dm0644 -t "$pkgdir/usr/share/licenses/$pkgname/" LICENSE
}

View file

@ -0,0 +1,65 @@
# QuickShell 0.3.1 et Qt 6.12
Ce dossier conserve les **sources de construction**, jamais le binaire compilé.
Il dérive du `PKGBUILD` officiel Arch `extra/quickshell 0.3.1-1` : `pkgrel=1.1`
identifie la reconstruction locale, et `prepare()` applique le correctif amont
fusionné dans [Quickshell PR 1002](https://github.com/quickshell-mirror/quickshell/pull/1002).
Le fichier `changelog/next.md` de cette PR n'existe pas dans l'archive `v0.3.1` ;
le patch conservé ici ne contient que ses changements de code applicables.
## Pourquoi
Le paquet officiel du 21 août 2026 a été construit avec Qt 6.11.2. Après le
passage du système à Qt 6.12.0, QuickShell signale ce décalage au démarrage.
Une reconstruction sans correctif échoue aussi à la compilation `moc` sur des
métatypes incomplets dans les modules Hyprland et I3/Sway. Le correctif amont
résout ces erreurs sans désactiver de module.
## Dépendances et construction
En plus des dépendances d'exécution déclarées dans `PKGBUILD`, la construction
demande `base-devel`, `cli11`, `cmake`, `ninja`, `qt6-shadertools`,
`spirv-tools`, `vulkan-headers` et `wayland-protocols`. Installer les
dépendances de compilation par le gestionnaire de paquets selon la politique de
la machine ; `makepkg -s` peut les demander. Ne pas utiliser une version de Qt
différente de celle qui sera chargée à l'exécution.
Depuis ce dossier :
```sh
makepkg --verifysource
makepkg --cleanbuild --syncdeps
sha256sum quickshell-0.3.1-1.1-x86_64.pkg.tar
pacman -Qip quickshell-0.3.1-1.1-x86_64.pkg.tar
```
La construction du 8 octobre 2026 a utilisé les sources amont `v0.3.1`
(SHA-256 vérifié par `makepkg`) et le patch de ce dossier. `cli11 2.7.2-1`
était absent du système : son paquet Arch signé a été vérifié par `gpgv`, puis
extrait dans un préfixe de construction utilisateur. Dans ce cas particulier,
`makepkg --nodeps` a ignoré uniquement le contrôle de présence système de
`cli11` ; CMake a trouvé ses en-têtes dans ce préfixe. Ce contournement de
**construction** ne s'applique jamais à l'installation du paquet QuickShell.
Le paquet produit a été conservé hors dépôt sous
`~/.cache/quickshell-qt-compat-v1/`, avec le SHA-256
`68d75a1421d4d78ea1ab72f00194788298b275e58933b48dfa86aace596df2a5`.
Une reconstruction future peut produire un hash différent, même à partir des
mêmes sources ; vérifier toujours les métadonnées et les dépendances du nouvel
artefact. Installer uniquement par Pacman, sans copie manuelle de fichiers.
## Maintenance et retour arrière
Après une mise à jour de Qt, vérifier d'abord si Arch fournit un paquet
QuickShell plus récent construit pour cette version. Sinon, reconstruire avec
le Qt installé et le correctif encore nécessaire. Si Arch intègre le correctif
dans une nouvelle version, revenir au paquet officiel. La révision locale
`0.3.1-1.1` ne sera pas remplacée automatiquement par l'ancien `0.3.1-1` ;
surveiller les versions du dépôt.
Avant une installation, conserver le paquet officiel précédent dans le cache
Pacman et relever l'état du service. Un retour à `0.3.1-1` est possible par
Pacman, mais ce paquet reste compilé contre Qt 6.11.2 : sous Qt 6.12, il ne
constitue pas un retour à la compatibilité. En cas de régression, garder les
journaux, identifier la cause et ne réinstaller l'ancien paquet que si son
fonctionnement est jugé plus sûr que celui du nouveau.

View file

@ -0,0 +1,287 @@
diff --git a/src/services/pipewire/peak.hpp b/src/services/pipewire/peak.hpp
index c4af3c2f7..346585273 100644
--- a/src/services/pipewire/peak.hpp
+++ b/src/services/pipewire/peak.hpp
@@ -8,18 +8,12 @@
#include <qvector.h>
#include "node.hpp"
+#include "qml.hpp"
namespace qs::service::pipewire {
-class PwNodeIface;
class PwPeakStream;
-} // namespace qs::service::pipewire
-
-Q_DECLARE_OPAQUE_POINTER(qs::service::pipewire::PwNodeIface*);
-
-namespace qs::service::pipewire {
-
///! Monitors peak levels of an audio node.
/// Tracks volume peaks for a node across all its channels.
///
diff --git a/src/wayland/hyprland/ipc/connection.cpp b/src/wayland/hyprland/ipc/connection.cpp
index 942270a75..45d25da37 100644
--- a/src/wayland/hyprland/ipc/connection.cpp
+++ b/src/wayland/hyprland/ipc/connection.cpp
@@ -37,6 +37,18 @@ QS_LOGGING_CATEGORY(logHyprlandIpc, "quickshell.hyprland.ipc", QtWarningMsg);
QS_LOGGING_CATEGORY(logHyprlandIpcEvents, "quickshell.hyprland.ipc.events", QtWarningMsg);
} // namespace
+QBindable<HyprlandMonitor*> HyprlandIpc::bindableFocusedMonitor() const {
+ return &this->bFocusedMonitor;
+}
+
+QBindable<HyprlandWorkspace*> HyprlandIpc::bindableFocusedWorkspace() const {
+ return &this->bFocusedWorkspace;
+}
+
+QBindable<HyprlandToplevel*> HyprlandIpc::bindableActiveToplevel() const {
+ return &this->bActiveToplevel;
+}
+
HyprlandIpc::HyprlandIpc() {
auto his = qEnvironmentVariable("HYPRLAND_INSTANCE_SIGNATURE");
if (his.isEmpty()) {
diff --git a/src/wayland/hyprland/ipc/connection.hpp b/src/wayland/hyprland/ipc/connection.hpp
index 8d79184e3..c4264e9a5 100644
--- a/src/wayland/hyprland/ipc/connection.hpp
+++ b/src/wayland/hyprland/ipc/connection.hpp
@@ -25,9 +25,9 @@ class HyprlandToplevel;
} // namespace qs::hyprland::ipc
-Q_DECLARE_OPAQUE_POINTER(qs::hyprland::ipc::HyprlandWorkspace*);
-Q_DECLARE_OPAQUE_POINTER(qs::hyprland::ipc::HyprlandMonitor*);
-Q_DECLARE_OPAQUE_POINTER(qs::hyprland::ipc::HyprlandToplevel*);
+Q_MOC_INCLUDE("hyprland_toplevel.hpp")
+Q_MOC_INCLUDE("monitor.hpp")
+Q_MOC_INCLUDE("workspace.hpp")
namespace qs::hyprland::ipc {
@@ -83,17 +83,9 @@ class HyprlandIpc: public QObject {
[[nodiscard]] QBindable<bool> bindableUsingLua() const { return &this->bUsingLua; }
- [[nodiscard]] QBindable<HyprlandMonitor*> bindableFocusedMonitor() const {
- return &this->bFocusedMonitor;
- }
-
- [[nodiscard]] QBindable<HyprlandWorkspace*> bindableFocusedWorkspace() const {
- return &this->bFocusedWorkspace;
- }
-
- [[nodiscard]] QBindable<HyprlandToplevel*> bindableActiveToplevel() const {
- return &this->bActiveToplevel;
- }
+ [[nodiscard]] QBindable<HyprlandMonitor*> bindableFocusedMonitor() const;
+ [[nodiscard]] QBindable<HyprlandWorkspace*> bindableFocusedWorkspace() const;
+ [[nodiscard]] QBindable<HyprlandToplevel*> bindableActiveToplevel() const;
void setFocusedMonitor(HyprlandMonitor* monitor);
diff --git a/src/wayland/hyprland/ipc/hyprland_toplevel.cpp b/src/wayland/hyprland/ipc/hyprland_toplevel.cpp
index e02dcdc5f..1c25dda1e 100644
--- a/src/wayland/hyprland/ipc/hyprland_toplevel.cpp
+++ b/src/wayland/hyprland/ipc/hyprland_toplevel.cpp
@@ -8,6 +8,7 @@
#include "../../toplevel/qml.hpp"
#include "connection.hpp"
+#include "monitor.hpp"
#include "toplevel_mapping.hpp"
#include "workspace.hpp"
@@ -15,6 +16,10 @@ using namespace qs::wayland::toplevel;
namespace qs::hyprland::ipc {
+QBindable<HyprlandWorkspace*> HyprlandToplevel::bindableWorkspace() { return &this->bWorkspace; }
+
+QBindable<HyprlandMonitor*> HyprlandToplevel::bindableMonitor() { return &this->bMonitor; }
+
HyprlandToplevel::HyprlandToplevel(HyprlandIpc* ipc): QObject(ipc), ipc(ipc) {
this->bMonitor.setBinding([this]() {
return this->bWorkspace ? this->bWorkspace->bindableMonitor().value() : nullptr;
diff --git a/src/wayland/hyprland/ipc/hyprland_toplevel.hpp b/src/wayland/hyprland/ipc/hyprland_toplevel.hpp
index 8e6aadb35..fe1262647 100644
--- a/src/wayland/hyprland/ipc/hyprland_toplevel.hpp
+++ b/src/wayland/hyprland/ipc/hyprland_toplevel.hpp
@@ -81,10 +81,10 @@ class HyprlandToplevel: public QObject {
return &this->bLastIpcObject;
};
- [[nodiscard]] QBindable<HyprlandWorkspace*> bindableWorkspace() { return &this->bWorkspace; }
+ [[nodiscard]] QBindable<HyprlandWorkspace*> bindableWorkspace();
void setWorkspace(HyprlandWorkspace* workspace);
- [[nodiscard]] QBindable<HyprlandMonitor*> bindableMonitor() { return &this->bMonitor; }
+ [[nodiscard]] QBindable<HyprlandMonitor*> bindableMonitor();
signals:
void addressChanged();
diff --git a/src/wayland/hyprland/ipc/monitor.cpp b/src/wayland/hyprland/ipc/monitor.cpp
index 7519a92e9..083dfccec 100644
--- a/src/wayland/hyprland/ipc/monitor.cpp
+++ b/src/wayland/hyprland/ipc/monitor.cpp
@@ -13,6 +13,10 @@ namespace qs::hyprland::ipc {
QVariantMap HyprlandMonitor::lastIpcObject() const { return this->mLastIpcObject; }
+QBindable<HyprlandWorkspace*> HyprlandMonitor::bindableActiveWorkspace() const {
+ return &this->bActiveWorkspace;
+}
+
void HyprlandMonitor::updateInitial(qint32 id, const QString& name, const QString& description) {
Qt::beginPropertyUpdateGroup();
this->bId = id;
diff --git a/src/wayland/hyprland/ipc/monitor.hpp b/src/wayland/hyprland/ipc/monitor.hpp
index b9ac43492..d368adf16 100644
--- a/src/wayland/hyprland/ipc/monitor.hpp
+++ b/src/wayland/hyprland/ipc/monitor.hpp
@@ -54,9 +54,7 @@ class HyprlandMonitor: public QObject {
[[nodiscard]] QBindable<qint32> bindableHeight() { return &this->bHeight; }
[[nodiscard]] QBindable<qreal> bindableScale() { return &this->bScale; }
- [[nodiscard]] QBindable<HyprlandWorkspace*> bindableActiveWorkspace() const {
- return &this->bActiveWorkspace;
- }
+ [[nodiscard]] QBindable<HyprlandWorkspace*> bindableActiveWorkspace() const;
[[nodiscard]] QBindable<bool> bindableFocused() const { return &this->bFocused; }
diff --git a/src/wayland/hyprland/ipc/workspace.cpp b/src/wayland/hyprland/ipc/workspace.cpp
index a527d825d..6205c818e 100644
--- a/src/wayland/hyprland/ipc/workspace.cpp
+++ b/src/wayland/hyprland/ipc/workspace.cpp
@@ -15,6 +15,8 @@ namespace qs::hyprland::ipc {
QVariantMap HyprlandWorkspace::lastIpcObject() const { return this->mLastIpcObject; }
+QBindable<HyprlandMonitor*> HyprlandWorkspace::bindableMonitor() { return &this->bMonitor; }
+
HyprlandWorkspace::HyprlandWorkspace(HyprlandIpc* ipc): QObject(ipc), ipc(ipc) {
Qt::beginPropertyUpdateGroup();
diff --git a/src/wayland/hyprland/ipc/workspace.hpp b/src/wayland/hyprland/ipc/workspace.hpp
index 86e0426d6..d36c6ad41 100644
--- a/src/wayland/hyprland/ipc/workspace.hpp
+++ b/src/wayland/hyprland/ipc/workspace.hpp
@@ -73,7 +73,7 @@ class HyprlandWorkspace: public QObject {
[[nodiscard]] QBindable<bool> bindableFocused() { return &this->bFocused; }
[[nodiscard]] QBindable<bool> bindableUrgent() { return &this->bUrgent; }
[[nodiscard]] QBindable<bool> bindableHasFullscreen() { return &this->bHasFullscreen; }
- [[nodiscard]] QBindable<HyprlandMonitor*> bindableMonitor() { return &this->bMonitor; }
+ [[nodiscard]] QBindable<HyprlandMonitor*> bindableMonitor();
[[nodiscard]] ObjectModel<HyprlandToplevel>* toplevels() { return &this->mToplevels; }
[[nodiscard]] QVariantMap lastIpcObject() const;
diff --git a/src/x11/i3/ipc/controller.cpp b/src/x11/i3/ipc/controller.cpp
index e4f32ff90..dfb52b46d 100644
--- a/src/x11/i3/ipc/controller.cpp
+++ b/src/x11/i3/ipc/controller.cpp
@@ -31,6 +31,14 @@ QS_LOGGING_CATEGORY(logI3Ipc, "quickshell.I3.ipc", QtWarningMsg);
QS_LOGGING_CATEGORY(logI3IpcEvents, "quickshell.I3.ipc.events", QtWarningMsg);
} // namespace
+QBindable<I3Monitor*> I3IpcController::bindableFocusedMonitor() const {
+ return &this->bFocusedMonitor;
+}
+
+QBindable<I3Workspace*> I3IpcController::bindableFocusedWorkspace() const {
+ return &this->bFocusedWorkspace;
+}
+
I3IpcController::I3IpcController(): I3Ipc({"workspace", "output"}) {
// bind focused workspace to focused monitor's active workspace
this->bFocusedWorkspace.setBinding([this]() -> I3Workspace* {
diff --git a/src/x11/i3/ipc/controller.hpp b/src/x11/i3/ipc/controller.hpp
index 464f6f655..9e9d9141c 100644
--- a/src/x11/i3/ipc/controller.hpp
+++ b/src/x11/i3/ipc/controller.hpp
@@ -20,8 +20,8 @@ class I3Workspace;
class I3Monitor;
} // namespace qs::i3::ipc
-Q_DECLARE_OPAQUE_POINTER(qs::i3::ipc::I3Workspace*);
-Q_DECLARE_OPAQUE_POINTER(qs::i3::ipc::I3Monitor*);
+Q_MOC_INCLUDE("monitor.hpp")
+Q_MOC_INCLUDE("workspace.hpp")
namespace qs::i3::ipc {
@@ -43,13 +43,8 @@ class I3IpcController: public I3Ipc {
I3Monitor* monitorFor(QuickshellScreenInfo* screen);
- [[nodiscard]] QBindable<I3Monitor*> bindableFocusedMonitor() const {
- return &this->bFocusedMonitor;
- };
-
- [[nodiscard]] QBindable<I3Workspace*> bindableFocusedWorkspace() const {
- return &this->bFocusedWorkspace;
- };
+ [[nodiscard]] QBindable<I3Monitor*> bindableFocusedMonitor() const;
+ [[nodiscard]] QBindable<I3Workspace*> bindableFocusedWorkspace() const;
[[nodiscard]] ObjectModel<I3Monitor>* monitors();
[[nodiscard]] ObjectModel<I3Workspace>* workspaces();
diff --git a/src/x11/i3/ipc/monitor.cpp b/src/x11/i3/ipc/monitor.cpp
index 7afb68ef9..17178b7d1 100644
--- a/src/x11/i3/ipc/monitor.cpp
+++ b/src/x11/i3/ipc/monitor.cpp
@@ -12,6 +12,10 @@
namespace qs::i3::ipc {
+QBindable<I3Workspace*> I3Monitor::bindableActiveWorkspace() const {
+ return &this->bActiveWorkspace;
+}
+
I3Monitor::I3Monitor(I3IpcController* ipc): QObject(ipc), ipc(ipc) {
// clang-format off
this->bFocused.setBinding([this]() { return this->ipc->bindableFocusedMonitor().value() == this; });
diff --git a/src/x11/i3/ipc/monitor.hpp b/src/x11/i3/ipc/monitor.hpp
index 3630460ea..54e834e1f 100644
--- a/src/x11/i3/ipc/monitor.hpp
+++ b/src/x11/i3/ipc/monitor.hpp
@@ -55,9 +55,7 @@ class I3Monitor: public QObject {
[[nodiscard]] QBindable<qreal> bindableScale() { return &this->bScale; }
[[nodiscard]] QBindable<bool> bindableFocused() { return &this->bFocused; }
- [[nodiscard]] QBindable<I3Workspace*> bindableActiveWorkspace() const {
- return &this->bActiveWorkspace;
- }
+ [[nodiscard]] QBindable<I3Workspace*> bindableActiveWorkspace() const;
[[nodiscard]] QVariantMap lastIpcObject() const;
diff --git a/src/x11/i3/ipc/workspace.cpp b/src/x11/i3/ipc/workspace.cpp
index 530f0a2bf..b9d5968eb 100644
--- a/src/x11/i3/ipc/workspace.cpp
+++ b/src/x11/i3/ipc/workspace.cpp
@@ -12,6 +12,8 @@
namespace qs::i3::ipc {
+QBindable<I3Monitor*> I3Workspace::bindableMonitor() const { return &this->bMonitor; }
+
I3Workspace::I3Workspace(I3IpcController* ipc): QObject(ipc), ipc(ipc) {
Qt::beginPropertyUpdateGroup();
diff --git a/src/x11/i3/ipc/workspace.hpp b/src/x11/i3/ipc/workspace.hpp
index c08e926c1..6c802e416 100644
--- a/src/x11/i3/ipc/workspace.hpp
+++ b/src/x11/i3/ipc/workspace.hpp
@@ -57,7 +57,7 @@ class I3Workspace: public QObject {
[[nodiscard]] QBindable<bool> bindableActive() { return &this->bActive; }
[[nodiscard]] QBindable<bool> bindableFocused() { return &this->bFocused; }
[[nodiscard]] QBindable<bool> bindableUrgent() { return &this->bUrgent; }
- [[nodiscard]] QBindable<I3Monitor*> bindableMonitor() const { return &this->bMonitor; }
+ [[nodiscard]] QBindable<I3Monitor*> bindableMonitor() const;
[[nodiscard]] QVariantMap lastIpcObject() const;
void updateFromObject(const QVariantMap& obj);

View file

@ -0,0 +1,28 @@
diff --git a/main.c b/main.c
index 135c98a..75b6f4f 100644
--- a/main.c
+++ b/main.c
@@ -305,6 +305,11 @@ static void keyboard_handle_key(void *data, struct wl_keyboard *wl_keyboard,
const uint32_t key_state) {
struct slurp_seat *seat = data;
struct slurp_state *state = seat->state;
+ /* WHY: SwayFX may send keyboard events before a keymap. Without an
+ * xkb_state, xkb_state_key_get_one_sym dereferences NULL. */
+ if (seat->xkb_state == NULL) {
+ return;
+ }
const xkb_keysym_t keysym = xkb_state_key_get_one_sym(seat->xkb_state, key + 8);
switch (key_state) {
@@ -351,6 +356,11 @@ static void keyboard_handle_modifiers(void *data, struct wl_keyboard *wl_keyboar
const uint32_t mods_latched, const uint32_t mods_locked,
const uint32_t group) {
struct slurp_seat *seat = data;
+ /* CONTRACT: modifier events received before keymap do not carry a
+ * usable XKB state; the next event after keymap will update it. */
+ if (seat->xkb_state == NULL) {
+ return;
+ }
xkb_state_update_mask(seat->xkb_state, mods_depressed, mods_latched,
mods_locked, 0, 0, group);
}

View file

@ -0,0 +1,51 @@
# Yazi : supports amovibles et espace disque
Configuration suivie par GNU Stow (`stow yazi`). Yazi 26.9.1 est requis.
## Supports
`devices.yazi` affiche un résumé au-dessus du panneau parent. La zone reste
informative : l'API du panneau parent ne fournit pas une liste indépendante
avec son propre curseur. Le menu `M` permet de choisir un support et une action :
ouvrir, monter puis ouvrir, démonter ou retirer en sécurité. Le montage d'un
support non monté demande confirmation. Les erreurs sont affichées par Yazi.
La source de vérité est `labfy-removable-media.service`, déjà fourni par ces
dotfiles avec `labfy-removable-mediactl`. Le service utilise UDisks2 pour les
volumes bloc et Gio/GVfs pour MTP. Il élimine les partitions système et les
montages techniques. Installer `udisks2`, `gvfs` et `gvfs-mtp` sur Arch ;
`udiskie` n'est pas requis. Le service et sa commande doivent être installés via
les paquets Stow `systemd` et `bin`. La liste est relue toutes les cinq secondes
par une tâche Lua de l'instance Yazi ; aucun `lsblk` n'est lancé au rendu.
Le service réagit lui-même aux changements UDisks2/Gio. L'espace libre des
volumes bloc montés est lu avec `df` au plus toutes les trente secondes. MTP est
présenté comme MTP, sans capacité inventée.
La colonne de gauche passe à 20 % de la largeur. Sous 17 colonnes, le résumé
est masqué afin de préserver le panneau parent. Sa hauteur est bornée à huit
lignes ; si beaucoup de supports sont connectés, `M` donne accès à la liste
complète. Les clics dans le résumé n'activent aucun périphérique.
## Espace disque
`disk-space.yazi` lit `df -B1 --output=size,avail -- <cwd>` en tâche
asynchrone au changement de répertoire. `avail` correspond à l'espace
utilisable par l'utilisateur courant, selon la réponse du système de fichiers. La capacité et l'espace libre sont affichés en unités
IEC. Si `df` échoue, la barre indique « Espace indisponible ». Le reste de la
barre de statut, notamment les ajouts existants, reste configuré dans
`init.lua`.
`sduf.yazi` a été évalué : il indique l'espace utilisé sur la capacité totale,
avec analyse de `df -h`, ce qui ne répond pas au besoin d'espace réellement
disponible. `mount.yazi` est compatible avec Yazi 26.9.1 mais n'apporte ni
panneau permanent filtré ni MTP. `gvfs.yazi` annonce ne plus être maintenu ;
aucun de ces trois plugins n'est installé.
## Vérification visuelle
Lancer `yazi`, brancher ou débrancher un support et attendre au plus cinq
secondes. Vérifier le panneau, puis `M`. Pour un volume monté, choisir son
numéro puis `o`. Pour un volume non monté, choisir `m` et confirmer ; `u` et `e`
sont des opérations explicites. Vérifier l'espace libre après navigation vers
un autre système de fichiers. Ne pas démonter ni éjecter un support en cours
d'utilisation.

View file

@ -25,3 +25,9 @@ Status:children_add(function()
" ", " ",
} }
end, 500, Status.RIGHT) end, 500, Status.RIGHT)
-- WHY: panneau non interactif au-dessus du parent et menu explicite sur M.
require("devices"):setup()
-- WHY: espace réellement disponible sur le filesystem du répertoire courant.
require("disk-space"):setup()

View file

@ -5,6 +5,7 @@
[mgr] [mgr]
keymap = [ keymap = [
{ on = "M", run = "plugin devices", desc = "Périphériques amovibles" },
{ on = "<Esc>", run = "escape", desc = "Exit visual mode, clear selection, or cancel search" }, { on = "<Esc>", run = "escape", desc = "Exit visual mode, clear selection, or cancel search" },
{ on = "<C-[>", run = "escape", desc = "Exit visual mode, clear selection, or cancel search" }, { on = "<C-[>", run = "escape", desc = "Exit visual mode, clear selection, or cancel search" },
{ on = "q", run = "quit", desc = "Quit the process" }, { on = "q", run = "quit", desc = "Quit the process" },

View file

@ -0,0 +1,5 @@
# devices.yazi
Zone informative des périphériques amovibles. Utilise le service
`labfy-removable-media` des dotfiles ; `M` ouvre le menu d'actions.
Le panneau parent reste navigable et cliquable dans sa propre zone.

View file

@ -0,0 +1,163 @@
--- @since 26.9.1
-- CONTRACT: le service UDisks2/Gio des dotfiles est l'unique source des
-- périphériques et des actions. Aucun point de montage n'est construit ici.
local M = {}
local client = (os.getenv("HOME") or "") .. "/.local/bin/labfy-removable-mediactl"
local devices, spaces, ready = {}, {}, false
local space_cache = {}
local function clean(s) return tostring(s or ""):gsub("[%c]", " ") end
local function name(d) return clean(type(d.display_name) == "string" and d.display_name ~= "" and d.display_name or "Périphérique") end
local function allows(d, action)
for _, candidate in ipairs(type(d.actions) == "table" and d.actions or {}) do
if candidate == action then return true end
end
return false
end
local function detail(d)
if d.kind == "mtp" then return "MTP" end
if not d.mounted then return "Non monté" end
return spaces[d.runtime_id] and (spaces[d.runtime_id] .. " libres") or "Espace indisponible"
end
local publish = ya.sync(function(_, list, free, ok)
devices, spaces, ready = list, free, ok
ui.render()
end)
local function list()
local output, err = Command(client):arg("list"):output()
if err or not output or not output.status.success then return nil end
local ok, state = pcall(ya.json_decode, output.stdout)
if not ok or type(state) ~= "table" or state.schema ~= "labfy.removable-media" or type(state.devices) ~= "table" then return nil end
return state.devices
end
local function free_space(path)
local output, err = Command("df"):arg({ "-B1", "--output=avail", "--", path }):output()
if err or not output or not output.status.success then return nil end
local bytes = tonumber(output.stdout:match("\n%s*(%d+)"))
return bytes and string.format("%.1f GiB", bytes / 1073741824) or nil
end
local function refresh()
local found = list()
if not found then publish({}, {}, false); return end
local free, next_cache, now = {}, {}, ya.time()
for _, d in ipairs(found) do
if d.kind == "block" and d.mounted and type(d.mount_point) == "string" and d.mount_point:sub(1, 1) == "/" then
local previous = space_cache[d.runtime_id]
local value = previous and previous.path == d.mount_point and now - previous.at < 30 and previous.value or nil
value = value or free_space(d.mount_point)
free[d.runtime_id] = value
next_cache[d.runtime_id] = { path = d.mount_point, at = now, value = value }
end
end
space_cache = next_cache
publish(found, free, true)
end
local function operate(action, id)
local output, err = Command(client):arg({ action, id }):output()
if err or not output or not output.status.success then
ya.notify { title = "Périphériques", content = clean(output and output.stderr or err or "Échec inconnu"):sub(1, 240), level = "error", timeout = 6 }
return nil
end
local ok, state = pcall(ya.json_decode, output.stdout)
return ok and type(state) == "table" and state or nil
end
function M:entry()
local found = list()
if not found then
ya.notify { title = "Périphériques", content = "Service indisponible", level = "error", timeout = 5 }
return
end
if #found == 0 then
ya.notify { title = "Périphériques", content = "Aucun périphérique amovible", timeout = 3 }
return
end
local keys, cands = "123456789abcdefghijklmnopqrstuvwxyz", {}
for i, d in ipairs(found) do
if i > #keys then break end
cands[i] = { on = keys:sub(i, i), desc = name(d) .. " · " .. detail(d) }
end
local selected = ya.which { cands = cands }
local d = selected and found[selected]
if not d then return end
local actions = {}
if d.mounted and allows(d, "open") then
actions[#actions + 1] = { on = "o", desc = "Ouvrir " .. name(d), action = "open" }
elseif not d.mounted and allows(d, "mount") then
actions[#actions + 1] = { on = "m", desc = "Monter puis ouvrir " .. name(d), action = "open" }
end
if allows(d, "unmount") then actions[#actions + 1] = { on = "u", desc = "Démonter " .. name(d), action = "unmount" } end
if allows(d, "safe-remove") then
actions[#actions + 1] = { on = "e", desc = "Retirer en sécurité " .. name(d), action = "safe-remove" }
end
if #actions == 0 then
ya.notify { title = "Périphériques", content = "Aucune action disponible", level = "warn", timeout = 4 }
return
end
local selected_action = ya.which { cands = actions }
if not selected_action then return end
local action = actions[selected_action].action
if action == "open" and not d.mounted then
if not ya.confirm { pos = { "center", w = 52, h = 8 }, title = "Montage", body = "Monter " .. name(d) .. " puis ouvrir ?" } then return end
end
-- WHY: l'opération « open » du service lance une nouvelle fenêtre Kitty.
-- Naviguer dans cette instance ; seul le montage passe par le service.
if action == "open" and d.mounted then
if type(d.mount_point) == "string" and d.mount_point:sub(1, 1) == "/" then
ya.emit("cd", { d.mount_point })
else
ya.notify { title = "Périphériques", content = "Point de montage indisponible", level = "error", timeout = 5 }
end
return
end
local result = operate(action == "open" and "mount" or action,
action == "safe-remove" and d.safe_remove_runtime_id or d.runtime_id)
if not result then return end
if action == "open" then
for _, item in ipairs(result.devices or {}) do
if item.runtime_id == d.runtime_id and item.mounted and type(item.mount_point) == "string" and item.mount_point:sub(1, 1) == "/" then
ya.emit("cd", { item.mount_point })
break
end
end
end
refresh()
end
function M:setup()
-- INVARIANT: un seul temporisateur Lua par instance ; pas de lsblk au rendu.
ya.async(function() while true do refresh(); ya.sleep(5) end end)
local original_new, original_redraw, original_click = Parent.new, Parent.redraw, Parent.click
function Parent:new(area, tab)
local rows = area.w >= 17 and math.min(8, math.floor(area.h / 3)) or 0
local body = ui.Rect { x = area.x, y = area.y + rows, w = area.w, h = area.h - rows }
local parent = original_new(self, body, tab)
parent._devices_header = rows > 0 and ui.Rect { x = area.x, y = area.y, w = area.w, h = rows } or nil
return parent
end
function Parent:redraw()
local output, area = original_redraw(self), self._devices_header
if not area then return output end
local lines = { ui.Line { ui.Span("PÉRIPHÉRIQUES"):fg("#b4befe"):bold() } }
if not ready then
lines[#lines + 1] = ui.Line(" État indisponible")
elseif #devices == 0 then
lines[#lines + 1] = ui.Line(" Aucun support")
else
for _, d in ipairs(devices) do
if #lines + 2 >= area.h then break end
local icon = d.kind == "mtp" and "󰄜 " or (d.mounted and "󰋊 " or "󰐑 ")
lines[#lines + 1] = ui.Line(ui.truncate(icon .. name(d), { max = area.w }))
lines[#lines + 1] = ui.Line { ui.Span(ui.truncate(" " .. detail(d), { max = area.w })):fg("#a6adc8") }
end
end
lines[#lines + 1] = ui.Line { ui.Span(string.rep("─", math.max(0, area.w - 1))):fg("#585b70") }
output[#output + 1] = ui.Text(lines):area(area)
return output
end
function Parent:click(event, up)
local area = self._devices_header
if area and event.y < area.y + area.h then return end
return original_click(self, event, up)
end
end
return M

View file

@ -0,0 +1,38 @@
--- @since 26.9.1
-- CONTRACT: « avail » est l'espace disponible pour l'utilisateur courant,
-- contrairement à la capacité totale ; df interroge le filesystem du CWD.
local M = {}
local value, last_path = "Espace indisponible", nil
local publish = ya.sync(function(_, path, display)
if last_path ~= path then return end
value = display
ui.render()
end)
local function human(bytes)
local units, i = { "B", "KiB", "MiB", "GiB", "TiB", "PiB" }, 1
while bytes >= 1024 and i < #units do bytes, i = bytes / 1024, i + 1 end
return i == 1 and string.format("%d B", bytes) or string.format("%.1f %s", bytes, units[i])
end
function M:setup()
local function load(path)
last_path, value = path, "Espace indisponible"
ui.render()
ya.async(function()
local output, err = Command("df"):arg({ "-B1", "--output=size,avail", "--", path }):output()
if err or not output or not output.status.success then return end
local total, available = output.stdout:match("\n%s*(%d+)%s+(%d+)")
total, available = tonumber(total), tonumber(available)
if total and available and total > 0 and available <= total then
publish(path, human(available) .. " libres / " .. human(total))
end
end)
end
ps.sub("cd", function() load(tostring(cx.active.current.cwd)) end)
Status:children_add(function()
local path = tostring(cx.active.current.cwd)
if path ~= last_path then load(path) end
return ui.Line { ui.Span(" " .. value):fg("#b4befe") }
end, 2800, Status.RIGHT)
end
return M

View file

@ -1,5 +1,5 @@
[manager] [manager]
ratio = [ 1, 4, 3 ] ratio = [ 2, 5, 3 ]
show_hidden = true # Voir les fichiers .cachés show_hidden = true # Voir les fichiers .cachés
linemode = "size" # Nom + taille linemode = "size" # Nom + taille
sort_by = "modified" # Tri par date sort_by = "modified" # Tri par date
@ -61,7 +61,7 @@ rules = [
"$schema" = "https://yazi-rs.github.io/schemas/yazi.json" "$schema" = "https://yazi-rs.github.io/schemas/yazi.json"
[mgr] [mgr]
ratio = [ 1, 4, 3 ] ratio = [ 2, 5, 3 ]
sort_by = "alphabetical" sort_by = "alphabetical"
sort_sensitive = false sort_sensitive = false
sort_reverse = false sort_reverse = false