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
|
Firefox demande `org.freedesktop.portal.Inhibit`, le backend GTK relaie vers
|
||||||
`org.freedesktop.ScreenSaver.Inhibit`, puis
|
`org.freedesktop.ScreenSaver.Inhibit`, puis
|
||||||
`labfy-idle-bridge.service` transmet le nombre de demandes valides à
|
`labfy-idle-bridge.service` transmet le nombre de demandes valides à
|
||||||
QuickShell. L'`IdleInhibitor` de la barre persistante est actif si la tasse
|
QuickShell. L'`IdleInhibitor` de la barre persistante est actif si le maintien
|
||||||
manuelle **ou** au moins une demande applicative est active. Les deux sources
|
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
|
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
|
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é
|
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
|
d'autres usages que d'une vidéo visible : le pont suit le portail, il ne
|
||||||
classe pas le contenu de la fenêtre.
|
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
|
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
|
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
|
de chaque état ; cette confirmation n'est pas un accusé du compositeur. Une
|
||||||
|
|
|
||||||
|
|
@ -27,16 +27,44 @@ PanelWindow {
|
||||||
required property var applicationCoordinator
|
required property var applicationCoordinator
|
||||||
required property bool authenticationActive
|
required property bool authenticationActive
|
||||||
required property var keepAwakeController
|
required property var keepAwakeController
|
||||||
|
required property var drawerService
|
||||||
required property var osdService
|
required property var osdService
|
||||||
screen: modelData
|
screen: modelData
|
||||||
|
|
||||||
|
function closeDrawer() { drawerIndicator.close(false) }
|
||||||
|
function toggleDrawer() { drawerIndicator.toggle() }
|
||||||
|
|
||||||
// INVARIANT: une seule surface de barre porte l'inhibiteur, même avec
|
// INVARIANT: une seule surface de barre porte l'inhibiteur, même avec
|
||||||
// plusieurs sorties. La tasse et les demandes applicatives sont deux
|
// plusieurs sorties. La tasse et les demandes applicatives sont deux
|
||||||
// propriétaires indépendants ; la libération de l'un préserve l'autre.
|
// propriétaires indépendants ; la libération de l'un préserve l'autre.
|
||||||
IdleInhibitor {
|
IdleInhibitor {
|
||||||
|
id: idleInhibitor
|
||||||
window: bar
|
window: bar
|
||||||
enabled: bar.startupHost && (bar.keepAwakeController.keepAwake
|
enabled: bar.startupHost && (bar.keepAwakeController.keepAwake
|
||||||
|| bar.keepAwakeController.applicationRequestCount > 0)
|
|| 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 {
|
anchors {
|
||||||
|
|
@ -115,6 +143,7 @@ PanelWindow {
|
||||||
dateCenter.requestClose();
|
dateCenter.requestClose();
|
||||||
windowStrip.closeMenu();
|
windowStrip.closeMenu();
|
||||||
rightStatusArea.closeRemovableMedia();
|
rightStatusArea.closeRemovableMedia();
|
||||||
|
bar.closeDrawer();
|
||||||
}
|
}
|
||||||
function onApplicationsOpening(output) {
|
function onApplicationsOpening(output) {
|
||||||
bar.closeOverview();
|
bar.closeOverview();
|
||||||
|
|
@ -123,6 +152,7 @@ PanelWindow {
|
||||||
dateCenter.requestClose();
|
dateCenter.requestClose();
|
||||||
windowStrip.closeMenu();
|
windowStrip.closeMenu();
|
||||||
rightStatusArea.closeRemovableMedia();
|
rightStatusArea.closeRemovableMedia();
|
||||||
|
bar.closeDrawer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IpcHandler {
|
IpcHandler {
|
||||||
|
|
@ -171,6 +201,7 @@ PanelWindow {
|
||||||
dateCenter.requestClose();
|
dateCenter.requestClose();
|
||||||
windowStrip.closeMenu();
|
windowStrip.closeMenu();
|
||||||
rightStatusArea.closeRemovableMedia();
|
rightStatusArea.closeRemovableMedia();
|
||||||
|
bar.closeDrawer();
|
||||||
clipboardLoader.active = true;
|
clipboardLoader.active = true;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -181,26 +212,41 @@ PanelWindow {
|
||||||
function closeOverview() {
|
function closeOverview() {
|
||||||
overviewRequested = false;
|
overviewRequested = false;
|
||||||
if (overviewLoader.item) overviewLoader.item.dismiss();
|
if (overviewLoader.item) overviewLoader.item.dismiss();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
function toggleOverview() {
|
function openOverview() {
|
||||||
if (authenticationActive) return false;
|
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();
|
closeApplications();
|
||||||
closeClipboard();
|
closeClipboard();
|
||||||
controlCenter.visible = false;
|
controlCenter.visible = false;
|
||||||
dateCenter.requestClose();
|
dateCenter.requestClose();
|
||||||
windowStrip.closeMenu();
|
windowStrip.closeMenu();
|
||||||
rightStatusArea.closeRemovableMedia();
|
rightStatusArea.closeRemovableMedia();
|
||||||
|
bar.closeDrawer();
|
||||||
overviewRequested = true;
|
overviewRequested = true;
|
||||||
if (!overviewCapture.running) overviewCapture.running = true;
|
if (!overviewCapture.running) overviewCapture.running = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
function toggleOverview() {
|
||||||
|
if (authenticationActive) return false;
|
||||||
|
if (overviewRequested || overviewLoader.active) return closeOverview();
|
||||||
|
return openOverview();
|
||||||
|
}
|
||||||
property bool overviewRequested: false
|
property bool overviewRequested: false
|
||||||
readonly property string overviewBackend: (Quickshell.env("XDG_CONFIG_HOME") ||
|
readonly property string overviewBackend: (Quickshell.env("XDG_CONFIG_HOME") ||
|
||||||
(Quickshell.env("HOME") + "/.config")) + "/quickshell/labfy-sway/overview/backend.py"
|
(Quickshell.env("HOME") + "/.config")) + "/quickshell/labfy-sway/overview/backend.py"
|
||||||
IpcHandler {
|
IpcHandler {
|
||||||
target: "overviewUi-" + bar.screen.name
|
target: "overviewUi-" + bar.screen.name
|
||||||
function toggle(): bool { return bar.toggleOverview(); }
|
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 {
|
function state(): string {
|
||||||
return JSON.stringify({ loaded: overviewLoader.active,
|
return JSON.stringify({ loaded: overviewLoader.active,
|
||||||
visible: !!overviewLoader.item && overviewLoader.item.visible,
|
visible: !!overviewLoader.item && overviewLoader.item.visible,
|
||||||
|
|
@ -308,6 +354,16 @@ PanelWindow {
|
||||||
} : null });
|
} : 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 {
|
LazyLoader {
|
||||||
id: clipboardLoader
|
id: clipboardLoader
|
||||||
active: false
|
active: false
|
||||||
|
|
@ -327,10 +383,23 @@ PanelWindow {
|
||||||
controlCenter.visible = false;
|
controlCenter.visible = false;
|
||||||
windowStrip.closeMenu();
|
windowStrip.closeMenu();
|
||||||
rightStatusArea.closeRemovableMedia();
|
rightStatusArea.closeRemovableMedia();
|
||||||
|
bar.closeDrawer();
|
||||||
if (opening) dateCenter.requestOpen();
|
if (opening) dateCenter.requestOpen();
|
||||||
else dateCenter.requestClose();
|
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 {
|
Item {
|
||||||
id: leftArea
|
id: leftArea
|
||||||
anchors.left: parent.left
|
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 {
|
Workspaces {
|
||||||
id: workspaces
|
id: workspaces
|
||||||
screen: bar.screen
|
screen: bar.screen
|
||||||
anchors.left: resizeIndicator.right
|
anchors.left: drawerIndicator.right
|
||||||
anchors.leftMargin: resizeIndicator.visible ? 6 : 0
|
anchors.leftMargin: drawerIndicator.visible ? 6
|
||||||
|
: resizeIndicator.visible ? 6 : 0
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -411,6 +495,8 @@ PanelWindow {
|
||||||
WindowStrip {
|
WindowStrip {
|
||||||
id: windowStrip
|
id: windowStrip
|
||||||
screen: bar.screen
|
screen: bar.screen
|
||||||
|
drawerService: bar.drawerService
|
||||||
|
authenticationActive: bar.authenticationActive
|
||||||
anchors.left: workspacesSeparator.right
|
anchors.left: workspacesSeparator.right
|
||||||
anchors.leftMargin: 8
|
anchors.leftMargin: 8
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
|
@ -422,6 +508,7 @@ PanelWindow {
|
||||||
controlCenter.visible = false;
|
controlCenter.visible = false;
|
||||||
dateCenter.requestClose();
|
dateCenter.requestClose();
|
||||||
rightStatusArea.closeRemovableMedia();
|
rightStatusArea.closeRemovableMedia();
|
||||||
|
bar.closeDrawer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -465,6 +552,7 @@ PanelWindow {
|
||||||
dateCenter.requestClose();
|
dateCenter.requestClose();
|
||||||
windowStrip.closeMenu();
|
windowStrip.closeMenu();
|
||||||
rightStatusArea.closeRemovableMedia();
|
rightStatusArea.closeRemovableMedia();
|
||||||
|
bar.closeDrawer();
|
||||||
controlCenter.currentPage = page;
|
controlCenter.currentPage = page;
|
||||||
controlCenter.visible = true;
|
controlCenter.visible = true;
|
||||||
}
|
}
|
||||||
|
|
@ -476,6 +564,7 @@ PanelWindow {
|
||||||
dateCenter.requestClose();
|
dateCenter.requestClose();
|
||||||
windowStrip.closeMenu();
|
windowStrip.closeMenu();
|
||||||
rightStatusArea.closeRemovableMedia();
|
rightStatusArea.closeRemovableMedia();
|
||||||
|
bar.closeDrawer();
|
||||||
controlCenter.currentPage = 0;
|
controlCenter.currentPage = 0;
|
||||||
controlCenter.visible = opening;
|
controlCenter.visible = opening;
|
||||||
}
|
}
|
||||||
|
|
@ -488,6 +577,7 @@ PanelWindow {
|
||||||
dateCenter.requestClose();
|
dateCenter.requestClose();
|
||||||
windowStrip.closeMenu();
|
windowStrip.closeMenu();
|
||||||
rightStatusArea.setRemovableMediaOpen(opening);
|
rightStatusArea.setRemovableMediaOpen(opening);
|
||||||
|
bar.closeDrawer();
|
||||||
}
|
}
|
||||||
onToggleClipboardRequested: bar.toggleClipboard()
|
onToggleClipboardRequested: bar.toggleClipboard()
|
||||||
}
|
}
|
||||||
|
|
@ -501,6 +591,8 @@ PanelWindow {
|
||||||
onVisibleChanged: bar.osdService.setPanel(bar.screen, visible, currentPage)
|
onVisibleChanged: bar.osdService.setPanel(bar.screen, visible, currentPage)
|
||||||
onCurrentPageChanged: 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 {
|
Connections {
|
||||||
target: controlCenter
|
target: controlCenter
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
|
|
|
||||||
|
|
@ -46,15 +46,20 @@ Bar
|
||||||
```
|
```
|
||||||
|
|
||||||
Dans **Réglages rapides → Maintenir éveillé**, sélectionner 30 minutes,
|
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
|
1 heure, 2 heures ou **Jusqu’à désactivation**. L’icône reste présente même
|
||||||
pendant l’inhibition ; son infobulle indique le temps restant et un clic rouvre
|
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
|
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`
|
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.
|
(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
|
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
|
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.
|
le cycle de vie de `swayidle.service` ne sont pas modifiés.
|
||||||
|
|
||||||
Les demandes automatiques des applications arrivent séparément par le pont
|
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 |
|
| Ancien bloc | QuickShell |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| scratchpad indicator | volontairement non repris |
|
| scratchpad indicator | tiroir natif SwayFX, voir [scratchpad/README.md](scratchpad/README.md) |
|
||||||
| focused_window | WindowStrip |
|
| focused_window | WindowStrip |
|
||||||
| music | Date Center MPRIS |
|
| music | Date Center MPRIS |
|
||||||
| bluetooth | status + Control Center |
|
| bluetooth | status + Control Center |
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,21 @@ Item {
|
||||||
font.pixelSize: 12
|
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 {
|
Repeater {
|
||||||
model: [
|
model: [
|
||||||
{ label: "30 minutes", minutes: 30 },
|
{ label: "30 minutes", minutes: 30 },
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,20 @@ Item {
|
||||||
property var currentSocket: null
|
property var currentSocket: null
|
||||||
property string generation: ""
|
property string generation: ""
|
||||||
property int lastSequence: 0
|
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) {
|
function receive(socket, line) {
|
||||||
if (socket !== currentSocket || line.length > 512) {
|
if (socket !== currentSocket || line.length > 512) {
|
||||||
|
|
@ -31,6 +45,8 @@ Item {
|
||||||
}
|
}
|
||||||
generation = frame.generation;
|
generation = frame.generation;
|
||||||
lastSequence = frame.sequence;
|
lastSequence = frame.sequence;
|
||||||
|
if (applicationRequestCount !== frame.count)
|
||||||
|
console.info("Demandes applicatives", frame.count, "ms", Date.now());
|
||||||
applicationRequestCount = frame.count;
|
applicationRequestCount = frame.count;
|
||||||
// CONTRACT: this acknowledges assignment of the QuickShell state,
|
// CONTRACT: this acknowledges assignment of the QuickShell state,
|
||||||
// not a compositor confirmation (idle-inhibit-v1 has no such ack).
|
// not a compositor confirmation (idle-inhibit-v1 has no such ack).
|
||||||
|
|
@ -40,21 +56,29 @@ Item {
|
||||||
}
|
}
|
||||||
|
|
||||||
SocketServer {
|
SocketServer {
|
||||||
active: true
|
active: bridge.socketReady
|
||||||
path: Quickshell.env("LABFY_IDLE_BRIDGE_SOCKET")
|
path: Quickshell.env("LABFY_IDLE_BRIDGE_SOCKET")
|
||||||
|| (Quickshell.env("XDG_RUNTIME_DIR") + "/labfy-idle-bridge.sock")
|
|| (Quickshell.env("XDG_RUNTIME_DIR") + "/labfy-idle-bridge.sock")
|
||||||
handler: Socket {
|
handler: Socket {
|
||||||
id: peer
|
id: peer
|
||||||
onConnectedChanged: {
|
onConnectedChanged: {
|
||||||
if (connected) {
|
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)
|
if (bridge.currentSocket && bridge.currentSocket !== peer)
|
||||||
bridge.currentSocket.connected = false;
|
bridge.currentSocket.connected = false;
|
||||||
bridge.currentSocket = peer;
|
bridge.currentSocket = peer;
|
||||||
|
if (bridge.applicationRequestCount !== 0)
|
||||||
|
console.info("Demandes applicatives", 0, "raison reconnexion", "ms", Date.now());
|
||||||
bridge.applicationRequestCount = 0;
|
bridge.applicationRequestCount = 0;
|
||||||
bridge.generation = "";
|
bridge.generation = "";
|
||||||
bridge.lastSequence = 0;
|
bridge.lastSequence = 0;
|
||||||
} else if (bridge.currentSocket === peer) {
|
} else if (bridge.currentSocket === peer) {
|
||||||
|
console.info("Pont d'inhibition déconnecté", "ms", Date.now());
|
||||||
bridge.currentSocket = null;
|
bridge.currentSocket = null;
|
||||||
|
if (bridge.applicationRequestCount !== 0)
|
||||||
|
console.info("Demandes applicatives", 0, "raison déconnexion", "ms", Date.now());
|
||||||
bridge.applicationRequestCount = 0;
|
bridge.applicationRequestCount = 0;
|
||||||
bridge.generation = "";
|
bridge.generation = "";
|
||||||
bridge.lastSequence = 0;
|
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
|
automatique tant qu'elle est visible ; les miniatures déjà validées restent
|
||||||
disponibles.
|
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
|
## Provenance des miniatures
|
||||||
|
|
||||||
`grim -o <sortie> -s 0.25 -t jpeg -q 72` prend un **snapshot réel de la sortie
|
`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 string actionName: ""
|
||||||
property var dirtyCaptures: []
|
property var dirtyCaptures: []
|
||||||
property bool skipDismissCaptures: false
|
property bool skipDismissCaptures: false
|
||||||
|
property bool closing: false
|
||||||
property var pendingWindowNumbers: []
|
property var pendingWindowNumbers: []
|
||||||
property var activeWindowNumbers: []
|
property var activeWindowNumbers: []
|
||||||
property int windowCaptureCursor: 0
|
property int windowCaptureCursor: 0
|
||||||
|
|
@ -47,6 +48,10 @@ PanelWindow {
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
|
|
||||||
function dismiss() {
|
function dismiss() {
|
||||||
|
if (closing) return;
|
||||||
|
// INVARIANT: relâcher pendant la fermeture ne valide aucun glissement.
|
||||||
|
closing = true;
|
||||||
|
dropTarget = 0;
|
||||||
visible = false;
|
visible = false;
|
||||||
// WHY: grim inclurait cette surface overlay. Après sa disparition,
|
// WHY: grim inclurait cette surface overlay. Après sa disparition,
|
||||||
// seules les sorties visibles dont l'arbre a changé sont recapturées.
|
// seules les sorties visibles dont l'arbre a changé sont recapturées.
|
||||||
|
|
@ -576,6 +581,7 @@ PanelWindow {
|
||||||
overview.selectedWorkspace = workspaceCard.number;
|
overview.selectedWorkspace = workspaceCard.number;
|
||||||
}
|
}
|
||||||
onClicked: mouse => {
|
onClicked: mouse => {
|
||||||
|
if (overview.closing) return;
|
||||||
overview.selectedWindow = windowVisual.index + 1;
|
overview.selectedWindow = windowVisual.index + 1;
|
||||||
if (mouse.button === Qt.RightButton) {
|
if (mouse.button === Qt.RightButton) {
|
||||||
overview.moveTarget = workspaceCard.number === 10 ? 1 : workspaceCard.number + 1;
|
overview.moveTarget = workspaceCard.number === 10 ? 1 : workspaceCard.number + 1;
|
||||||
|
|
@ -590,6 +596,7 @@ PanelWindow {
|
||||||
overview.dropTarget = target !== workspaceCard.number ? target : 0;
|
overview.dropTarget = target !== workspaceCard.number ? target : 0;
|
||||||
}
|
}
|
||||||
onReleased: mouse => {
|
onReleased: mouse => {
|
||||||
|
if (overview.closing) return;
|
||||||
if (drag.active) {
|
if (drag.active) {
|
||||||
const point = windowPointer.mapToItem(keyboardRoot, mouse.x, mouse.y);
|
const point = windowPointer.mapToItem(keyboardRoot, mouse.x, mouse.y);
|
||||||
const target = overview.workspaceAt(point.x, point.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")
|
overlay_outputs = [item["name"] for item in outputs if item.get("active")
|
||||||
and any(surface.get("namespace") in {
|
and any(surface.get("namespace") in {
|
||||||
"labfy-workspace-overview", "labfy-applications-menu",
|
"labfy-workspace-overview", "labfy-applications-menu",
|
||||||
"labfy-setting-osd"}
|
"labfy-scratchpad-drawer", "labfy-setting-osd"}
|
||||||
for surface in item.get("layer_shell_surfaces") or [])]
|
for surface in item.get("layer_shell_surfaces") or [])]
|
||||||
workspace_nodes = {}
|
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 "idlebridge"
|
||||||
import "theme"
|
import "theme"
|
||||||
import "polkit"
|
import "polkit"
|
||||||
|
import "scratchpad"
|
||||||
import "osd"
|
import "osd"
|
||||||
|
|
||||||
ShellRoot {
|
ShellRoot {
|
||||||
id: shell
|
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
|
// CONTRACT: one PolkitAgent owns the native request queue for the entire
|
||||||
// user session. A dialog is constructed only for its current flow.
|
// user session. A dialog is constructed only for its current flow.
|
||||||
PolkitAgent { id: polkitAgent }
|
PolkitAgent { id: polkitAgent }
|
||||||
OsdService { id: shellOsdService; authenticationActive: shell.authenticationActive }
|
|
||||||
property var polkitScreen: null
|
property var polkitScreen: null
|
||||||
readonly property bool authenticationActive: polkitAgent.flow !== 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()
|
signal authenticationOpening()
|
||||||
function choosePolkitScreen(output) {
|
function choosePolkitScreen(output) {
|
||||||
const screens = Quickshell.screens;
|
const screens = Quickshell.screens;
|
||||||
|
|
@ -98,41 +105,82 @@ ShellRoot {
|
||||||
if (polkitAgent.flow) beginPolkitPresentation();
|
if (polkitAgent.flow) beginPolkitPresentation();
|
||||||
}
|
}
|
||||||
property bool resizeMode: false
|
property bool resizeMode: false
|
||||||
// CONTRACT: une seule sélection pour toutes les barres. L'inhibition est
|
// CONTRACT: seules la sélection manuelle et son échéance passent d'une
|
||||||
// volontairement éphémère : un redémarrage de QuickShell la libère.
|
// génération QML à la suivante. Ni socket ni objet Wayland n'est conservé.
|
||||||
property bool keepAwake: false
|
// Un arrêt du processus ou une nouvelle session repartent désactivés.
|
||||||
property int keepAwakeMinutes: 0
|
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
|
property int keepAwakeRemainingSeconds: 0
|
||||||
// INVARIANT: the helper's application requests never change the user's
|
// INVARIANT: the helper's application requests never change the user's
|
||||||
// manual duration or its monotonic expiry timer.
|
// manual duration or its monotonic expiry timer.
|
||||||
IdleBridgeReceiver { id: idleBridge }
|
IdleBridgeReceiver { id: idleBridge }
|
||||||
readonly property int applicationRequestCount: idleBridge.applicationRequestCount
|
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) {
|
function setKeepAwake(minutes) {
|
||||||
if (![0, 30, 60, 120].includes(minutes)) return false;
|
if (![0, 30, 60, 120].includes(minutes)) return false;
|
||||||
keepAwake = true;
|
// INVARIANT: l'échéance est écrite avant l'activation, afin qu'un
|
||||||
keepAwakeMinutes = minutes;
|
// 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;
|
keepAwakeRemainingSeconds = minutes * 60;
|
||||||
if (minutes > 0) keepAwakeElapsed.restartMs();
|
console.info("Maintien manuel activé", minutes === 0 ? "illimité" : minutes + " min",
|
||||||
|
"échéance", manualKeepAwake.deadlineEpochMs);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
function clearKeepAwake() {
|
function clearKeepAwake(reason) {
|
||||||
keepAwake = false;
|
if (manualKeepAwake.active)
|
||||||
keepAwakeMinutes = 0;
|
console.info("Maintien manuel désactivé", reason || "action utilisateur");
|
||||||
|
manualKeepAwake.active = false;
|
||||||
|
manualKeepAwake.minutes = 0;
|
||||||
|
manualKeepAwake.deadlineEpochMs = 0;
|
||||||
keepAwakeRemainingSeconds = 0;
|
keepAwakeRemainingSeconds = 0;
|
||||||
}
|
}
|
||||||
// WHY: l'horloge monotone évite qu'un changement d'heure allonge ou
|
function refreshKeepAwake(reason) {
|
||||||
// raccourcisse une durée choisie. Le tick ne sert qu'à publier l'affichage.
|
if (!manualKeepAwake.active) return;
|
||||||
ElapsedTimer { id: keepAwakeElapsed }
|
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 {
|
Timer {
|
||||||
interval: 1000
|
interval: 1000
|
||||||
repeat: true
|
repeat: true
|
||||||
running: shell.keepAwake && shell.keepAwakeMinutes > 0
|
running: shell.keepAwake && shell.keepAwakeMinutes > 0
|
||||||
onTriggered: {
|
onTriggered: shell.refreshKeepAwake("minuteur")
|
||||||
const remaining = shell.keepAwakeMinutes * 60
|
|
||||||
- Math.floor(keepAwakeElapsed.elapsedMs() / 1000);
|
|
||||||
if (remaining <= 0) shell.clearKeepAwake();
|
|
||||||
else shell.keepAwakeRemainingSeconds = remaining;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// CONTRACT: one owner across all output bars; opening a menu asks every
|
// CONTRACT: one owner across all output bars; opening a menu asks every
|
||||||
// bar to release its other transient panels before the overlay appears.
|
// bar to release its other transient panels before the overlay appears.
|
||||||
|
|
@ -202,6 +250,7 @@ ShellRoot {
|
||||||
model: Quickshell.screens
|
model: Quickshell.screens
|
||||||
|
|
||||||
Bar {
|
Bar {
|
||||||
|
drawerService: shellDrawerService
|
||||||
osdService: shellOsdService
|
osdService: shellOsdService
|
||||||
applicationCoordinator: shell
|
applicationCoordinator: shell
|
||||||
authenticationActive: shell.authenticationActive
|
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 {
|
Item {
|
||||||
id: indicator
|
id: indicator
|
||||||
required property bool active
|
required property bool manualActive
|
||||||
|
required property bool automaticActive
|
||||||
required property bool unlimited
|
required property bool unlimited
|
||||||
required property int remainingSeconds
|
required property int remainingSeconds
|
||||||
signal activated()
|
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
|
height: 26
|
||||||
visible: active
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
anchors.fill: parent
|
|
||||||
radius: 4
|
|
||||||
color: pointer.containsMouse ? Theme.buttonHover : Theme.emphasisBackground
|
|
||||||
}
|
|
||||||
NerdIcon {
|
NerdIcon {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: ""
|
text: ""
|
||||||
font.pixelSize: 22
|
font.pixelSize: 22
|
||||||
color: Theme.accentForeground
|
color: indicator.active ? Theme.red : Theme.foreground
|
||||||
}
|
}
|
||||||
MouseArea {
|
MouseArea {
|
||||||
id: pointer
|
id: pointer
|
||||||
|
|
@ -32,8 +36,6 @@ Item {
|
||||||
StatusTooltip {
|
StatusTooltip {
|
||||||
target: indicator
|
target: indicator
|
||||||
hovered: pointer.containsMouse
|
hovered: pointer.containsMouse
|
||||||
message: indicator.unlimited ? "Maintenir éveillé · jusqu’à désactivation"
|
message: indicator.detail
|
||||||
: "Maintenir éveillé · " + Math.ceil(indicator.remainingSeconds / 60)
|
|
||||||
+ " min restantes"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,8 @@ Row {
|
||||||
spacing: 5
|
spacing: 5
|
||||||
height: 26
|
height: 26
|
||||||
KeepAwakeIndicator {
|
KeepAwakeIndicator {
|
||||||
active: area.keepAwakeController.keepAwake
|
manualActive: area.keepAwakeController.keepAwake
|
||||||
|
automaticActive: area.keepAwakeController.applicationRequestCount > 0
|
||||||
remainingSeconds: area.keepAwakeController.keepAwakeRemainingSeconds
|
remainingSeconds: area.keepAwakeController.keepAwakeRemainingSeconds
|
||||||
unlimited: area.keepAwakeController.keepAwakeMinutes === 0
|
unlimited: area.keepAwakeController.keepAwakeMinutes === 0
|
||||||
onActivated: area.openPageRequested(16)
|
onActivated: area.openPageRequested(16)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ PopupWindow {
|
||||||
property var anchorItem: null
|
property var anchorItem: null
|
||||||
property var selectedWindow: null
|
property var selectedWindow: null
|
||||||
property var windows: []
|
property var windows: []
|
||||||
|
signal storeRequested(int id)
|
||||||
readonly property var appWindows: selectedWindow
|
readonly property var appWindows: selectedWindow
|
||||||
? windows.filter(window => window.appKey === selectedWindow.appKey) : []
|
? windows.filter(window => window.appKey === selectedWindow.appKey) : []
|
||||||
readonly property var entry: selectedWindow
|
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 {
|
Text {
|
||||||
visible: menu.actions.length > 0
|
visible: menu.actions.length > 0
|
||||||
text: "Actions"
|
text: "Actions"
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ Item {
|
||||||
id: strip
|
id: strip
|
||||||
|
|
||||||
required property var screen
|
required property var screen
|
||||||
|
required property var drawerService
|
||||||
|
required property bool authenticationActive
|
||||||
required property int maxWidth
|
required property int maxWidth
|
||||||
property var tree: null
|
property var tree: null
|
||||||
property bool componentReady: false
|
property bool componentReady: false
|
||||||
|
|
@ -50,7 +52,7 @@ Item {
|
||||||
function collect(container) {
|
function collect(container) {
|
||||||
const appId = container.app_id
|
const appId = container.app_id
|
||||||
|| (container.window_properties && container.window_properties.class) || "";
|
|| (container.window_properties && container.window_properties.class) || "";
|
||||||
if (container.type === "con" && appId) {
|
if ((container.type === "con" || container.type === "floating_con") && appId) {
|
||||||
found.push({
|
found.push({
|
||||||
id: container.id,
|
id: container.id,
|
||||||
title: container.name || appId,
|
title: container.name || appId,
|
||||||
|
|
@ -120,6 +122,7 @@ Item {
|
||||||
}
|
}
|
||||||
|
|
||||||
function showMenu(item, windowInfo) {
|
function showMenu(item, windowInfo) {
|
||||||
|
if (authenticationActive) return;
|
||||||
if (windowMenu.visible && menuWindow && menuWindow.id === windowInfo.id) {
|
if (windowMenu.visible && menuWindow && menuWindow.id === windowInfo.id) {
|
||||||
windowMenu.visible = false;
|
windowMenu.visible = false;
|
||||||
return;
|
return;
|
||||||
|
|
@ -240,5 +243,9 @@ Item {
|
||||||
anchorItem: strip.menuAnchor
|
anchorItem: strip.menuAnchor
|
||||||
selectedWindow: strip.menuWindow
|
selectedWindow: strip.menuWindow
|
||||||
windows: strip.windows
|
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.
|
# Super+W appartient à l'Overview ; conserver tabbed sur Super+Shift+W.
|
||||||
bindsym $mod+Shift+w layout tabbed
|
bindsym $mod+Shift+w layout tabbed
|
||||||
bindsym $mod+w exec ~/.config/sway/scripts/overview-quickshell
|
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
|
bindsym $mod+e layout toggle split
|
||||||
|
|
||||||
# Make the current focus fullscreen
|
# 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