Compare commits
2 commits
a556cd56b7
...
91c39ecf9b
| Author | SHA1 | Date | |
|---|---|---|---|
| 91c39ecf9b | |||
| d4e2a0013e |
137
docs/quickshell-dock-tela-polish-v2.md
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
# QuickShell Dock Tela — Polish V2
|
||||||
|
|
||||||
|
Livraison isolée du 22 octobre 2026, base `d4e2a001`, branche
|
||||||
|
`quickshell-dock-tela-polish-v2`, worktree
|
||||||
|
`/home/fy59/.dotfiles-worktrees/quickshell-dock-tela-polish-v2`.
|
||||||
|
La configuration du bureau actif n’a pas été modifiée ou rechargée.
|
||||||
|
|
||||||
|
## Cause et correction du hover
|
||||||
|
|
||||||
|
La V1 animait la largeur de chaque case de la `Row` et celle de chaque
|
||||||
|
`ApplicationIcon`. Chaque déplacement du pointeur modifiait donc les positions
|
||||||
|
suivantes, la largeur de la rangée et potentiellement celle de la surface.
|
||||||
|
De plus, `ApplicationIcon` calcule le `sourceSize` de son image depuis sa largeur :
|
||||||
|
la variation de taille demandait de nouvelles résolutions de rasterisation pour
|
||||||
|
les SVG Tela asynchrones. Ces deux chemins constituent les causes structurelles
|
||||||
|
identifiées des saccades et du risque de clignotement. Aucun profilage GPU du
|
||||||
|
bureau physique n’est revendiqué.
|
||||||
|
|
||||||
|
Les cases gardent désormais une largeur de 54 px (plus le séparateur éventuel),
|
||||||
|
un pas de 57 px et des cibles souris fixes. Les images restent à 46 × 46 px,
|
||||||
|
avec une résolution demandée fixe de 92 × 92 px. Seul leur `scale` varie de
|
||||||
|
1 à 1,39, ancré en bas, avec une interpolation OutCubic de 80 ms. La taille
|
||||||
|
visuelle maximale est ainsi d’environ 64 px. Les deux marges de 12 px à
|
||||||
|
l’intérieur du viewport protègent aussi les icônes agrandies aux extrémités,
|
||||||
|
y compris après défilement sur un petit écran.
|
||||||
|
|
||||||
|
L’influence du pointeur est une courbe smoothstep, symétrique, de rayon 76 px.
|
||||||
|
Ce rayon est inférieur à un pas et demi : seules la case la plus proche et ses
|
||||||
|
voisines immédiates peuvent recevoir une cible de grossissement non nulle.
|
||||||
|
Les positions de référence restent indépendantes de la transformation.
|
||||||
|
Les transitions peuvent brièvement finir leur interpolation après la sortie
|
||||||
|
du rayon. Aucun IPC, blur, chargement d’image ni relayout n’est déclenché par
|
||||||
|
l’animation elle-même. Les petits calculs d’influence restent liés au pointeur ;
|
||||||
|
aucun timer de polling ou délai de regroupement supplémentaire n’est nécessaire.
|
||||||
|
|
||||||
|
## Fond flottant
|
||||||
|
|
||||||
|
Le voile principal passe de 89 % à **16 % d’opacité**, sa hauteur de 70 à 58 px,
|
||||||
|
avec une bordure de 1 px à 9 % d’opacité. Une `RectangularShadow` QtQuick.Effects,
|
||||||
|
de géométrie fixe, produit l’ombre diffuse (blur 24, spread 2, noir à 32 %).
|
||||||
|
Elle ne capture ni les icônes ni le bureau. Le mode de contraste élevé conserve
|
||||||
|
un fond plus soutenu à 94 % et une bordure à 50 % pour la lisibilité.
|
||||||
|
|
||||||
|
La règle SwayFX du seul namespace `labfy-sway-dock` désactive le flou du fond.
|
||||||
|
Le compromis retenu est un verre teinté très transparent, sans réfraction ni
|
||||||
|
flou des fenêtres derrière. Le rendu ne dépend donc plus d’un flou compositeur
|
||||||
|
recalculé pendant le hover. Les effets de la barre restent inchangés.
|
||||||
|
`RectangularShadow` nécessite Qt ≥ 6.9 ; l’hôte testé utilise Qt 6.12.0,
|
||||||
|
QuickShell 0.3.2 et SwayFX 0.6 (Sway 1.12.0).
|
||||||
|
|
||||||
|
## Contrats préservés
|
||||||
|
|
||||||
|
La machine auto-hide conserve sa bande de 4 px, son armement de 180 ms,
|
||||||
|
sa grâce de sortie de 450 ms et son animation verticale de 210 ms.
|
||||||
|
`WlrKeyboardFocus.None`, couche Top, `exclusiveZone: 0` et le masque d’entrée
|
||||||
|
restent en place. Aucune place permanente n’est réservée à l’écran.
|
||||||
|
Le menu, `FavoritesService`, la résolution Tela et le format des favoris
|
||||||
|
partagés sont inchangés. Aucun fichier Session V2, Keyring, PAM, greetd ou
|
||||||
|
logique de session n’a été modifié.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Les tests s’exécutent dans un Sway privé headless, avec HOME, XDG,
|
||||||
|
sockets Wayland/Sway et profil Firefox privés, sans bus de session hérité. Les enfants sont arrêtés par
|
||||||
|
le laboratoire. Les journaux et résultats sont conservés dans
|
||||||
|
[validation/quickshell-dock-tela-polish-v2](validation/quickshell-dock-tela-polish-v2/).
|
||||||
|
|
||||||
|
Commandes reproductibles depuis le worktree :
|
||||||
|
|
||||||
|
```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 -m unittest discover -s tests/session_v2 -v
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
Résultats : 7 tests applications, 6 tests dock, 93 tests Session V2 backend,
|
||||||
|
les deux suites JavaScript, le chargement du shell complet, le scénario dock
|
||||||
|
et les scénarios Session V2/Keyring avec dock passent. Le validateur Keyring
|
||||||
|
complet termine avec ses onze contrôles à zéro (succès).
|
||||||
|
|
||||||
|
Le test dock vérifie notamment :
|
||||||
|
|
||||||
|
- 73 positions latérales sans changement de géométrie, sans changement d’état
|
||||||
|
des images et avec au plus trois influences non nulles ;
|
||||||
|
- 4,5 secondes de balayage continu, un mouvement souris par frame Qt,
|
||||||
|
sans changement de géométrie ni d’état de chargement des images ;
|
||||||
|
- extrémités non rognées après défilement sur une sortie de 400 px ;
|
||||||
|
- auto-hide, maintien pendant le menu contextuel, drag réel et redémarrage vide ;
|
||||||
|
- synchronisation menu/dock/client externe et réordonnancement durable ;
|
||||||
|
- absence de vol de focus vers le dock, fenêtres Kitty/Firefox privées,
|
||||||
|
reconnexion IPC et changements de résolution ;
|
||||||
|
- navigation clavier du menu et présence de la règle SwayFX sans blur du dock.
|
||||||
|
|
||||||
|
Le dernier passage mesure 281 intervalles : moyenne 16.00 ms,
|
||||||
|
95e percentile 16.46 ms, maximum 16.62 ms.
|
||||||
|
Les intervalles d’animation sont conservés dans `result.json` ; ce sont des
|
||||||
|
mesures de cadence Qt avec rendu logiciel, pas des timestamps de présentation
|
||||||
|
matérielle. Ils établissent la stabilité du laboratoire privé. La perception
|
||||||
|
sur le GPU, le fond d’écran et les sorties physiques de la session active reste
|
||||||
|
à confirmer après intégration, sans prétendre l’avoir observée ici.
|
||||||
|
|
||||||
|
Captures issues de `grim`, sans retouche :
|
||||||
|
|
||||||
|
- [Dock fermé](validation/quickshell-dock-tela-polish-v2/closed.png).
|
||||||
|
- [Dock ouvert](validation/quickshell-dock-tela-polish-v2/open.png).
|
||||||
|
- [Dock survolé](validation/quickshell-dock-tela-polish-v2/zoom.png).
|
||||||
|
- [Survol sur fond clair : ombre et transparence](validation/quickshell-dock-tela-polish-v2/zoom-light.png).
|
||||||
|
|
||||||
|
## Intégration
|
||||||
|
|
||||||
|
Le patch complet, fichiers nouveaux et PNG compris, se trouve à côté du worktree :
|
||||||
|
`/home/fy59/.dotfiles-worktrees/quickshell-dock-tela-polish-v2.patch`.
|
||||||
|
Les changements restent non commités et aucun fichier de favoris n’est livré.
|
||||||
|
|
||||||
|
Après revue, depuis le dépôt principal à la base compatible :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /home/fy59/.dotfiles
|
||||||
|
git apply --check /home/fy59/.dotfiles-worktrees/quickshell-dock-tela-polish-v2.patch
|
||||||
|
git apply /home/fy59/.dotfiles-worktrees/quickshell-dock-tela-polish-v2.patch
|
||||||
|
swaymsg reload
|
||||||
|
systemctl --user restart quickshell-labfy-sway.service
|
||||||
|
```
|
||||||
|
|
||||||
|
La vérification `git apply --check` est effectuée à la livraison. Les trois
|
||||||
|
commandes d’application/rechargement restent à exécuter lors de l’intégration.
|
||||||
|
Le redémarrage QuickShell recharge aussi les autres composants du shell.
|
||||||
|
Après intégration, vérifier visuellement un balayage latéral, l’auto-hide et
|
||||||
|
le menu sur les sorties physiques. Il n’est pas nécessaire de réécrire les
|
||||||
|
favoris ou de redémarrer la session utilisateur.
|
||||||
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é.
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
test_bad_state_does_not_get_overwritten (test_backend.FavoritesTests.test_bad_state_does_not_get_overwritten) ... ok
|
||||||
|
test_seed_order_atomic_update_and_missing_entry (test_backend.FavoritesTests.test_seed_order_atomic_update_and_missing_entry) ... ok
|
||||||
|
test_graphical_uses_desktop_id_and_uwsm_service (test_backend.LaunchTests.test_graphical_uses_desktop_id_and_uwsm_service) ... ok
|
||||||
|
test_invalid_id_and_missing_entry (test_backend.LaunchTests.test_invalid_id_and_missing_entry) ... ok
|
||||||
|
test_nodisplay_entry_is_refused_before_launch (test_backend.LaunchTests.test_nodisplay_entry_is_refused_before_launch) ... ok
|
||||||
|
test_terminal_entry_already_running_kitty_is_not_wrapped_twice (test_backend.LaunchTests.test_terminal_entry_already_running_kitty_is_not_wrapped_twice) ... ok
|
||||||
|
test_terminal_preserves_desktop_exec_fields_and_kitty (test_backend.LaunchTests.test_terminal_preserves_desktop_exec_fields_and_kitty) ... ok
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Ran 7 tests in 0.004s
|
||||||
|
|
||||||
|
OK
|
||||||
BIN
docs/validation/quickshell-dock-tela-polish-v2/closed.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
18
docs/validation/quickshell-dock-tela-polish-v2/compat.log
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
/tmp/labfy-session-v2-TEST_ONLY-s23v41ci
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
Dock headless: PASS /tmp/labfy-session-v2-TEST_ONLY-dlggtemr
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
Dock state and window models: PASS
|
||||||
|
ApplicationsModel: PASS
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
............................/usr/lib/python3.14/site-packages/gi/events.py:803: DeprecationWarning: 'asyncio.AbstractEventLoopPolicy' is deprecated and slated for removal in Python 3.16
|
||||||
|
AbstractEventLoopPolicy = getattr(asyncio, "AbstractEventLoopPolicy", object)
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
../usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
./usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
../usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
../usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
..........................................................
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Ran 93 tests in 8.443s
|
||||||
|
|
||||||
|
OK
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
.............................
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Ran 29 tests in 0.129s
|
||||||
|
|
||||||
|
OK
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
/tmp/labfy-session-v2-TEST_ONLY-0p63m0if
|
||||||
|
stable-final-focus PASS
|
||||||
|
/tmp/labfy-session-v2-TEST_ONLY-vn3vcup_
|
||||||
|
late-refused PASS
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
/tmp/labfy-session-v2-TEST_ONLY-p4jqsmpt
|
||||||
|
ready PASS
|
||||||
|
accept PASS
|
||||||
|
cancel PASS
|
||||||
|
timeout PASS
|
||||||
|
absent PASS
|
||||||
|
still-locked PASS
|
||||||
|
different PASS
|
||||||
|
quickshell-restart-during-unlock PASS
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
guard-replacement PASS APPLICATION_MEMBERSHIP_CHANGED
|
||||||
|
guard-extra PASS APPLICATION_MEMBERSHIP_CHANGED
|
||||||
|
last-capture-layout PASS FINAL_TREE_MISMATCH
|
||||||
|
last-capture-swap PASS RECOVERY_ORDER_CHANGED
|
||||||
|
guard-finish-layout PASS FINAL_TREE_MISMATCH
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
pendant-guard SUCCÈS : quatre swaps, quatre slots, arbre exact, focus vérifié
|
||||||
|
apres-phase0 SUCCÈS : quatre swaps, quatre slots, arbre exact, focus vérifié
|
||||||
|
deux-frontieres SUCCÈS : quatre swaps, quatre slots, arbre exact, focus vérifié
|
||||||
|
autotiling-guard SUCCÈS : quatre swaps, quatre slots, arbre exact, focus vérifié
|
||||||
|
provider-unaire SUCCÈS : changement externe refusé avant IPC suivant
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
success PASS
|
||||||
|
failure PASS
|
||||||
|
invalid PASS
|
||||||
|
new PASS
|
||||||
|
claimed PASS
|
||||||
|
real PASS
|
||||||
|
deferred-real PASS
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
********* Start testing of qmltestrunner *********
|
||||||
|
Config: Using QtTest library 6.12.0, Qt 6.12.0 (x86_64-little_endian-lp64 shared (dynamic) release build; by GCC 16.2.1 20260810), arch unknown
|
||||||
|
PASS : qmltestrunner::SessionExitGateV2::initTestCase()
|
||||||
|
PASS : qmltestrunner::SessionExitGateV2::test_backend_reason_is_visible_and_logout_stays_gated()
|
||||||
|
PASS : qmltestrunner::SessionExitGateV2::test_error_or_legacy_success_never_exits()
|
||||||
|
PASS : qmltestrunner::SessionExitGateV2::test_quit_without_saving_requires_explicit_call()
|
||||||
|
PASS : qmltestrunner::SessionExitGateV2::test_saved_releases_only_confirmed_action()
|
||||||
|
PASS : qmltestrunner::SessionExitGateV2::cleanupTestCase()
|
||||||
|
Totals: 6 passed, 0 failed, 0 skipped, 0 blacklisted, 5ms
|
||||||
|
********* Finished testing of qmltestrunner *********
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
...............
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Ran 15 tests in 20.330s
|
||||||
|
|
||||||
|
OK
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
/tmp/labfy-session-v2-TEST_ONLY-1lfda8ji
|
||||||
|
PASS save/store/restore/show/hide/new-membership/all-hidden/cold-kitty/no-duplicate
|
||||||
12
docs/validation/quickshell-dock-tela-polish-v2/keyring.log
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
/tmp/labfy-keyring-validation-ibq2bukh
|
||||||
|
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
|
||||||
BIN
docs/validation/quickshell-dock-tela-polish-v2/open.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
1637
docs/validation/quickshell-dock-tela-polish-v2/result.json
Normal file
152
docs/validation/quickshell-dock-tela-polish-v2/session.log
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
test_absent_last_existing_directory (test_bootstrap.BootstrapTests.test_absent_last_existing_directory) ... ok
|
||||||
|
test_absent_no_creation (test_bootstrap.BootstrapTests.test_absent_no_creation) ... ok
|
||||||
|
test_allowlist_denied (test_bootstrap.BootstrapTests.test_allowlist_denied) ... ok
|
||||||
|
test_checkpoint_created_between_observations (test_bootstrap.BootstrapTests.test_checkpoint_created_between_observations) ... ok
|
||||||
|
test_empty_sessions_with_or_without_automatic_allows_without_creation (test_bootstrap.BootstrapTests.test_empty_sessions_with_or_without_automatic_allows_without_creation) ... ok
|
||||||
|
test_envelope_and_framing_rejected (test_bootstrap.BootstrapTests.test_envelope_and_framing_rejected) ... ok
|
||||||
|
test_home_fallback_matches_canonical_location (test_bootstrap.BootstrapTests.test_home_fallback_matches_canonical_location) ... ok
|
||||||
|
test_interleaved_operations_map_unchanged (test_bootstrap.BootstrapTests.test_interleaved_operations_map_unchanged) ... ok
|
||||||
|
test_last_with_sessions_absent_refuses (test_bootstrap.BootstrapTests.test_last_with_sessions_absent_refuses) ... ok
|
||||||
|
test_named_only_without_automatic_refuses (test_bootstrap.BootstrapTests.test_named_only_without_automatic_refuses) ... ok
|
||||||
|
test_os_error_is_not_absence (test_bootstrap.BootstrapTests.test_os_error_is_not_absence) ... ok
|
||||||
|
test_present_and_corrupt_always_refuse (test_bootstrap.BootstrapTests.test_present_and_corrupt_always_refuse) ... ok
|
||||||
|
test_sessions_any_entry_refuses_without_content_read (test_bootstrap.BootstrapTests.test_sessions_any_entry_refuses_without_content_read) ... ok
|
||||||
|
test_sessions_list_error_refuses (test_bootstrap.BootstrapTests.test_sessions_list_error_refuses) ... ok
|
||||||
|
test_sessions_unsafe_directory_refuses (test_bootstrap.BootstrapTests.test_sessions_unsafe_directory_refuses) ... ok
|
||||||
|
test_socket_authority_failure_refuses_absence (test_bootstrap.BootstrapTests.test_socket_authority_failure_refuses_absence) ... ok
|
||||||
|
test_state_symlink_and_relative_state_refuse (test_bootstrap.BootstrapTests.test_state_symlink_and_relative_state_refuse) ... ok
|
||||||
|
test_symlinks_and_special_last_refuse_without_open (test_bootstrap.BootstrapTests.test_symlinks_and_special_last_refuse_without_open) ... ok
|
||||||
|
test_unreadable_and_nonprivate_directory_refuse (test_bootstrap.BootstrapTests.test_unreadable_and_nonprivate_directory_refuse) ... ok
|
||||||
|
test_valid_store_checkpoint_and_unreadable_file_refuse (test_bootstrap.BootstrapTests.test_valid_store_checkpoint_and_unreadable_file_refuse) ... ok
|
||||||
|
test_equivalent_unary_paths_are_not_identity_changes (test_focus_recovery.FocusRecoveryTests.test_equivalent_unary_paths_are_not_identity_changes) ... ok
|
||||||
|
test_firefox_command_proven_then_kitty_late_conflict_is_preserved (test_focus_recovery.FocusRecoveryTests.test_firefox_command_proven_then_kitty_late_conflict_is_preserved) ... ok
|
||||||
|
test_interrupted_intention_preserves_all_evidence (test_focus_recovery.FocusRecoveryTests.test_interrupted_intention_preserves_all_evidence) ... ok
|
||||||
|
test_layout_change_with_identical_raw_paths_still_refuses (test_focus_recovery.FocusRecoveryTests.test_layout_change_with_identical_raw_paths_still_refuses) ... ok
|
||||||
|
test_path_evidence_limit_refuses_without_overwriting_history (test_focus_recovery.FocusRecoveryTests.test_path_evidence_limit_refuses_without_overwriting_history) ... ok
|
||||||
|
test_real_identity_order_workspace_and_branch_changes_still_refuse (test_focus_recovery.FocusRecoveryTests.test_real_identity_order_workspace_and_branch_changes_still_refuse) ... ok
|
||||||
|
test_prepare_quits_before_resume_and_finish_has_no_delayed_exit (test_guard_protocol.GuardProtocolTests.test_prepare_quits_before_resume_and_finish_has_no_delayed_exit) ... ok
|
||||||
|
test_resume_without_prepare_refuses_and_still_cleans_owned_helper (test_guard_protocol.GuardProtocolTests.test_resume_without_prepare_refuses_and_still_cleans_owned_helper) ... ok
|
||||||
|
test_actual_dbus_states (test_keyring.KeyringTests.test_actual_dbus_states) ... /usr/lib/python3.14/site-packages/gi/events.py:803: DeprecationWarning: 'asyncio.AbstractEventLoopPolicy' is deprecated and slated for removal in Python 3.16
|
||||||
|
AbstractEventLoopPolicy = getattr(asyncio, "AbstractEventLoopPolicy", object)
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
ok
|
||||||
|
test_apply_is_excluded_during_unlock (test_keyring.KeyringTests.test_apply_is_excluded_during_unlock) ... ok
|
||||||
|
test_apply_last_cannot_bypass_gate (test_keyring.KeyringTests.test_apply_last_cannot_bypass_gate) ... /usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
ok
|
||||||
|
test_ready_preserves_executor_result_and_recovery_required (test_keyring.KeyringTests.test_ready_preserves_executor_result_and_recovery_required) ... /usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
ok
|
||||||
|
test_remote_error_text_is_never_logged_or_persisted (test_keyring.KeyringTests.test_remote_error_text_is_never_logged_or_persisted) ... ok
|
||||||
|
test_transaction_prevents_prompt (test_keyring.KeyringTests.test_transaction_prevents_prompt) ... /usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
ok
|
||||||
|
test_two_processes_and_restart (test_keyring.KeyringTests.test_two_processes_and_restart) ... ok
|
||||||
|
test_unlock_never_restores (test_keyring.KeyringTests.test_unlock_never_restores) ... /usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
/usr/lib/python3.14/site-packages/gi/events.py:890: DeprecationWarning: 'asyncio.get_event_loop_policy' is deprecated and slated for removal in Python 3.16
|
||||||
|
if not isinstance(_get_event_loop_policy(), GLibEventLoopPolicy):
|
||||||
|
ok
|
||||||
|
test_complete_refuses_full_identity_placement_and_pending (test_p0_boundaries.BoundaryTests.test_complete_refuses_full_identity_placement_and_pending) ... ok
|
||||||
|
test_extra_after_first_move_refuses_before_second_staging (test_p0_boundaries.BoundaryTests.test_extra_after_first_move_refuses_before_second_staging) ... ok
|
||||||
|
test_guard_replacement_and_extra_refuse_before_any_mutation (test_p0_boundaries.BoundaryTests.test_guard_replacement_and_extra_refuse_before_any_mutation) ... ok
|
||||||
|
test_last_provider_capture_cannot_hide_structure_or_identity_change (test_p0_boundaries.BoundaryTests.test_last_provider_capture_cannot_hide_structure_or_identity_change) ... ok
|
||||||
|
test_layout_change_in_second_split_focus_observation_refuses (test_p0_boundaries.BoundaryTests.test_layout_change_in_second_split_focus_observation_refuses) ... ok
|
||||||
|
test_unary_parent_normalization_preserves_canonical_layout (test_p0_boundaries.BoundaryTests.test_unary_parent_normalization_preserves_canonical_layout) ... ok
|
||||||
|
test_unchanged_final_observation_completes (test_p0_boundaries.BoundaryTests.test_unchanged_final_observation_completes) ... ok
|
||||||
|
test_changement_apres_capture_canonique_refuse_avant_deplacement (test_path_stability.PathTests.test_changement_apres_capture_canonique_refuse_avant_deplacement) ... ok
|
||||||
|
test_guard_refuse_les_divergences_avant_tout_ipc (test_path_stability.PathTests.test_guard_refuse_les_divergences_avant_tout_ipc) ... ok
|
||||||
|
test_ack_requires_verified_current_transaction (test_phase3b.DeploymentTests.test_ack_requires_verified_current_transaction) ... ok
|
||||||
|
test_limusic_never_launched (test_phase3b.DeploymentTests.test_limusic_never_launched) ... ok
|
||||||
|
test_async_mapping_completes_before_apply_and_focus_proof (test_prewarm.PrewarmTests.test_async_mapping_completes_before_apply_and_focus_proof) ... ok
|
||||||
|
test_deferred_marker_and_cli_preserve_cause_for_explicit_retry (test_prewarm.PrewarmTests.test_deferred_marker_and_cli_preserve_cause_for_explicit_retry) ... ok
|
||||||
|
test_existing_required_journal_refuses_before_launch (test_prewarm.PrewarmTests.test_existing_required_journal_refuses_before_launch) ... ok
|
||||||
|
test_focus_change_after_proof_refuses_without_publication (test_prewarm.PrewarmTests.test_focus_change_after_proof_refuses_without_publication) ... ok
|
||||||
|
test_intruder_extra_identity_session_and_owner_refuse_without_publication (test_prewarm.PrewarmTests.test_intruder_extra_identity_session_and_owner_refuse_without_publication) ... ok
|
||||||
|
test_launch_failure_preserves_clients_and_retry_does_not_duplicate (test_prewarm.PrewarmTests.test_launch_failure_preserves_clients_and_retry_does_not_duplicate) ... ok
|
||||||
|
test_missing_managed_autostart_refuses_before_launch (test_prewarm.PrewarmTests.test_missing_managed_autostart_refuses_before_launch) ... ok
|
||||||
|
test_missing_collision_and_live_publisher (test_provider.ProviderTests.test_missing_collision_and_live_publisher) ... ok
|
||||||
|
test_capture_is_deterministic_for_a_stable_tree (test_scratchpad.ScratchpadTests.test_capture_is_deterministic_for_a_stable_tree) ... ok
|
||||||
|
test_capture_store_reload_and_no_internal_workspace (test_scratchpad.ScratchpadTests.test_capture_store_reload_and_no_internal_workspace) ... ok
|
||||||
|
test_change_between_reads_and_ambiguous_focus_refuse (test_scratchpad.ScratchpadTests.test_change_between_reads_and_ambiguous_focus_refuse) ... ok
|
||||||
|
test_empty_focus_workspace_is_persisted (test_scratchpad.ScratchpadTests.test_empty_focus_workspace_is_persisted) ... ok
|
||||||
|
test_group_ancestor_is_not_admitted_by_leaf_identity (test_scratchpad.ScratchpadTests.test_group_ancestor_is_not_admitted_by_leaf_identity) ... ok
|
||||||
|
test_hidden_focus_is_rejected_before_command (test_scratchpad.ScratchpadTests.test_hidden_focus_is_rejected_before_command) ... ok
|
||||||
|
test_initial_scratchpad_add_predicts_source_workspace_focus (test_scratchpad.ScratchpadTests.test_initial_scratchpad_add_predicts_source_workspace_focus) ... ok
|
||||||
|
test_journal_detects_identity_output_scratchpad_and_interruption (test_scratchpad.ScratchpadTests.test_journal_detects_identity_output_scratchpad_and_interruption) ... ok
|
||||||
|
test_layer_shell_can_leave_no_sway_focus (test_scratchpad.ScratchpadTests.test_layer_shell_can_leave_no_sway_focus) ... ok
|
||||||
|
test_legacy_journal_not_reinterpreted (test_scratchpad.ScratchpadTests.test_legacy_journal_not_reinterpreted) ... ok
|
||||||
|
test_multiple_focused_nodes_are_still_refused (test_scratchpad.ScratchpadTests.test_multiple_focused_nodes_are_still_refused) ... ok
|
||||||
|
test_runtime_visibility_assignment_never_exposes_hidden_peer (test_scratchpad.ScratchpadTests.test_runtime_visibility_assignment_never_exposes_hidden_peer) ... ok
|
||||||
|
test_shown_and_multiple_hidden (test_scratchpad.ScratchpadTests.test_shown_and_multiple_hidden) ... ok
|
||||||
|
test_strict_versions_and_scratchpad_references (test_scratchpad.ScratchpadTests.test_strict_versions_and_scratchpad_references) ... ok
|
||||||
|
test_checkpoint_failed_validation_preserves_old (test_unit.BackendTests.test_checkpoint_failed_validation_preserves_old) ... ok
|
||||||
|
test_checkpoint_permissions (test_unit.BackendTests.test_checkpoint_permissions) ... ok
|
||||||
|
test_cli_execute_required (test_unit.BackendTests.test_cli_execute_required) ... ok
|
||||||
|
test_cli_storage_checkpoint_and_startup_mock (test_unit.BackendTests.test_cli_storage_checkpoint_and_startup_mock) ... ok
|
||||||
|
test_coexistence_and_migration (test_unit.BackendTests.test_coexistence_and_migration) ... ok
|
||||||
|
test_command_injection (test_unit.BackendTests.test_command_injection) ... ok
|
||||||
|
test_counts (test_unit.BackendTests.test_counts) ... ok
|
||||||
|
test_exact_inverted (test_unit.BackendTests.test_exact_inverted) ... ok
|
||||||
|
test_interchangeable_best_effort (test_unit.BackendTests.test_interchangeable_best_effort) ... ok
|
||||||
|
test_intermediate_directory_permissions (test_unit.BackendTests.test_intermediate_directory_permissions) ... ok
|
||||||
|
test_json_schema_matches_fixture (test_unit.BackendTests.test_json_schema_matches_fixture) ... ok
|
||||||
|
test_json_strict (test_unit.BackendTests.test_json_strict) ... ok
|
||||||
|
test_launch_policy (test_unit.BackendTests.test_launch_policy) ... ok
|
||||||
|
test_lock (test_unit.BackendTests.test_lock) ... ok
|
||||||
|
test_multi_output_preflight_synthetic (test_unit.BackendTests.test_multi_output_preflight_synthetic) ... ok
|
||||||
|
test_native_strict (test_unit.BackendTests.test_native_strict) ... ok
|
||||||
|
test_paths_links (test_unit.BackendTests.test_paths_links) ... ok
|
||||||
|
test_plan_pure (test_unit.BackendTests.test_plan_pure) ... ok
|
||||||
|
test_references (test_unit.BackendTests.test_references) ... ok
|
||||||
|
test_report_rejects_private_fields_and_bool_version (test_unit.BackendTests.test_report_rejects_private_fields_and_bool_version) ... ok
|
||||||
|
test_resume_attempts_all_owned_helpers (test_unit.BackendTests.test_resume_attempts_all_owned_helpers) ... ok
|
||||||
|
test_schema_exact (test_unit.BackendTests.test_schema_exact) ... ok
|
||||||
|
test_types_version_and_identity (test_unit.BackendTests.test_types_version_and_identity) ... ok
|
||||||
|
test_unsupported_states (test_unit.BackendTests.test_unsupported_states) ... ok
|
||||||
|
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
Ran 93 tests in 8.405s
|
||||||
|
|
||||||
|
OK
|
||||||
1
docs/validation/quickshell-dock-tela-polish-v2/shell.log
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Full shell private-compositor load: PASS /tmp/labfy-session-v2-TEST_ONLY-vx5tlv5i
|
||||||
11
docs/validation/quickshell-dock-tela-polish-v2/unit.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.735s
|
||||||
|
|
||||||
|
OK
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
{
|
||||||
|
"status": "PASS",
|
||||||
|
"base": "d4e2a0013e7a9452add80dddc110e036ebe4ee96",
|
||||||
|
"backendTests": {
|
||||||
|
"applications": 7,
|
||||||
|
"dock": 6,
|
||||||
|
"sessionV2": 93
|
||||||
|
},
|
||||||
|
"javascript": [
|
||||||
|
"ApplicationsModel: PASS",
|
||||||
|
"Dock state and window models: PASS"
|
||||||
|
],
|
||||||
|
"graphical": [
|
||||||
|
"private Sway dock scenario",
|
||||||
|
"complete shell load",
|
||||||
|
"Session V2 and Keyring with dock"
|
||||||
|
],
|
||||||
|
"keyring": {
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"finalChecks": [
|
||||||
|
"git diff --check: PASS",
|
||||||
|
"git apply --check against unchanged main checkout: PASS",
|
||||||
|
"no Binding loop, assignment, ReferenceError or TypeError in final dock lab logs",
|
||||||
|
"main checkout git status --short: empty"
|
||||||
|
],
|
||||||
|
"finalShellLab": "/tmp/labfy-session-v2-TEST_ONLY-ywdppzn5",
|
||||||
|
"activeSessionModified": false
|
||||||
|
}
|
||||||
BIN
docs/validation/quickshell-dock-tela-polish-v2/zoom-light.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
docs/validation/quickshell-dock-tela-polish-v2/zoom.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/validation/quickshell-dock-tela/closed.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
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
|
After Width: | Height: | Size: 37 KiB |
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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
After Width: | Height: | Size: 37 KiB |
|
|
@ -103,8 +103,7 @@ PanelWindow {
|
||||||
|
|
||||||
property string applicationsError: ""
|
property string applicationsError: ""
|
||||||
property string queuedApplicationId: ""
|
property string queuedApplicationId: ""
|
||||||
readonly property string applicationsBackend: (Quickshell.env("XDG_CONFIG_HOME") ||
|
readonly property string applicationsBackend: FavoritesService.backend
|
||||||
(Quickshell.env("HOME") + "/.config")) + "/quickshell/labfy-sway/applications/backend.py"
|
|
||||||
function closeApplications() { applicationCoordinator.applicationsOutput = ""; }
|
function closeApplications() { applicationCoordinator.applicationsOutput = ""; }
|
||||||
function toggleApplications() {
|
function toggleApplications() {
|
||||||
if (authenticationActive) return false;
|
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
|
Voir [Session Restore V2](../../../../docs/session-restore-v2.md) pour les
|
||||||
commandes, les preuves et les limites de l’environnement. La recette réussie
|
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.
|
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 menuDismissed()
|
||||||
signal launchRequested(string desktopId)
|
signal launchRequested(string desktopId)
|
||||||
property string errorMessage: ""
|
property string errorMessage: ""
|
||||||
property var allApps: []
|
readonly property var allApps: FavoritesService.apps
|
||||||
property var favoriteIds: []
|
readonly property var favoriteIds: FavoritesService.ids
|
||||||
property string selectedCategory: "Toutes"
|
property string selectedCategory: "Toutes"
|
||||||
property int selectedIndex: 0
|
property int selectedIndex: 0
|
||||||
property int navIndex: 0
|
property int navIndex: 0
|
||||||
|
|
@ -44,10 +44,6 @@ PanelWindow {
|
||||||
WlrLayershell.namespace: "labfy-applications-menu"
|
WlrLayershell.namespace: "labfy-applications-menu"
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
|
|
||||||
function refreshCatalog() {
|
|
||||||
allApps = ApplicationsModel.catalog(DesktopEntries.applications.values);
|
|
||||||
if (!favoriteIds.length && selectedCategory === "Favoris") selectedCategory = "Toutes";
|
|
||||||
}
|
|
||||||
function chooseCategory(key) {
|
function chooseCategory(key) {
|
||||||
selectedCategory = key;
|
selectedCategory = key;
|
||||||
navIndex = navigation.findIndex(item => item.key === key);
|
navIndex = navigation.findIndex(item => item.key === key);
|
||||||
|
|
@ -63,10 +59,7 @@ PanelWindow {
|
||||||
if (app) launchRequested(app.id);
|
if (app) launchRequested(app.id);
|
||||||
}
|
}
|
||||||
function toggleFavorite(id) {
|
function toggleFavorite(id) {
|
||||||
const had = favoriteIds.includes(id);
|
FavoritesService.toggle(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");
|
|
||||||
}
|
}
|
||||||
function dismiss() {
|
function dismiss() {
|
||||||
if (closing) return;
|
if (closing) return;
|
||||||
|
|
@ -80,39 +73,10 @@ PanelWindow {
|
||||||
interval: 150; repeat: false
|
interval: 150; repeat: false
|
||||||
onTriggered: { menu.visible = false; menu.menuDismissed(); }
|
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))
|
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: {
|
Component.onCompleted: {
|
||||||
refreshCatalog();
|
selectedCategory = favoriteIds.some(id => allApps.some(app => app.id === id)) ? "Favoris" : "Toutes";
|
||||||
favoritesReader.running = true;
|
navIndex = selectedCategory === "Favoris" ? 0 : 1;
|
||||||
}
|
}
|
||||||
// The Wayland surface exists after completion; focus at the next frame.
|
// The Wayland surface exists after completion; focus at the next frame.
|
||||||
Timer { interval: 120; running: true; repeat: false; onTriggered: search.forceActiveFocus() }
|
Timer { interval: 120; running: true; repeat: false; onTriggered: search.forceActiveFocus() }
|
||||||
|
|
@ -166,6 +130,10 @@ PanelWindow {
|
||||||
font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 13
|
font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 13
|
||||||
background: Item {}
|
background: Item {}
|
||||||
onTextChanged: { menu.selectedIndex = 0; grid.positionViewAtBeginning(); }
|
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 => {
|
Keys.onPressed: event => {
|
||||||
if (event.key === Qt.Key_Escape) { menu.dismiss(); event.accepted = true; }
|
if (event.key === Qt.Key_Escape) { menu.dismiss(); event.accepted = true; }
|
||||||
else if (event.key === Qt.Key_Backtab ||
|
else if (event.key === Qt.Key_Backtab ||
|
||||||
|
|
@ -182,8 +150,8 @@ PanelWindow {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
visible: menu.errorMessage !== ""
|
visible: menu.errorMessage !== "" || FavoritesService.error !== ""
|
||||||
width: parent.width; text: menu.errorMessage
|
width: parent.width; text: menu.errorMessage || FavoritesService.error
|
||||||
color: Theme.danger; font.pixelSize: 12; elide: Text.ElideRight
|
color: Theme.danger; font.pixelSize: 12; elide: Text.ElideRight
|
||||||
}
|
}
|
||||||
Item {
|
Item {
|
||||||
|
|
@ -199,6 +167,7 @@ PanelWindow {
|
||||||
anchors.fill: parent; anchors.margins: 5
|
anchors.fill: parent; anchors.margins: 5
|
||||||
clip: true; spacing: 3
|
clip: true; spacing: 3
|
||||||
model: menu.navigation
|
model: menu.navigation
|
||||||
|
Keys.priority: Keys.BeforeItem
|
||||||
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
||||||
Keys.onPressed: event => {
|
Keys.onPressed: event => {
|
||||||
if (event.key === Qt.Key_Up || event.key === Qt.Key_Down) {
|
if (event.key === Qt.Key_Up || event.key === Qt.Key_Down) {
|
||||||
|
|
@ -255,6 +224,7 @@ PanelWindow {
|
||||||
visible: menu.visibleApps.length > 0
|
visible: menu.visibleApps.length > 0
|
||||||
clip: true
|
clip: true
|
||||||
model: menu.visibleApps
|
model: menu.visibleApps
|
||||||
|
Keys.priority: Keys.BeforeItem
|
||||||
cellWidth: Math.max(80, Math.floor(width / (width >= 420 ? 4 : 3)))
|
cellWidth: Math.max(80, Math.floor(width / (width >= 420 ? 4 : 3)))
|
||||||
cellHeight: 104
|
cellHeight: 104
|
||||||
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
||||||
|
|
@ -293,23 +263,12 @@ PanelWindow {
|
||||||
border.color: menu.selectedIndex === index && grid.activeFocus
|
border.color: menu.selectedIndex === index && grid.activeFocus
|
||||||
? Theme.lavender : "transparent"
|
? Theme.lavender : "transparent"
|
||||||
}
|
}
|
||||||
Image {
|
ApplicationIcon {
|
||||||
id: appIcon
|
id: appIcon
|
||||||
anchors.top: parent.top; anchors.topMargin: 12
|
anchors.top: parent.top; anchors.topMargin: 12
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
width: 46; height: 46
|
width: 46; height: 46
|
||||||
source: modelData.icon.startsWith("/") ? "file://" + modelData.icon
|
iconSource: modelData.iconSource
|
||||||
: 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
|
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
anchors.top: appIcon.bottom; anchors.topMargin: 5
|
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",
|
.map(entry => ({ id: entry.id.endsWith(".desktop") ? entry.id : entry.id + ".desktop",
|
||||||
name: entry.name || entry.id,
|
name: entry.name || entry.id,
|
||||||
genericName: entry.genericName || "", keywords: entry.keywords || [],
|
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));
|
.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)
|
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||||
try:
|
try:
|
||||||
fcntl.flock(directory, fcntl.LOCK_EX)
|
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)
|
exists, current = read_favorites(path)
|
||||||
if action == "seed":
|
if action == "seed":
|
||||||
if exists:
|
if exists:
|
||||||
return current
|
return current
|
||||||
current = list(dict.fromkeys(ids))[:MAX_FAVORITES]
|
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 ids[0] not in current:
|
||||||
if len(current) >= MAX_FAVORITES:
|
if len(current) >= MAX_FAVORITES:
|
||||||
raise ValueError("Trop de favoris")
|
raise ValueError("Trop de favoris")
|
||||||
current.append(ids[0])
|
current.append(ids[0])
|
||||||
elif action == "remove":
|
elif action in ("remove", "toggle"):
|
||||||
current = [item for item in current if item != ids[0]]
|
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:
|
else:
|
||||||
raise ValueError("Action de favoris invalide")
|
raise ValueError("Action de favoris invalide")
|
||||||
if any(not valid_id(item) for item in current):
|
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):
|
if len(ids) > 4 or any(not valid_id(item) for item in ids):
|
||||||
raise ValueError("Favoris initiaux invalides")
|
raise ValueError("Favoris initiaux invalides")
|
||||||
result = {"ids": write_favorites(state_path(), "seed", ids)}
|
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]):
|
if not valid_id(argv[2]):
|
||||||
raise ValueError("Identifiant de favori invalide")
|
raise ValueError("Identifiant de favori invalide")
|
||||||
result = {"ids": write_favorites(state_path(), argv[1], [argv[2]])}
|
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":
|
elif len(argv) == 2 and argv[0] == "launch":
|
||||||
launch(argv[1])
|
launch(argv[1])
|
||||||
result = {"ok": True}
|
result = {"ok": True}
|
||||||
|
|
@ -166,7 +191,7 @@ def main(argv):
|
||||||
raise ValueError("Commande invalide")
|
raise ValueError("Commande invalide")
|
||||||
print(json.dumps(result, ensure_ascii=False))
|
print(json.dumps(result, ensure_ascii=False))
|
||||||
return 0
|
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))
|
print(json.dumps({"error": str(error)}, ensure_ascii=False))
|
||||||
return 1
|
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
|
|
@ -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
|
||||||
25
quickshell/.config/quickshell/labfy-sway/dock/AutoHide.js
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
.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) {
|
||||||
|
// CONTRACT: 57 px resting pitch; a radius below 1.5 pitches affects
|
||||||
|
// at most the nearest slot and its immediate neighbours. Smoothstep has
|
||||||
|
// zero slope at both ends, avoiding a kink as an icon enters the halo.
|
||||||
|
const t = Math.max(0, 1 - Math.abs(distance) / 76);
|
||||||
|
return t * t * (3 - 2 * t);
|
||||||
|
}
|
||||||
273
quickshell/.config/quickshell/labfy-sway/dock/Dock.qml
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Effects
|
||||||
|
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 + 52))
|
||||||
|
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
|
||||||
|
}
|
||||||
|
// WHY: an analytic shadow needs neither a captured backdrop nor a
|
||||||
|
// moving blur source. Its geometry stays fixed throughout hover.
|
||||||
|
RectangularShadow {
|
||||||
|
x: 16; y: 43; width: parent.width - 32; height: 48
|
||||||
|
radius: 20; blur: 24; spread: 2
|
||||||
|
color: Qt.rgba(0, 0, 0, 0.32)
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
x: 14; y: 36; width: parent.width - 28; height: 58
|
||||||
|
radius: 20
|
||||||
|
color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, Theme.highContrast ? 0.94 : 0.16)
|
||||||
|
border.color: Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, Theme.highContrast ? 0.5 : 0.09)
|
||||||
|
border.width: 1
|
||||||
|
}
|
||||||
|
Flickable {
|
||||||
|
id: viewport
|
||||||
|
x: 12; y: 0; width: parent.width - 24; height: 105
|
||||||
|
// CONTRACT: reserve visual overflow inside the clipped viewport,
|
||||||
|
// including at both scroll limits, for the 1.39x end icons.
|
||||||
|
contentWidth: items.width + 24; contentHeight: height
|
||||||
|
clip: true
|
||||||
|
interactive: dock.draggingId === ""
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
ScrollBar.horizontal: ScrollBar { policy: ScrollBar.AsNeeded }
|
||||||
|
Row {
|
||||||
|
id: items
|
||||||
|
x: 12
|
||||||
|
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 + items.x + x + width / 2 - viewport.contentX
|
||||||
|
// INVARIANT: pointer influence uses fixed layout slots.
|
||||||
|
// Only the visual transform changes: Row, hit targets,
|
||||||
|
// window width and SVG raster size never follow hover.
|
||||||
|
readonly property real baseCenter: viewport.x + items.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 + (separator ? 13 : 0)
|
||||||
|
height: 105
|
||||||
|
Rectangle {
|
||||||
|
visible: tile.separator
|
||||||
|
x: 2; y: 45; width: 1; height: 32; color: Theme.strongBorder
|
||||||
|
}
|
||||||
|
ApplicationIcon {
|
||||||
|
id: icon
|
||||||
|
// WHY: ApplicationIcon derives sourceSize from width.
|
||||||
|
// Fixed dimensions avoid async SVG reload/fallback flashes.
|
||||||
|
width: 46; height: 46
|
||||||
|
x: (parent.width - width + (tile.separator ? 12 : 0)) / 2
|
||||||
|
y: 37
|
||||||
|
transformOrigin: Item.Bottom
|
||||||
|
scale: 1 + tile.growth * 0.39
|
||||||
|
Behavior on scale { NumberAnimation { duration: 80; easing.type: Easing.OutCubic } }
|
||||||
|
iconSource: tile.modelData.iconSource || ""
|
||||||
|
opacity: dock.draggingId === tile.modelData.id ? 0.5 : 1
|
||||||
|
}
|
||||||
|
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
|
|
@ -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 "polkit"
|
||||||
import "scratchpad"
|
import "scratchpad"
|
||||||
import "osd"
|
import "osd"
|
||||||
|
import "applications"
|
||||||
|
import "dock"
|
||||||
|
|
||||||
ShellRoot {
|
ShellRoot {
|
||||||
id: shell
|
id: shell
|
||||||
|
|
@ -242,6 +244,12 @@ ShellRoot {
|
||||||
return AppearanceController.acceptPublishedNightLight(payload);
|
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.
|
// Une barre par écran, y compris si les sorties changent pendant la session.
|
||||||
// CONTRACT: un seul serveur D-Bus pour toutes les sorties.
|
// CONTRACT: un seul serveur D-Bus pour toutes les sorties.
|
||||||
NotificationService { id: rootNotificationService }
|
NotificationService { id: rootNotificationService }
|
||||||
|
|
|
||||||
|
|
@ -26,3 +26,14 @@ layer_effects "labfy-sway-bar" {
|
||||||
shadows enable
|
shadows enable
|
||||||
corner_radius 4
|
corner_radius 4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# CONTRACT: dock glass uses a light tint and analytic QML shadow. Disabling
|
||||||
|
# backdrop blur avoids compositor blur work on every magnification frame.
|
||||||
|
# No window rule or focus/restore policy is changed.
|
||||||
|
layer_effects "labfy-sway-dock" {
|
||||||
|
blur disable
|
||||||
|
blur_xray disable
|
||||||
|
blur_ignore_transparent enable
|
||||||
|
shadows disable
|
||||||
|
corner_radius 25
|
||||||
|
}
|
||||||
|
|
|
||||||
296
tests/dock/headless.py
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
#!/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-polish-v2'
|
||||||
|
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('layer_effects "labfy-sway-dock"')[1]
|
||||||
|
effects='layer_effects "labfy-sway-dock"'+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 } }
|
||||||
|
// TEST_ONLY: one pointer move per animation frame avoids measuring IPC
|
||||||
|
// process startup instead of continuous production hover. These are Qt
|
||||||
|
// animation intervals, not hardware presentation timestamps.
|
||||||
|
FrameAnimation {
|
||||||
|
id: sweepFrames
|
||||||
|
property var intervals: []
|
||||||
|
property real origin: 0
|
||||||
|
property bool geometryChanged: false
|
||||||
|
property bool imageChanged: false
|
||||||
|
property var baseline: []
|
||||||
|
running: false
|
||||||
|
onTriggered: {
|
||||||
|
if (currentFrame > 1) intervals.push(frameTime * 1000);
|
||||||
|
const cycle = (elapsedTime * 0.7) % 2;
|
||||||
|
input.mouseMove(dock.surfaceBody, 51 + 420 * (cycle <= 1 ? cycle : 2 - cycle), 70);
|
||||||
|
const tiles=Array.from(dock.contentItemRow.children).filter(t => t.modelData);
|
||||||
|
for (let i=0; i<tiles.length; ++i) {
|
||||||
|
const t=tiles[i], b=baseline[i];
|
||||||
|
if (t.x!==b.x || t.width!==b.width || t.children[1].width!==46 || dock.width!==origin)
|
||||||
|
geometryChanged=true;
|
||||||
|
if (t.children[1].children[0].status!==b.status) imageChanged=true;
|
||||||
|
}
|
||||||
|
if (elapsedTime >= 4.5) running=false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
tiles:Array.from(dock.contentItemRow.children).filter(t => t.modelData).map(t => ({
|
||||||
|
x:t.x, width:t.width, growth:t.growth, scale:t.children[1].scale,
|
||||||
|
visualLeft:t.children[1].mapToItem(dock.contentItemRow.parent.parent,0,0).x,
|
||||||
|
visualRight:t.children[1].mapToItem(dock.contentItemRow.parent.parent,46,0).x,
|
||||||
|
iconWidth:t.children[1].width, imageStatus:t.children[1].children[0].status})),
|
||||||
|
context:!!dock.contextEntry});
|
||||||
|
}
|
||||||
|
function startFrames(): void {
|
||||||
|
sweepFrames.intervals=[]; sweepFrames.origin=dock.width;
|
||||||
|
sweepFrames.baseline=Array.from(dock.contentItemRow.children).filter(t => t.modelData)
|
||||||
|
.map(t => ({x:t.x,width:t.width,status:t.children[1].children[0].status}));
|
||||||
|
sweepFrames.start();
|
||||||
|
}
|
||||||
|
function frames(): string {
|
||||||
|
return JSON.stringify({running:sweepFrames.running, intervals:sweepFrames.intervals,
|
||||||
|
geometryChanged:sweepFrames.geometryChanged,imageChanged:sweepFrames.imageChanged});
|
||||||
|
}
|
||||||
|
function edge(): void { input.mouseMove(dock.edgeArea, dock.edgeArea.width/2, 2); }
|
||||||
|
function body(): void { input.mouseMove(dock.surfaceBody, 35, 70); }
|
||||||
|
function sweep(x:real): void { input.mouseMove(dock.surfaceBody, x, 70); }
|
||||||
|
function scrollEnd(): void {
|
||||||
|
const v=dock.contentItemRow.parent.parent; v.contentX=Math.max(0,v.contentWidth-v.width);
|
||||||
|
}
|
||||||
|
function scrollStart(): void { dock.contentItemRow.parent.parent.contentX=0; }
|
||||||
|
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')
|
||||||
|
resting=call('state')
|
||||||
|
call('body'); time.sleep(.3); zoom=call('state'); capture('zoom')
|
||||||
|
lab.command('output HEADLESS-1 bg #aab4c8 solid_color')
|
||||||
|
time.sleep(.2); capture('zoom-light')
|
||||||
|
lab.command('output HEADLESS-1 bg #11111b solid_color')
|
||||||
|
assert zoom['tileWidth']==54 and zoom['tiles'][0]['scale']>1, zoom
|
||||||
|
assert zoom['tiles'][0]['visualLeft']>=0,zoom
|
||||||
|
# Exercise production hover across slot boundaries. Layout and image
|
||||||
|
# readiness must remain stable while visual transforms change.
|
||||||
|
samples=[]
|
||||||
|
for x in range(45, 480, 6):
|
||||||
|
call('sweep',x)
|
||||||
|
state=call('state')
|
||||||
|
assert state['phase']=='shown',state
|
||||||
|
assert state['width']==resting['width'],state
|
||||||
|
assert [(t['x'],t['width'],t['iconWidth']) for t in state['tiles']]==[(t['x'],t['width'],t['iconWidth']) for t in resting['tiles']],state
|
||||||
|
assert sum(t['growth']>0 for t in state['tiles'])<=3,state
|
||||||
|
assert [t['imageStatus'] for t in state['tiles']]==[t['imageStatus'] for t in resting['tiles']],state
|
||||||
|
samples.append({'pointerX':state['pointerX'],'scales':[t['scale'] for t in state['tiles']]})
|
||||||
|
call('startFrames')
|
||||||
|
time.sleep(4.8)
|
||||||
|
frames=call('frames')
|
||||||
|
assert not frames['running'] and len(frames['intervals'])>30,frames
|
||||||
|
assert not frames['geometryChanged'] and not frames['imageChanged'],frames
|
||||||
|
intervals=sorted(frames['intervals'])
|
||||||
|
timing={'frames':len(intervals),'meanMs':sum(intervals)/len(intervals),
|
||||||
|
'p95Ms':intervals[int((len(intervals)-1)*.95)],'maxMs':max(intervals)}
|
||||||
|
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
|
||||||
|
# Magnified end icons keep their visual margin at both scroll limits.
|
||||||
|
lab.command('output HEADLESS-1 mode 400x600')
|
||||||
|
wait(lambda s:s['screenWidth']==400)
|
||||||
|
call('edge'); wait(lambda s:s['revealed']); time.sleep(.3)
|
||||||
|
call('scrollEnd'); call('sweep',325); time.sleep(.15)
|
||||||
|
end=call('state')
|
||||||
|
assert end['tiles'][-1]['scale']>1 and end['tiles'][-1]['visualRight']<=end['width']-24,end
|
||||||
|
call('scrollStart'); call('sweep',51); time.sleep(.15)
|
||||||
|
start=call('state')
|
||||||
|
assert start['tiles'][0]['scale']>1 and start['tiles'][0]['visualLeft']>=0,start
|
||||||
|
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,hoverSamples=samples,animationTiming=timing,frameSweep=frames,
|
||||||
|
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','fixed layout transform hover', 'stable SVG readiness', 'lateral sweep 73 samples','IPC reconnect','Firefox private profile','desktop install/remove monitoring','external writer','keyboard menu navigation','resolution change','SwayFX dock blur disabled']),indent=2)+'\n')
|
||||||
|
proxy.close()
|
||||||
|
print('Dock headless: PASS',lab.directory)
|
||||||
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
|
|
@ -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
|
|
@ -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)
|
||||||
53
tests/dock/test_models.js
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
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(25)>hide.influence(55));
|
||||||
|
assert.equal(hide.influence(76),0);
|
||||||
|
assert.equal(hide.influence(-76),0);
|
||||||
|
assert.equal(hide.influence(-35),hide.influence(35));
|
||||||
|
for (let cursor=0; cursor<570; ++cursor) {
|
||||||
|
const affected=Array.from({length:10},(_,i)=>i).filter(i=>hide.influence(i*57-cursor)>0);
|
||||||
|
assert.ok(affected.length<=3);
|
||||||
|
assert.ok(affected.length===0 || affected.at(-1)-affected[0]<=2);
|
||||||
|
}
|
||||||
|
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
|
|
@ -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()
|
||||||