Compare commits
3 commits
21b5ae2571
...
25a2d7b555
| Author | SHA1 | Date | |
|---|---|---|---|
| 25a2d7b555 | |||
| dd72ba36b9 | |||
| c06c55db13 |
71 changed files with 5147 additions and 172 deletions
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal 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
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
8
portal/.config/xdg-desktop-portal-termfilechooser/config
Normal file
8
portal/.config/xdg-desktop-portal-termfilechooser/config
Normal 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
|
||||||
66
portal/.config/xdg-desktop-portal-termfilechooser/yazi-wrapper.sh
Executable file
66
portal/.config/xdg-desktop-portal-termfilechooser/yazi-wrapper.sh
Executable 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
|
||||||
8
portal/.config/xdg-desktop-portal/portals.conf
Normal file
8
portal/.config/xdg-desktop-portal/portals.conf
Normal 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
84
portal/README.md
Normal 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.
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
quickshell/.config/quickshell/labfy-sway/clipboard/README.md
Normal file
42
quickshell/.config/quickshell/labfy-sway/clipboard/README.md
Normal 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.
|
||||||
164
quickshell/.config/quickshell/labfy-sway/clipboard/backend.py
Executable file
164
quickshell/.config/quickshell/labfy-sway/clipboard/backend.py
Executable 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)
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
||||||
|
}
|
||||||
|
|
@ -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]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
98
quickshell/.config/quickshell/labfy-sway/overview/README.md
Normal file
98
quickshell/.config/quickshell/labfy-sway/overview/README.md
Normal 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.
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
390
quickshell/.config/quickshell/labfy-sway/overview/backend.py
Normal file
390
quickshell/.config/quickshell/labfy-sway/overview/backend.py
Normal 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)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 + " %" : "—";
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
###############################################
|
###############################################
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
13
sway/.config/sway/scripts/clipboard-quickshell
Executable file
13
sway/.config/sway/scripts/clipboard-quickshell
Executable 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
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
13
sway/.config/sway/scripts/overview-quickshell
Executable file
13
sway/.config/sway/scripts/overview-quickshell
Executable 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
|
||||||
42
sway/.config/sway/scripts/screenshot-region
Executable file
42
sway/.config/sway/scripts/screenshot-region
Executable 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"
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
71
tests/clipboard/test_backend.py
Normal file
71
tests/clipboard/test_backend.py
Normal 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()
|
||||||
204
tests/clipboard/ui_quickshell_smoke.py
Normal file
204
tests/clipboard/ui_quickshell_smoke.py
Normal 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:]))
|
||||||
245
tests/overview/test_backend.py
Normal file
245
tests/overview/test_backend.py
Normal 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()
|
||||||
18
tests/quickshell/test_percent_math.js
Normal file
18
tests/quickshell/test_percent_math.js
Normal 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");
|
||||||
14
tests/quickshell/test_percent_presentation.js
Normal file
14
tests/quickshell/test_percent_presentation.js
Normal 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");
|
||||||
50
tests/quickshell/test_scroll_steps.js
Normal file
50
tests/quickshell/test_scroll_steps.js
Normal 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");
|
||||||
39
tests/quickshell/test_status_color_roles.js
Normal file
39
tests/quickshell/test_status_color_roles.js
Normal 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");
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
347
tests/removable_media/test_mtp.py
Normal file
347
tests/removable_media/test_mtp.py
Normal 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
22
tools/build-slurp-keymap-guard
Executable 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"
|
||||||
64
tools/quickshell-qt-compat-v1/PKGBUILD
Normal file
64
tools/quickshell-qt-compat-v1/PKGBUILD
Normal 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
|
||||||
|
}
|
||||||
65
tools/quickshell-qt-compat-v1/README.md
Normal file
65
tools/quickshell-qt-compat-v1/README.md
Normal 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.
|
||||||
287
tools/quickshell-qt-compat-v1/qt612-metatypes-v031.patch
Normal file
287
tools/quickshell-qt-compat-v1/qt612-metatypes-v031.patch
Normal 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);
|
||||||
28
tools/slurp-keymap-guard.patch
Normal file
28
tools/slurp-keymap-guard.patch
Normal 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);
|
||||||
|
}
|
||||||
51
yazi/.config/yazi/README.md
Normal file
51
yazi/.config/yazi/README.md
Normal 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.
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
|
|
|
||||||
5
yazi/.config/yazi/plugins/devices.yazi/README.md
Normal file
5
yazi/.config/yazi/plugins/devices.yazi/README.md
Normal 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.
|
||||||
163
yazi/.config/yazi/plugins/devices.yazi/main.lua
Normal file
163
yazi/.config/yazi/plugins/devices.yazi/main.lua
Normal 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
|
||||||
38
yazi/.config/yazi/plugins/disk-space.yazi/main.lua
Normal file
38
yazi/.config/yazi/plugins/disk-space.yazi/main.lua
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue