feat(quickshell): add auto-hide Tela dock with shared favorites
This commit is contained in:
parent
a556cd56b7
commit
d4e2a0013e
32 changed files with 1683 additions and 63 deletions
178
docs/quickshell-dock-tela-shared-favorites-v1.md
Normal file
178
docs/quickshell-dock-tela-shared-favorites-v1.md
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
# Dock QuickShell, Tela et favoris partagés V1
|
||||
|
||||
Implémentation dans le worktree `/home/fy59/.dotfiles-worktrees/quickshell-dock-tela`,
|
||||
branche `codex/quickshell-dock-tela`, base `a556cd56`. Aucun commit ni déploiement.
|
||||
|
||||
## Architecture et persistance
|
||||
|
||||
`applications/FavoritesService.qml` est le singleton du processus QuickShell.
|
||||
Le menu et chaque dock lisent exactement ses `ids`, son catalogue et ses chemins
|
||||
d’icônes. `ApplicationIcon.qml` est leur composant visuel commun.
|
||||
Le menu conserve sa navigation clavier, sa recherche, ses catégories et son
|
||||
chemin de lancement UWSM/GIO. Les handlers clavier passent explicitement avant
|
||||
le traitement interne des contrôles (`Keys.BeforeItem`) pour respecter le cycle
|
||||
Tab recherche → catégories → grille → recherche. Le chemin du backend est désormais relatif au module,
|
||||
ce qui permet aussi de tester un worktree sans modifier la configuration active.
|
||||
|
||||
`~/.local/state/labfy-sway/applications-favorites.json` reste le seul stockage,
|
||||
avec le même format version 1. Aucun changement de schéma. Les neuf favoris
|
||||
existants ne sont ni migrés ni réordonnés au démarrage. Une liste vide existante
|
||||
reste vide ; seul un fichier absent autorise le seed historique du menu.
|
||||
Un favori absent du catalogue reste enregistré et apparaît dans le dock comme
|
||||
indisponible, avec possibilité de le désépingler. Une réinstallation le résout
|
||||
à nouveau sans changer son ordre.
|
||||
|
||||
Le backend Python conserve le verrou de répertoire `flock`, le fichier temporaire
|
||||
privé, le remplacement atomique et les `fsync`. Les opérations ajoutées sont :
|
||||
|
||||
- `favorites toggle ID` : bascule atomique, y compris pour deux clics rapides
|
||||
avant réception de la première réponse.
|
||||
- `favorites move ID AVANT_ID` : déplacement avant une cible encore présente.
|
||||
Une chaîne vide comme cible signifie la fin de la liste. Source ou cible
|
||||
disparue : refus sans écriture. Aucun remplacement global d’une liste périmée.
|
||||
- `serve` : protocole JSON par lignes sur stdin/stdout, lecture initiale,
|
||||
mutations sérialisées et surveillance Gio du répertoire. Les remplacements
|
||||
atomiques et les écritures des anciens clients CLI sont observés.
|
||||
|
||||
La réponse publiée relit le stockage. Le QML ne modifie jamais les favoris par
|
||||
anticipation ; un refus publie une erreur et laisse les deux interfaces sur le
|
||||
même état durable. Les autres processus restent protégés par le même verrou.
|
||||
Entrées bornées à 16 Kio, limite existante de 64 favoris conservée.
|
||||
|
||||
## Icônes
|
||||
|
||||
Le réglage unique est `ICON_THEME` dans `applications/shared.py`, actuellement
|
||||
`Tela-circle-purple-dark`. Une instance **privée** de `Gtk.IconTheme` effectue
|
||||
la résolution du `GIcon` fourni par Gio pour chaque `.desktop`, avec l’héritage
|
||||
natif du thème. Les icônes absolues sont traitées comme `GFileIcon` et les URI
|
||||
échappent correctement espaces et caractères spéciaux. Les icônes installées
|
||||
par l’application dans hicolor/pixmaps servent de repli ; ensuite vient
|
||||
`application-x-executable`. Le composant QML dessine un secours local si un
|
||||
fichier est absent ou illisible, sans dépendre d’une police d’icônes.
|
||||
|
||||
Aucun réglage GTK/Qt global n’est écrit. Le processus utilise PyGObject/Gio déjà
|
||||
employé par le backend et le typelib GTK 3 déjà présent sur cet hôte ; aucun
|
||||
paquet n’a été installé. Le résolveur natif GTK évite de réimplémenter les règles
|
||||
d’héritage, là où `Quickshell.iconPath` ne permet pas de choisir un thème privé.
|
||||
Yazi, OrcaSlicer et AppFlowy n’ont pas d’icône correspondante dans Tela installé :
|
||||
leurs icônes hicolor sont volontairement conservées. Aucun nom d’application
|
||||
n’est transformé en chemin d’icône codé en dur.
|
||||
|
||||
## Dock et focus
|
||||
|
||||
Un `dock/Dock.qml` par sortie, centré et sans zone exclusive. Surface layer-shell
|
||||
`Top`, namespace `labfy-sway-dock`, `WlrKeyboardFocus.None`. Les menus contextuels
|
||||
sont dessinés dans cette même surface : ils n’installent aucun grab clavier.
|
||||
Palette et couleurs viennent du thème Catppuccin existant. Icônes au repos à
|
||||
46 px, agrandissement jusqu’à 67 px avec influence décroissante sur les voisines.
|
||||
Les positions au repos servent au calcul pour éviter une boucle taille/position.
|
||||
Les mouvements et le glissement vertical sont animés. Le contenu dépassement
|
||||
reste défilable horizontalement avec une barre de défilement.
|
||||
|
||||
Machine à états pure dans `dock/AutoHide.js` : `hidden`, `arming`, `shown`,
|
||||
`waiting`, `held`. Au démarrage : `hidden`. Bande sensible de 4 px, centrée,
|
||||
limitée à 360 px au repos ; attente de 180 ms avant apparition, grâce de 450 ms
|
||||
avant fermeture. **Un seul Timer** porte l’échéance de la machine. Pression,
|
||||
glisser-déposer et menu contextuel bloquent la fermeture. Le masque d’entrée
|
||||
laisse passer le pointeur hors du corps, du menu et de la bande. Les changements
|
||||
de résolution recalculent la largeur disponible. Polkit et le menu d’applications
|
||||
suppriment temporairement le dock via son propriétaire dans `shell.qml`.
|
||||
|
||||
Le bloc ajouté à `sway/.config/sway/swayfx` limite le flou au namespace du dock
|
||||
et reprend `$bar_blur`. Aucun effet global supplémentaire.
|
||||
|
||||
## Fenêtres et actions
|
||||
|
||||
Un seul `swaymsg -m -t subscribe` pour les événements window/workspace/output,
|
||||
possédé par le singleton. Après coupure, relance à 1,5 s et réconciliation de
|
||||
l’arbre. Les rafales sont regroupées à 60 ms, et un indicateur dirty préserve
|
||||
un événement survenu pendant une requête. Aucune interrogation périodique en
|
||||
fonctionnement normal. Les requêtes Python utilisent directement le socket IPC,
|
||||
un encodage little-endian explicite, une réponse limitée à 16 Mio et un timeout.
|
||||
|
||||
Correspondances par app_id, classe/instance XWayland, StartupWMClass, identifiant
|
||||
desktop et exécutable. Les alias ambigus ne sont pas attribués ; aucun titre
|
||||
n’est utilisé pour reconnaître une application. Les titres servent seulement
|
||||
à afficher le choix entre fenêtres.
|
||||
|
||||
Clic gauche : lancement d’un favori installé sans fenêtre, focus explicite d’une
|
||||
fenêtre, ou choix si plusieurs fenêtres. Les applications ouvertes non épinglées
|
||||
suivent un séparateur. Clic droit : fenêtres, ouvrir, épingler/désépingler, fermer
|
||||
le menu. Glisser un favori puis relâcher sur sa nouvelle position enregistre son
|
||||
ordre. Clic molette : demande de nouvelle instance pour les terminaux dont la
|
||||
politique est connue (`NEW_INSTANCE_IDS`), sans promesse pour une application
|
||||
singleton inconnue.
|
||||
|
||||
`focus` valide le con_id dans l’arbre courant ; un scratchpad caché est montré
|
||||
avant focus. Cette commande n’est appelée que par les handlers de clic. Ni la
|
||||
machine à états, ni le watcher, ni les animations ne l’appellent. Les surfaces
|
||||
layer-shell n’entrent pas dans l’arbre des toplevels et les snapshots Session V2.
|
||||
Aucun fichier du greeter, PAM, Keyring ou moteur Session V2 n’est modifié.
|
||||
|
||||
## Validation reproductible
|
||||
|
||||
Depuis le worktree, sans arrêter la session graphique active :
|
||||
|
||||
```sh
|
||||
python -m unittest discover -s tests/applications -v
|
||||
node tests/applications/test_model.js
|
||||
python -m unittest discover -s tests/dock -v
|
||||
node tests/dock/test_models.js
|
||||
python tests/dock/headless.py
|
||||
python tests/dock/shell_load.py
|
||||
python tests/dock/session_compat.py
|
||||
python tests/session_v2/validate_keyring.py --sway /usr/bin/sway
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Les tests graphiques créent un compositeur SwayFX headless enfant avec HOME,
|
||||
XDG, socket Wayland et socket Sway privés. Le test Firefox utilise son propre
|
||||
profil. `wtype` envoie Tab uniquement au compositeur privé. Le proxy de test coupe
|
||||
les connexions IPC pour exercer la reconnexion réelle. Les enfants du laboratoire
|
||||
sont arrêtés à la sortie. Aucun environnement ni journal Session V2 utilisateur
|
||||
n’est réutilisé.
|
||||
|
||||
Les captures `validation/quickshell-dock-tela/{closed,open,zoom}.png` et le JSON
|
||||
`result.json` proviennent de ce laboratoire. Les scénarios incluent clic droit,
|
||||
choix/pinning, drag souris réel, agrandissement mesuré, entrée/sortie, ordre durable,
|
||||
refus de déplacement, zéro favori après redémarrage, installation/désinstallation
|
||||
d’une entrée de catalogue, client CLI externe, navigation clavier du menu,
|
||||
Firefox, deux Kitty, flottant, scratchpad, fermeture et résolution.
|
||||
|
||||
La suite de non-régression Session V2/Keyring couvre 93 tests backend et ses
|
||||
scénarios graphiques. `session_compat.py` exécute aussi les scénarios chooser et
|
||||
Keyring existants avec le dock présent : prêt, acceptation, annulation, timeout,
|
||||
absence, verrouillage persistant, collection différente, redémarrage pendant
|
||||
l’unlock, succès/échec de restauration, nouvelle session et focus différé réel.
|
||||
|
||||
## Limites et intégration après revue
|
||||
|
||||
- Une application sans identité desktop correspondante reste visible sous son
|
||||
identité fenêtre avec une icône générique, sans épinglage inventé. Les alias
|
||||
réellement nécessaires peuvent être ajoutés après observation de l’app_id ou
|
||||
de StartupWMClass ; le titre ne doit pas devenir une heuristique.
|
||||
- Yazi/Neovim hébergés dans une fenêtre Kitty ordinaire ont l’identité Kitty.
|
||||
Le dock ne déduit pas leur identité du titre ou du processus enfant. Le
|
||||
comportement de lancement terminal existant est conservé.
|
||||
- XWayland est couvert par des arbres de test ; la recette graphique privée
|
||||
désactive XWayland. Une vérification avec une vraie application X11 reste utile.
|
||||
- Le greeter/PAM réels ne sont pas exécutés dans la recette. La compatibilité
|
||||
Keyring et restauration est exercée avec leurs fixtures privées et les vrais
|
||||
composants de production. Un cycle de connexion matériel reste une recette
|
||||
manuelle après revue, jamais déclenchée ici.
|
||||
- Les PNG ne prouvent pas à eux seuls la qualité du flou sur une fenêtre réelle ;
|
||||
la règle SwayFX est chargée dans le laboratoire. Aucun test tactile ou écran
|
||||
à facteur d’échelle fractionnaire n’est revendiqué.
|
||||
|
||||
La livraison reste dans le worktree. Examiner les sources, les tests, les PNG et
|
||||
le patch complet adjacent au worktree. Après validation humaine, appliquer le
|
||||
patch sur la base compatible dans le dépôt principal, sans copier le fichier de
|
||||
favoris. Vérifier d’abord avec `git apply --check CHEMIN_DU_PATCH`, puis appliquer
|
||||
selon votre procédure de revue. Recharger ensuite QuickShell et Sway pour le
|
||||
nouveau module et la règle de flou. Le rechargement n’a pas été exécuté ici.
|
||||
|
||||
## Références des contrats de plateforme
|
||||
|
||||
- [QuickShell WlrLayershell](https://quickshell.org/docs/v0.3.1/types/Quickshell.Wayland/WlrLayershell/) : modes de focus layer-shell, recoupés avec les types installés de 0.3.2.
|
||||
- [Spécification des thèmes d’icônes](https://specifications.freedesktop.org/icon-theme/) : résolution et héritage délégués au toolkit.
|
||||
- [Protocole IPC i3/Sway](https://i3wm.org/docs/ipc.html) : framing et événements, vérifiés avec le SwayFX privé installé.
|
||||
BIN
docs/validation/quickshell-dock-tela/closed.png
Normal file
BIN
docs/validation/quickshell-dock-tela/closed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
1
docs/validation/quickshell-dock-tela/headless.log
Normal file
1
docs/validation/quickshell-dock-tela/headless.log
Normal file
|
|
@ -0,0 +1 @@
|
|||
Dock headless: PASS /tmp/labfy-session-v2-TEST_ONLY-88x0lspm
|
||||
BIN
docs/validation/quickshell-dock-tela/open.png
Normal file
BIN
docs/validation/quickshell-dock-tela/open.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
225
docs/validation/quickshell-dock-tela/result.json
Normal file
225
docs/validation/quickshell-dock-tela/result.json
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
{
|
||||
"status": "PASS",
|
||||
"initial": {
|
||||
"ready": true,
|
||||
"ids": [
|
||||
"firefox.desktop",
|
||||
"kitty.desktop",
|
||||
"yazi.desktop",
|
||||
"nvim.desktop",
|
||||
"org.darktable.darktable.desktop",
|
||||
"orca-slicer.desktop",
|
||||
"org.freecad.FreeCAD.desktop",
|
||||
"org.kde.digikam.desktop",
|
||||
"AppFlowy.desktop"
|
||||
],
|
||||
"error": "",
|
||||
"phase": "hidden",
|
||||
"revealed": false,
|
||||
"entries": [
|
||||
{
|
||||
"id": "firefox.desktop",
|
||||
"name": "Mozilla Firefox",
|
||||
"genericName": "Web Browser",
|
||||
"keywords": [
|
||||
"Internet",
|
||||
"WWW",
|
||||
"Browser",
|
||||
"Web",
|
||||
"Explorer"
|
||||
],
|
||||
"icon": "firefox",
|
||||
"iconSource": "file:///usr/share/icons/Tela-circle-purple-dark/scalable/apps/firefox.svg",
|
||||
"aliases": [
|
||||
"firefox"
|
||||
],
|
||||
"newInstance": false,
|
||||
"category": "Internet",
|
||||
"pinned": true,
|
||||
"windows": []
|
||||
},
|
||||
{
|
||||
"id": "kitty.desktop",
|
||||
"name": "kitty",
|
||||
"genericName": "Terminal emulator",
|
||||
"keywords": [],
|
||||
"icon": "kitty",
|
||||
"iconSource": "file:///usr/share/icons/Tela-circle-purple-dark/scalable/apps/kitty.svg",
|
||||
"aliases": [
|
||||
"kitty"
|
||||
],
|
||||
"newInstance": true,
|
||||
"category": "Syst\u00e8me",
|
||||
"pinned": true,
|
||||
"windows": []
|
||||
},
|
||||
{
|
||||
"id": "yazi.desktop",
|
||||
"name": "Yazi File Manager",
|
||||
"genericName": "",
|
||||
"keywords": [
|
||||
"File",
|
||||
"Manager",
|
||||
"Explorer",
|
||||
"Browser",
|
||||
"Launcher"
|
||||
],
|
||||
"icon": "yazi",
|
||||
"iconSource": "file:///usr/share/icons/hicolor/48x48/apps/yazi.png",
|
||||
"aliases": [
|
||||
"yazi"
|
||||
],
|
||||
"newInstance": false,
|
||||
"category": "Syst\u00e8me",
|
||||
"pinned": true,
|
||||
"windows": []
|
||||
},
|
||||
{
|
||||
"id": "nvim.desktop",
|
||||
"name": "Neovim",
|
||||
"genericName": "Text Editor",
|
||||
"keywords": [
|
||||
"Text",
|
||||
"editor"
|
||||
],
|
||||
"icon": "nvim",
|
||||
"iconSource": "file:///usr/share/icons/Tela-circle-purple-dark/scalable/apps/nvim.svg",
|
||||
"aliases": [
|
||||
"nvim"
|
||||
],
|
||||
"newInstance": false,
|
||||
"category": "D\u00e9veloppement",
|
||||
"pinned": true,
|
||||
"windows": []
|
||||
},
|
||||
{
|
||||
"id": "org.darktable.darktable.desktop",
|
||||
"name": "Darktable Photo Workflow Software",
|
||||
"genericName": "Virtual Lighttable and Darkroom",
|
||||
"keywords": [
|
||||
"graphics",
|
||||
"photography",
|
||||
"raw"
|
||||
],
|
||||
"icon": "darktable",
|
||||
"iconSource": "file:///usr/share/icons/Tela-circle-purple-dark/scalable/apps/darktable.svg",
|
||||
"aliases": [
|
||||
"darktable",
|
||||
"org.darktable.darktable"
|
||||
],
|
||||
"newInstance": false,
|
||||
"category": "Graphisme",
|
||||
"pinned": true,
|
||||
"windows": []
|
||||
},
|
||||
{
|
||||
"id": "orca-slicer.desktop",
|
||||
"name": "OrcaSlicer",
|
||||
"genericName": "",
|
||||
"keywords": [],
|
||||
"icon": "OrcaSlicer",
|
||||
"iconSource": "file:///usr/share/icons/hicolor/192x192/apps/OrcaSlicer.png",
|
||||
"aliases": [
|
||||
"apprun",
|
||||
"orca-slicer"
|
||||
],
|
||||
"newInstance": false,
|
||||
"category": "Utilitaires",
|
||||
"pinned": true,
|
||||
"windows": []
|
||||
},
|
||||
{
|
||||
"id": "org.freecad.FreeCAD.desktop",
|
||||
"name": "FreeCAD",
|
||||
"genericName": "CAD Application",
|
||||
"keywords": [],
|
||||
"icon": "org.freecad.FreeCAD",
|
||||
"iconSource": "file:///usr/share/icons/Tela-circle-purple-dark/scalable/apps/org.freecad.FreeCAD.svg",
|
||||
"aliases": [
|
||||
"freecad",
|
||||
"org.freecad.freecad"
|
||||
],
|
||||
"newInstance": false,
|
||||
"category": "Graphisme",
|
||||
"pinned": true,
|
||||
"windows": []
|
||||
},
|
||||
{
|
||||
"id": "org.kde.digikam.desktop",
|
||||
"name": "digiKam",
|
||||
"genericName": "Photo Management Program",
|
||||
"keywords": [],
|
||||
"icon": "digikam",
|
||||
"iconSource": "file:///usr/share/icons/Tela-circle-purple-dark/scalable/apps/digikam.svg",
|
||||
"aliases": [
|
||||
"digikam",
|
||||
"org.kde.digikam"
|
||||
],
|
||||
"newInstance": false,
|
||||
"category": "Graphisme",
|
||||
"pinned": true,
|
||||
"windows": []
|
||||
},
|
||||
{
|
||||
"id": "AppFlowy.desktop",
|
||||
"name": "AppFlowy",
|
||||
"genericName": "",
|
||||
"keywords": [
|
||||
"Notes"
|
||||
],
|
||||
"icon": "appflowy",
|
||||
"iconSource": "file:///usr/share/icons/hicolor/scalable/apps/appflowy.svg",
|
||||
"aliases": [
|
||||
"appflowy"
|
||||
],
|
||||
"newInstance": false,
|
||||
"category": "Internet",
|
||||
"pinned": true,
|
||||
"windows": []
|
||||
}
|
||||
],
|
||||
"theme": "Tela-circle-purple-dark",
|
||||
"menu": null,
|
||||
"searchFocused": false,
|
||||
"navFocused": false,
|
||||
"gridFocused": false,
|
||||
"screenWidth": 1280,
|
||||
"bodyY": 364,
|
||||
"width": 538,
|
||||
"pointerX": -1000,
|
||||
"tileWidth": 54,
|
||||
"context": false
|
||||
},
|
||||
"reordered": [
|
||||
"kitty.desktop",
|
||||
"firefox.desktop",
|
||||
"yazi.desktop",
|
||||
"nvim.desktop",
|
||||
"org.darktable.darktable.desktop",
|
||||
"orca-slicer.desktop",
|
||||
"org.freecad.FreeCAD.desktop",
|
||||
"org.kde.digikam.desktop",
|
||||
"AppFlowy.desktop"
|
||||
],
|
||||
"checks": [
|
||||
"shared favorites",
|
||||
"menu and dock mutations",
|
||||
"durable reorder",
|
||||
"empty restart",
|
||||
"autohide",
|
||||
"context hold",
|
||||
"kitty multi-window",
|
||||
"floating scratchpad explicit focus",
|
||||
"window close",
|
||||
"no hover focus",
|
||||
"real pointer drag",
|
||||
"zoom growth",
|
||||
"IPC reconnect",
|
||||
"Firefox private profile",
|
||||
"desktop install/remove monitoring",
|
||||
"external writer",
|
||||
"keyboard menu navigation",
|
||||
"resolution change",
|
||||
"SwayFX dock blur rule"
|
||||
]
|
||||
}
|
||||
18
docs/validation/quickshell-dock-tela/session-compat.log
Normal file
18
docs/validation/quickshell-dock-tela/session-compat.log
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/tmp/labfy-session-v2-TEST_ONLY-43bkifog
|
||||
ready PASS
|
||||
accept PASS
|
||||
cancel PASS
|
||||
timeout PASS
|
||||
absent PASS
|
||||
still-locked PASS
|
||||
different PASS
|
||||
quickshell-restart-during-unlock PASS
|
||||
keyring_ui WITH_DOCK PASS
|
||||
success PASS
|
||||
failure PASS
|
||||
invalid PASS
|
||||
new PASS
|
||||
claimed PASS
|
||||
real PASS
|
||||
deferred-real PASS
|
||||
phase3b_ui WITH_DOCK PASS
|
||||
13
docs/validation/quickshell-dock-tela/session-v2-results.json
Normal file
13
docs/validation/quickshell-dock-tela/session-v2-results.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"backend": 0,
|
||||
"focus": 0,
|
||||
"qml-unit": 0,
|
||||
"qml-qt": 0,
|
||||
"keyring-ui": 0,
|
||||
"keyring-runtime": 0,
|
||||
"scratchpad-runtime": 0,
|
||||
"phase3b-ui": 0,
|
||||
"p0-runtime": 0,
|
||||
"path-runtime": 0,
|
||||
"diff-check": 0
|
||||
}
|
||||
1
docs/validation/quickshell-dock-tela/shell-load.log
Normal file
1
docs/validation/quickshell-dock-tela/shell-load.log
Normal file
|
|
@ -0,0 +1 @@
|
|||
Full shell private-compositor load: PASS /tmp/labfy-session-v2-TEST_ONLY-rfgtrd4k
|
||||
31
docs/validation/quickshell-dock-tela/summary.json
Normal file
31
docs/validation/quickshell-dock-tela/summary.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"status": "PASS",
|
||||
"baseline": "a556cd56",
|
||||
"quickshell": "0.3.2",
|
||||
"sway": "SwayFX 0.6 / Sway 1.12.0",
|
||||
"tests": {
|
||||
"existingApplicationsPython": 7,
|
||||
"dockPython": 6,
|
||||
"existingApplicationsJS": "PASS",
|
||||
"dockModelsJS": "PASS",
|
||||
"sessionV2BackendPython": 93,
|
||||
"sessionV2ValidationGroups": 11,
|
||||
"keyringWithDock": "PASS (8 cases)",
|
||||
"restoreUIWithDock": "PASS (7 cases)",
|
||||
"headless": "PASS",
|
||||
"fullShellRelativeImports": "PASS",
|
||||
"diffCheck": "PASS"
|
||||
},
|
||||
"activeSession": "not modified; original nine favorites preserved",
|
||||
"limitations": [
|
||||
"XWayland fixture coverage only",
|
||||
"physical greeter/PAM login not exercised",
|
||||
"terminal-host identity is preserved",
|
||||
"fractional scale and touch not exercised"
|
||||
],
|
||||
"notes": [
|
||||
"Absolute-import fixture emits QuickShell scanner warnings; actual relative-import shell loads.",
|
||||
"Virtual keyboard teardown emits Qt Wayland leave/text-input warnings; keyboard cycle passes.",
|
||||
"Full shell intentionally has no system services in the private compositor."
|
||||
]
|
||||
}
|
||||
11
docs/validation/quickshell-dock-tela/unit-tests.log
Normal file
11
docs/validation/quickshell-dock-tela/unit-tests.log
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
test_inheritance_from_a_private_test_theme (test_shared.Icons.test_inheritance_from_a_private_test_theme) ... ok
|
||||
test_tela_desktop_icon_and_absolute (test_shared.Icons.test_tela_desktop_icon_and_absolute) ... ok
|
||||
test_nine_move_missing_anchor_and_empty (test_shared.Persistence.test_nine_move_missing_anchor_and_empty) ... ok
|
||||
test_real_concurrent_processes_keep_all_additions_during_moves (test_shared.Persistence.test_real_concurrent_processes_keep_all_additions_during_moves) ... ok
|
||||
test_toggle_is_atomic_and_does_not_depend_on_a_stale_ui_list (test_shared.Persistence.test_toggle_is_atomic_and_does_not_depend_on_a_stale_ui_list) ... ok
|
||||
test_wayland_xwayland_scratchpad_and_explicit_focus (test_shared.Sway.test_wayland_xwayland_scratchpad_and_explicit_focus) ... ok
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 6 tests in 0.705s
|
||||
|
||||
OK
|
||||
BIN
docs/validation/quickshell-dock-tela/zoom.png
Normal file
BIN
docs/validation/quickshell-dock-tela/zoom.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
|
|
@ -103,8 +103,7 @@ PanelWindow {
|
|||
|
||||
property string applicationsError: ""
|
||||
property string queuedApplicationId: ""
|
||||
readonly property string applicationsBackend: (Quickshell.env("XDG_CONFIG_HOME") ||
|
||||
(Quickshell.env("HOME") + "/.config")) + "/quickshell/labfy-sway/applications/backend.py"
|
||||
readonly property string applicationsBackend: FavoritesService.backend
|
||||
function closeApplications() { applicationCoordinator.applicationsOutput = ""; }
|
||||
function toggleApplications() {
|
||||
if (authenticationActive) return false;
|
||||
|
|
|
|||
|
|
@ -389,3 +389,10 @@ relance n’est automatique, y compris après redémarrage QuickShell.
|
|||
Voir [Session Restore V2](../../../../docs/session-restore-v2.md) pour les
|
||||
commandes, les preuves et les limites de l’environnement. La recette réussie
|
||||
du 7 octobre est historique ; elle ne valide pas cette révision au login réel.
|
||||
|
||||
## Dock et favoris partagés
|
||||
|
||||
Le dock à masquage automatique et le menu utilisent `applications/FavoritesService.qml`
|
||||
et `ApplicationIcon.qml`. Le stockage existant reste inchangé. Architecture,
|
||||
interactions, tests headless, limites et procédure d’intégration :
|
||||
[Dock Tela / favoris V1](../../../../docs/quickshell-dock-tela-shared-favorites-v1.md).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import QtQuick
|
||||
import "../theme"
|
||||
|
||||
Item {
|
||||
property string iconSource: ""
|
||||
Image {
|
||||
id: image
|
||||
anchors.fill: parent
|
||||
source: parent.iconSource
|
||||
sourceSize.width: Math.ceil(width * 2)
|
||||
sourceSize.height: Math.ceil(height * 2)
|
||||
fillMode: Image.PreserveAspectFit
|
||||
asynchronous: true
|
||||
}
|
||||
// CONTRACT: a corrupt, removed or unresolvable icon always has a visible
|
||||
// fallback independent of any installed font or desktop icon theme.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
visible: image.status !== Image.Ready
|
||||
radius: width / 2; color: Theme.surface1
|
||||
border.color: Theme.lavender
|
||||
Text { anchors.centerIn: parent; text: "◇"; color: Theme.lavender; font.pixelSize: parent.width * 0.6 }
|
||||
}
|
||||
}
|
||||
|
|
@ -12,8 +12,8 @@ PanelWindow {
|
|||
signal menuDismissed()
|
||||
signal launchRequested(string desktopId)
|
||||
property string errorMessage: ""
|
||||
property var allApps: []
|
||||
property var favoriteIds: []
|
||||
readonly property var allApps: FavoritesService.apps
|
||||
readonly property var favoriteIds: FavoritesService.ids
|
||||
property string selectedCategory: "Toutes"
|
||||
property int selectedIndex: 0
|
||||
property int navIndex: 0
|
||||
|
|
@ -44,10 +44,6 @@ PanelWindow {
|
|||
WlrLayershell.namespace: "labfy-applications-menu"
|
||||
color: "transparent"
|
||||
|
||||
function refreshCatalog() {
|
||||
allApps = ApplicationsModel.catalog(DesktopEntries.applications.values);
|
||||
if (!favoriteIds.length && selectedCategory === "Favoris") selectedCategory = "Toutes";
|
||||
}
|
||||
function chooseCategory(key) {
|
||||
selectedCategory = key;
|
||||
navIndex = navigation.findIndex(item => item.key === key);
|
||||
|
|
@ -63,10 +59,7 @@ PanelWindow {
|
|||
if (app) launchRequested(app.id);
|
||||
}
|
||||
function toggleFavorite(id) {
|
||||
const had = favoriteIds.includes(id);
|
||||
favoriteIds = had ? favoriteIds.filter(value => value !== id) : favoriteIds.concat([id]);
|
||||
Quickshell.execDetached(["python3", "-B", backend, "favorites", had ? "remove" : "add", id]);
|
||||
if (selectedCategory === "Favoris" && favoriteIds.length === 0) chooseCategory("Toutes");
|
||||
FavoritesService.toggle(id);
|
||||
}
|
||||
function dismiss() {
|
||||
if (closing) return;
|
||||
|
|
@ -80,39 +73,10 @@ PanelWindow {
|
|||
interval: 150; repeat: false
|
||||
onTriggered: { menu.visible = false; menu.menuDismissed(); }
|
||||
}
|
||||
readonly property string backend: (Quickshell.env("XDG_CONFIG_HOME") ||
|
||||
(Quickshell.env("HOME") + "/.config")) + "/quickshell/labfy-sway/applications/backend.py"
|
||||
|
||||
onVisibleAppsChanged: selectedIndex = Math.min(selectedIndex, Math.max(0, visibleApps.length - 1))
|
||||
Connections {
|
||||
target: DesktopEntries
|
||||
function onApplicationsChanged() { menu.refreshCatalog(); }
|
||||
}
|
||||
Process {
|
||||
id: favoritesReader
|
||||
command: ["python3", "-B", menu.backend, "favorites", "read"]
|
||||
stdout: StdioCollector { id: favoritesOutput; waitForEnd: true }
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0) { menu.errorMessage = "Favoris indisponibles"; return; }
|
||||
try {
|
||||
const data = JSON.parse(favoritesOutput.text);
|
||||
if (data.exists) {
|
||||
menu.favoriteIds = data.ids;
|
||||
} else {
|
||||
const suggested = ApplicationsModel.initialFavorites(menu.allApps);
|
||||
menu.favoriteIds = suggested;
|
||||
Quickshell.execDetached(["python3", "-B", menu.backend, "favorites", "seed"]
|
||||
.concat(suggested));
|
||||
}
|
||||
menu.selectedCategory = menu.favoriteIds.some(id => menu.allApps.some(app => app.id === id))
|
||||
? "Favoris" : "Toutes";
|
||||
menu.navIndex = menu.selectedCategory === "Favoris" ? 0 : 1;
|
||||
} catch (_) { menu.errorMessage = "Favoris invalides"; }
|
||||
}
|
||||
}
|
||||
Component.onCompleted: {
|
||||
refreshCatalog();
|
||||
favoritesReader.running = true;
|
||||
selectedCategory = favoriteIds.some(id => allApps.some(app => app.id === id)) ? "Favoris" : "Toutes";
|
||||
navIndex = selectedCategory === "Favoris" ? 0 : 1;
|
||||
}
|
||||
// The Wayland surface exists after completion; focus at the next frame.
|
||||
Timer { interval: 120; running: true; repeat: false; onTriggered: search.forceActiveFocus() }
|
||||
|
|
@ -166,6 +130,10 @@ PanelWindow {
|
|||
font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 13
|
||||
background: Item {}
|
||||
onTextChanged: { menu.selectedIndex = 0; grid.positionViewAtBeginning(); }
|
||||
// WHY: TextField's built-in Tab navigation consumes the
|
||||
// event before AfterItem handlers. Preserve this menu's
|
||||
// explicit search/sidebar/grid keyboard cycle.
|
||||
Keys.priority: Keys.BeforeItem
|
||||
Keys.onPressed: event => {
|
||||
if (event.key === Qt.Key_Escape) { menu.dismiss(); event.accepted = true; }
|
||||
else if (event.key === Qt.Key_Backtab ||
|
||||
|
|
@ -182,8 +150,8 @@ PanelWindow {
|
|||
}
|
||||
}
|
||||
Text {
|
||||
visible: menu.errorMessage !== ""
|
||||
width: parent.width; text: menu.errorMessage
|
||||
visible: menu.errorMessage !== "" || FavoritesService.error !== ""
|
||||
width: parent.width; text: menu.errorMessage || FavoritesService.error
|
||||
color: Theme.danger; font.pixelSize: 12; elide: Text.ElideRight
|
||||
}
|
||||
Item {
|
||||
|
|
@ -199,6 +167,7 @@ PanelWindow {
|
|||
anchors.fill: parent; anchors.margins: 5
|
||||
clip: true; spacing: 3
|
||||
model: menu.navigation
|
||||
Keys.priority: Keys.BeforeItem
|
||||
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
||||
Keys.onPressed: event => {
|
||||
if (event.key === Qt.Key_Up || event.key === Qt.Key_Down) {
|
||||
|
|
@ -255,6 +224,7 @@ PanelWindow {
|
|||
visible: menu.visibleApps.length > 0
|
||||
clip: true
|
||||
model: menu.visibleApps
|
||||
Keys.priority: Keys.BeforeItem
|
||||
cellWidth: Math.max(80, Math.floor(width / (width >= 420 ? 4 : 3)))
|
||||
cellHeight: 104
|
||||
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
||||
|
|
@ -293,23 +263,12 @@ PanelWindow {
|
|||
border.color: menu.selectedIndex === index && grid.activeFocus
|
||||
? Theme.lavender : "transparent"
|
||||
}
|
||||
Image {
|
||||
ApplicationIcon {
|
||||
id: appIcon
|
||||
anchors.top: parent.top; anchors.topMargin: 12
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: 46; height: 46
|
||||
source: modelData.icon.startsWith("/") ? "file://" + modelData.icon
|
||||
: Quickshell.iconPath(modelData.icon || "application-x-executable",
|
||||
"application-x-executable")
|
||||
sourceSize.width: 46; sourceSize.height: 46
|
||||
fillMode: Image.PreserveAspectFit
|
||||
asynchronous: true
|
||||
}
|
||||
Text {
|
||||
visible: appIcon.status === Image.Error
|
||||
anchors.centerIn: appIcon
|
||||
text: ""; color: Theme.lavender
|
||||
font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 32
|
||||
iconSource: modelData.iconSource
|
||||
}
|
||||
Text {
|
||||
anchors.top: appIcon.bottom; anchors.topMargin: 5
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ function catalog(entries) {
|
|||
.map(entry => ({ id: entry.id.endsWith(".desktop") ? entry.id : entry.id + ".desktop",
|
||||
name: entry.name || entry.id,
|
||||
genericName: entry.genericName || "", keywords: entry.keywords || [],
|
||||
icon: entry.icon || "", category: classify(entry) }))
|
||||
icon: entry.icon || "", iconSource: entry.iconSource || "",
|
||||
aliases: entry.aliases || [], newInstance: !!entry.newInstance, category: classify(entry) }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "fr", { sensitivity: "base" }) || a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
pragma Singleton
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import "ApplicationsModel.js" as Model
|
||||
import "WindowModel.js" as Windows
|
||||
|
||||
Singleton {
|
||||
id: service
|
||||
// CONTRACT: both interfaces bind these backend-authoritative properties.
|
||||
// No UI writes ids; a failed command never leaves an optimistic favorite.
|
||||
property var ids: []
|
||||
property var apps: []
|
||||
property var windows: []
|
||||
property bool ready: false
|
||||
property bool available: false
|
||||
property string error: ""
|
||||
property string iconTheme: ""
|
||||
readonly property string backend: decodeURIComponent(Qt.resolvedUrl("backend.py").toString().replace(/^file:\/\//, ""))
|
||||
readonly property var dockEntries: Windows.entries(apps, ids, windows)
|
||||
function mutate(action, values) {
|
||||
if (!available) { error = "Service de favoris indisponible"; return; }
|
||||
bridge.write(JSON.stringify({action: action, ids: values}) + "\n");
|
||||
}
|
||||
function toggle(id) { mutate("toggle", [id]); }
|
||||
function move(id, before) { mutate("move", [id, before]); }
|
||||
function launch(id) { act("launch", id); }
|
||||
function focusWindow(id) { act("focus", id); }
|
||||
function act(kind, id) {
|
||||
// INVARIANT: callers are user-click handlers only. Neither snapshots,
|
||||
// state transitions nor animations may call this action boundary.
|
||||
if (action.running) { error = "Action en cours"; return; }
|
||||
action.command = ["python3", "-B", backend, kind, id];
|
||||
action.running = true;
|
||||
}
|
||||
Process {
|
||||
id: bridge
|
||||
command: ["python3", "-B", service.backend, "serve"]
|
||||
stdinEnabled: true
|
||||
running: true
|
||||
stdout: SplitParser {
|
||||
onRead: data => {
|
||||
try {
|
||||
const state = JSON.parse(data);
|
||||
service.error = state.error || "";
|
||||
if (!state.ids) return;
|
||||
service.ids = state.ids;
|
||||
service.apps = Model.catalog(state.apps);
|
||||
service.iconTheme = state.theme;
|
||||
service.ready = true;
|
||||
service.available = true;
|
||||
// WHY: an existing empty file means intentionally empty.
|
||||
// Seed only an absent file; backend repeats that check under lock.
|
||||
if (!state.exists) service.mutate("seed", Model.initialFavorites(service.apps));
|
||||
} catch (_) { service.error = "Réponse des favoris invalide"; }
|
||||
}
|
||||
}
|
||||
onExited: { service.available = false; reconnect.start(); }
|
||||
}
|
||||
Timer { id: reconnect; interval: 1500; onTriggered: bridge.running = true }
|
||||
Process {
|
||||
id: action
|
||||
stdout: StdioCollector { id: actionReply; waitForEnd: true }
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0) {
|
||||
try { service.error = JSON.parse(actionReply.text).error || "Action refusée"; }
|
||||
catch (_) { service.error = "Action refusée"; }
|
||||
}
|
||||
}
|
||||
}
|
||||
// CONTRACT: one native event subscription for all outputs. Reconcile on
|
||||
// connect and after bursts; dirty preserves events received during a query.
|
||||
property bool dirty: false
|
||||
function refreshWindows() { dirty = true; if (!snapshot.running) settle.restart(); }
|
||||
Process {
|
||||
id: subscription
|
||||
command: ["swaymsg", "-m", "-t", "subscribe", '["window","workspace","output"]']
|
||||
running: true
|
||||
stdout: SplitParser { onRead: data => service.refreshWindows() }
|
||||
onStarted: service.refreshWindows()
|
||||
onExited: { service.windows = []; reconnectEvents.restart(); }
|
||||
}
|
||||
// WHY: the native I3 listener in 0.3.2 did not resubscribe after a forced
|
||||
// socket loss in the private-compositor test. This one owned process has
|
||||
// a single restart deadline; there is no steady-state window polling.
|
||||
Timer { id: reconnectEvents; interval: 1500; onTriggered: subscription.running = true }
|
||||
Timer { id: settle; interval: 60; onTriggered: { service.dirty = false; snapshot.running = true; } }
|
||||
Timer { id: retrySnapshot; interval: 1500; onTriggered: service.refreshWindows() }
|
||||
Process {
|
||||
id: snapshot
|
||||
command: ["python3", "-B", service.backend, "windows"]
|
||||
stdout: StdioCollector { id: snapshotReply; waitForEnd: true }
|
||||
onExited: (code, status) => {
|
||||
if (code === 0) {
|
||||
try { service.windows = JSON.parse(snapshotReply.text).windows; }
|
||||
catch (_) { service.windows = []; }
|
||||
} else { service.windows = []; retrySnapshot.restart(); }
|
||||
if (service.dirty) settle.restart();
|
||||
}
|
||||
}
|
||||
Component.onCompleted: refreshWindows()
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
.pragma library
|
||||
|
||||
function match(apps, window) {
|
||||
// CONTRACT: declared identities only, never titles. Exact desktop stem
|
||||
// wins; an ambiguous executable/WM_CLASS alias must remain unknown.
|
||||
const keys = [window.appId, window.wmClass, window.instance]
|
||||
.filter(Boolean).map(value => value.toLowerCase());
|
||||
for (const key of keys) {
|
||||
const exact = apps.filter(app => app.id.slice(0, -8).toLowerCase() === key);
|
||||
if (exact.length === 1) return exact[0];
|
||||
const aliases = apps.filter(app => (app.aliases || []).includes(key));
|
||||
if (aliases.length === 1) return aliases[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function entries(apps, ids, windows) {
|
||||
const byId = new Map(apps.map(app => [app.id, app]));
|
||||
const result = ids.map(id => Object.assign({}, byId.get(id) || {
|
||||
id: id, name: id.replace(/\.desktop$/, "") + " (indisponible)",
|
||||
iconSource: "", newInstance: false, missing: true
|
||||
}, {pinned: true, windows: []}));
|
||||
const groups = new Map(result.map(entry => [entry.id, entry]));
|
||||
for (const window of windows) {
|
||||
const app = match(apps, window);
|
||||
const id = app ? app.id : "window:" + (window.appId || window.wmClass || window.id);
|
||||
if (!groups.has(id)) {
|
||||
const entry = Object.assign({}, app || {id: id, name: window.appId || window.wmClass || "Application",
|
||||
iconSource: "", missing: true, newInstance: false}, {pinned: false, windows: []});
|
||||
groups.set(id, entry); result.push(entry);
|
||||
}
|
||||
groups.get(id).windows.push(window);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -57,18 +57,31 @@ def write_favorites(path, action, ids):
|
|||
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
fcntl.flock(directory, fcntl.LOCK_EX)
|
||||
if (action in ("add", "remove", "toggle") and (len(ids) != 1 or not valid_id(ids[0]))) or (
|
||||
action == "move" and (len(ids) != 2 or not valid_id(ids[0])
|
||||
or (ids[1] and not valid_id(ids[1])))):
|
||||
raise ValueError("Identifiant de favori invalide")
|
||||
exists, current = read_favorites(path)
|
||||
if action == "seed":
|
||||
if exists:
|
||||
return current
|
||||
current = list(dict.fromkeys(ids))[:MAX_FAVORITES]
|
||||
elif action == "add":
|
||||
elif action == "add" or (action == "toggle" and ids[0] not in current):
|
||||
if ids[0] not in current:
|
||||
if len(current) >= MAX_FAVORITES:
|
||||
raise ValueError("Trop de favoris")
|
||||
current.append(ids[0])
|
||||
elif action == "remove":
|
||||
elif action in ("remove", "toggle"):
|
||||
current = [item for item in current if item != ids[0]]
|
||||
elif action == "move":
|
||||
# CONTRACT: move one existing ID before a live anchor (or to the
|
||||
# end). Never replace a client's stale whole list: concurrent adds
|
||||
# and removes survive. A vanished source/anchor rejects atomically.
|
||||
if len(ids) != 2 or ids[0] not in current or (ids[1] and ids[1] not in current):
|
||||
raise ValueError("Favori déplacé ou cible indisponible")
|
||||
if ids[0] != ids[1]:
|
||||
current.remove(ids[0])
|
||||
current.insert(current.index(ids[1]) if ids[1] else len(current), ids[0])
|
||||
else:
|
||||
raise ValueError("Action de favoris invalide")
|
||||
if any(not valid_id(item) for item in current):
|
||||
|
|
@ -155,10 +168,22 @@ def main(argv):
|
|||
if len(ids) > 4 or any(not valid_id(item) for item in ids):
|
||||
raise ValueError("Favoris initiaux invalides")
|
||||
result = {"ids": write_favorites(state_path(), "seed", ids)}
|
||||
elif len(argv) == 3 and argv[:2] in (["favorites", "add"], ["favorites", "remove"]):
|
||||
elif len(argv) == 3 and argv[:2] in (["favorites", "add"], ["favorites", "remove"], ["favorites", "toggle"]):
|
||||
if not valid_id(argv[2]):
|
||||
raise ValueError("Identifiant de favori invalide")
|
||||
result = {"ids": write_favorites(state_path(), argv[1], [argv[2]])}
|
||||
elif len(argv) == 4 and argv[:2] == ["favorites", "move"]:
|
||||
result = {"ids": write_favorites(state_path(), "move", argv[2:])}
|
||||
elif argv == ["serve"]:
|
||||
from shared import serve
|
||||
return serve()
|
||||
elif argv == ["windows"]:
|
||||
from shared import windows, sway_request
|
||||
result = {"windows": windows(sway_request(4, ""))}
|
||||
elif len(argv) == 2 and argv[0] == "focus":
|
||||
from shared import focus
|
||||
focus(argv[1])
|
||||
result = {"ok": True}
|
||||
elif len(argv) == 2 and argv[0] == "launch":
|
||||
launch(argv[1])
|
||||
result = {"ok": True}
|
||||
|
|
@ -166,7 +191,7 @@ def main(argv):
|
|||
raise ValueError("Commande invalide")
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 0
|
||||
except (OSError, ValueError, GLib.Error, json.JSONDecodeError, subprocess.TimeoutExpired) as error:
|
||||
except (OSError, ValueError, KeyError, GLib.Error, json.JSONDecodeError, subprocess.TimeoutExpired) as error:
|
||||
print(json.dumps({"error": str(error)}, ensure_ascii=False))
|
||||
return 1
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
singleton FavoritesService 1.0 FavoritesService.qml
|
||||
ApplicationsMenu 1.0 ApplicationsMenu.qml
|
||||
ApplicationIcon 1.0 ApplicationIcon.qml
|
||||
196
quickshell/.config/quickshell/labfy-sway/applications/shared.py
Normal file
196
quickshell/.config/quickshell/labfy-sway/applications/shared.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"""Shared application catalog, private icon theme and bounded Sway requests.
|
||||
|
||||
No session/restore state is consulted or changed. The only persistent write
|
||||
remains backend.write_favorites, under its existing directory transaction lock.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
from gi.repository import Gio, GioUnix, GLib, Gtk
|
||||
from backend import state_path, read_favorites, write_favorites, valid_id
|
||||
|
||||
# CONTRACT: private GtkIconTheme instance, never Gtk settings or desktop-wide
|
||||
# theme changes. GTK implements theme inheritance and absolute GIcon lookup.
|
||||
ICON_THEME = 'Tela-circle-purple-dark'
|
||||
# CONTRACT: middle-click is conservative. These terminal launchers create a
|
||||
# new toplevel per invocation. Unknown and single-instance GUI applications
|
||||
# retain their ordinary Open action without promising another instance.
|
||||
NEW_INSTANCE_IDS = frozenset(('kitty.desktop', 'foot.desktop', 'Alacritty.desktop',
|
||||
'org.wezfurlong.wezterm.desktop'))
|
||||
|
||||
|
||||
class IconResolver:
|
||||
def __init__(self, theme=ICON_THEME):
|
||||
self.theme = Gtk.IconTheme.new()
|
||||
self.theme.set_custom_theme(theme)
|
||||
|
||||
def resolve(self, icon):
|
||||
if icon:
|
||||
info = self.theme.lookup_by_gicon(icon, 48, Gtk.IconLookupFlags.FORCE_SIZE)
|
||||
if info and Path(info.get_filename()).is_file():
|
||||
return Path(info.get_filename()).as_uri()
|
||||
info = self.theme.lookup_icon('application-x-executable', 48, 0)
|
||||
return Path(info.get_filename()).as_uri() if info and Path(info.get_filename()).is_file() else ''
|
||||
|
||||
|
||||
def catalog():
|
||||
resolver = IconResolver()
|
||||
result = []
|
||||
for app in Gio.AppInfo.get_all():
|
||||
identifier = app.get_id()
|
||||
if not valid_id(identifier) or not app.should_show() or not isinstance(app, GioUnix.DesktopAppInfo):
|
||||
continue
|
||||
icon = app.get_icon()
|
||||
# WHY: StartupWMClass is the desktop author's declared window identity.
|
||||
# Filename stem and executable basename cover standard Wayland app_ids;
|
||||
# ambiguous aliases are deliberately left unmatched by the UI model.
|
||||
aliases = [identifier[:-8], app.get_startup_wm_class(),
|
||||
os.path.basename(app.get_executable() or '')]
|
||||
result.append(dict(id=identifier, name=app.get_display_name(),
|
||||
genericName=app.get_generic_name() or '',
|
||||
keywords=list(app.get_keywords() or []),
|
||||
categories=(app.get_categories() or '').split(';'),
|
||||
icon=icon.to_string() if icon else '', iconSource=resolver.resolve(icon),
|
||||
aliases=sorted({v.casefold() for v in aliases if v}),
|
||||
# D-Bus activation may only activate an existing window;
|
||||
# do not promise a new instance for these applications.
|
||||
newInstance=identifier in NEW_INSTANCE_IDS and not app.get_boolean('DBusActivatable')))
|
||||
return sorted(result, key=lambda a: a['id'])
|
||||
|
||||
|
||||
def sway_request(kind, payload):
|
||||
# CONTRACT: direct IPC, explicit little-endian widths, bounded reply and
|
||||
# timeout. No shell, no inherited process, no background focus operations.
|
||||
data = payload.encode()
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||
deadline = time.monotonic() + 2
|
||||
sock.settimeout(2)
|
||||
sock.connect(os.environ['SWAYSOCK'])
|
||||
sock.sendall(b'i3-ipc' + struct.pack('<II', len(data), kind) + data)
|
||||
def receive(size):
|
||||
parts = bytearray()
|
||||
while len(parts) < size:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError('Délai IPC Sway dépassé')
|
||||
sock.settimeout(remaining)
|
||||
block = sock.recv(size - len(parts))
|
||||
if not block:
|
||||
raise ValueError('IPC Sway interrompu')
|
||||
parts.extend(block)
|
||||
return parts
|
||||
header = receive(14)
|
||||
length, reply_kind = struct.unpack('<II', header[6:])
|
||||
if header[:6] != b'i3-ipc' or reply_kind != kind or length > 16 * 1024 * 1024:
|
||||
raise ValueError('Réponse IPC Sway invalide')
|
||||
return json.loads(receive(length))
|
||||
|
||||
|
||||
def windows(tree):
|
||||
result = []
|
||||
pending = [(tree, '')]
|
||||
while pending:
|
||||
node, workspace = pending.pop()
|
||||
if node.get('type') == 'workspace':
|
||||
workspace = node.get('name', '')
|
||||
pending.extend((child, workspace) for child in
|
||||
node.get('nodes', []) + node.get('floating_nodes', []))
|
||||
props = node.get('window_properties') or {}
|
||||
if not (node.get('app_id') or node.get('window')):
|
||||
continue
|
||||
result.append(dict(id=str(node['id']), title=node.get('name') or 'Fenêtre',
|
||||
appId=node.get('app_id') or '', wmClass=props.get('class') or '',
|
||||
instance=props.get('instance') or '', workspace=workspace,
|
||||
focused=bool(node.get('focused'))))
|
||||
return sorted(result, key=lambda w: int(w['id']))
|
||||
|
||||
|
||||
def focus(identifier):
|
||||
if not isinstance(identifier, str) or not identifier.isascii() or not identifier.isdigit():
|
||||
raise ValueError('Identifiant de fenêtre invalide')
|
||||
target = next((w for w in windows(sway_request(4, '')) if w['id'] == identifier), None)
|
||||
if not target:
|
||||
raise ValueError('Fenêtre fermée')
|
||||
command = f'[con_id={int(identifier)}] '
|
||||
if target['workspace'] == '__i3_scratch':
|
||||
replies = sway_request(0, command + 'scratchpad show')
|
||||
if not all(reply.get('success') for reply in replies):
|
||||
raise ValueError('Scratchpad indisponible')
|
||||
if not all(reply.get('success') for reply in sway_request(0, command + 'focus')):
|
||||
raise ValueError('Focus refusé')
|
||||
|
||||
|
||||
def serve():
|
||||
"""Single stream serializes UI mutations and disk notifications.
|
||||
|
||||
INVARIANT: every published list is re-read from disk. A rejected mutation
|
||||
publishes the unchanged durable state plus an error, never an optimistic
|
||||
list. File monitoring survives atomic replacement by watching the directory.
|
||||
"""
|
||||
path = state_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
apps = catalog()
|
||||
def publish(error=''):
|
||||
try:
|
||||
exists, ids = read_favorites(path)
|
||||
print(json.dumps(dict(ids=ids, exists=exists, apps=apps,
|
||||
theme=ICON_THEME, error=error)), flush=True)
|
||||
except (OSError, ValueError) as exc:
|
||||
print(json.dumps(dict(error=str(exc))), flush=True)
|
||||
return False
|
||||
monitor = Gio.File.new_for_path(str(path.parent)).monitor_directory(Gio.FileMonitorFlags.NONE, None)
|
||||
pending = [0]
|
||||
def changed(*_args):
|
||||
if not pending[0]:
|
||||
pending[0] = GLib.timeout_add(35, refresh)
|
||||
def refresh():
|
||||
pending[0] = 0
|
||||
return publish()
|
||||
monitor.connect('changed', changed)
|
||||
app_monitor = Gio.AppInfoMonitor.get()
|
||||
def apps_changed(*_args):
|
||||
nonlocal apps
|
||||
apps = catalog()
|
||||
publish()
|
||||
app_monitor.connect('changed', apps_changed)
|
||||
loop = GLib.MainLoop()
|
||||
# CONTRACT: newline JSON input is bounded before parsing. GLib owns the
|
||||
# single main thread; EOF ends this helper with its QuickShell owner.
|
||||
buffer = bytearray()
|
||||
def request(fd, condition):
|
||||
chunk = os.read(fd, 4096)
|
||||
if not chunk:
|
||||
loop.quit()
|
||||
return False
|
||||
buffer.extend(chunk)
|
||||
if len(buffer) > 16384:
|
||||
loop.quit()
|
||||
return False
|
||||
while b'\n' in buffer:
|
||||
line, _, rest = buffer.partition(b'\n')
|
||||
buffer[:] = rest
|
||||
try:
|
||||
data = json.loads(line)
|
||||
action = data.get('action')
|
||||
ids = data.get('ids')
|
||||
if action not in ('add', 'remove', 'toggle', 'move', 'seed') or not isinstance(ids, list):
|
||||
raise ValueError('Commande de favoris invalide')
|
||||
if action == 'seed' and (len(ids) > 4 or any(not valid_id(i) for i in ids)):
|
||||
raise ValueError('Favoris initiaux invalides')
|
||||
write_favorites(path, action, ids)
|
||||
publish()
|
||||
except (OSError, ValueError, TypeError) as exc:
|
||||
publish(str(exc))
|
||||
return True
|
||||
GLib.io_add_watch(sys.stdin.fileno(), GLib.IO_IN | GLib.IO_HUP, request)
|
||||
publish()
|
||||
loop.run()
|
||||
monitor.cancel()
|
||||
return 0
|
||||
21
quickshell/.config/quickshell/labfy-sway/dock/AutoHide.js
Normal file
21
quickshell/.config/quickshell/labfy-sway/dock/AutoHide.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
.pragma library
|
||||
|
||||
// CONTRACT: the sole deadline belongs to arming (edge dwell) or waiting
|
||||
// (exit grace). Holding any interaction cancels it. No animation sends IPC.
|
||||
function next(state, event, input) {
|
||||
if (input.suppressed) return "hidden";
|
||||
if (input.held) return "held";
|
||||
if (input.body) return "shown";
|
||||
if (input.edge) {
|
||||
if (state === "hidden") return "arming";
|
||||
if (state === "arming") return event === "deadline" ? "shown" : "arming";
|
||||
return "shown";
|
||||
}
|
||||
if (state === "hidden" || state === "arming") return "hidden";
|
||||
if (state === "waiting") return event === "deadline" ? "hidden" : "waiting";
|
||||
return "waiting";
|
||||
}
|
||||
|
||||
function influence(distance) {
|
||||
return Math.max(0, 1 - Math.abs(distance) / 115);
|
||||
}
|
||||
263
quickshell/.config/quickshell/labfy-sway/dock/Dock.qml
Normal file
263
quickshell/.config/quickshell/labfy-sway/dock/Dock.qml
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import "../applications"
|
||||
import "../theme"
|
||||
import "AutoHide.js" as AutoHide
|
||||
|
||||
PanelWindow {
|
||||
id: dock
|
||||
required property var modelData
|
||||
property bool suppressed: false
|
||||
screen: modelData
|
||||
anchors.bottom: true
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
exclusiveZone: 0
|
||||
aboveWindows: true
|
||||
WlrLayershell.layer: WlrLayer.Top
|
||||
// CONTRACT: the dock and its in-surface menus never accept keyboard focus.
|
||||
// Layer surfaces are absent from get_tree, hence Session V2 snapshots.
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
WlrLayershell.namespace: "labfy-sway-dock"
|
||||
color: "transparent"
|
||||
implicitWidth: Math.max(96, Math.min(screen.width - 24, items.width + 28))
|
||||
implicitHeight: 360
|
||||
property string phase: "hidden"
|
||||
readonly property bool revealed: phase === "shown" || phase === "waiting" || phase === "held"
|
||||
property bool pressed: false
|
||||
property string draggingId: ""
|
||||
property var contextEntry: null
|
||||
property real contextX: 0
|
||||
property real pointerX: -1000
|
||||
readonly property bool held: pressed || draggingId !== "" || contextEntry !== null
|
||||
readonly property var entries: FavoritesService.dockEntries
|
||||
readonly property int pinnedCount: FavoritesService.ids.length
|
||||
property alias contentItemRow: items
|
||||
property alias surfaceBody: body
|
||||
property alias contextCard: context
|
||||
property alias edgeArea: sensor
|
||||
// INVARIANT: only the bottom activation strip receives input while hidden.
|
||||
// Visible input follows the animated body, with a bridge to the edge.
|
||||
mask: Region {
|
||||
item: sensor
|
||||
Region { item: body; intersection: dock.revealed ? Intersection.Combine : Intersection.Subtract }
|
||||
Region { item: context; intersection: context.visible ? Intersection.Combine : Intersection.Subtract }
|
||||
}
|
||||
function reconcile(event) {
|
||||
phase = AutoHide.next(phase, event || "input", {
|
||||
suppressed: suppressed, held: held,
|
||||
body: bodyHover.hovered || contextHover.hovered,
|
||||
edge: sensor.containsMouse
|
||||
});
|
||||
}
|
||||
onHeldChanged: reconcile()
|
||||
onSuppressedChanged: { contextEntry = null; draggingId = ""; pressed = false; reconcile(); }
|
||||
onEntriesChanged: {
|
||||
if (contextEntry) {
|
||||
contextEntry = entries.find(entry => entry.id === contextEntry.id) || null;
|
||||
}
|
||||
}
|
||||
Timer {
|
||||
id: deadline
|
||||
interval: dock.phase === "arming" ? 180 : 450
|
||||
running: dock.phase === "arming" || dock.phase === "waiting"
|
||||
onTriggered: dock.reconcile("deadline")
|
||||
}
|
||||
function activate(entry) {
|
||||
if (entry.windows.length === 1) FavoritesService.focusWindow(entry.windows[0].id);
|
||||
else if (entry.windows.length > 1) openContext(entry, width / 2);
|
||||
else if (!entry.missing) FavoritesService.launch(entry.id);
|
||||
}
|
||||
function openContext(entry, x) {
|
||||
contextX = x;
|
||||
contextEntry = entry;
|
||||
}
|
||||
function closeContext() { contextEntry = null; }
|
||||
MouseArea {
|
||||
id: sensor
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: dock.revealed ? dock.width : Math.min(360, dock.width)
|
||||
height: 4
|
||||
hoverEnabled: true
|
||||
onEntered: dock.reconcile()
|
||||
onExited: dock.reconcile()
|
||||
onClicked: dock.closeContext()
|
||||
}
|
||||
Item {
|
||||
id: body
|
||||
width: parent.width
|
||||
height: 110
|
||||
y: dock.revealed ? dock.height - height : dock.height + 4
|
||||
Behavior on y { NumberAnimation { duration: 210; easing.type: Easing.OutCubic } }
|
||||
HoverHandler {
|
||||
id: bodyHover
|
||||
onHoveredChanged: { dock.reconcile(); if (!hovered) dock.pointerX = -1000; }
|
||||
onPointChanged: dock.pointerX = point.position.x
|
||||
}
|
||||
Rectangle {
|
||||
// Discreet local shadow, no screen capture or blur sampling.
|
||||
x: 2; y: 34; width: parent.width - 4; height: 70
|
||||
radius: 26; color: Theme.shadow
|
||||
}
|
||||
Rectangle {
|
||||
x: 0; y: 29; width: parent.width; height: 70
|
||||
radius: 25
|
||||
color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, Theme.highContrast ? 1 : 0.89)
|
||||
border.color: Theme.strongBorder; border.width: 1
|
||||
}
|
||||
Flickable {
|
||||
id: viewport
|
||||
x: 12; y: 0; width: parent.width - 24; height: 105
|
||||
contentWidth: items.width; contentHeight: height
|
||||
clip: true
|
||||
interactive: dock.draggingId === ""
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
ScrollBar.horizontal: ScrollBar { policy: ScrollBar.AsNeeded }
|
||||
Row {
|
||||
id: items
|
||||
height: viewport.height
|
||||
spacing: 3
|
||||
move: Transition { NumberAnimation { properties: "x"; duration: 110; easing.type: Easing.OutCubic } }
|
||||
Repeater {
|
||||
id: icons
|
||||
model: dock.entries
|
||||
delegate: Item {
|
||||
id: tile
|
||||
required property var modelData
|
||||
required property int index
|
||||
readonly property bool separator: index === dock.pinnedCount && dock.pinnedCount > 0
|
||||
readonly property real center: viewport.x + x + width / 2 - viewport.contentX
|
||||
// WHY: measuring against the animated x/width feeds
|
||||
// scale back into its own binding. Stable resting slots
|
||||
// keep magnification deterministic and free of loops.
|
||||
readonly property real baseCenter: viewport.x + index * 57 + 27
|
||||
+ (index >= dock.pinnedCount && dock.pinnedCount > 0 ? 13 : 0) - viewport.contentX
|
||||
readonly property real growth: dock.revealed && bodyHover.hovered
|
||||
? AutoHide.influence(baseCenter - dock.pointerX) : 0
|
||||
width: 54 + growth * 19 + (separator ? 13 : 0)
|
||||
height: 105
|
||||
Behavior on width { NumberAnimation { duration: 95; easing.type: Easing.OutCubic } }
|
||||
Rectangle {
|
||||
visible: tile.separator
|
||||
x: 2; y: 45; width: 1; height: 32; color: Theme.strongBorder
|
||||
}
|
||||
ApplicationIcon {
|
||||
id: icon
|
||||
width: 46 + tile.growth * 21; height: width
|
||||
x: (parent.width - width + (tile.separator ? 12 : 0)) / 2
|
||||
y: 83 - height
|
||||
iconSource: tile.modelData.iconSource || ""
|
||||
opacity: dock.draggingId === tile.modelData.id ? 0.5 : 1
|
||||
Behavior on width { NumberAnimation { duration: 95; easing.type: Easing.OutCubic } }
|
||||
}
|
||||
Rectangle {
|
||||
anchors.horizontalCenter: icon.horizontalCenter
|
||||
y: 90; width: 4; height: 4; radius: 2
|
||||
color: Theme.lavender; visible: tile.modelData.windows.length > 0
|
||||
}
|
||||
MouseArea {
|
||||
id: pointer
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
||||
hoverEnabled: true
|
||||
preventStealing: tile.modelData.pinned
|
||||
property real startX: 0
|
||||
property real startY: 0
|
||||
property bool moved: false
|
||||
onPressed: mouse => {
|
||||
dock.pressed = true; startX = mouse.x; startY = mouse.y; moved = false;
|
||||
}
|
||||
onPositionChanged: mouse => {
|
||||
if (pressed && pressedButtons === Qt.LeftButton && tile.modelData.pinned
|
||||
&& Math.hypot(mouse.x - startX, mouse.y - startY) > 8) {
|
||||
moved = true; dock.draggingId = tile.modelData.id;
|
||||
}
|
||||
}
|
||||
onReleased: mouse => {
|
||||
if (moved) {
|
||||
const p = mapToItem(items, mouse.x, mouse.y);
|
||||
let before = "";
|
||||
for (let i = 0; i < icons.count; ++i) {
|
||||
const candidate = icons.itemAt(i);
|
||||
if (candidate.modelData.pinned && candidate.modelData.id !== dock.draggingId
|
||||
&& p.x < candidate.x + candidate.width / 2) {
|
||||
before = candidate.modelData.id; break;
|
||||
}
|
||||
}
|
||||
FavoritesService.move(dock.draggingId, before);
|
||||
} else if (mouse.button === Qt.RightButton) {
|
||||
dock.openContext(tile.modelData, tile.center);
|
||||
} else if (mouse.button === Qt.MiddleButton) {
|
||||
if (!tile.modelData.missing && tile.modelData.newInstance)
|
||||
FavoritesService.launch(tile.modelData.id);
|
||||
} else {
|
||||
dock.closeContext(); dock.activate(tile.modelData);
|
||||
}
|
||||
dock.draggingId = ""; dock.pressed = false;
|
||||
}
|
||||
onCanceled: { dock.draggingId = ""; dock.pressed = false; }
|
||||
ToolTip.visible: containsMouse && !dock.held
|
||||
ToolTip.text: tile.modelData.name
|
||||
ToolTip.delay: 600
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle {
|
||||
id: context
|
||||
visible: dock.contextEntry !== null
|
||||
width: Math.min(330, dock.width)
|
||||
height: Math.min(230, contextContent.height + 16)
|
||||
x: Math.max(0, Math.min(dock.width - width, dock.contextX - width / 2))
|
||||
y: dock.height - 116 - height
|
||||
radius: 12; color: Theme.popupBackground
|
||||
border.color: Theme.strongBorder
|
||||
HoverHandler { id: contextHover; onHoveredChanged: dock.reconcile() }
|
||||
Flickable {
|
||||
anchors.fill: parent; anchors.margins: 8
|
||||
contentHeight: contextContent.height; clip: true
|
||||
Column {
|
||||
id: contextContent
|
||||
width: parent.width
|
||||
Text {
|
||||
width: parent.width; height: 28
|
||||
text: dock.contextEntry ? dock.contextEntry.name : ""
|
||||
color: Theme.lavender; elide: Text.ElideRight; font.bold: true
|
||||
}
|
||||
Repeater {
|
||||
model: dock.contextEntry ? dock.contextEntry.windows : []
|
||||
delegate: DockAction {
|
||||
required property var modelData
|
||||
width: contextContent.width
|
||||
text: modelData.title + " · " + modelData.workspace
|
||||
onTriggered: { FavoritesService.focusWindow(modelData.id); dock.closeContext(); }
|
||||
}
|
||||
}
|
||||
DockAction {
|
||||
width: parent.width
|
||||
visible: !!dock.contextEntry && !dock.contextEntry.missing
|
||||
text: "Ouvrir"
|
||||
onTriggered: { FavoritesService.launch(dock.contextEntry.id); dock.closeContext(); }
|
||||
}
|
||||
DockAction {
|
||||
width: parent.width
|
||||
visible: !!dock.contextEntry && (dock.contextEntry.pinned || !dock.contextEntry.missing)
|
||||
objectName: "dock-pin-action"
|
||||
text: dock.contextEntry && dock.contextEntry.pinned ? "Désépingler" : "Épingler"
|
||||
onTriggered: { FavoritesService.toggle(dock.contextEntry.id); dock.closeContext(); }
|
||||
}
|
||||
DockAction { width: parent.width; text: "Fermer le menu"; onTriggered: dock.closeContext() }
|
||||
}
|
||||
}
|
||||
}
|
||||
Text {
|
||||
visible: dock.revealed && FavoritesService.error !== "" && !context.visible
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
y: dock.height - 140
|
||||
text: FavoritesService.error; color: Theme.danger; font.pixelSize: 12
|
||||
}
|
||||
}
|
||||
13
quickshell/.config/quickshell/labfy-sway/dock/DockAction.qml
Normal file
13
quickshell/.config/quickshell/labfy-sway/dock/DockAction.qml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import QtQuick
|
||||
import "../theme"
|
||||
Rectangle {
|
||||
id: action
|
||||
property string text: ""
|
||||
signal triggered()
|
||||
height: visible ? 30 : 0
|
||||
radius: 5
|
||||
color: pointer.containsMouse ? Theme.buttonHover : "transparent"
|
||||
Text { anchors.fill: parent; anchors.margins: 5; text: action.text; color: Theme.foreground
|
||||
elide: Text.ElideRight; verticalAlignment: Text.AlignVCenter; font.pixelSize: 12 }
|
||||
MouseArea { id: pointer; anchors.fill: parent; hoverEnabled: true; onClicked: action.triggered() }
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ import "theme"
|
|||
import "polkit"
|
||||
import "scratchpad"
|
||||
import "osd"
|
||||
import "applications"
|
||||
import "dock"
|
||||
|
||||
ShellRoot {
|
||||
id: shell
|
||||
|
|
@ -242,6 +244,12 @@ ShellRoot {
|
|||
return AppearanceController.acceptPublishedNightLight(payload);
|
||||
}
|
||||
}
|
||||
// CONTRACT: shell-owned singleton remains alive while the menu is closed.
|
||||
readonly property bool applicationsReady: FavoritesService.ready
|
||||
Variants {
|
||||
model: Quickshell.screens
|
||||
Dock { suppressed: shell.authenticationActive || shell.applicationsOutput !== "" }
|
||||
}
|
||||
// Une barre par écran, y compris si les sorties changent pendant la session.
|
||||
// CONTRACT: un seul serveur D-Bus pour toutes les sorties.
|
||||
NotificationService { id: rootNotificationService }
|
||||
|
|
|
|||
|
|
@ -26,3 +26,13 @@ layer_effects "labfy-sway-bar" {
|
|||
shadows enable
|
||||
corner_radius 4
|
||||
}
|
||||
|
||||
# CONTRACT: only the dock's layer surface receives blur; no window rule or
|
||||
# focus/restore policy is changed. Its transparent input strip stays clear.
|
||||
layer_effects "labfy-sway-dock" {
|
||||
blur $bar_blur
|
||||
blur_xray disable
|
||||
blur_ignore_transparent enable
|
||||
shadows disable
|
||||
corner_radius 25
|
||||
}
|
||||
|
|
|
|||
216
tests/dock/headless.py
Normal file
216
tests/dock/headless.py
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Private Sway only; production Dock/Menu, real Python bridge and input paths."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(REPO / 'tests/session_v2/fixtures'))
|
||||
from headless import Lab
|
||||
from ipc_proxy import IpcProxy
|
||||
|
||||
SOURCE = REPO / 'quickshell/.config/quickshell/labfy-sway'
|
||||
ARTIFACTS = REPO / 'docs/validation/quickshell-dock-tela'
|
||||
NINE = ['firefox.desktop', 'kitty.desktop', 'yazi.desktop', 'nvim.desktop',
|
||||
'org.darktable.darktable.desktop', 'orca-slicer.desktop', 'org.freecad.FreeCAD.desktop',
|
||||
'org.kde.digikam.desktop', 'AppFlowy.desktop']
|
||||
|
||||
class DockLab(Lab):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.config=self.directory/'dock-sway.conf'
|
||||
base=(REPO/'tests/session_v2/fixtures/headless.conf').read_text()
|
||||
effects=(REPO/'sway/.config/sway/swayfx').read_text().split('# CONTRACT: only the dock')[1]
|
||||
effects=effects[effects.index('layer_effects'):]
|
||||
self.config.write_text(base+'\noutput HEADLESS-1 bg #11111b solid_color\nset $bar_blur enable\n'+effects)
|
||||
def start(self, argv):
|
||||
if argv[0]==os.environ.get('LABFY_TEST_SWAY','sway'):
|
||||
argv=[*argv[:-1],str(self.config)]
|
||||
return super().start(argv)
|
||||
|
||||
with DockLab() as lab:
|
||||
proxy=IpcProxy(lab.directory/'dock-ipc.sock',lab.socket)
|
||||
lab.env['SWAYSOCK']=str(lab.directory/'dock-ipc.sock')
|
||||
path = Path(lab.env['XDG_STATE_HOME']) / 'labfy-sway/applications-favorites.json'
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(json.dumps(dict(version=1, ids=NINE)))
|
||||
qml = lab.directory / 'fixture.qml'
|
||||
qml.write_text('''//@ pragma UseQApplication
|
||||
import QtQuick
|
||||
import QtTest
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import "SOURCE/applications" as A
|
||||
import "SOURCE/dock" as D
|
||||
ShellRoot {
|
||||
D.Dock { id: dock; modelData: Quickshell.screens[0] }
|
||||
PanelWindow { id: bar; implicitWidth: 100; implicitHeight: 30; anchors.top: true; color: "transparent" }
|
||||
LazyLoader { id: menu; active: false; A.ApplicationsMenu { barWindow: bar } }
|
||||
TestCase { id: input; name: "DockInput"; when: false }
|
||||
IpcHandler {
|
||||
target: "dockTest"
|
||||
function state(): string {
|
||||
return JSON.stringify({ready:A.FavoritesService.ready, ids:A.FavoritesService.ids,
|
||||
error:A.FavoritesService.error, phase:dock.phase, revealed:dock.revealed,
|
||||
entries:dock.entries, theme:A.FavoritesService.iconTheme,
|
||||
menu:menu.active ? menu.item.favoriteIds : null,
|
||||
searchFocused:menu.active && menu.item.searchFocused,
|
||||
navFocused:menu.active && menu.item.navigationFocused,
|
||||
gridFocused:menu.active && menu.item.gridFocused,
|
||||
screenWidth:dock.screen.width,
|
||||
bodyY:dock.surfaceBody.y, width:dock.width, pointerX:dock.pointerX,
|
||||
|
||||
tileWidth:dock.contentItemRow.children[0] ? dock.contentItemRow.children[0].width : 0,
|
||||
context:!!dock.contextEntry});
|
||||
}
|
||||
function edge(): void { input.mouseMove(dock.edgeArea, dock.edgeArea.width/2, 2); }
|
||||
function body(): void { input.mouseMove(dock.surfaceBody, 35, 70); }
|
||||
function leave(): void { input.mouseMove(dock.contentItem, 1, 1); }
|
||||
function menuToggle(id:string): void { if (!menu.active) menu.active=true; menu.item.toggleFavorite(id); }
|
||||
function dockToggle(id:string): void { A.FavoritesService.toggle(id); }
|
||||
function move(id:string, before:string): void { A.FavoritesService.move(id,before); }
|
||||
function closeMenu(): void { menu.active=false; }
|
||||
function tab(): void { input.keyClick(Qt.Key_Tab); }
|
||||
function clickPin(): void {
|
||||
const action=input.findChild(dock.contextCard,"dock-pin-action");
|
||||
input.mouseClick(action,action.width/2,action.height/2);
|
||||
}
|
||||
function rightClick(): void {
|
||||
input.mouseClick(dock.contentItemRow.children[0],27,65,Qt.RightButton);
|
||||
}
|
||||
function dragFirstAfterSecond(): void {
|
||||
const first=dock.contentItemRow.children[0];
|
||||
input.mousePress(first,27,65,Qt.LeftButton);
|
||||
input.mouseMove(first,125,65,100);
|
||||
input.mouseRelease(first,125,65,Qt.LeftButton);
|
||||
}
|
||||
function activateFirst(): void { input.mouseClick(dock.contentItemRow.children[0],27,65); }
|
||||
function context(): void { dock.openContext(dock.entries[0],dock.width/2); }
|
||||
function closeContext(): void { dock.closeContext(); }
|
||||
function suppress(value:bool): void { dock.suppressed=value; }
|
||||
function focus(id:string): void { A.FavoritesService.focusWindow(id); }
|
||||
}
|
||||
}
|
||||
'''.replace('SOURCE', SOURCE.as_uri()))
|
||||
lab.env['QT_QPA_PLATFORM'] = 'wayland'
|
||||
process = lab.start(['quickshell', '-p', str(qml)])
|
||||
def call(name, *args):
|
||||
r = subprocess.run(['quickshell','ipc','-p',str(qml),'call','dockTest',name,*map(str,args)],
|
||||
env=lab.env, text=True, capture_output=True, timeout=8)
|
||||
if r.returncode:
|
||||
logs = '\n'.join(p.read_text()[-9000:] for p in lab.directory.glob('child-*.log'))
|
||||
raise AssertionError((name,r.stdout,r.stderr,logs))
|
||||
return json.loads(r.stdout) if r.stdout.strip() else None
|
||||
def wait(predicate, seconds=8):
|
||||
end = time.monotonic()+seconds
|
||||
while time.monotonic()<end:
|
||||
state=call('state')
|
||||
if predicate(state): return state
|
||||
time.sleep(.08)
|
||||
raise AssertionError(call('state'))
|
||||
time.sleep(1.2)
|
||||
initial=wait(lambda s:s['ready'])
|
||||
assert initial['ids']==NINE and initial['phase']=='hidden', initial
|
||||
ARTIFACTS.mkdir(parents=True,exist_ok=True)
|
||||
def capture(name):
|
||||
subprocess.run(['grim',str(ARTIFACTS / (name+'.png'))],env=lab.env,check=True)
|
||||
capture('closed')
|
||||
call('edge'); wait(lambda s:s['revealed']); time.sleep(.35)
|
||||
capture('open')
|
||||
call('body'); time.sleep(.3); zoom=call('state'); capture('zoom')
|
||||
assert zoom['tileWidth']>54, zoom
|
||||
call('leave'); wait(lambda s:s['phase']=='hidden')
|
||||
call('edge'); wait(lambda s:s['revealed']); time.sleep(.3)
|
||||
call('rightClick'); wait(lambda s:s['phase']=='held'); time.sleep(.7)
|
||||
assert call('state')['context']
|
||||
call('clickPin'); wait(lambda s:'firefox.desktop' not in s['ids'])
|
||||
call('dockToggle','firefox.desktop'); wait(lambda s:'firefox.desktop' in s['ids'])
|
||||
call('move','firefox.desktop','kitty.desktop'); wait(lambda s:s['ids']==NINE)
|
||||
call('leave'); wait(lambda s:s['phase']=='hidden')
|
||||
# Refused move leaves both interfaces at the same durable list.
|
||||
call('move','kitty.desktop','removed.desktop')
|
||||
wait(lambda s:bool(s['error']))
|
||||
assert call('state')['ids']==NINE
|
||||
call('menuToggle','fixture.desktop'); wait(lambda s:'fixture.desktop' in s['ids'] and s['menu']==s['ids'])
|
||||
# Gio catalog monitoring replaces the placeholder without dropping its ID.
|
||||
desktop=Path(lab.env['HOME'])/'.local/share/applications/fixture.desktop'
|
||||
desktop.parent.mkdir(parents=True,exist_ok=True)
|
||||
desktop.write_text('[Desktop Entry]\nType=Application\nName=Installed Fixture\nExec=/usr/bin/true\nIcon=firefox\n')
|
||||
wait(lambda s:any(e['id']=='fixture.desktop' and not e.get('missing') for e in s['entries']))
|
||||
desktop.unlink()
|
||||
wait(lambda s:any(e['id']=='fixture.desktop' and e.get('missing') for e in s['entries']))
|
||||
# Keep the virtual keyboard alive: the headless seat has no physical
|
||||
# keyboard, so destroying wtype would remove keyboard capabilities and
|
||||
# deactivate the QML focus window before we could sample its focus item.
|
||||
keyboard=lab.start(['wtype','-s','300','-k','Tab','-s','1500','-k','Tab',
|
||||
'-s','1500','-k','Tab','-s','1500'])
|
||||
wait(lambda s:s['navFocused'])
|
||||
wait(lambda s:s['gridFocused'])
|
||||
wait(lambda s:s['searchFocused'])
|
||||
keyboard.wait(timeout=8)
|
||||
# An independent legacy CLI writer is observed by menu and dock live.
|
||||
subprocess.run(['python','-B',str(SOURCE/'applications/backend.py'),'favorites','add','external.desktop'],
|
||||
env=lab.env,check=True,capture_output=True)
|
||||
wait(lambda s:'external.desktop' in s['ids'] and s['menu']==s['ids'])
|
||||
call('dockToggle','external.desktop'); wait(lambda s:'external.desktop' not in s['ids'])
|
||||
call('dockToggle','fixture.desktop'); wait(lambda s:'fixture.desktop' not in s['ids'] and s['menu']==s['ids'])
|
||||
call('move','kitty.desktop','firefox.desktop'); reordered=wait(lambda s:s['ids'][0]=='kitty.desktop')
|
||||
assert json.loads(path.read_text())['ids']==reordered['ids']
|
||||
call('closeMenu')
|
||||
call('edge'); wait(lambda s:s['revealed']); time.sleep(.3)
|
||||
call('dragFirstAfterSecond')
|
||||
dragged=wait(lambda s:s['ids'][0]=='firefox.desktop')
|
||||
assert dragged['ids'][1]=='kitty.desktop',dragged
|
||||
call('leave'); wait(lambda s:s['phase']=='hidden')
|
||||
# No focus changes while the pointer reveals, zooms, hides, or opens menus.
|
||||
win=lab.kitty('kitty')
|
||||
time.sleep(.5)
|
||||
focused=lambda: next(n['id'] for n in lab.windows() if n.get('focused'))
|
||||
assert focused()==win
|
||||
for action in ('edge','body','context','leave','closeContext'):
|
||||
call(action); time.sleep(.25); assert focused()==win, action
|
||||
call('suppress','true'); assert call('state')['phase']=='hidden'
|
||||
call('suppress','false')
|
||||
wait(lambda s:any(e['id']=='kitty.desktop' and len(e['windows'])==1 for e in s['entries']))
|
||||
proxy.disconnect()
|
||||
time.sleep(2)
|
||||
second=lab.kitty('kitty')
|
||||
wait(lambda s:any(e['id']=='kitty.desktop' and len(e['windows'])==2 for e in s['entries']))
|
||||
lab.command(f'[con_id={win}] floating enable')
|
||||
lab.command(f'[con_id={win}] move scratchpad')
|
||||
call('focus',str(win)); time.sleep(.3); assert focused()==win
|
||||
lab.command(f'[con_id={second}] kill')
|
||||
wait(lambda s:any(e['id']=='kitty.desktop' and len(e['windows'])==1 for e in s['entries']))
|
||||
# Firefox runs with the private HOME/profile and no inherited session bus.
|
||||
profile=lab.directory/'firefox-profile'; profile.mkdir()
|
||||
firefox=lab.start(['firefox','--no-remote','--profile',str(profile),'--new-window','about:blank'])
|
||||
firefox_state=wait(lambda s:any(e['id']=='firefox.desktop' and e['windows'] for e in s['entries']),20)
|
||||
firewin=next(e for e in firefox_state['entries'] if e['id']=='firefox.desktop')['windows'][0]['id']
|
||||
call('closeContext'); call('edge'); wait(lambda s:s['revealed']); time.sleep(.3)
|
||||
call('activateFirst'); time.sleep(.3)
|
||||
assert str(focused())==firewin
|
||||
for action in ('edge','body','leave'):
|
||||
call(action); time.sleep(.25); assert str(focused())==firewin
|
||||
# Layer-shell surfaces cannot enter the Session V2 toplevel inventory.
|
||||
assert not any('labfy-sway-dock' in str(n.get('app_id','')) for n in lab.windows())
|
||||
lab.command('output HEADLESS-1 mode 800x600')
|
||||
resized=wait(lambda s:s['screenWidth']==800)
|
||||
assert resized['width']<=776,resized
|
||||
lab.command('output HEADLESS-1 mode 1280x720')
|
||||
wait(lambda s:s['screenWidth']==1280)
|
||||
# Existing empty state must stay empty, including a bridge restart.
|
||||
for identifier in list(call('state')['ids']): call('dockToggle',identifier)
|
||||
wait(lambda s:not s['ids'])
|
||||
process.terminate(); process.wait(timeout=5)
|
||||
process=lab.start(['quickshell','-p',str(qml)])
|
||||
time.sleep(1)
|
||||
assert wait(lambda s:s['ready'])['ids']==[]
|
||||
(ARTIFACTS/'result.json').write_text(json.dumps(dict(status='PASS',initial=initial,
|
||||
reordered=reordered['ids'],checks=['shared favorites','menu and dock mutations','durable reorder',
|
||||
'empty restart','autohide','context hold','kitty multi-window','floating scratchpad explicit focus',
|
||||
'window close','no hover focus','real pointer drag','zoom growth','IPC reconnect','Firefox private profile','desktop install/remove monitoring','external writer','keyboard menu navigation','resolution change','SwayFX dock blur rule']),indent=2)+'\n')
|
||||
proxy.close()
|
||||
print('Dock headless: PASS',lab.directory)
|
||||
54
tests/dock/ipc_proxy.py
Normal file
54
tests/dock/ipc_proxy.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""Test-only disconnectable proxy; targets only a fixture-owned Sway socket."""
|
||||
import select
|
||||
import socket
|
||||
import threading
|
||||
|
||||
|
||||
class IpcProxy:
|
||||
def __init__(self, path, target):
|
||||
self.target = target
|
||||
self.socket = socket.socket(socket.AF_UNIX,socket.SOCK_STREAM)
|
||||
self.socket.bind(str(path)); self.socket.listen(8); self.socket.settimeout(.2)
|
||||
self.stopped = threading.Event()
|
||||
self.lock = threading.Lock()
|
||||
self.connections = set()
|
||||
self.thread = threading.Thread(target=self.accept,daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def accept(self):
|
||||
while not self.stopped.is_set():
|
||||
try: client,_ = self.socket.accept()
|
||||
except socket.timeout: continue
|
||||
except OSError: break
|
||||
upstream=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM)
|
||||
upstream.settimeout(2)
|
||||
try: upstream.connect(self.target)
|
||||
except OSError: client.close(); upstream.close(); continue
|
||||
with self.lock:
|
||||
if len(self.connections)>=16:
|
||||
client.close(); upstream.close(); continue
|
||||
self.connections.add(client); self.connections.add(upstream)
|
||||
threading.Thread(target=self.forward,args=(client,upstream),daemon=True).start()
|
||||
|
||||
def forward(self, client, upstream):
|
||||
try:
|
||||
while not self.stopped.is_set():
|
||||
ready,_,_=select.select([client,upstream],[],[],.2)
|
||||
for source in ready:
|
||||
data=source.recv(65536)
|
||||
if not data: return
|
||||
(upstream if source is client else client).sendall(data)
|
||||
except (OSError,ValueError): pass
|
||||
finally:
|
||||
with self.lock:
|
||||
self.connections.discard(client); self.connections.discard(upstream)
|
||||
client.close(); upstream.close()
|
||||
|
||||
def disconnect(self):
|
||||
with self.lock:
|
||||
for connection in self.connections:
|
||||
try: connection.shutdown(socket.SHUT_RDWR)
|
||||
except OSError: pass
|
||||
|
||||
def close(self):
|
||||
self.stopped.set(); self.disconnect(); self.socket.close(); self.thread.join(2)
|
||||
28
tests/dock/session_compat.py
Normal file
28
tests/dock/session_compat.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Run existing production chooser/restore scenarios with the dock present.
|
||||
|
||||
Only the test harness is extended in memory. No Session V2 source, journal,
|
||||
keyring/PAM configuration or active graphical environment is changed.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0,str(ROOT/'tests/session_v2'))
|
||||
import keyring_ui
|
||||
import phase3b_ui
|
||||
|
||||
source=(ROOT/'quickshell/.config/quickshell/labfy-sway/dock').as_uri()
|
||||
for module in (keyring_ui,phase3b_ui):
|
||||
module.HARNESS=module.HARNESS.replace('import Quickshell\n',
|
||||
'import Quickshell\nimport "'+source+'" as D\n')
|
||||
module.HARNESS=module.HARNESS.replace('ShellRoot {','''ShellRoot {
|
||||
D.Dock { id: compatibilityDock; modelData: Quickshell.screens[0] }
|
||||
Timer { interval: 3500; running: true; onTriggered: {
|
||||
if (compatibilityDock.phase !== "hidden") {
|
||||
console.error("DOCK_COMPAT_FAIL_NOT_HIDDEN"); Qt.quit();
|
||||
}
|
||||
} }
|
||||
''')
|
||||
module.main()
|
||||
print(module.__name__+' WITH_DOCK PASS',flush=True)
|
||||
23
tests/dock/shell_load.py
Normal file
23
tests/dock/shell_load.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Load the actual shell through its relative imports in a private Sway.
|
||||
|
||||
The session bus is private; the system bus address deliberately points to a
|
||||
missing fixture socket so no real Polkit agent or system service is contacted.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0,str(ROOT/'tests/session_v2/fixtures'))
|
||||
from headless import Lab
|
||||
|
||||
with Lab() as lab:
|
||||
lab.env.update(DBUS_SYSTEM_BUS_ADDRESS='unix:path='+str(lab.directory/'no-system-bus'),QT_QPA_PLATFORM='wayland')
|
||||
shell=ROOT/'quickshell/.config/quickshell/labfy-sway/shell.qml'
|
||||
process=lab.start(['dbus-run-session','--','quickshell','-p',str(shell)])
|
||||
time.sleep(3)
|
||||
log=(lab.directory/'child-1.log').read_text()
|
||||
assert 'Configuration Loaded' in log and process.poll() is None,log
|
||||
assert 'Binding loop' not in log,log
|
||||
print('Full shell private-compositor load: PASS',lab.directory)
|
||||
46
tests/dock/test_models.js
Normal file
46
tests/dock/test_models.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const path = require('node:path');
|
||||
function load(file) {
|
||||
const context=vm.createContext({});
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname,'../../quickshell/.config/quickshell/labfy-sway',file),'utf8')
|
||||
.replace(/^\.pragma library\s*\n/,''),context);
|
||||
return context;
|
||||
}
|
||||
const hide=load('dock/AutoHide.js');
|
||||
let phase='hidden';
|
||||
const input={suppressed:false,held:false,body:false,edge:false};
|
||||
const event=(name='input') => phase=hide.next(phase,name,input);
|
||||
assert.equal(event(),'hidden');
|
||||
input.edge=true; assert.equal(event(),'arming');
|
||||
input.edge=false; assert.equal(event(),'hidden');
|
||||
input.edge=true; event(); assert.equal(event('deadline'),'shown');
|
||||
input.edge=false; assert.equal(event(),'waiting');
|
||||
input.body=true; assert.equal(event(),'shown');
|
||||
input.body=false; event(); input.held=true; assert.equal(event(),'held');
|
||||
assert.equal(event('deadline'),'held');
|
||||
input.held=false; assert.equal(event(),'waiting');
|
||||
assert.equal(event('deadline'),'hidden');
|
||||
input.held=true; event(); input.suppressed=true; assert.equal(event(),'hidden');
|
||||
assert.equal(hide.influence(0),1);
|
||||
assert.ok(hide.influence(55)>hide.influence(100));
|
||||
assert.equal(hide.influence(115),0);
|
||||
const model=load('applications/WindowModel.js');
|
||||
const apps=[{id:'kitty.desktop',aliases:['kitty'],iconSource:'file:///tela/kitty.svg'},
|
||||
{id:'org.app.desktop',aliases:['startup-class','executable']},
|
||||
{id:'org.other.desktop',aliases:['executable']}];
|
||||
assert.equal(model.match(apps,{appId:'kitty'}).id,'kitty.desktop');
|
||||
assert.equal(model.match(apps,{wmClass:'Startup-Class'}).id,'org.app.desktop');
|
||||
assert.equal(model.match(apps,{appId:'executable'}),null);
|
||||
assert.equal(model.match(apps,{title:'kitty'}),null);
|
||||
let entries=model.entries(apps,['absent.desktop','kitty.desktop'],[{id:'10',appId:'kitty'},{id:'11',appId:'kitty'},
|
||||
{id:'12',appId:'unknown'}]);
|
||||
assert.equal(entries[0].missing,true);
|
||||
assert.equal(entries[1].windows.length,2);
|
||||
assert.equal(entries[1].iconSource,apps[0].iconSource);
|
||||
assert.equal(entries[2].pinned,false);
|
||||
assert.equal(model.entries(apps,[],[]).length,0);
|
||||
assert.equal(model.entries(apps,[],[{id:'11',appId:'kitty'}])[0].pinned,false);
|
||||
assert.equal(model.entries(apps,['kitty.desktop'],[])[0].windows.length,0);
|
||||
console.log('Dock state and window models: PASS');
|
||||
109
tests/dock/test_shared.py
Normal file
109
tests/dock/test_shared.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""Durable real backend operations and private toolkit icon resolution."""
|
||||
import concurrent.futures
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
APP = ROOT/'quickshell/.config/quickshell/labfy-sway/applications'
|
||||
sys.path.insert(0, str(APP))
|
||||
import backend
|
||||
import shared
|
||||
from gi.repository import Gio
|
||||
|
||||
NINE = ['firefox.desktop','kitty.desktop','yazi.desktop','nvim.desktop',
|
||||
'org.darktable.darktable.desktop','orca-slicer.desktop','org.freecad.FreeCAD.desktop',
|
||||
'org.kde.digikam.desktop','AppFlowy.desktop']
|
||||
|
||||
|
||||
class Persistence(unittest.TestCase):
|
||||
def test_nine_move_missing_anchor_and_empty(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path=Path(temp)/'labfy-sway/applications-favorites.json'
|
||||
path.parent.mkdir()
|
||||
path.write_text(json.dumps(dict(version=1,ids=NINE)))
|
||||
self.assertEqual(backend.read_favorites(path)[1],NINE)
|
||||
expected=[NINE[1],NINE[0]]+NINE[2:]
|
||||
self.assertEqual(backend.write_favorites(path,'move',[NINE[1],NINE[0]]),expected)
|
||||
before=path.read_bytes()
|
||||
for pair in ([NINE[0],'absent.desktop'], ['absent.desktop',NINE[0]], [NINE[0],'../bad']):
|
||||
with self.assertRaises(ValueError): backend.write_favorites(path,'move',pair)
|
||||
self.assertEqual(before,path.read_bytes())
|
||||
for identifier in NINE: backend.write_favorites(path,'remove',[identifier])
|
||||
self.assertEqual(backend.write_favorites(path,'seed',NINE[:4]),[])
|
||||
self.assertEqual(backend.read_favorites(path),(True,[]))
|
||||
|
||||
def test_toggle_is_atomic_and_does_not_depend_on_a_stale_ui_list(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path=Path(temp)/'favorites.json'
|
||||
backend.write_favorites(path,'toggle',['kitty.desktop'])
|
||||
self.assertEqual(backend.write_favorites(path,'toggle',['kitty.desktop']),[])
|
||||
|
||||
def test_real_concurrent_processes_keep_all_additions_during_moves(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
env=dict(os.environ,XDG_STATE_HOME=temp)
|
||||
path=Path(temp)/'labfy-sway/applications-favorites.json'
|
||||
backend.write_favorites(path,'seed',['a.desktop','b.desktop'])
|
||||
def run(args):
|
||||
result=subprocess.run([sys.executable,'-B',str(APP/'backend.py'),'favorites',*args],
|
||||
env=env,capture_output=True,text=True,timeout=10)
|
||||
self.assertEqual(result.returncode,0,result.stdout+result.stderr)
|
||||
jobs=[['add',f'added{i}.desktop'] for i in range(24)]
|
||||
jobs += [['move','b.desktop','a.desktop'] for _ in range(10)]
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
|
||||
list(executor.map(run,jobs))
|
||||
ids=backend.read_favorites(path)[1]
|
||||
self.assertEqual(ids[:2],['b.desktop','a.desktop'])
|
||||
self.assertEqual(len(ids),26)
|
||||
self.assertEqual(set(ids[2:]),{f'added{i}.desktop' for i in range(24)})
|
||||
|
||||
|
||||
class Icons(unittest.TestCase):
|
||||
def test_tela_desktop_icon_and_absolute(self):
|
||||
resolver=shared.IconResolver()
|
||||
self.assertIn('/Tela-circle-purple-dark/',resolver.resolve(Gio.ThemedIcon.new('firefox')))
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path=Path(temp)/'custom icon.svg'
|
||||
path.write_text('<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"/>')
|
||||
self.assertEqual(resolver.resolve(Gio.FileIcon.new(Gio.File.new_for_path(str(path)))),path.as_uri())
|
||||
self.assertTrue(resolver.resolve(Gio.ThemedIcon.new('labfy-icon-does-not-exist')))
|
||||
self.assertTrue(resolver.resolve(Gio.FileIcon.new(Gio.File.new_for_path('/missing/icon.svg'))))
|
||||
|
||||
def test_inheritance_from_a_private_test_theme(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root=Path(temp)
|
||||
for name,parent in [('Child','Parent'),('Parent','hicolor')]:
|
||||
directory=root/name; (directory/'48x48/apps').mkdir(parents=True)
|
||||
(directory/'index.theme').write_text('[Icon Theme]\nName='+name+'\nInherits='+parent+
|
||||
'\nDirectories=48x48/apps\n[48x48/apps]\nSize=48\nType=Fixed\nContext=Applications\n')
|
||||
icon=root/'Parent/48x48/apps/inherited.svg'
|
||||
icon.write_text('<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"/>')
|
||||
resolver=shared.IconResolver('Child')
|
||||
resolver.theme.set_search_path([temp])
|
||||
self.assertEqual(resolver.resolve(Gio.ThemedIcon.new('inherited')),icon.as_uri())
|
||||
|
||||
|
||||
class Sway(unittest.TestCase):
|
||||
def test_wayland_xwayland_scratchpad_and_explicit_focus(self):
|
||||
tree=dict(nodes=[dict(type='workspace',name='__i3_scratch',floating_nodes=[
|
||||
dict(id=42,window=3,window_properties={'class':'Fixture','instance':'fixture'},name='Arbitrary'),
|
||||
dict(id=43,app_id='kitty',name='Completely different title')])])
|
||||
windows=shared.windows(tree)
|
||||
self.assertEqual(windows[0]['wmClass'],'Fixture')
|
||||
self.assertEqual(windows[1]['appId'],'kitty')
|
||||
self.assertEqual(windows[0]['workspace'],'__i3_scratch')
|
||||
with patch.object(shared,'sway_request',side_effect=[tree,[{'success':True}],[{'success':True}]]) as ipc:
|
||||
shared.focus('42')
|
||||
self.assertEqual([c.args for c in ipc.call_args_list],[(4,''),(0,'[con_id=42] scratchpad show'),
|
||||
(0,'[con_id=42] focus')])
|
||||
with patch.object(shared,'sway_request') as ipc:
|
||||
with self.assertRaises(ValueError): shared.focus('42] focus; exec bad')
|
||||
ipc.assert_not_called()
|
||||
|
||||
if __name__=='__main__': unittest.main()
|
||||
Loading…
Reference in a new issue