diff --git a/docs/assets/screenshots/quickshell-scratchpad-drawer.png b/docs/assets/screenshots/quickshell-scratchpad-drawer.png new file mode 100644 index 000000000..f7f224930 Binary files /dev/null and b/docs/assets/screenshots/quickshell-scratchpad-drawer.png differ diff --git a/docs/assets/screenshots/quickshell-scratchpad-left-placement.png b/docs/assets/screenshots/quickshell-scratchpad-left-placement.png new file mode 100644 index 000000000..fa95b9138 Binary files /dev/null and b/docs/assets/screenshots/quickshell-scratchpad-left-placement.png differ diff --git a/portal/README.md b/portal/README.md index 097856179..3d254d203 100644 --- a/portal/README.md +++ b/portal/README.md @@ -143,8 +143,11 @@ installée et aucun portail ni `swayidle` n'a été redémarré. Firefox demande `org.freedesktop.portal.Inhibit`, le backend GTK relaie vers `org.freedesktop.ScreenSaver.Inhibit`, puis `labfy-idle-bridge.service` transmet le nombre de demandes valides à -QuickShell. L'`IdleInhibitor` de la barre persistante est actif si la tasse -manuelle **ou** au moins une demande applicative est active. Les deux sources +QuickShell. L'`IdleInhibitor` de la barre persistante est actif si le maintien +manuel **ou** au moins une demande applicative est actif. La tasse reste +visible sans fond dans la barre : elle est rouge dès qu'une de ces sources est +active, et neutre sinon. Son infobulle distingue les sources et la durée +manuelle restante lorsqu'elle existe. Les deux sources restent indépendantes : désactiver ou laisser expirer la tasse ne libère pas une vidéo encore demandée ; une pause vidéo ne change ni la durée ni le minuteur manuels. La petite indication dans la sous-page Maintenir éveillé @@ -176,6 +179,108 @@ restent à vérifier séparément. Les demandes applicatives peuvent provenir d'autres usages que d'une vidéo visible : le pont suit le portail, il ne classe pas le contenu de la fenêtre. +Contrôle ciblé du 9 octobre 2026 : une activation manuelle de 30 minutes, +Control Center fermé, a conservé la tasse et la surface de barre pendant +130 secondes. Un observateur `swayidle` temporaire avec configuration vide, +seul un marqueur privé à 8 s et aucune action de verrouillage ou d'extinction, +ne s'est pas déclenché. Le même contrôle de 130 secondes en mode « Jusqu'à +désactivation », avec un nouveau marqueur, a donné le même résultat. Les +relevés à 0, 30, 55, 65, 90 et 130 secondes ont conservé le PID QuickShell +2349 et un seul chargement de configuration. La sous-page confirmait le mode +illimité après la seconde mesure. Ces contrôles établissent le fonctionnement +au-delà d'une minute dans la session stable ; ils ne mesurent ni l'expiration +complète à 30 minutes ni une demande applicative concurrente durant ces deux +phases. Après désactivation manuelle, un contrôle négatif neuf à 6 s a fini +par déclencher son marqueur ; les marqueurs des phases actives n'étaient donc +pas simplement inopérants. + +Le journal utilisateur de la session précédente montre des rechargements +QuickShell à 08:01:31 et 08:02:15 sous le même PID 14333. Les fichiers +`WorkspaceOverview.qml` et `Bar.qml` portent des modifications à ces secondes. +À cette date, un rechargement reconstruisait `ShellRoot` avec le maintien +manuel initialisé à `false` ; il pouvait donc retirer la protection même si +le PID ne changeait pas. Faute d'horodatage du constat initial, ce lien restait +une explication plausible, sans attribution prouvée. La correction V5 +ci-dessous conserve désormais ce choix lors des rechargements QML ; un arrêt +complet du processus reste distinct et libère l'inhibition. + +Un rechargement ultérieur, pendant la mise à jour de la tasse, a révélé un +second défaut distinct : le nouveau `SocketServer` ouvrait le chemin fixe +avant la destruction de l'ancien. L'ancien retirait ensuite ce chemin ; le +helper ne pouvait plus se reconnecter et répondait `bridge unavailable or +full` aux nouvelles demandes. `IdleBridgeReceiver.qml` diffère maintenant +l'ouverture de la socket de 250 ms après le chargement QML. Une demande +applicative temporaire a été acquise puis libérée avec succès ; la tasse est +devenue rouge pendant la demande, et un observateur neuf à 6 s n'a déclenché +qu'après sa libération. Le même résultat a été vérifié avec un rechargement +pendant que la demande restait active : chemin de socket présent, demande +retransmise, tasse rouge et inhibition effective. Ce défaut concernait la +reconnexion automatique, pas l'échéance du minuteur manuel. + +Après ces changements QML, deux nouveaux contrôles réels de 130 secondes +ont été effectués, l'un à 30 minutes et l'autre en mode illimité. À chacun +des relevés 0, 30, 55, 65, 90 et 130 s, la tasse est restée rouge, la surface +de barre était présente, le PID 2349 et le nombre de chargements étaient +stables, et un observateur à 8 s avec marqueur neuf n'avait pas déclenché. +Enfin, une demande D-Bus temporaire acquise puis libérée pendant le mode +manuel illimité n'a ni masqué la tasse ni libéré l'inhibition manuelle. Après +désactivation du mode illimité de test, la tasse est redevenue neutre et un +dernier observateur à 6 s a déclenché son marqueur privé. L'état manuel a +ainsi été ramené à sa valeur initiale. + +## Maintien manuel après rechargement QML (contrôle V5, 9 octobre 2026) + +Le signalement situait un nouveau verrouillage vers 13:36–13:40, sans heure +exacte. Le processus QuickShell 2349, lancé à 09:23:01, avait rechargé sa +configuration pour la dernière fois à 13:24:37 avant ce signalement. +`swayidle.service` utilisait toujours 300 s pour `swaylock`, 360 s pour +l'extinction, 420 s pour la pause média et `before-sleep` pour le verrouillage +avant suspension. Aucun fichier de configuration implicite de `swayidle` +n'était présent. Le journal contient deux succès `swaymsg` sous l'identifiant +`swayidle` à 13:36:40 et 13:38:41, compatibles avec l'extinction et la reprise +de sortie. Il ne consignait ni la sélection manuelle, ni l'état de +l'inhibiteur au moment du verrouillage : la chaîne précise de cet incident +reste donc non prouvée. L'état observé après déverrouillage n'a pas été traité +comme une mesure rétroactive. + +Le code actif avant V5 plaçait le booléen manuel dans `ShellRoot` avec la +valeur initiale `false`. V5 utilise `PersistentProperties` avec une identité +stable pour conserver seulement l'activation, la durée choisie et l'échéance +absolue. Une expiration pendant un rechargement est effacée au chargement. +Les demandes applicatives restent resynchronisées depuis le pont, sans +partager le stockage manuel. Les deux rechargements réels à 14:00:34 et +14:01:09 ont restauré le mode illimité sous le même PID. Lors du second, +le nouvel hôte de l'inhibiteur était mappé et `enabled=true` avant la +destruction journalisée de l'ancien hôte, environ 24 ms après. Il n'existe +pas d'accusé d'inhibition du compositeur ; ces transitions décrivent le +cycle QML, et l'observation `swayidle` ci-dessous teste son effet réel. + +Le mode illimité a été activé depuis le bouton à 13:47:51, panneau fermé. +Un observateur `swayidle` sur `wayland-1` et `seat0`, avec fichier de +configuration vide, respect normal des inhibiteurs et seule action de +création d'un marqueur privé à 8 s, n'a pas déclenché entre 13:48:55 et +13:59:17, soit 10 min 22 s sans rechargement. Un nouvel observateur de même +type n'a pas déclenché entre 13:59:34 et 14:11:28 ; son dernier rechargement +réel était à 14:01:09, soit 10 min 19 s avant la fin de la mesure. Aucun +`swaylock` n'était présent lors des relevés. Ces mesures dépassent 300 et +360 s dans la session réelle ; elles ne transforment pas l'icône seule en +preuve de protection. L'observateur ne sait pas mesurer les entrées humaines : +la confirmation de leur absence pendant ces fenêtres reste à obtenir. + +Le contrôle de libération volontaire du mode manuel reste à terminer. À +14:19:36, le diagnostic indiquait encore `manual: true` et zéro demande +applicative ; aucun marqueur négatif n'avait donc pu être obtenu. Les deux +observateurs temporaires ont été arrêtés sans modifier `swayidle.service`. + +Le mécanisme `PersistentProperties` ne sauvegarde rien après l'arrêt ou le +crash du processus QuickShell ; une nouvelle session repart désactivée. Une +échéance limitée est exprimée en temps civil, afin de survivre au +rechargement : un changement manuel de l'horloge système peut donc modifier +la durée effective. Le premier chargement de V5 depuis l'ancien code ne +pouvait pas récupérer le booléen non conservé ; le mode a été réactivé +volontairement pour les mesures ci-dessus. Le verrouillage manuel et celui +avant suspension restent indépendants. + Le helper envoie un état complet puis des mises à jour ordonnées sur la socket privée du répertoire `XDG_RUNTIME_DIR`. QuickShell confirme la prise en compte de chaque état ; cette confirmation n'est pas un accusé du compositeur. Une diff --git a/quickshell/.config/quickshell/labfy-sway/Bar.qml b/quickshell/.config/quickshell/labfy-sway/Bar.qml index eb5c339de..b8f543dcd 100644 --- a/quickshell/.config/quickshell/labfy-sway/Bar.qml +++ b/quickshell/.config/quickshell/labfy-sway/Bar.qml @@ -27,16 +27,44 @@ PanelWindow { required property var applicationCoordinator required property bool authenticationActive required property var keepAwakeController + required property var drawerService required property var osdService screen: modelData + function closeDrawer() { drawerIndicator.close(false) } + function toggleDrawer() { drawerIndicator.toggle() } + // INVARIANT: une seule surface de barre porte l'inhibiteur, même avec // plusieurs sorties. La tasse et les demandes applicatives sont deux // propriétaires indépendants ; la libération de l'un préserve l'autre. IdleInhibitor { + id: idleInhibitor window: bar enabled: bar.startupHost && (bar.keepAwakeController.keepAwake || bar.keepAwakeController.applicationRequestCount > 0) + // WHY: les transitions seules permettent de dater un éventuel trou + // d'inhibition entre deux générations, sans journal par frame. + onEnabledChanged: console.info("Inhibiteur activé", enabled, + "génération", bar.keepAwakeController.keepAwakeGeneration, + "ms", Date.now()) + onWindowChanged: console.info("Surface inhibiteur présente", window === bar, + "génération", bar.keepAwakeController.keepAwakeGeneration, + "ms", Date.now()) + } + // CONTRACT: le mapping observé ne vaut pas accusé d'inhibition du compositeur. + onBackingWindowVisibleChanged: { + if (startupHost) console.info("Surface barre mappée", backingWindowVisible, + "génération", keepAwakeController.keepAwakeGeneration, + "ms", Date.now()); + } + Component.onCompleted: { + if (startupHost) console.info("Hôte inhibiteur créé", idleInhibitor.enabled, + backingWindowVisible, "génération", + keepAwakeController.keepAwakeGeneration, "ms", Date.now()); + } + Component.onDestruction: { + if (startupHost) console.info("Hôte inhibiteur détruit", "génération", + keepAwakeController.keepAwakeGeneration, "ms", Date.now()); } anchors { @@ -115,6 +143,7 @@ PanelWindow { dateCenter.requestClose(); windowStrip.closeMenu(); rightStatusArea.closeRemovableMedia(); + bar.closeDrawer(); } function onApplicationsOpening(output) { bar.closeOverview(); @@ -123,6 +152,7 @@ PanelWindow { dateCenter.requestClose(); windowStrip.closeMenu(); rightStatusArea.closeRemovableMedia(); + bar.closeDrawer(); } } IpcHandler { @@ -171,6 +201,7 @@ PanelWindow { dateCenter.requestClose(); windowStrip.closeMenu(); rightStatusArea.closeRemovableMedia(); + bar.closeDrawer(); clipboardLoader.active = true; } return true; @@ -181,26 +212,41 @@ PanelWindow { function closeOverview() { overviewRequested = false; if (overviewLoader.item) overviewLoader.item.dismiss(); + return true; } - function toggleOverview() { + function openOverview() { if (authenticationActive) return false; - if (overviewRequested || overviewLoader.active) { closeOverview(); return true; } + // INVARIANT: deux ouvertures ne créent ni une seconde capture ni une fermeture. + if (overviewRequested || overviewLoader.active) return true; closeApplications(); closeClipboard(); controlCenter.visible = false; dateCenter.requestClose(); windowStrip.closeMenu(); rightStatusArea.closeRemovableMedia(); + bar.closeDrawer(); overviewRequested = true; if (!overviewCapture.running) overviewCapture.running = true; return true; } + function toggleOverview() { + if (authenticationActive) return false; + if (overviewRequested || overviewLoader.active) return closeOverview(); + return openOverview(); + } 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(); } + // CONTRACT: chaque geste demande un état explicite. La fermeture retire + // la demande avant qu'une capture asynchrone puisse afficher la vue. + function open(): bool { return bar.openOverview(); } + function close(): bool { return bar.closeOverview(); } + // CONTRACT: le dispatcher ne lit pas la liste potentiellement longue + // des cartes pour attendre la libération du panneau. + function active(): bool { return bar.overviewRequested || overviewLoader.active; } function state(): string { return JSON.stringify({ loaded: overviewLoader.active, visible: !!overviewLoader.item && overviewLoader.item.visible, @@ -308,6 +354,16 @@ PanelWindow { } : null }); } } + IpcHandler { + target: "drawerUi-" + bar.screen.name + function toggle(): bool { bar.toggleDrawer(); return true; } + function close(): bool { bar.closeDrawer(); return true; } + function state(): string { + return JSON.stringify({count: bar.drawerService ? bar.drawerService.entries.length : 0, + open: drawerIndicator.open, busy: bar.drawerService ? bar.drawerService.busy : false, + error: bar.drawerService ? bar.drawerService.error : ""}); + } + } LazyLoader { id: clipboardLoader active: false @@ -327,10 +383,23 @@ PanelWindow { controlCenter.visible = false; windowStrip.closeMenu(); rightStatusArea.closeRemovableMedia(); + bar.closeDrawer(); if (opening) dateCenter.requestOpen(); else dateCenter.requestClose(); } + // CONTRACT: le tiroir prend le focus clavier sur une seule sortie et + // libère tous les autres panneaux transitoires avant son ouverture. + function prepareDrawer() { + closeApplications(); + closeOverview(); + closeClipboard(); + controlCenter.visible = false; + dateCenter.requestClose(); + windowStrip.closeMenu(); + rightStatusArea.closeRemovableMedia(); + } + Item { id: leftArea anchors.left: parent.left @@ -391,11 +460,26 @@ PanelWindow { } } + // CONTRACT: une seule instance, dans le même groupe de navigation que + // les workspaces ; sa largeur n'occupe aucun espace si le tiroir est vide. + DrawerIndicator { + id: drawerIndicator + anchors.left: resizeIndicator.right + anchors.leftMargin: visible && resizeIndicator.visible ? 6 : 0 + anchors.verticalCenter: parent.verticalCenter + service: bar.drawerService + barWindow: bar + output: bar.screen.name + authenticationActive: bar.authenticationActive + onOpened: bar.prepareDrawer() + } + Workspaces { id: workspaces screen: bar.screen - anchors.left: resizeIndicator.right - anchors.leftMargin: resizeIndicator.visible ? 6 : 0 + anchors.left: drawerIndicator.right + anchors.leftMargin: drawerIndicator.visible ? 6 + : resizeIndicator.visible ? 6 : 0 anchors.verticalCenter: parent.verticalCenter } @@ -411,6 +495,8 @@ PanelWindow { WindowStrip { id: windowStrip screen: bar.screen + drawerService: bar.drawerService + authenticationActive: bar.authenticationActive anchors.left: workspacesSeparator.right anchors.leftMargin: 8 anchors.verticalCenter: parent.verticalCenter @@ -422,6 +508,7 @@ PanelWindow { controlCenter.visible = false; dateCenter.requestClose(); rightStatusArea.closeRemovableMedia(); + bar.closeDrawer(); } } } @@ -465,6 +552,7 @@ PanelWindow { dateCenter.requestClose(); windowStrip.closeMenu(); rightStatusArea.closeRemovableMedia(); + bar.closeDrawer(); controlCenter.currentPage = page; controlCenter.visible = true; } @@ -476,6 +564,7 @@ PanelWindow { dateCenter.requestClose(); windowStrip.closeMenu(); rightStatusArea.closeRemovableMedia(); + bar.closeDrawer(); controlCenter.currentPage = 0; controlCenter.visible = opening; } @@ -488,6 +577,7 @@ PanelWindow { dateCenter.requestClose(); windowStrip.closeMenu(); rightStatusArea.setRemovableMediaOpen(opening); + bar.closeDrawer(); } onToggleClipboardRequested: bar.toggleClipboard() } @@ -501,6 +591,8 @@ PanelWindow { onVisibleChanged: bar.osdService.setPanel(bar.screen, visible, currentPage) onCurrentPageChanged: bar.osdService.setPanel(bar.screen, visible, currentPage) } + // CONTRACT: seul l'hôte existant transmet les lectures partagées ; les + // autres barres ne multiplient pas les événements lors d'un changement. Connections { target: controlCenter function snapshot() { diff --git a/quickshell/.config/quickshell/labfy-sway/README.md b/quickshell/.config/quickshell/labfy-sway/README.md index f09c2e79b..2e8b573fc 100644 --- a/quickshell/.config/quickshell/labfy-sway/README.md +++ b/quickshell/.config/quickshell/labfy-sway/README.md @@ -46,15 +46,20 @@ Bar ``` Dans **Réglages rapides → Maintenir éveillé**, sélectionner 30 minutes, -1 heure, 2 heures ou **Jusqu’à désactivation**. L’icône de la barre apparaît -pendant l’inhibition ; son infobulle indique le temps restant et un clic rouvre +1 heure, 2 heures ou **Jusqu’à désactivation**. L’icône reste présente même +quand le mode est désactivé ; sa couleur et son infobulle indiquent la demande +manuelle ou applicative, et un clic rouvre la page pour changer la durée ou désactiver le mode. Une seule barre porte -l’inhibiteur Wayland, même avec plusieurs écrans. Le mode empêche l’inactivité +l’inhibiteur Wayland, même avec plusieurs écrans. L’icône ne prouve pas à elle +seule que le compositeur respecte l’inhibiteur. Le mode empêche l’inactivité détectée par le compositeur, donc les actions automatiques de `swayidle` (verrouillage et extinction de l’écran) tant que l’inhibition est respectée. Il ne bloque pas le verrouillage manuel, la veille demandée explicitement ni le verrouillage avant mise en veille. L’état est propre au processus -QuickShell : son arrêt ou redémarrage libère l’inhibition. La configuration et +QuickShell : un rechargement QML conserve la sélection manuelle et l’échéance +initiale grâce à `PersistentProperties`, tandis qu’un arrêt ou redémarrage du +processus libère l’inhibition. Une nouvelle session graphique repart aussi +désactivée. La configuration et le cycle de vie de `swayidle.service` ne sont pas modifiés. Les demandes automatiques des applications arrivent séparément par le pont @@ -302,7 +307,7 @@ pas. Les états rares sont vérifiés avec des données isolées par | Ancien bloc | QuickShell | | --- | --- | -| scratchpad indicator | volontairement non repris | +| scratchpad indicator | tiroir natif SwayFX, voir [scratchpad/README.md](scratchpad/README.md) | | focused_window | WindowStrip | | music | Date Center MPRIS | | bluetooth | status + Control Center | diff --git a/quickshell/.config/quickshell/labfy-sway/controlcenter/KeepAwakePage.qml b/quickshell/.config/quickshell/labfy-sway/controlcenter/KeepAwakePage.qml index 0562c8d8d..f208792c7 100644 --- a/quickshell/.config/quickshell/labfy-sway/controlcenter/KeepAwakePage.qml +++ b/quickshell/.config/quickshell/labfy-sway/controlcenter/KeepAwakePage.qml @@ -52,6 +52,21 @@ Item { font.pixelSize: 12 } + Text { + width: parent.width + visible: page.controller.keepAwake + // CONTRACT: cette durée décrit seulement la sélection manuelle ; + // les demandes applicatives n'ont pas d'échéance connue ici. + text: page.controller.keepAwakeMinutes === 0 + ? "Maintien manuel · jusqu’à désactivation" + : "Maintien manuel · " + + Math.ceil(page.controller.keepAwakeRemainingSeconds / 60) + + " min restantes" + wrapMode: Text.Wrap + color: Theme.secondaryForeground + font.pixelSize: 12 + } + Repeater { model: [ { label: "30 minutes", minutes: 30 }, diff --git a/quickshell/.config/quickshell/labfy-sway/idlebridge/IdleBridgeReceiver.qml b/quickshell/.config/quickshell/labfy-sway/idlebridge/IdleBridgeReceiver.qml index 19eb60bc1..1af04b54b 100644 --- a/quickshell/.config/quickshell/labfy-sway/idlebridge/IdleBridgeReceiver.qml +++ b/quickshell/.config/quickshell/labfy-sway/idlebridge/IdleBridgeReceiver.qml @@ -11,6 +11,20 @@ Item { property var currentSocket: null property string generation: "" property int lastSequence: 0 + property bool socketReady: false + + // WHY: QuickShell construit la nouvelle configuration avant de détruire + // l'ancienne. Deux SocketServer sur le même chemin peuvent alors se + // croiser : l'ancien supprime le fichier de socket que le nouveau vient + // de créer. Le premier tick après le rechargement laisse finir la + // destruction précédente avant de publier le nouveau chemin. Le helper + // reconnecte alors ses demandes encore valides depuis son état en mémoire. + Timer { + interval: 250 + running: true + repeat: false + onTriggered: bridge.socketReady = true + } function receive(socket, line) { if (socket !== currentSocket || line.length > 512) { @@ -31,6 +45,8 @@ Item { } generation = frame.generation; lastSequence = frame.sequence; + if (applicationRequestCount !== frame.count) + console.info("Demandes applicatives", frame.count, "ms", Date.now()); applicationRequestCount = frame.count; // CONTRACT: this acknowledges assignment of the QuickShell state, // not a compositor confirmation (idle-inhibit-v1 has no such ack). @@ -40,21 +56,29 @@ Item { } SocketServer { - active: true + active: bridge.socketReady path: Quickshell.env("LABFY_IDLE_BRIDGE_SOCKET") || (Quickshell.env("XDG_RUNTIME_DIR") + "/labfy-idle-bridge.sock") handler: Socket { id: peer onConnectedChanged: { if (connected) { + // CONTRACT: journaliser seulement les transitions du + // pont, jamais les libellés privés reçus par le helper. + console.info("Pont d'inhibition connecté", "ms", Date.now()); if (bridge.currentSocket && bridge.currentSocket !== peer) bridge.currentSocket.connected = false; bridge.currentSocket = peer; + if (bridge.applicationRequestCount !== 0) + console.info("Demandes applicatives", 0, "raison reconnexion", "ms", Date.now()); bridge.applicationRequestCount = 0; bridge.generation = ""; bridge.lastSequence = 0; } else if (bridge.currentSocket === peer) { + console.info("Pont d'inhibition déconnecté", "ms", Date.now()); bridge.currentSocket = null; + if (bridge.applicationRequestCount !== 0) + console.info("Demandes applicatives", 0, "raison déconnexion", "ms", Date.now()); bridge.applicationRequestCount = 0; bridge.generation = ""; bridge.lastSequence = 0; diff --git a/quickshell/.config/quickshell/labfy-sway/overview/README.md b/quickshell/.config/quickshell/labfy-sway/overview/README.md index 2b8382924..d8fd5de51 100644 --- a/quickshell/.config/quickshell/labfy-sway/overview/README.md +++ b/quickshell/.config/quickshell/labfy-sway/overview/README.md @@ -13,6 +13,40 @@ La surface `labfy-setting-osd` suspend seulement une nouvelle capture automatique tant qu'elle est visible ; les miniatures déjà validées restent disponibles. +## Gestes à trois doigts (SwayFX) + +Sur le touchpad ASUS `2362:12311:ASCP1201:00_093A:3017_Touchpad`, les +`bindgesture` natifs du mode `default` déclenchent : haut → `open`, bas → +`close`, gauche → `workspace next_on_output`, droite → +`workspace prev_on_output`. Les directions désignent le mouvement physique des +doigts. Les autres nombres de doigts, dont le défilement à deux doigts, ne sont +pas liés ici. `--exact` n'est pas imposé avant l'essai matériel des diagonales. + +Le script ponctuel `~/.config/sway/scripts/touchpad-gestures` choisit la sortie +du workspace focalisé à l'instant du geste. Il interroge `polkitUi state` et +ignore ces quatre gestes pendant une demande d'authentification. Haut et bas +appellent respectivement `overviewUi- open` et `close` ; ils ne passent +pas par `toggle`. Une fermeture annule aussi l'ouverture différée après la +capture. Si l'Overview est ouverte, le dispatcher la ferme puis attend +`overviewUi- active=false` avant de naviguer. La navigation parcourt +seulement les workspaces existants de la sortie, dans l'ordre et avec le +bouclage natifs de Sway ; aucun workspace vide n'est +créé et aucune fenêtre n'est déplacée. `Super+W` garde son `toggle`. + +Ces liaisons appartiennent seulement au mode `default` : en mode `resize`, elles +ne s'exécutent pas et le mode n'est pas quitté automatiquement. Les gestes +Sway ne sont pas garantis au-dessus d'une barre layer-shell : la barre +QuickShell peut recevoir le swipe à la place du compositeur. Ce cas demande +un essai physique distinct. Le focus exclusif de l'Overview reste nécessaire +au clavier ; la réception du swipe au-dessus de ses cartes et de son fond doit +également être confirmée sur le touchpad réel. + +Pour retirer uniquement ces gestes, supprimer les quatre lignes `bindgesture` +marquées dans `~/.config/sway/bind`, puis lancer `swaymsg reload`. Le dispatcher +et les opérations IPC explicites peuvent ensuite être supprimés s'ils ne sont +plus utilisés ; les raccourcis clavier et le reste de l'Overview continuent de +fonctionner. + ## Provenance des miniatures `grim -o -s 0.25 -t jpeg -q 72` prend un **snapshot réel de la sortie diff --git a/quickshell/.config/quickshell/labfy-sway/overview/WorkspaceOverview.qml b/quickshell/.config/quickshell/labfy-sway/overview/WorkspaceOverview.qml index 1600956a5..47f3963f0 100644 --- a/quickshell/.config/quickshell/labfy-sway/overview/WorkspaceOverview.qml +++ b/quickshell/.config/quickshell/labfy-sway/overview/WorkspaceOverview.qml @@ -21,6 +21,7 @@ PanelWindow { property string actionName: "" property var dirtyCaptures: [] property bool skipDismissCaptures: false + property bool closing: false property var pendingWindowNumbers: [] property var activeWindowNumbers: [] property int windowCaptureCursor: 0 @@ -47,6 +48,10 @@ PanelWindow { color: "transparent" function dismiss() { + if (closing) return; + // INVARIANT: relâcher pendant la fermeture ne valide aucun glissement. + closing = true; + dropTarget = 0; visible = false; // WHY: grim inclurait cette surface overlay. Après sa disparition, // seules les sorties visibles dont l'arbre a changé sont recapturées. @@ -576,6 +581,7 @@ PanelWindow { overview.selectedWorkspace = workspaceCard.number; } onClicked: mouse => { + if (overview.closing) return; overview.selectedWindow = windowVisual.index + 1; if (mouse.button === Qt.RightButton) { overview.moveTarget = workspaceCard.number === 10 ? 1 : workspaceCard.number + 1; @@ -590,6 +596,7 @@ PanelWindow { overview.dropTarget = target !== workspaceCard.number ? target : 0; } onReleased: mouse => { + if (overview.closing) return; if (drag.active) { const point = windowPointer.mapToItem(keyboardRoot, mouse.x, mouse.y); const target = overview.workspaceAt(point.x, point.y); diff --git a/quickshell/.config/quickshell/labfy-sway/overview/backend.py b/quickshell/.config/quickshell/labfy-sway/overview/backend.py index 259075010..5b2fcdd80 100644 --- a/quickshell/.config/quickshell/labfy-sway/overview/backend.py +++ b/quickshell/.config/quickshell/labfy-sway/overview/backend.py @@ -174,7 +174,7 @@ def state(): overlay_outputs = [item["name"] for item in outputs if item.get("active") and any(surface.get("namespace") in { "labfy-workspace-overview", "labfy-applications-menu", - "labfy-setting-osd"} + "labfy-scratchpad-drawer", "labfy-setting-osd"} for surface in item.get("layer_shell_surfaces") or [])] workspace_nodes = {} diff --git a/quickshell/.config/quickshell/labfy-sway/scratchpad/DrawerService.qml b/quickshell/.config/quickshell/labfy-sway/scratchpad/DrawerService.qml new file mode 100644 index 000000000..905f164a3 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/scratchpad/DrawerService.qml @@ -0,0 +1,86 @@ +import QtQuick +import Quickshell +import Quickshell.I3 +import Quickshell.I3._Ipc +import Quickshell.Io + +Item { + id: service + property var entries: [] + property int focusedId: 0 + property string error: "" + property bool authenticationActive: false + property bool reading: false + property bool pending: false + property bool busy: actionProcess.running + readonly property string backend: (Quickshell.env("XDG_CONFIG_HOME") || + (Quickshell.env("HOME") + "/.config")) + "/quickshell/labfy-sway/scratchpad/backend.py" + + // CONTRACT: une seule file d'opérations dans le ShellRoot sérialise même + // deux clics arrivés depuis des barres différentes. Le backend relit Sway. + function act(action, id, output) { + if (authenticationActive || actionProcess.running || !Number.isSafeInteger(id) || id <= 0) + return false; + error = ""; + actionProcess.command = ["python3", "-B", backend, action, String(id), output || ""]; + actionProcess.running = true; + return true; + } + // WHY: un titre animé ne doit pas repousser indéfiniment le relevé ; la + // première notification ouvre une fenêtre de coalescence fixe. + function refresh() { if (!refreshDelay.running) refreshDelay.start(); } + Timer { + id: refreshDelay + interval: 90 + onTriggered: { + if (service.reading) service.pending = true; + else { + service.reading = true; + listProcess.running = true; + } + } + } + Process { + id: listProcess + command: ["python3", "-B", service.backend, "list"] + stdout: StdioCollector { id: listOutput; waitForEnd: true } + onExited: (code, status) => { + if (code === 0) { + try { + const state = JSON.parse(listOutput.text); + service.entries = state.entries; + service.focusedId = state.focused; + } + catch (error) { console.warn("Tiroir Sway illisible :", error); } + } + service.reading = false; + if (service.pending) { + service.pending = false; + service.refresh(); + } + } + } + Process { + id: actionProcess + stdout: StdioCollector { id: actionOutput; waitForEnd: true } + onExited: (code, status) => { + if (code !== 0) { + try { service.error = JSON.parse(actionOutput.text).error; } + catch (_) { service.error = "Action sur le tiroir impossible"; } + } + service.refresh(); + } + } + I3IpcListener { + subscriptions: ["window"] + onIpcEvent: event => service.refresh() + } + Connections { + target: I3 + function onConnected() { service.refresh(); } + function onRawEvent(event) { + if (event.type === "workspace" || event.type === "output") service.refresh(); + } + } + Component.onCompleted: refresh() +} diff --git a/quickshell/.config/quickshell/labfy-sway/scratchpad/README.md b/quickshell/.config/quickshell/labfy-sway/scratchpad/README.md new file mode 100644 index 000000000..1aafc412c --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/scratchpad/README.md @@ -0,0 +1,57 @@ +# Tiroir de fenêtres SwayFX V1 + +Le tiroir utilise le scratchpad natif de SwayFX. `DrawerService.qml` conserve un +modèle partagé entre les sorties : un relevé `get_tree` au démarrage et après les +événements `window`, `workspace` ou `output` suffit. Aucun inventaire persistant +n'est créé. Les fenêtres Wayland (`app_id`) et Xwayland (`window_properties.class`) +sont identifiées par `con_id`. Le compteur inclut les membres masqués sous +`__i3_scratch` et les membres affichés sur un workspace ordinaire, reconnus +par `scratchpad_state`. + +Dans `WindowStrip`, le menu de la fenêtre choisie propose **Ranger dans le +tiroir**. Le raccourci existant `Super+Ctrl+I` range le conteneur focalisé et +`Super+I` garde le cycle scratchpad natif ; aucun raccourci ni geste n'a changé. +L'indicateur Teal se place à gauche, immédiatement avant les workspaces et +après `Resize` lorsqu'il est actif. Il n'ajoute aucun séparateur et n'apparaît +que si le tiroir contient au moins une fenêtre. Son panneau s'ouvre sous +l'icône sur la sortie de la barre cliquée. Clic +pour ouvrir ou fermer ; flèches haut/bas pour choisir une entrée, gauche/droite +pour choisir l'action, Entrée pour exécuter, Échap pour annuler et rendre le +focus précédent. + +**Afficher** déploie une entrée sur le workspace visible de la sortie de la +barre choisie et lui donne le focus ; si elle s'y trouve déjà, l'action se +limite au focus. **Masquer** la remet dans le scratchpad sans cycle. **Remettre +ici** retire son appartenance au scratchpad, la place sur ce workspace et +conserve le flottement. SwayFX garde l'appartenance après un simple `move` : +le retrait utilise `floating disable` puis `floating enable`, sans appliquer +de layout au workspace entier. L'ancienne place exacte dans la mosaïque n'est +pas restaurée. Une fenêtre rangée affichée reste comptée comme membre. + +Les commandes ciblées relisent l'arbre avant et après, valident la fenêtre et +le workspace visible sur la sortie, puis vérifient l'état final. Les noms +contenant espaces, point-virgule ou un type de guillemet sont cités suivant +la grammaire Sway. Par prudence, les noms contenant `$`, un contrôle ou les +deux types de guillemets sont refusés. Les conteneurs multi-applications du +scratchpad sont visibles mais leurs actions individuelles sont désactivées. +Le menu n'ouvre pas et les commandes n'avancent pas durant Polkit. Une seule +opération est en vol dans QuickShell ; le backend ne dépend pas du focus du +client pour identifier la fenêtre. Le panneau possède une surface nommée +`labfy-scratchpad-drawer`, exclue des captures automatiques Overview. + +Tests : `python3 tests/quickshell/test_scratchpad_drawer.py`, +`python3 tests/overview/test_backend.py`, `qmllint`, `git diff --check`. +Le cycle complet, les répétitions idempotentes, la sortie depuis l'état masqué, +la navigation clavier et le retour du focus ont été vérifiés avec Zenity +uniquement. Les comportements multi-écrans sont conçus autour du nom de +sortie mais n'ont pas été essayés physiquement. + +Capture rapprochée du placement gauche avec une fenêtre Zenity synthétique +rangée et l'entrée déjà présente de l'utilisateur, sans déplacer cette dernière : +[quickshell-scratchpad-left-placement.png](../../../../../docs/assets/screenshots/quickshell-scratchpad-left-placement.png). + +Retour arrière complet du tiroir : retirer l'intégration `DrawerService` de +`shell.qml`, les propriétés et callbacks `drawerService` de `Bar.qml` et +`WindowStrip.qml`, l'entrée de `WindowMenu.qml`, le namespace de +`overview/backend.py`, puis les nouveaux fichiers `scratchpad/` et +`status/DrawerIndicator.qml`. Les raccourcis Sway restent inchangés. diff --git a/quickshell/.config/quickshell/labfy-sway/scratchpad/backend.py b/quickshell/.config/quickshell/labfy-sway/scratchpad/backend.py new file mode 100644 index 000000000..8b73bb7c8 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/scratchpad/backend.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""Vue et commandes ponctuelles du scratchpad natif SwayFX.""" + +import argparse +import json +import re +import subprocess +import sys + + +class DrawerError(Exception): + pass + + +def sway(kind): + result = subprocess.run(["swaymsg", "-r", "-t", kind], capture_output=True, + text=True, timeout=5, check=True) + return json.loads(result.stdout) + + +def command(value): + # CONTRACT: argv évite le shell, mais Sway possède sa propre grammaire. + # Seuls des con_id numériques et des noms de workspace cités y entrent. + result = subprocess.run(["swaymsg", "-r", value], capture_output=True, + text=True, timeout=5, check=True) + replies = json.loads(result.stdout) + if not isinstance(replies, list) or not replies or not all( + item.get("success") is True for item in replies): + raise DrawerError("Commande Sway refusée : " + str(replies)) + + +def safe_command(value): + # CONTRACT: une demande Polkit arrivée entre le clic et l'IPC empêche + # encore la transition ; une lecture indisponible échoue sans déplacement. + try: + result = subprocess.run(["qs", "-c", "labfy-sway", "ipc", "call", + "polkitUi", "state"], capture_output=True, + text=True, timeout=3, check=True) + state = json.loads(result.stdout) + except (OSError, ValueError, subprocess.SubprocessError) as error: + raise DrawerError("État Polkit indisponible") from error + if state.get("active") or state.get("dialog"): + raise DrawerError("Authentification en cours") + command(value) + + +def quoted_workspace(name): + if (not isinstance(name, str) or not name or len(name) > 256 + or any(ord(char) < 32 or char == "$" for char in name)): + raise DrawerError("Nom de workspace invalide") + # Sway garde littéralement un backslash devant un guillemet dans l'IPC. + # Choisir l'autre délimiteur conserve le nom exact et protège , et ;. + if '"' not in name: + return '"' + name + '"' + if "'" not in name: + return "'" + name + "'" + raise DrawerError("Nom de workspace contenant les deux types de guillemets non pris en charge") + + +def walk(node, workspace=None, scratch=False, parent=None): + if node.get("type") == "workspace": + workspace = node.get("name") + scratch = scratch or node.get("scratchpad_state") not in (None, "none") + yield node, workspace, scratch, parent + for child in (node.get("nodes") or []) + (node.get("floating_nodes") or []): + yield from walk(child, workspace, scratch, node) + + +def application_count(root): + return sum(bool(node.get("app_id") or (node.get("window_properties") or {}).get("class")) + for node, _, _, _ in walk(root) + if node.get("type") in ("con", "floating_con")) + + +def members(tree): + result = [] + def visit(node, workspace=None, scratch_root=None): + if node.get("type") == "workspace": + workspace = node.get("name") + if scratch_root is None and node.get("scratchpad_state") not in (None, "none"): + scratch_root = node + if node.get("type") not in ("con", "floating_con"): + app = None + else: + app = node.get("app_id") or (node.get("window_properties") or {}).get("class") + if app and scratch_root is not None: + # Un groupe scratchpad est une seule unité de déplacement. L'UI le + # signale et bloque les actions individuelles ambiguës. + result.append({"id": node["id"], "app": app, "title": node.get("name") or app, + "workspace": workspace, "shown": workspace != "__i3_scratch", + "group": application_count(scratch_root) > 1, + "border": node.get("border")}) + for child in (node.get("nodes") or []) + (node.get("floating_nodes") or []): + visit(child, workspace, scratch_root) + + visit(tree) + return result + + +def target_workspace(output): + workspaces = sway("get_workspaces") + target = next((item for item in workspaces if item.get("output") == output + and item.get("visible") is True), None) + if target is None: + raise DrawerError("Aucun workspace visible sur cette sortie") + return target["name"] + + +def valid_app(tree, con_id): + found = next(((node, parent, workspace) for node, workspace, _, parent in walk(tree) + if node.get("id") == con_id), None) + if found is None: + raise DrawerError("La fenêtre a disparu") + node, parent, workspace = found + app = node.get("app_id") or (node.get("window_properties") or {}).get("class") + if node.get("type") not in ("con", "floating_con") or not app: + raise DrawerError("Ce conteneur n'est pas une fenêtre applicative") + if (node.get("nodes") or node.get("floating_nodes")): + raise DrawerError("Un groupe de fenêtres ne peut pas être rangé comme une seule fenêtre") + # Sway remonte au conteneur flottant parent pour certaines commandes + # ciblées. Refuser un parent multi-app avant de déplacer tout le groupe. + if parent and parent.get("type") in ("con", "floating_con") and application_count(parent) > 1: + raise DrawerError("Fenêtre dans un groupe : déplacement individuel indisponible") + return node, parent, workspace + + +def operate(action, con_id, output): + tree = sway("get_tree") + node, parent, workspace = valid_app(tree, con_id) + found = next((item for item in members(tree) if item["id"] == con_id), None) + if action == "store": + if found is None: + safe_command(f"[con_id={con_id}] move scratchpad") + elif found["group"]: + raise DrawerError("Groupe scratchpad : action individuelle désactivée") + elif found["shown"]: + safe_command(f"[con_id={con_id}] move scratchpad") + else: + if found is None: + raise DrawerError("Cette fenêtre n'appartient plus au tiroir") + if found["group"]: + raise DrawerError("Groupe scratchpad : action individuelle désactivée") + destination = target_workspace(output) + if action == "show": + if found["shown"] and workspace == destination: + safe_command(f"[con_id={con_id}] focus") + else: + safe_command("workspace " + quoted_workspace(destination)) + safe_command(f"[con_id={con_id}] scratchpad show") + safe_command(f"[con_id={con_id}] focus") + elif action == "hide": + if found["shown"]: + # scratchpad show sur un autre workspace déplace la fenêtre au + # lieu de la masquer : move scratchpad est la transition directe. + safe_command(f"[con_id={con_id}] move scratchpad") + elif action == "release": + safe_command(f"[con_id={con_id}] move container to workspace " + + quoted_workspace(destination)) + # Sway conserve scratchpad_state après un simple move. Son retrait + # natif passe par floating disable ; réactiver ensuite le flottement + # restitue la politique visible sans toucher au layout du workspace. + safe_command(f"[con_id={con_id}] floating disable") + safe_command(f"[con_id={con_id}] floating enable") + safe_command(f"[con_id={con_id}] focus") + else: + raise DrawerError("Action inconnue") + final_tree = sway("get_tree") + after = next((item for item in members(final_tree) if item["id"] == con_id), None) + if action == "store" and after is None or action == "release" and after is not None: + raise DrawerError("L'état final du tiroir ne correspond pas à l'action") + if action in ("show", "hide") and (after is None or after["shown"] != (action == "show")): + raise DrawerError("La visibilité finale ne correspond pas à l'action") + if action == "store" and (after is None or after["shown"]): + raise DrawerError("La fenêtre n'est pas masquée dans le tiroir") + if action == "show" and after["workspace"] != destination: + raise DrawerError("La fenêtre n'est pas sur le workspace choisi") + if action == "release": + final_node, _, final_workspace = valid_app(final_tree, con_id) + if final_workspace != destination or final_node.get("floating") not in ("user_on", "auto_on"): + raise DrawerError("La fenêtre n'est pas flottante sur le workspace choisi") + return {"ok": True, "member": after is not None, "shown": bool(after and after["shown"])} + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("action", choices=["list", "store", "show", "hide", "release"]) + parser.add_argument("con_id", nargs="?", type=int) + parser.add_argument("output", nargs="?") + args = parser.parse_args() + if args.action == "list": + tree = sway("get_tree") + focused = next((node["id"] for node, _, _, _ in walk(tree) + if node.get("focused") is True), 0) + print(json.dumps({"entries": members(tree), "focused": focused}, ensure_ascii=False)) + return + if args.con_id is None or args.con_id <= 0 or (args.action != "store" and not args.output): + raise DrawerError("Arguments invalides") + print(json.dumps(operate(args.action, args.con_id, args.output))) + + +if __name__ == "__main__": + try: + main() + except (DrawerError, subprocess.SubprocessError, ValueError) as error: + print(json.dumps({"error": str(error)}, ensure_ascii=False)) + sys.exit(1) diff --git a/quickshell/.config/quickshell/labfy-sway/shell.qml b/quickshell/.config/quickshell/labfy-sway/shell.qml index 6c1887b69..c49e30bfc 100644 --- a/quickshell/.config/quickshell/labfy-sway/shell.qml +++ b/quickshell/.config/quickshell/labfy-sway/shell.qml @@ -8,16 +8,23 @@ import "notifications" import "idlebridge" import "theme" import "polkit" +import "scratchpad" import "osd" ShellRoot { id: shell + // WHY: distingue les générations QML du PID lors d'un incident. La + // valeur est purement technique et n'est pas conservée au rechargement. + readonly property string keepAwakeGeneration: String(Date.now()) // CONTRACT: one PolkitAgent owns the native request queue for the entire // user session. A dialog is constructed only for its current flow. PolkitAgent { id: polkitAgent } - OsdService { id: shellOsdService; authenticationActive: shell.authenticationActive } property var polkitScreen: null readonly property bool authenticationActive: polkitAgent.flow !== null + // CONTRACT: le tiroir est global ; aucune barre n'installe son propre + // abonnement window ni son propre ordonnanceur de commandes. + DrawerService { id: shellDrawerService; authenticationActive: shell.authenticationActive } + OsdService { id: shellOsdService; authenticationActive: shell.authenticationActive } signal authenticationOpening() function choosePolkitScreen(output) { const screens = Quickshell.screens; @@ -98,41 +105,82 @@ ShellRoot { if (polkitAgent.flow) beginPolkitPresentation(); } property bool resizeMode: false - // CONTRACT: une seule sélection pour toutes les barres. L'inhibition est - // volontairement éphémère : un redémarrage de QuickShell la libère. - property bool keepAwake: false - property int keepAwakeMinutes: 0 + // CONTRACT: seules la sélection manuelle et son échéance passent d'une + // génération QML à la suivante. Ni socket ni objet Wayland n'est conservé. + // Un arrêt du processus ou une nouvelle session repartent désactivés. + PersistentProperties { + id: manualKeepAwake + reloadableId: "manualKeepAwake" + property bool active: false + property int minutes: 0 + property real deadlineEpochMs: 0 + onLoaded: shell.refreshKeepAwake("chargement") + } + readonly property bool keepAwake: manualKeepAwake.active + readonly property int keepAwakeMinutes: manualKeepAwake.minutes property int keepAwakeRemainingSeconds: 0 // INVARIANT: the helper's application requests never change the user's // manual duration or its monotonic expiry timer. IdleBridgeReceiver { id: idleBridge } readonly property int applicationRequestCount: idleBridge.applicationRequestCount + IpcHandler { + target: "keepAwakeDiagnostics" + function state(): string { + // CONTRACT: diagnostic local, sans identité d'application ni + // contenu de fenêtre. L'état applicatif vient toujours du pont. + return JSON.stringify({ generation: shell.keepAwakeGeneration, + manual: shell.keepAwake, minutes: shell.keepAwakeMinutes, + deadlineEpochMs: manualKeepAwake.deadlineEpochMs, + remainingSeconds: shell.keepAwakeRemainingSeconds, + applicationRequests: shell.applicationRequestCount }); + } + } function setKeepAwake(minutes) { if (![0, 30, 60, 120].includes(minutes)) return false; - keepAwake = true; - keepAwakeMinutes = minutes; + // INVARIANT: l'échéance est écrite avant l'activation, afin qu'un + // rechargement ne redémarre jamais une durée entière. + manualKeepAwake.deadlineEpochMs = minutes ? Date.now() + minutes * 60000 : 0; + manualKeepAwake.minutes = minutes; + manualKeepAwake.active = true; keepAwakeRemainingSeconds = minutes * 60; - if (minutes > 0) keepAwakeElapsed.restartMs(); + console.info("Maintien manuel activé", minutes === 0 ? "illimité" : minutes + " min", + "échéance", manualKeepAwake.deadlineEpochMs); return true; } - function clearKeepAwake() { - keepAwake = false; - keepAwakeMinutes = 0; + function clearKeepAwake(reason) { + if (manualKeepAwake.active) + console.info("Maintien manuel désactivé", reason || "action utilisateur"); + manualKeepAwake.active = false; + manualKeepAwake.minutes = 0; + manualKeepAwake.deadlineEpochMs = 0; keepAwakeRemainingSeconds = 0; } - // WHY: l'horloge monotone évite qu'un changement d'heure allonge ou - // raccourcisse une durée choisie. Le tick ne sert qu'à publier l'affichage. - ElapsedTimer { id: keepAwakeElapsed } + function refreshKeepAwake(reason) { + if (!manualKeepAwake.active) return; + if (manualKeepAwake.minutes === 0) { + keepAwakeRemainingSeconds = 0; + } else { + // WHY: une date absolue survit au rechargement, contrairement à + // ElapsedTimer. Elle empêche la réactivation d'une durée expirée. + const remaining = Math.ceil((manualKeepAwake.deadlineEpochMs - Date.now()) / 1000); + if (remaining <= 0) { + clearKeepAwake("échéance atteinte pendant " + reason); + return; + } + keepAwakeRemainingSeconds = remaining; + } + if (reason === "chargement") + console.info("Maintien manuel restauré", manualKeepAwake.minutes === 0 + ? "illimité" : manualKeepAwake.minutes + " min", + "échéance", manualKeepAwake.deadlineEpochMs); + } + // CONTRACT: un seul tick dans ShellRoot publie le temps restant. Chaque + // barre lit cet état ; aucune n'avance séparément le compte à rebours. Timer { interval: 1000 repeat: true running: shell.keepAwake && shell.keepAwakeMinutes > 0 - onTriggered: { - const remaining = shell.keepAwakeMinutes * 60 - - Math.floor(keepAwakeElapsed.elapsedMs() / 1000); - if (remaining <= 0) shell.clearKeepAwake(); - else shell.keepAwakeRemainingSeconds = remaining; - } + onTriggered: shell.refreshKeepAwake("minuteur") } // CONTRACT: one owner across all output bars; opening a menu asks every // bar to release its other transient panels before the overlay appears. @@ -202,6 +250,7 @@ ShellRoot { model: Quickshell.screens Bar { + drawerService: shellDrawerService osdService: shellOsdService applicationCoordinator: shell authenticationActive: shell.authenticationActive diff --git a/quickshell/.config/quickshell/labfy-sway/status/DrawerIndicator.qml b/quickshell/.config/quickshell/labfy-sway/status/DrawerIndicator.qml new file mode 100644 index 000000000..6228c94a5 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/status/DrawerIndicator.qml @@ -0,0 +1,195 @@ +import QtQuick +import Quickshell +import Quickshell.I3 +import Quickshell.Wayland +import "../components" +import "../theme" + +Item { + id: indicator + required property var service + required property var barWindow + required property string output + required property bool authenticationActive + property int previousFocus: 0 + property int selectedIndex: 0 + property int selectedAction: 0 + property real anchorX: 8 + readonly property bool open: popup.visible + signal opened() + + visible: !!service && service.entries.length > 0 + width: visible ? iconRow.implicitWidth + 4 : 0 + height: 26 + onVisibleChanged: if (!visible) popup.visible = false + function close(restore) { + if (!popup.visible) return; + popup.visible = false; + if (restore && !authenticationActive && previousFocus > 0) + Qt.callLater(() => I3.dispatch("[con_id=" + previousFocus + "] focus")); + } + function toggle() { + if (authenticationActive) return; + if (popup.visible) { close(true); return; } + opened(); + previousFocus = service.focusedId; + selectedIndex = 0; + selectedAction = 0; + service.refresh(); + // Le panneau et la barre utilisent la même sortie. Les coordonnées + // locales de la barre plus sa marge évitent toute origine globale. + anchorX = mapToItem(null, 0, 0).x + barWindow.margins.left; + popup.visible = true; + Qt.callLater(() => keys.forceActiveFocus()); + } + function perform(entry, action) { + if (!entry || entry.group || service.busy || authenticationActive) return; + // WHY: la surface popup libère le focus avant scratchpad show/focus. + close(false); + Qt.callLater(() => service.act(action, entry.id, output)); + } + + Rectangle { + anchors.fill: parent + radius: 4 + color: popup.visible || pointer.containsMouse ? Theme.border : "transparent" + } + Row { + id: iconRow + anchors.centerIn: parent + spacing: 3 + NerdIcon { text: "󰅖"; color: Theme.teal; font.pixelSize: 19 } + Text { + anchors.verticalCenter: parent.verticalCenter + text: indicator.service ? String(indicator.service.entries.length) : "" + color: Theme.foreground + font.pixelSize: 11 + } + } + MouseArea { id: pointer; anchors.fill: parent; hoverEnabled: true; onClicked: indicator.toggle() } + StatusTooltip { target: indicator; hovered: pointer.containsMouse && !popup.visible + message: "Tiroir de fenêtres" } + + PanelWindow { + id: popup + screen: indicator.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-scratchpad-drawer" + visible: false + color: "transparent" + MouseArea { anchors.fill: parent; onClicked: indicator.close(true) } + Rectangle { + anchors.top: parent.top + anchors.topMargin: 40 + x: Math.max(8, Math.min(indicator.anchorX, popup.width - width - 8)) + width: Math.min(420, Math.max(0, popup.width - 16)) + height: Math.max(0, Math.min(540, popup.height - 48, body.implicitHeight + 24)) + radius: 5 + color: Theme.popupBackground + border.color: Theme.outline + FocusScope { + id: keys + anchors.fill: parent + focus: true + Keys.onPressed: event => { + const count = indicator.service ? indicator.service.entries.length : 0; + if (event.key === Qt.Key_Escape) indicator.close(true); + else if (event.key === Qt.Key_Down && count) + indicator.selectedIndex = (indicator.selectedIndex + 1) % count; + else if (event.key === Qt.Key_Up && count) + indicator.selectedIndex = (indicator.selectedIndex + count - 1) % count; + else if (event.key === Qt.Key_Left || event.key === Qt.Key_Right) + indicator.selectedAction = 1 - indicator.selectedAction; + else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + const entry = indicator.service.entries[indicator.selectedIndex]; + if (entry) indicator.perform(entry, indicator.selectedAction === 1 + ? "release" : entry.shown ? "hide" : "show"); + } else return; + event.accepted = true; + } + Flickable { + anchors.fill: parent + anchors.margins: 12 + contentWidth: width + contentHeight: body.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + Column { + id: body + width: parent.width + spacing: 8 + Text { text: "Tiroir de fenêtres"; color: Theme.foreground + font.pixelSize: 15; font.bold: true } + Text { visible: !!indicator.service && indicator.service.error.length > 0 + text: indicator.service ? indicator.service.error : ""; color: Theme.red + width: parent.width; wrapMode: Text.Wrap } + Repeater { + model: indicator.service ? indicator.service.entries : [] + Rectangle { + required property var modelData + required property int index + readonly property var desktop: DesktopEntries.heuristicLookup(modelData.app) + width: body.width + height: 68 + radius: 4 + color: index === indicator.selectedIndex ? Theme.border : Theme.buttonBackground + Image { + id: appIcon + x: 8; y: 8; width: 26; height: 26 + source: desktop && desktop.icon + ? (desktop.icon.startsWith("/") ? "file://" + desktop.icon + : Quickshell.iconPath(desktop.icon, "application-x-executable")) + : Quickshell.iconPath("application-x-executable") + fillMode: Image.PreserveAspectFit + asynchronous: true + } + Text { anchors.centerIn: appIcon; visible: appIcon.status === Image.Error + text: "󰀻"; color: Theme.teal; font.pixelSize: 19 } + Text { x: 42; y: 6; width: parent.width - 50 + text: desktop ? desktop.name : modelData.app + color: Theme.foreground; font.bold: true; font.pixelSize: 12 + elide: Text.ElideRight; textFormat: Text.PlainText } + Text { x: 42; y: 24; width: parent.width - 50 + text: modelData.title; color: Theme.secondaryForeground + font.pixelSize: 11; elide: Text.ElideRight; textFormat: Text.PlainText } + Text { x: 8; y: 49 + text: modelData.group ? "Groupe : action individuelle indisponible" + : modelData.shown ? "Affichée" : "Masquée" + color: Theme.secondaryForeground; font.pixelSize: 10 } + Rectangle { + x: parent.width - 205; y: 44; width: 85; height: 20; radius: 3 + color: actionPointer.containsMouse ? Theme.buttonHover : Theme.border + border.width: index === indicator.selectedIndex + && indicator.selectedAction === 0 ? 1 : 0 + border.color: Theme.teal + Text { anchors.centerIn: parent; text: modelData.shown ? "Masquer" : "Afficher" + color: Theme.foreground; font.pixelSize: 10 } + MouseArea { id: actionPointer; anchors.fill: parent; hoverEnabled: true + enabled: !modelData.group && !indicator.service.busy + onClicked: indicator.perform(modelData, modelData.shown ? "hide" : "show") } + } + Rectangle { + x: parent.width - 114; y: 44; width: 106; height: 20; radius: 3 + color: releasePointer.containsMouse ? Theme.buttonHover : Theme.border + border.width: index === indicator.selectedIndex + && indicator.selectedAction === 1 ? 1 : 0 + border.color: Theme.teal + Text { anchors.centerIn: parent; text: "Remettre ici" + color: Theme.foreground; font.pixelSize: 10 } + MouseArea { id: releasePointer; anchors.fill: parent; hoverEnabled: true + enabled: !modelData.group && !indicator.service.busy + onClicked: indicator.perform(modelData, "release") } + } + } + } + } + } + } + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/status/KeepAwakeIndicator.qml b/quickshell/.config/quickshell/labfy-sway/status/KeepAwakeIndicator.qml index bb33bf824..9367dee0c 100644 --- a/quickshell/.config/quickshell/labfy-sway/status/KeepAwakeIndicator.qml +++ b/quickshell/.config/quickshell/labfy-sway/status/KeepAwakeIndicator.qml @@ -4,24 +4,28 @@ import "../theme" Item { id: indicator - required property bool active + required property bool manualActive + required property bool automaticActive required property bool unlimited required property int remainingSeconds signal activated() - width: active ? 26 : 0 + // CONTRACT: la tasse occupe toujours sa place ; sa couleur décrit l'état + // combiné sans modifier les deux propriétaires de l'inhibition Wayland. + readonly property bool active: manualActive || automaticActive + readonly property string detail: manualActive + ? "Maintien manuel · " + (unlimited ? "jusqu’à désactivation" + : Math.ceil(remainingSeconds / 60) + " min restantes") + + (automaticActive ? "\nMaintien automatique demandé par une application" : "") + : automaticActive ? "Maintien automatique demandé par une application" + : "Maintenir éveillé · désactivé" + width: 26 height: 26 - visible: active - Rectangle { - anchors.fill: parent - radius: 4 - color: pointer.containsMouse ? Theme.buttonHover : Theme.emphasisBackground - } NerdIcon { anchors.centerIn: parent text: "󰅶" font.pixelSize: 22 - color: Theme.accentForeground + color: indicator.active ? Theme.red : Theme.foreground } MouseArea { id: pointer @@ -32,8 +36,6 @@ Item { StatusTooltip { target: indicator hovered: pointer.containsMouse - message: indicator.unlimited ? "Maintenir éveillé · jusqu’à désactivation" - : "Maintenir éveillé · " + Math.ceil(indicator.remainingSeconds / 60) - + " min restantes" + message: indicator.detail } } diff --git a/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml b/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml index b508cbcaf..3004eee87 100644 --- a/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml +++ b/quickshell/.config/quickshell/labfy-sway/status/RightStatusArea.qml @@ -70,7 +70,8 @@ Row { spacing: 5 height: 26 KeepAwakeIndicator { - active: area.keepAwakeController.keepAwake + manualActive: area.keepAwakeController.keepAwake + automaticActive: area.keepAwakeController.applicationRequestCount > 0 remainingSeconds: area.keepAwakeController.keepAwakeRemainingSeconds unlimited: area.keepAwakeController.keepAwakeMinutes === 0 onActivated: area.openPageRequested(16) diff --git a/quickshell/.config/quickshell/labfy-sway/windows/WindowMenu.qml b/quickshell/.config/quickshell/labfy-sway/windows/WindowMenu.qml index b5bd57449..21ac37524 100644 --- a/quickshell/.config/quickshell/labfy-sway/windows/WindowMenu.qml +++ b/quickshell/.config/quickshell/labfy-sway/windows/WindowMenu.qml @@ -9,6 +9,7 @@ PopupWindow { property var anchorItem: null property var selectedWindow: null property var windows: [] + signal storeRequested(int id) readonly property var appWindows: selectedWindow ? windows.filter(window => window.appKey === selectedWindow.appKey) : [] readonly property var entry: selectedWindow @@ -92,6 +93,31 @@ PopupWindow { } } + Rectangle { + width: content.width + height: 32 + radius: 4 + color: storePointer.containsMouse ? Theme.border : Theme.buttonBackground + Text { + anchors.left: parent.left + anchors.leftMargin: 8 + anchors.verticalCenter: parent.verticalCenter + text: "Ranger dans le tiroir" + color: Theme.foreground + font.pixelSize: 12 + } + MouseArea { + id: storePointer + anchors.fill: parent + hoverEnabled: true + onClicked: { + const id = menu.selectedWindow ? menu.selectedWindow.id : 0; + menu.visible = false; + if (id > 0) menu.storeRequested(id); + } + } + } + Text { visible: menu.actions.length > 0 text: "Actions" diff --git a/quickshell/.config/quickshell/labfy-sway/windows/WindowStrip.qml b/quickshell/.config/quickshell/labfy-sway/windows/WindowStrip.qml index 761b80d99..c0609a809 100644 --- a/quickshell/.config/quickshell/labfy-sway/windows/WindowStrip.qml +++ b/quickshell/.config/quickshell/labfy-sway/windows/WindowStrip.qml @@ -8,6 +8,8 @@ Item { id: strip required property var screen + required property var drawerService + required property bool authenticationActive required property int maxWidth property var tree: null property bool componentReady: false @@ -50,7 +52,7 @@ Item { function collect(container) { const appId = container.app_id || (container.window_properties && container.window_properties.class) || ""; - if (container.type === "con" && appId) { + if ((container.type === "con" || container.type === "floating_con") && appId) { found.push({ id: container.id, title: container.name || appId, @@ -120,6 +122,7 @@ Item { } function showMenu(item, windowInfo) { + if (authenticationActive) return; if (windowMenu.visible && menuWindow && menuWindow.id === windowInfo.id) { windowMenu.visible = false; return; @@ -240,5 +243,9 @@ Item { anchorItem: strip.menuAnchor selectedWindow: strip.menuWindow windows: strip.windows + onStoreRequested: id => { + strip.closeMenu(); + strip.drawerService.act("store", id, strip.screen.name); + } } } diff --git a/sway/.config/sway/bind b/sway/.config/sway/bind index 871092155..eb3bf5fe4 100644 --- a/sway/.config/sway/bind +++ b/sway/.config/sway/bind @@ -110,6 +110,13 @@ bindsym $mod+s layout stacking # 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 +# CONTRACT: seuls les swipes ASUS à trois doigts déclenchent ces actions. +# Un geste natif achevé émet une commande ; les diagonales restent à essayer +# sur le vrai pavé avant d'imposer --exact. +bindgesture --input-device=2362:12311:ASCP1201:00_093A:3017_Touchpad swipe:3:up exec ~/.config/sway/scripts/touchpad-gestures open +bindgesture --input-device=2362:12311:ASCP1201:00_093A:3017_Touchpad swipe:3:down exec ~/.config/sway/scripts/touchpad-gestures close +bindgesture --input-device=2362:12311:ASCP1201:00_093A:3017_Touchpad swipe:3:left exec ~/.config/sway/scripts/touchpad-gestures next +bindgesture --input-device=2362:12311:ASCP1201:00_093A:3017_Touchpad swipe:3:right exec ~/.config/sway/scripts/touchpad-gestures prev bindsym $mod+e layout toggle split # Make the current focus fullscreen diff --git a/sway/.config/sway/scripts/touchpad-gestures b/sway/.config/sway/scripts/touchpad-gestures new file mode 100755 index 000000000..254e51bae --- /dev/null +++ b/sway/.config/sway/scripts/touchpad-gestures @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Traiter un geste Sway natif sans service d'entrée permanent.""" + +import json +import subprocess +import sys +import time + + +TIMEOUT = 1.5 + + +def run(*args): + # CONTRACT: IPC borné avec liste d'arguments ; un échec ne présume jamais + # l'état d'authentification et n'envoie pas de commande workspace. + result = subprocess.run(args, capture_output=True, text=True, timeout=TIMEOUT, + check=True) + return result.stdout.strip() + + +def ipc(target, operation): + return run("quickshell", "ipc", "-c", "labfy-sway", "call", target, operation) + + +def state(target): + return json.loads(ipc(target, "state")) + + +def focused_output(): + workspaces = json.loads(run("swaymsg", "-t", "get_workspaces", "-r")) + focused = [item for item in workspaces if item.get("focused") is True] + if len(focused) != 1 or not isinstance(focused[0].get("output"), str): + raise ValueError("Aucune sortie focalisée unique") + return focused[0]["output"] + + +def authentication_idle(): + auth = state("polkitUi") + # INVARIANT: une réponse absente ou incomplète ne vaut jamais permission + # de changer de panneau ou de workspace. + return (auth.get("registered") is True and auth.get("active") is False + and auth.get("dialog") is False and auth.get("queryRunning") is False) + + +def dispatch(action): + # WHY: seul l'état de l'agent Polkit fait foi ; titres et focus ne + # permettent pas d'identifier une demande d'authentification. + if not authentication_idle(): + return 0 + + output = focused_output() + target = "overviewUi-" + output + if action == "open": + return 0 if ipc(target, "open") == "true" else 1 + if action == "close": + return 0 if ipc(target, "close") == "true" else 1 + + # CONTRACT: Sway ne change de workspace existant sur la sortie focalisée + # qu'après la libération de l'Overview. Aucun conteneur n'est déplacé. + if ipc(target, "active") == "true": + if ipc(target, "close") != "true": + return 1 + deadline = time.monotonic() + TIMEOUT + while time.monotonic() < deadline: + if ipc(target, "active") == "false": + break + time.sleep(0.02) + else: + return 1 + # Un aller-retour au compositeur suit la disparition du panneau. + run("swaymsg", "-t", "get_version", "-r") + + # Une demande peut débuter durant la fermeture : vérifier de nouveau + # l'agent juste avant la commande workspace. + if not authentication_idle(): + return 0 + command = "next_on_output" if action == "next" else "prev_on_output" + replies = json.loads(run("swaymsg", "-r", "workspace", command)) + return 0 if replies and all(reply.get("success") for reply in replies) else 1 + + +def main(argv): + if len(argv) != 2 or argv[1] not in {"open", "close", "next", "prev"}: + return 2 + try: + return dispatch(argv[1]) + except (subprocess.SubprocessError, OSError, ValueError, KeyError, TypeError, + json.JSONDecodeError): + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tests/overview/test_touchpad_gestures.py b/tests/overview/test_touchpad_gestures.py new file mode 100644 index 000000000..368f522ee --- /dev/null +++ b/tests/overview/test_touchpad_gestures.py @@ -0,0 +1,101 @@ +"""Contrôles du dispatcher sans geste matériel ni effet sur la session.""" + +import importlib.machinery +import importlib.util +import json +from pathlib import Path +import subprocess +import unittest +from unittest.mock import patch + + +SCRIPT = Path(__file__).resolve().parents[2] / "sway/.config/sway/scripts/touchpad-gestures" +loader = importlib.machinery.SourceFileLoader("touchpad_gestures", str(SCRIPT)) +spec = importlib.util.spec_from_loader(loader.name, loader) +gesture = importlib.util.module_from_spec(spec) +loader.exec_module(gesture) + + +class TouchpadGestureTests(unittest.TestCase): + def run_action(self, action, overview=None, auth=None, output="eDP-1"): + overview = overview or {"loaded": False, "requested": False} + auth = auth or {"registered": True, "active": False, "dialog": False, + "queryRunning": False} + calls = [] + + def fake_run(*args): + calls.append(args) + if args[:3] == ("swaymsg", "-t", "get_workspaces"): + return json.dumps([{"focused": False, "output": "HDMI-A-1"}, + {"focused": True, "output": output}]) + if args[:3] == ("swaymsg", "-t", "get_version"): + return '{}' + if args[:2] == ("swaymsg", "-r"): + return '[{"success":true}]' + raise AssertionError(args) + + def fake_state(target): + calls.append(("state", target)) + return auth if target == "polkitUi" else overview + + def fake_ipc(target, operation): + calls.append(("ipc", target, operation)) + if operation == "active": + return "true" if overview["loaded"] or overview["requested"] else "false" + if operation == "close": + overview["loaded"] = False + overview["requested"] = False + return "true" + + with patch.object(gesture, "run", fake_run), \ + patch.object(gesture, "state", fake_state), \ + patch.object(gesture, "ipc", fake_ipc): + result = gesture.dispatch(action) + return result, calls + + def test_open_and_close_use_explicit_ipc(self): + for action in ("open", "close"): + with self.subTest(action=action): + result, calls = self.run_action(action) + self.assertEqual(result, 0) + self.assertIn(("ipc", "overviewUi-eDP-1", action), calls) + self.assertFalse(any(call[:2] == ("swaymsg", "-r") for call in calls)) + + def test_horizontal_closes_before_native_workspace_command(self): + result, calls = self.run_action("next", {"loaded": True, "requested": True}) + self.assertEqual(result, 0) + self.assertLess(calls.index(("ipc", "overviewUi-eDP-1", "close")), + calls.index(("swaymsg", "-r", "workspace", "next_on_output"))) + self.assertIn(("swaymsg", "-t", "get_version", "-r"), calls) + self.assertFalse(any("move" in str(call) for call in calls)) + + def test_previous_uses_current_output_and_no_move(self): + result, calls = self.run_action("prev") + self.assertEqual(result, 0) + self.assertIn(("swaymsg", "-r", "workspace", "prev_on_output"), calls) + self.assertFalse(any("move" in str(call) for call in calls)) + + def test_overview_targets_focused_output_not_first_output(self): + result, calls = self.run_action("open", output="DP-2") + self.assertEqual(result, 0) + self.assertIn(("ipc", "overviewUi-DP-2", "open"), calls) + + def test_polkit_blocks_all_actions(self): + for action in ("open", "close", "next", "prev"): + with self.subTest(action=action): + result, calls = self.run_action(action, auth={"registered": True, "active": True}) + self.assertEqual(result, 0) + self.assertEqual(calls, [("state", "polkitUi")]) + + def test_incomplete_polkit_state_fails_closed(self): + result, calls = self.run_action("next", auth={"registered": True}) + self.assertEqual(result, 0) + self.assertEqual(calls, [("state", "polkitUi")]) + + def test_ipc_failure_is_bounded_and_fails_closed(self): + with patch.object(gesture, "state", side_effect=subprocess.TimeoutExpired("quickshell", 1.5)): + self.assertEqual(gesture.main(["touchpad-gestures", "next"]), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/quickshell/test_scratchpad_drawer.py b/tests/quickshell/test_scratchpad_drawer.py new file mode 100644 index 000000000..7449975ed --- /dev/null +++ b/tests/quickshell/test_scratchpad_drawer.py @@ -0,0 +1,94 @@ +"""Contrats du modèle scratchpad Sway, sans toucher au bureau réel.""" + +import importlib.util +from pathlib import Path +import unittest +from unittest.mock import patch + + +BACKEND = (Path(__file__).resolve().parents[2] / "quickshell/.config/quickshell" + / "labfy-sway/scratchpad/backend.py") +spec = importlib.util.spec_from_file_location("drawer_backend", BACKEND) +drawer = importlib.util.module_from_spec(spec) +spec.loader.exec_module(drawer) + + +def app(cid, name="Application", app_id="demo", state="none"): + return {"id": cid, "type": "con", "name": name, "app_id": app_id, + "scratchpad_state": state, "nodes": [], "floating_nodes": []} + + +def tree(*nodes): + return {"id": 1, "type": "root", "nodes": [ + {"id": 2, "type": "output", "name": "__i3", "nodes": [ + {"id": 3, "type": "workspace", "name": "__i3_scratch", + "nodes": [], "floating_nodes": [n for n in nodes if n.get("hidden")]}]}, + {"id": 4, "type": "output", "name": "DP-2", "nodes": [ + {"id": 5, "type": "workspace", "name": 'Travail ; "A"', + "nodes": [], "floating_nodes": [n for n in nodes if not n.get("hidden")]}]}]} + + +class DrawerModelTest(unittest.TestCase): + def test_empty_and_duplicate_app_identity(self): + self.assertEqual(drawer.members(tree()), []) + a, b = app(20, "Premier", state="fresh"), app(21, "Second", state="fresh") + a["hidden"] = True + result = drawer.members(tree(a, b)) + self.assertEqual([(x["id"], x["shown"]) for x in result], [(20, False), (21, True)]) + + def test_group_is_blocked_even_when_apps_are_nested(self): + group = {"id": 10, "type": "floating_con", "scratchpad_state": "fresh", + "nodes": [{"id": 11, "type": "con", "nodes": [app(20)], + "floating_nodes": [app(21)]}], "floating_nodes": []} + group["hidden"] = True + self.assertTrue(all(x["group"] for x in drawer.members(tree(group)))) + + def test_workspace_quoting_and_missing_target(self): + self.assertEqual(drawer.quoted_workspace('Travail ; "A"'), "'Travail ; \"A\"'") + with self.assertRaises(drawer.DrawerError): + drawer.quoted_workspace("A 'B' and \"C\"") + with self.assertRaises(drawer.DrawerError): + drawer.valid_app(tree(), 99) + with patch.object(drawer, "sway", return_value=[{"name": "A", "output": "DP-2", "visible": True}]): + with self.assertRaises(drawer.DrawerError): + drawer.target_workspace("HDMI-A-1") + + def test_non_app_container_is_rejected(self): + group = {"id": 30, "type": "con", "nodes": [app(31)], "floating_nodes": []} + with self.assertRaises(drawer.DrawerError): + drawer.valid_app(tree(group), 30) + + def test_store_rejects_multi_app_parent(self): + group = {"id": 30, "type": "floating_con", "nodes": [app(31), app(32)], + "floating_nodes": []} + with self.assertRaises(drawer.DrawerError): + drawer.valid_app(tree(group), 31) + + def test_repeated_show_only_focuses_and_repeated_hide_sends_nothing(self): + displayed = tree(app(20, state="fresh")) + hidden = app(20, state="fresh") + hidden["hidden"] = True + hidden_tree = tree(hidden) + workspaces = [{"name": 'Travail ; "A"', "output": "DP-2", "visible": True}] + + def answer(kind, current): + return current if kind == "get_tree" else workspaces + + with patch.object(drawer, "sway", side_effect=lambda kind: answer(kind, displayed)), \ + patch.object(drawer, "safe_command") as sent: + self.assertTrue(drawer.operate("show", 20, "DP-2")["shown"]) + sent.assert_called_once_with("[con_id=20] focus") + with patch.object(drawer, "sway", side_effect=lambda kind: answer(kind, hidden_tree)), \ + patch.object(drawer, "safe_command") as sent: + self.assertFalse(drawer.operate("hide", 20, "DP-2")["shown"]) + sent.assert_not_called() + + def test_rejected_command_does_not_report_success(self): + with patch.object(drawer, "sway", return_value=tree(app(20))), \ + patch.object(drawer, "safe_command", side_effect=drawer.DrawerError("refusée")): + with self.assertRaisesRegex(drawer.DrawerError, "refusée"): + drawer.operate("store", 20, "DP-2") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_keep_awake_timer.js b/tests/test_keep_awake_timer.js new file mode 100644 index 000000000..01dbbccb9 --- /dev/null +++ b/tests/test_keep_awake_timer.js @@ -0,0 +1,113 @@ +// Run with node tests/test_keep_awake_timer.js. +// CONTRACT: execute production function bodies with a controlled clock; +// only PersistentProperties are shared between simulated ShellRoot generations. +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const vm = require("node:vm"); +const path = require("node:path"); +const root = path.resolve(__dirname, ".."); +const source = fs.readFileSync(path.join(root, "quickshell/.config/quickshell/labfy-sway/shell.qml"), "utf8"); +const bar = fs.readFileSync(path.join(root, "quickshell/.config/quickshell/labfy-sway/Bar.qml"), "utf8"); +const page = fs.readFileSync(path.join(root, "quickshell/.config/quickshell/labfy-sway/controlcenter/KeepAwakePage.qml"), "utf8"); +const indicator = fs.readFileSync(path.join(root, "quickshell/.config/quickshell/labfy-sway/status/KeepAwakeIndicator.qml"), "utf8"); + +function body(signature) { + const marker = `function ${signature}`; + const start = source.indexOf(marker); + assert.notEqual(start, -1); + const open = source.indexOf("{", start + marker.length); + let depth = 0; + for (let i = open; i < source.length; i++) { + if (source[i] === "{") depth++; + else if (source[i] === "}" && --depth === 0) return source.slice(open + 1, i); + } + throw new Error(`unclosed ${signature}`); +} +const set = body("setKeepAwake(minutes)"); +const clear = body("clearKeepAwake(reason)"); +const refresh = body("refreshKeepAwake(reason)"); + +function fixture() { + let now = 0; + const saved = { active: false, minutes: 0, deadlineEpochMs: 0 }; + function generation(requestCount = 0) { + const state = { manualKeepAwake: saved, keepAwakeRemainingSeconds: 0, + applicationRequestCount: requestCount, Date: { now: () => now }, + console: { info() {} } }; + Object.defineProperties(state, { + keepAwake: { get: () => saved.active }, + keepAwakeMinutes: { get: () => saved.minutes } + }); + state.shell = state; + vm.createContext(state); + vm.runInContext(`function setKeepAwake(minutes) {${set}} + function clearKeepAwake(reason) {${clear}} + function refreshKeepAwake(reason) {${refresh}}`, state); + state.refreshKeepAwake("chargement"); + return state; + } + return { generation, advance(ms) { now = ms; } }; +} + +for (const seconds of [60, 130, 300, 360, 600]) { + const clock = fixture(); + let state = clock.generation(); + assert.equal(state.setKeepAwake(0), true); + clock.advance(seconds * 1000); + state = clock.generation(); + assert.equal(state.keepAwake, true, `unlimited at ${seconds}s`); +} + +for (const minutes of [30, 60, 120]) { + const clock = fixture(); + let state = clock.generation(); + state.setKeepAwake(minutes); + const deadline = state.manualKeepAwake.deadlineEpochMs; + clock.advance(minutes * 30000); + state = clock.generation(); + assert.equal(state.keepAwake, true); + assert.equal(state.manualKeepAwake.deadlineEpochMs, deadline); + assert.equal(state.keepAwakeRemainingSeconds, minutes * 30); + clock.advance(deadline - 1); + state.refreshKeepAwake("minuteur"); + assert.equal(state.keepAwake, true); + assert.equal(state.keepAwakeRemainingSeconds, 1); + clock.advance(deadline); + state.refreshKeepAwake("minuteur"); + assert.equal(state.keepAwake, false); + assert.equal(state.keepAwakeRemainingSeconds, 0); +} + +{ + const clock = fixture(); + let state = clock.generation(); + state.setKeepAwake(0); + for (let i = 1; i <= 3; i++) { + clock.advance(i * 130000); + state = clock.generation(); + assert.equal(state.keepAwake, true); + } + state.applicationRequestCount = 1; + state.clearKeepAwake(); + assert.equal(state.keepAwake, false); + assert.equal(state.applicationRequestCount, 1); +} + +{ + const clock = fixture(); + let state = clock.generation(1); + state.setKeepAwake(30); + clock.advance(1800000); + state = clock.generation(1); + assert.equal(state.keepAwake, false, "expired during reload"); + assert.equal(state.applicationRequestCount, 1); +} + +assert.match(source, /PersistentProperties\s*\{[\s\S]*?reloadableId:\s*"manualKeepAwake"/); +assert.match(source, /running:\s*shell\.keepAwake\s*&&\s*shell\.keepAwakeMinutes\s*>\s*0/); +assert.equal((source.match(/function refreshKeepAwake\(reason\)/g) || []).length, 1); +assert.doesNotMatch(page, /Component\.onCompleted|Timer\s*\{|ElapsedTimer/); +assert.match(bar, /enabled:\s*bar\.startupHost\s*&&\s*\(bar\.keepAwakeController\.keepAwake\s*\|\|\s*bar\.keepAwakeController\.applicationRequestCount\s*>\s*0\)/); +assert.match(indicator, /readonly property bool active: manualActive \|\| automaticActive/); +assert.match(indicator, /width: 26/); +console.log("keep-awake timer: controlled-clock and reload checks passed");