feat(quickshell): publish clipboard, overview, bar and local fixes

This commit is contained in:
fy59 2026-10-08 20:58:18 +02:00
parent dd72ba36b9
commit 25a2d7b555
46 changed files with 3776 additions and 117 deletions

3
.gitattributes vendored Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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