secure push
This commit is contained in:
parent
2bafbcd2be
commit
02236cfdeb
24 changed files with 1366 additions and 47 deletions
BIN
docs/assets/screenshots/quickshell-scratchpad-drawer.png
Normal file
BIN
docs/assets/screenshots/quickshell-scratchpad-drawer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/assets/screenshots/quickshell-scratchpad-left-placement.png
Normal file
BIN
docs/assets/screenshots/quickshell-scratchpad-left-placement.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
109
portal/README.md
109
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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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-<sortie> 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-<sortie> 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 <sortie> -s 0.25 -t jpeg -q 72` prend un **snapshot réel de la sortie
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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.
|
||||
206
quickshell/.config/quickshell/labfy-sway/scratchpad/backend.py
Normal file
206
quickshell/.config/quickshell/labfy-sway/scratchpad/backend.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
93
sway/.config/sway/scripts/touchpad-gestures
Executable file
93
sway/.config/sway/scripts/touchpad-gestures
Executable file
|
|
@ -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))
|
||||
101
tests/overview/test_touchpad_gestures.py
Normal file
101
tests/overview/test_touchpad_gestures.py
Normal file
|
|
@ -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()
|
||||
94
tests/quickshell/test_scratchpad_drawer.py
Normal file
94
tests/quickshell/test_scratchpad_drawer.py
Normal file
|
|
@ -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()
|
||||
113
tests/test_keep_awake_timer.js
Normal file
113
tests/test_keep_awake_timer.js
Normal file
|
|
@ -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");
|
||||
Loading…
Reference in a new issue