feat(quickshell): add auto-hide Tela dock with shared favorites

This commit is contained in:
fy59 2026-10-22 10:50:48 +02:00
parent a556cd56b7
commit d4e2a0013e
32 changed files with 1683 additions and 63 deletions

View 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é.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1 @@
Dock headless: PASS /tmp/labfy-session-v2-TEST_ONLY-88x0lspm

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View 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"
]
}

View 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

View 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
}

View file

@ -0,0 +1 @@
Full shell private-compositor load: PASS /tmp/labfy-session-v2-TEST_ONLY-rfgtrd4k

View 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."
]
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View file

@ -103,8 +103,7 @@ PanelWindow {
property string applicationsError: ""
property string queuedApplicationId: ""
readonly property string applicationsBackend: (Quickshell.env("XDG_CONFIG_HOME") ||
(Quickshell.env("HOME") + "/.config")) + "/quickshell/labfy-sway/applications/backend.py"
readonly property string applicationsBackend: FavoritesService.backend
function closeApplications() { applicationCoordinator.applicationsOutput = ""; }
function toggleApplications() {
if (authenticationActive) return false;

View file

@ -389,3 +389,10 @@ relance n’est automatique, y compris après redémarrage QuickShell.
Voir [Session Restore V2](../../../../docs/session-restore-v2.md) pour les
commandes, les preuves et les limites de l’environnement. La recette réussie
du 7 octobre est historique ; elle ne valide pas cette révision au login réel.
## Dock et favoris partagés
Le dock à masquage automatique et le menu utilisent `applications/FavoritesService.qml`
et `ApplicationIcon.qml`. Le stockage existant reste inchangé. Architecture,
interactions, tests headless, limites et procédure d’intégration :
[Dock Tela / favoris V1](../../../../docs/quickshell-dock-tela-shared-favorites-v1.md).

View file

@ -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 }
}
}

View file

@ -12,8 +12,8 @@ PanelWindow {
signal menuDismissed()
signal launchRequested(string desktopId)
property string errorMessage: ""
property var allApps: []
property var favoriteIds: []
readonly property var allApps: FavoritesService.apps
readonly property var favoriteIds: FavoritesService.ids
property string selectedCategory: "Toutes"
property int selectedIndex: 0
property int navIndex: 0
@ -44,10 +44,6 @@ PanelWindow {
WlrLayershell.namespace: "labfy-applications-menu"
color: "transparent"
function refreshCatalog() {
allApps = ApplicationsModel.catalog(DesktopEntries.applications.values);
if (!favoriteIds.length && selectedCategory === "Favoris") selectedCategory = "Toutes";
}
function chooseCategory(key) {
selectedCategory = key;
navIndex = navigation.findIndex(item => item.key === key);
@ -63,10 +59,7 @@ PanelWindow {
if (app) launchRequested(app.id);
}
function toggleFavorite(id) {
const had = favoriteIds.includes(id);
favoriteIds = had ? favoriteIds.filter(value => value !== id) : favoriteIds.concat([id]);
Quickshell.execDetached(["python3", "-B", backend, "favorites", had ? "remove" : "add", id]);
if (selectedCategory === "Favoris" && favoriteIds.length === 0) chooseCategory("Toutes");
FavoritesService.toggle(id);
}
function dismiss() {
if (closing) return;
@ -80,39 +73,10 @@ PanelWindow {
interval: 150; repeat: false
onTriggered: { menu.visible = false; menu.menuDismissed(); }
}
readonly property string backend: (Quickshell.env("XDG_CONFIG_HOME") ||
(Quickshell.env("HOME") + "/.config")) + "/quickshell/labfy-sway/applications/backend.py"
onVisibleAppsChanged: selectedIndex = Math.min(selectedIndex, Math.max(0, visibleApps.length - 1))
Connections {
target: DesktopEntries
function onApplicationsChanged() { menu.refreshCatalog(); }
}
Process {
id: favoritesReader
command: ["python3", "-B", menu.backend, "favorites", "read"]
stdout: StdioCollector { id: favoritesOutput; waitForEnd: true }
onExited: (code, status) => {
if (code !== 0) { menu.errorMessage = "Favoris indisponibles"; return; }
try {
const data = JSON.parse(favoritesOutput.text);
if (data.exists) {
menu.favoriteIds = data.ids;
} else {
const suggested = ApplicationsModel.initialFavorites(menu.allApps);
menu.favoriteIds = suggested;
Quickshell.execDetached(["python3", "-B", menu.backend, "favorites", "seed"]
.concat(suggested));
}
menu.selectedCategory = menu.favoriteIds.some(id => menu.allApps.some(app => app.id === id))
? "Favoris" : "Toutes";
menu.navIndex = menu.selectedCategory === "Favoris" ? 0 : 1;
} catch (_) { menu.errorMessage = "Favoris invalides"; }
}
}
Component.onCompleted: {
refreshCatalog();
favoritesReader.running = true;
selectedCategory = favoriteIds.some(id => allApps.some(app => app.id === id)) ? "Favoris" : "Toutes";
navIndex = selectedCategory === "Favoris" ? 0 : 1;
}
// The Wayland surface exists after completion; focus at the next frame.
Timer { interval: 120; running: true; repeat: false; onTriggered: search.forceActiveFocus() }
@ -166,6 +130,10 @@ PanelWindow {
font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 13
background: Item {}
onTextChanged: { menu.selectedIndex = 0; grid.positionViewAtBeginning(); }
// WHY: TextField's built-in Tab navigation consumes the
// event before AfterItem handlers. Preserve this menu's
// explicit search/sidebar/grid keyboard cycle.
Keys.priority: Keys.BeforeItem
Keys.onPressed: event => {
if (event.key === Qt.Key_Escape) { menu.dismiss(); event.accepted = true; }
else if (event.key === Qt.Key_Backtab ||
@ -182,8 +150,8 @@ PanelWindow {
}
}
Text {
visible: menu.errorMessage !== ""
width: parent.width; text: menu.errorMessage
visible: menu.errorMessage !== "" || FavoritesService.error !== ""
width: parent.width; text: menu.errorMessage || FavoritesService.error
color: Theme.danger; font.pixelSize: 12; elide: Text.ElideRight
}
Item {
@ -199,6 +167,7 @@ PanelWindow {
anchors.fill: parent; anchors.margins: 5
clip: true; spacing: 3
model: menu.navigation
Keys.priority: Keys.BeforeItem
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
Keys.onPressed: event => {
if (event.key === Qt.Key_Up || event.key === Qt.Key_Down) {
@ -255,6 +224,7 @@ PanelWindow {
visible: menu.visibleApps.length > 0
clip: true
model: menu.visibleApps
Keys.priority: Keys.BeforeItem
cellWidth: Math.max(80, Math.floor(width / (width >= 420 ? 4 : 3)))
cellHeight: 104
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
@ -293,23 +263,12 @@ PanelWindow {
border.color: menu.selectedIndex === index && grid.activeFocus
? Theme.lavender : "transparent"
}
Image {
ApplicationIcon {
id: appIcon
anchors.top: parent.top; anchors.topMargin: 12
anchors.horizontalCenter: parent.horizontalCenter
width: 46; height: 46
source: modelData.icon.startsWith("/") ? "file://" + modelData.icon
: Quickshell.iconPath(modelData.icon || "application-x-executable",
"application-x-executable")
sourceSize.width: 46; sourceSize.height: 46
fillMode: Image.PreserveAspectFit
asynchronous: true
}
Text {
visible: appIcon.status === Image.Error
anchors.centerIn: appIcon
text: "󰀻"; color: Theme.lavender
font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 32
iconSource: modelData.iconSource
}
Text {
anchors.top: appIcon.bottom; anchors.topMargin: 5

View file

@ -31,7 +31,8 @@ function catalog(entries) {
.map(entry => ({ id: entry.id.endsWith(".desktop") ? entry.id : entry.id + ".desktop",
name: entry.name || entry.id,
genericName: entry.genericName || "", keywords: entry.keywords || [],
icon: entry.icon || "", category: classify(entry) }))
icon: entry.icon || "", iconSource: entry.iconSource || "",
aliases: entry.aliases || [], newInstance: !!entry.newInstance, category: classify(entry) }))
.sort((a, b) => a.name.localeCompare(b.name, "fr", { sensitivity: "base" }) || a.id.localeCompare(b.id));
}

View file

@ -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()
}

View file

@ -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;
}

View file

@ -57,18 +57,31 @@ def write_favorites(path, action, ids):
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
fcntl.flock(directory, fcntl.LOCK_EX)
if (action in ("add", "remove", "toggle") and (len(ids) != 1 or not valid_id(ids[0]))) or (
action == "move" and (len(ids) != 2 or not valid_id(ids[0])
or (ids[1] and not valid_id(ids[1])))):
raise ValueError("Identifiant de favori invalide")
exists, current = read_favorites(path)
if action == "seed":
if exists:
return current
current = list(dict.fromkeys(ids))[:MAX_FAVORITES]
elif action == "add":
elif action == "add" or (action == "toggle" and ids[0] not in current):
if ids[0] not in current:
if len(current) >= MAX_FAVORITES:
raise ValueError("Trop de favoris")
current.append(ids[0])
elif action == "remove":
elif action in ("remove", "toggle"):
current = [item for item in current if item != ids[0]]
elif action == "move":
# CONTRACT: move one existing ID before a live anchor (or to the
# end). Never replace a client's stale whole list: concurrent adds
# and removes survive. A vanished source/anchor rejects atomically.
if len(ids) != 2 or ids[0] not in current or (ids[1] and ids[1] not in current):
raise ValueError("Favori déplacé ou cible indisponible")
if ids[0] != ids[1]:
current.remove(ids[0])
current.insert(current.index(ids[1]) if ids[1] else len(current), ids[0])
else:
raise ValueError("Action de favoris invalide")
if any(not valid_id(item) for item in current):
@ -155,10 +168,22 @@ def main(argv):
if len(ids) > 4 or any(not valid_id(item) for item in ids):
raise ValueError("Favoris initiaux invalides")
result = {"ids": write_favorites(state_path(), "seed", ids)}
elif len(argv) == 3 and argv[:2] in (["favorites", "add"], ["favorites", "remove"]):
elif len(argv) == 3 and argv[:2] in (["favorites", "add"], ["favorites", "remove"], ["favorites", "toggle"]):
if not valid_id(argv[2]):
raise ValueError("Identifiant de favori invalide")
result = {"ids": write_favorites(state_path(), argv[1], [argv[2]])}
elif len(argv) == 4 and argv[:2] == ["favorites", "move"]:
result = {"ids": write_favorites(state_path(), "move", argv[2:])}
elif argv == ["serve"]:
from shared import serve
return serve()
elif argv == ["windows"]:
from shared import windows, sway_request
result = {"windows": windows(sway_request(4, ""))}
elif len(argv) == 2 and argv[0] == "focus":
from shared import focus
focus(argv[1])
result = {"ok": True}
elif len(argv) == 2 and argv[0] == "launch":
launch(argv[1])
result = {"ok": True}
@ -166,7 +191,7 @@ def main(argv):
raise ValueError("Commande invalide")
print(json.dumps(result, ensure_ascii=False))
return 0
except (OSError, ValueError, GLib.Error, json.JSONDecodeError, subprocess.TimeoutExpired) as error:
except (OSError, ValueError, KeyError, GLib.Error, json.JSONDecodeError, subprocess.TimeoutExpired) as error:
print(json.dumps({"error": str(error)}, ensure_ascii=False))
return 1

View file

@ -0,0 +1,3 @@
singleton FavoritesService 1.0 FavoritesService.qml
ApplicationsMenu 1.0 ApplicationsMenu.qml
ApplicationIcon 1.0 ApplicationIcon.qml

View 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

View file

@ -0,0 +1,21 @@
.pragma library
// CONTRACT: the sole deadline belongs to arming (edge dwell) or waiting
// (exit grace). Holding any interaction cancels it. No animation sends IPC.
function next(state, event, input) {
if (input.suppressed) return "hidden";
if (input.held) return "held";
if (input.body) return "shown";
if (input.edge) {
if (state === "hidden") return "arming";
if (state === "arming") return event === "deadline" ? "shown" : "arming";
return "shown";
}
if (state === "hidden" || state === "arming") return "hidden";
if (state === "waiting") return event === "deadline" ? "hidden" : "waiting";
return "waiting";
}
function influence(distance) {
return Math.max(0, 1 - Math.abs(distance) / 115);
}

View file

@ -0,0 +1,263 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Wayland
import "../applications"
import "../theme"
import "AutoHide.js" as AutoHide
PanelWindow {
id: dock
required property var modelData
property bool suppressed: false
screen: modelData
anchors.bottom: true
exclusionMode: ExclusionMode.Ignore
exclusiveZone: 0
aboveWindows: true
WlrLayershell.layer: WlrLayer.Top
// CONTRACT: the dock and its in-surface menus never accept keyboard focus.
// Layer surfaces are absent from get_tree, hence Session V2 snapshots.
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
WlrLayershell.namespace: "labfy-sway-dock"
color: "transparent"
implicitWidth: Math.max(96, Math.min(screen.width - 24, items.width + 28))
implicitHeight: 360
property string phase: "hidden"
readonly property bool revealed: phase === "shown" || phase === "waiting" || phase === "held"
property bool pressed: false
property string draggingId: ""
property var contextEntry: null
property real contextX: 0
property real pointerX: -1000
readonly property bool held: pressed || draggingId !== "" || contextEntry !== null
readonly property var entries: FavoritesService.dockEntries
readonly property int pinnedCount: FavoritesService.ids.length
property alias contentItemRow: items
property alias surfaceBody: body
property alias contextCard: context
property alias edgeArea: sensor
// INVARIANT: only the bottom activation strip receives input while hidden.
// Visible input follows the animated body, with a bridge to the edge.
mask: Region {
item: sensor
Region { item: body; intersection: dock.revealed ? Intersection.Combine : Intersection.Subtract }
Region { item: context; intersection: context.visible ? Intersection.Combine : Intersection.Subtract }
}
function reconcile(event) {
phase = AutoHide.next(phase, event || "input", {
suppressed: suppressed, held: held,
body: bodyHover.hovered || contextHover.hovered,
edge: sensor.containsMouse
});
}
onHeldChanged: reconcile()
onSuppressedChanged: { contextEntry = null; draggingId = ""; pressed = false; reconcile(); }
onEntriesChanged: {
if (contextEntry) {
contextEntry = entries.find(entry => entry.id === contextEntry.id) || null;
}
}
Timer {
id: deadline
interval: dock.phase === "arming" ? 180 : 450
running: dock.phase === "arming" || dock.phase === "waiting"
onTriggered: dock.reconcile("deadline")
}
function activate(entry) {
if (entry.windows.length === 1) FavoritesService.focusWindow(entry.windows[0].id);
else if (entry.windows.length > 1) openContext(entry, width / 2);
else if (!entry.missing) FavoritesService.launch(entry.id);
}
function openContext(entry, x) {
contextX = x;
contextEntry = entry;
}
function closeContext() { contextEntry = null; }
MouseArea {
id: sensor
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
width: dock.revealed ? dock.width : Math.min(360, dock.width)
height: 4
hoverEnabled: true
onEntered: dock.reconcile()
onExited: dock.reconcile()
onClicked: dock.closeContext()
}
Item {
id: body
width: parent.width
height: 110
y: dock.revealed ? dock.height - height : dock.height + 4
Behavior on y { NumberAnimation { duration: 210; easing.type: Easing.OutCubic } }
HoverHandler {
id: bodyHover
onHoveredChanged: { dock.reconcile(); if (!hovered) dock.pointerX = -1000; }
onPointChanged: dock.pointerX = point.position.x
}
Rectangle {
// Discreet local shadow, no screen capture or blur sampling.
x: 2; y: 34; width: parent.width - 4; height: 70
radius: 26; color: Theme.shadow
}
Rectangle {
x: 0; y: 29; width: parent.width; height: 70
radius: 25
color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, Theme.highContrast ? 1 : 0.89)
border.color: Theme.strongBorder; border.width: 1
}
Flickable {
id: viewport
x: 12; y: 0; width: parent.width - 24; height: 105
contentWidth: items.width; contentHeight: height
clip: true
interactive: dock.draggingId === ""
boundsBehavior: Flickable.StopAtBounds
ScrollBar.horizontal: ScrollBar { policy: ScrollBar.AsNeeded }
Row {
id: items
height: viewport.height
spacing: 3
move: Transition { NumberAnimation { properties: "x"; duration: 110; easing.type: Easing.OutCubic } }
Repeater {
id: icons
model: dock.entries
delegate: Item {
id: tile
required property var modelData
required property int index
readonly property bool separator: index === dock.pinnedCount && dock.pinnedCount > 0
readonly property real center: viewport.x + x + width / 2 - viewport.contentX
// WHY: measuring against the animated x/width feeds
// scale back into its own binding. Stable resting slots
// keep magnification deterministic and free of loops.
readonly property real baseCenter: viewport.x + index * 57 + 27
+ (index >= dock.pinnedCount && dock.pinnedCount > 0 ? 13 : 0) - viewport.contentX
readonly property real growth: dock.revealed && bodyHover.hovered
? AutoHide.influence(baseCenter - dock.pointerX) : 0
width: 54 + growth * 19 + (separator ? 13 : 0)
height: 105
Behavior on width { NumberAnimation { duration: 95; easing.type: Easing.OutCubic } }
Rectangle {
visible: tile.separator
x: 2; y: 45; width: 1; height: 32; color: Theme.strongBorder
}
ApplicationIcon {
id: icon
width: 46 + tile.growth * 21; height: width
x: (parent.width - width + (tile.separator ? 12 : 0)) / 2
y: 83 - height
iconSource: tile.modelData.iconSource || ""
opacity: dock.draggingId === tile.modelData.id ? 0.5 : 1
Behavior on width { NumberAnimation { duration: 95; easing.type: Easing.OutCubic } }
}
Rectangle {
anchors.horizontalCenter: icon.horizontalCenter
y: 90; width: 4; height: 4; radius: 2
color: Theme.lavender; visible: tile.modelData.windows.length > 0
}
MouseArea {
id: pointer
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
hoverEnabled: true
preventStealing: tile.modelData.pinned
property real startX: 0
property real startY: 0
property bool moved: false
onPressed: mouse => {
dock.pressed = true; startX = mouse.x; startY = mouse.y; moved = false;
}
onPositionChanged: mouse => {
if (pressed && pressedButtons === Qt.LeftButton && tile.modelData.pinned
&& Math.hypot(mouse.x - startX, mouse.y - startY) > 8) {
moved = true; dock.draggingId = tile.modelData.id;
}
}
onReleased: mouse => {
if (moved) {
const p = mapToItem(items, mouse.x, mouse.y);
let before = "";
for (let i = 0; i < icons.count; ++i) {
const candidate = icons.itemAt(i);
if (candidate.modelData.pinned && candidate.modelData.id !== dock.draggingId
&& p.x < candidate.x + candidate.width / 2) {
before = candidate.modelData.id; break;
}
}
FavoritesService.move(dock.draggingId, before);
} else if (mouse.button === Qt.RightButton) {
dock.openContext(tile.modelData, tile.center);
} else if (mouse.button === Qt.MiddleButton) {
if (!tile.modelData.missing && tile.modelData.newInstance)
FavoritesService.launch(tile.modelData.id);
} else {
dock.closeContext(); dock.activate(tile.modelData);
}
dock.draggingId = ""; dock.pressed = false;
}
onCanceled: { dock.draggingId = ""; dock.pressed = false; }
ToolTip.visible: containsMouse && !dock.held
ToolTip.text: tile.modelData.name
ToolTip.delay: 600
}
}
}
}
}
}
Rectangle {
id: context
visible: dock.contextEntry !== null
width: Math.min(330, dock.width)
height: Math.min(230, contextContent.height + 16)
x: Math.max(0, Math.min(dock.width - width, dock.contextX - width / 2))
y: dock.height - 116 - height
radius: 12; color: Theme.popupBackground
border.color: Theme.strongBorder
HoverHandler { id: contextHover; onHoveredChanged: dock.reconcile() }
Flickable {
anchors.fill: parent; anchors.margins: 8
contentHeight: contextContent.height; clip: true
Column {
id: contextContent
width: parent.width
Text {
width: parent.width; height: 28
text: dock.contextEntry ? dock.contextEntry.name : ""
color: Theme.lavender; elide: Text.ElideRight; font.bold: true
}
Repeater {
model: dock.contextEntry ? dock.contextEntry.windows : []
delegate: DockAction {
required property var modelData
width: contextContent.width
text: modelData.title + " · " + modelData.workspace
onTriggered: { FavoritesService.focusWindow(modelData.id); dock.closeContext(); }
}
}
DockAction {
width: parent.width
visible: !!dock.contextEntry && !dock.contextEntry.missing
text: "Ouvrir"
onTriggered: { FavoritesService.launch(dock.contextEntry.id); dock.closeContext(); }
}
DockAction {
width: parent.width
visible: !!dock.contextEntry && (dock.contextEntry.pinned || !dock.contextEntry.missing)
objectName: "dock-pin-action"
text: dock.contextEntry && dock.contextEntry.pinned ? "Désépingler" : "Épingler"
onTriggered: { FavoritesService.toggle(dock.contextEntry.id); dock.closeContext(); }
}
DockAction { width: parent.width; text: "Fermer le menu"; onTriggered: dock.closeContext() }
}
}
}
Text {
visible: dock.revealed && FavoritesService.error !== "" && !context.visible
anchors.horizontalCenter: parent.horizontalCenter
y: dock.height - 140
text: FavoritesService.error; color: Theme.danger; font.pixelSize: 12
}
}

View 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() }
}

View file

@ -10,6 +10,8 @@ import "theme"
import "polkit"
import "scratchpad"
import "osd"
import "applications"
import "dock"
ShellRoot {
id: shell
@ -242,6 +244,12 @@ ShellRoot {
return AppearanceController.acceptPublishedNightLight(payload);
}
}
// CONTRACT: shell-owned singleton remains alive while the menu is closed.
readonly property bool applicationsReady: FavoritesService.ready
Variants {
model: Quickshell.screens
Dock { suppressed: shell.authenticationActive || shell.applicationsOutput !== "" }
}
// Une barre par écran, y compris si les sorties changent pendant la session.
// CONTRACT: un seul serveur D-Bus pour toutes les sorties.
NotificationService { id: rootNotificationService }

View file

@ -26,3 +26,13 @@ layer_effects "labfy-sway-bar" {
shadows enable
corner_radius 4
}
# CONTRACT: only the dock's layer surface receives blur; no window rule or
# focus/restore policy is changed. Its transparent input strip stays clear.
layer_effects "labfy-sway-dock" {
blur $bar_blur
blur_xray disable
blur_ignore_transparent enable
shadows disable
corner_radius 25
}

216
tests/dock/headless.py Normal file
View file

@ -0,0 +1,216 @@
#!/usr/bin/env python3
"""Private Sway only; production Dock/Menu, real Python bridge and input paths."""
import json
import os
from pathlib import Path
import subprocess
import sys
import time
REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO / 'tests/session_v2/fixtures'))
from headless import Lab
from ipc_proxy import IpcProxy
SOURCE = REPO / 'quickshell/.config/quickshell/labfy-sway'
ARTIFACTS = REPO / 'docs/validation/quickshell-dock-tela'
NINE = ['firefox.desktop', 'kitty.desktop', 'yazi.desktop', 'nvim.desktop',
'org.darktable.darktable.desktop', 'orca-slicer.desktop', 'org.freecad.FreeCAD.desktop',
'org.kde.digikam.desktop', 'AppFlowy.desktop']
class DockLab(Lab):
def __init__(self):
super().__init__()
self.config=self.directory/'dock-sway.conf'
base=(REPO/'tests/session_v2/fixtures/headless.conf').read_text()
effects=(REPO/'sway/.config/sway/swayfx').read_text().split('# CONTRACT: only the dock')[1]
effects=effects[effects.index('layer_effects'):]
self.config.write_text(base+'\noutput HEADLESS-1 bg #11111b solid_color\nset $bar_blur enable\n'+effects)
def start(self, argv):
if argv[0]==os.environ.get('LABFY_TEST_SWAY','sway'):
argv=[*argv[:-1],str(self.config)]
return super().start(argv)
with DockLab() as lab:
proxy=IpcProxy(lab.directory/'dock-ipc.sock',lab.socket)
lab.env['SWAYSOCK']=str(lab.directory/'dock-ipc.sock')
path = Path(lab.env['XDG_STATE_HOME']) / 'labfy-sway/applications-favorites.json'
path.parent.mkdir(parents=True)
path.write_text(json.dumps(dict(version=1, ids=NINE)))
qml = lab.directory / 'fixture.qml'
qml.write_text('''//@ pragma UseQApplication
import QtQuick
import QtTest
import Quickshell
import Quickshell.Io
import "SOURCE/applications" as A
import "SOURCE/dock" as D
ShellRoot {
D.Dock { id: dock; modelData: Quickshell.screens[0] }
PanelWindow { id: bar; implicitWidth: 100; implicitHeight: 30; anchors.top: true; color: "transparent" }
LazyLoader { id: menu; active: false; A.ApplicationsMenu { barWindow: bar } }
TestCase { id: input; name: "DockInput"; when: false }
IpcHandler {
target: "dockTest"
function state(): string {
return JSON.stringify({ready:A.FavoritesService.ready, ids:A.FavoritesService.ids,
error:A.FavoritesService.error, phase:dock.phase, revealed:dock.revealed,
entries:dock.entries, theme:A.FavoritesService.iconTheme,
menu:menu.active ? menu.item.favoriteIds : null,
searchFocused:menu.active && menu.item.searchFocused,
navFocused:menu.active && menu.item.navigationFocused,
gridFocused:menu.active && menu.item.gridFocused,
screenWidth:dock.screen.width,
bodyY:dock.surfaceBody.y, width:dock.width, pointerX:dock.pointerX,
tileWidth:dock.contentItemRow.children[0] ? dock.contentItemRow.children[0].width : 0,
context:!!dock.contextEntry});
}
function edge(): void { input.mouseMove(dock.edgeArea, dock.edgeArea.width/2, 2); }
function body(): void { input.mouseMove(dock.surfaceBody, 35, 70); }
function leave(): void { input.mouseMove(dock.contentItem, 1, 1); }
function menuToggle(id:string): void { if (!menu.active) menu.active=true; menu.item.toggleFavorite(id); }
function dockToggle(id:string): void { A.FavoritesService.toggle(id); }
function move(id:string, before:string): void { A.FavoritesService.move(id,before); }
function closeMenu(): void { menu.active=false; }
function tab(): void { input.keyClick(Qt.Key_Tab); }
function clickPin(): void {
const action=input.findChild(dock.contextCard,"dock-pin-action");
input.mouseClick(action,action.width/2,action.height/2);
}
function rightClick(): void {
input.mouseClick(dock.contentItemRow.children[0],27,65,Qt.RightButton);
}
function dragFirstAfterSecond(): void {
const first=dock.contentItemRow.children[0];
input.mousePress(first,27,65,Qt.LeftButton);
input.mouseMove(first,125,65,100);
input.mouseRelease(first,125,65,Qt.LeftButton);
}
function activateFirst(): void { input.mouseClick(dock.contentItemRow.children[0],27,65); }
function context(): void { dock.openContext(dock.entries[0],dock.width/2); }
function closeContext(): void { dock.closeContext(); }
function suppress(value:bool): void { dock.suppressed=value; }
function focus(id:string): void { A.FavoritesService.focusWindow(id); }
}
}
'''.replace('SOURCE', SOURCE.as_uri()))
lab.env['QT_QPA_PLATFORM'] = 'wayland'
process = lab.start(['quickshell', '-p', str(qml)])
def call(name, *args):
r = subprocess.run(['quickshell','ipc','-p',str(qml),'call','dockTest',name,*map(str,args)],
env=lab.env, text=True, capture_output=True, timeout=8)
if r.returncode:
logs = '\n'.join(p.read_text()[-9000:] for p in lab.directory.glob('child-*.log'))
raise AssertionError((name,r.stdout,r.stderr,logs))
return json.loads(r.stdout) if r.stdout.strip() else None
def wait(predicate, seconds=8):
end = time.monotonic()+seconds
while time.monotonic()<end:
state=call('state')
if predicate(state): return state
time.sleep(.08)
raise AssertionError(call('state'))
time.sleep(1.2)
initial=wait(lambda s:s['ready'])
assert initial['ids']==NINE and initial['phase']=='hidden', initial
ARTIFACTS.mkdir(parents=True,exist_ok=True)
def capture(name):
subprocess.run(['grim',str(ARTIFACTS / (name+'.png'))],env=lab.env,check=True)
capture('closed')
call('edge'); wait(lambda s:s['revealed']); time.sleep(.35)
capture('open')
call('body'); time.sleep(.3); zoom=call('state'); capture('zoom')
assert zoom['tileWidth']>54, zoom
call('leave'); wait(lambda s:s['phase']=='hidden')
call('edge'); wait(lambda s:s['revealed']); time.sleep(.3)
call('rightClick'); wait(lambda s:s['phase']=='held'); time.sleep(.7)
assert call('state')['context']
call('clickPin'); wait(lambda s:'firefox.desktop' not in s['ids'])
call('dockToggle','firefox.desktop'); wait(lambda s:'firefox.desktop' in s['ids'])
call('move','firefox.desktop','kitty.desktop'); wait(lambda s:s['ids']==NINE)
call('leave'); wait(lambda s:s['phase']=='hidden')
# Refused move leaves both interfaces at the same durable list.
call('move','kitty.desktop','removed.desktop')
wait(lambda s:bool(s['error']))
assert call('state')['ids']==NINE
call('menuToggle','fixture.desktop'); wait(lambda s:'fixture.desktop' in s['ids'] and s['menu']==s['ids'])
# Gio catalog monitoring replaces the placeholder without dropping its ID.
desktop=Path(lab.env['HOME'])/'.local/share/applications/fixture.desktop'
desktop.parent.mkdir(parents=True,exist_ok=True)
desktop.write_text('[Desktop Entry]\nType=Application\nName=Installed Fixture\nExec=/usr/bin/true\nIcon=firefox\n')
wait(lambda s:any(e['id']=='fixture.desktop' and not e.get('missing') for e in s['entries']))
desktop.unlink()
wait(lambda s:any(e['id']=='fixture.desktop' and e.get('missing') for e in s['entries']))
# Keep the virtual keyboard alive: the headless seat has no physical
# keyboard, so destroying wtype would remove keyboard capabilities and
# deactivate the QML focus window before we could sample its focus item.
keyboard=lab.start(['wtype','-s','300','-k','Tab','-s','1500','-k','Tab',
'-s','1500','-k','Tab','-s','1500'])
wait(lambda s:s['navFocused'])
wait(lambda s:s['gridFocused'])
wait(lambda s:s['searchFocused'])
keyboard.wait(timeout=8)
# An independent legacy CLI writer is observed by menu and dock live.
subprocess.run(['python','-B',str(SOURCE/'applications/backend.py'),'favorites','add','external.desktop'],
env=lab.env,check=True,capture_output=True)
wait(lambda s:'external.desktop' in s['ids'] and s['menu']==s['ids'])
call('dockToggle','external.desktop'); wait(lambda s:'external.desktop' not in s['ids'])
call('dockToggle','fixture.desktop'); wait(lambda s:'fixture.desktop' not in s['ids'] and s['menu']==s['ids'])
call('move','kitty.desktop','firefox.desktop'); reordered=wait(lambda s:s['ids'][0]=='kitty.desktop')
assert json.loads(path.read_text())['ids']==reordered['ids']
call('closeMenu')
call('edge'); wait(lambda s:s['revealed']); time.sleep(.3)
call('dragFirstAfterSecond')
dragged=wait(lambda s:s['ids'][0]=='firefox.desktop')
assert dragged['ids'][1]=='kitty.desktop',dragged
call('leave'); wait(lambda s:s['phase']=='hidden')
# No focus changes while the pointer reveals, zooms, hides, or opens menus.
win=lab.kitty('kitty')
time.sleep(.5)
focused=lambda: next(n['id'] for n in lab.windows() if n.get('focused'))
assert focused()==win
for action in ('edge','body','context','leave','closeContext'):
call(action); time.sleep(.25); assert focused()==win, action
call('suppress','true'); assert call('state')['phase']=='hidden'
call('suppress','false')
wait(lambda s:any(e['id']=='kitty.desktop' and len(e['windows'])==1 for e in s['entries']))
proxy.disconnect()
time.sleep(2)
second=lab.kitty('kitty')
wait(lambda s:any(e['id']=='kitty.desktop' and len(e['windows'])==2 for e in s['entries']))
lab.command(f'[con_id={win}] floating enable')
lab.command(f'[con_id={win}] move scratchpad')
call('focus',str(win)); time.sleep(.3); assert focused()==win
lab.command(f'[con_id={second}] kill')
wait(lambda s:any(e['id']=='kitty.desktop' and len(e['windows'])==1 for e in s['entries']))
# Firefox runs with the private HOME/profile and no inherited session bus.
profile=lab.directory/'firefox-profile'; profile.mkdir()
firefox=lab.start(['firefox','--no-remote','--profile',str(profile),'--new-window','about:blank'])
firefox_state=wait(lambda s:any(e['id']=='firefox.desktop' and e['windows'] for e in s['entries']),20)
firewin=next(e for e in firefox_state['entries'] if e['id']=='firefox.desktop')['windows'][0]['id']
call('closeContext'); call('edge'); wait(lambda s:s['revealed']); time.sleep(.3)
call('activateFirst'); time.sleep(.3)
assert str(focused())==firewin
for action in ('edge','body','leave'):
call(action); time.sleep(.25); assert str(focused())==firewin
# Layer-shell surfaces cannot enter the Session V2 toplevel inventory.
assert not any('labfy-sway-dock' in str(n.get('app_id','')) for n in lab.windows())
lab.command('output HEADLESS-1 mode 800x600')
resized=wait(lambda s:s['screenWidth']==800)
assert resized['width']<=776,resized
lab.command('output HEADLESS-1 mode 1280x720')
wait(lambda s:s['screenWidth']==1280)
# Existing empty state must stay empty, including a bridge restart.
for identifier in list(call('state')['ids']): call('dockToggle',identifier)
wait(lambda s:not s['ids'])
process.terminate(); process.wait(timeout=5)
process=lab.start(['quickshell','-p',str(qml)])
time.sleep(1)
assert wait(lambda s:s['ready'])['ids']==[]
(ARTIFACTS/'result.json').write_text(json.dumps(dict(status='PASS',initial=initial,
reordered=reordered['ids'],checks=['shared favorites','menu and dock mutations','durable reorder',
'empty restart','autohide','context hold','kitty multi-window','floating scratchpad explicit focus',
'window close','no hover focus','real pointer drag','zoom growth','IPC reconnect','Firefox private profile','desktop install/remove monitoring','external writer','keyboard menu navigation','resolution change','SwayFX dock blur rule']),indent=2)+'\n')
proxy.close()
print('Dock headless: PASS',lab.directory)

54
tests/dock/ipc_proxy.py Normal file
View 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)

View 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
View file

@ -0,0 +1,23 @@
#!/usr/bin/env python3
"""Load the actual shell through its relative imports in a private Sway.
The session bus is private; the system bus address deliberately points to a
missing fixture socket so no real Polkit agent or system service is contacted.
"""
from pathlib import Path
import sys
import time
ROOT=Path(__file__).resolve().parents[2]
sys.path.insert(0,str(ROOT/'tests/session_v2/fixtures'))
from headless import Lab
with Lab() as lab:
lab.env.update(DBUS_SYSTEM_BUS_ADDRESS='unix:path='+str(lab.directory/'no-system-bus'),QT_QPA_PLATFORM='wayland')
shell=ROOT/'quickshell/.config/quickshell/labfy-sway/shell.qml'
process=lab.start(['dbus-run-session','--','quickshell','-p',str(shell)])
time.sleep(3)
log=(lab.directory/'child-1.log').read_text()
assert 'Configuration Loaded' in log and process.poll() is None,log
assert 'Binding loop' not in log,log
print('Full shell private-compositor load: PASS',lab.directory)

46
tests/dock/test_models.js Normal file
View file

@ -0,0 +1,46 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
function load(file) {
const context=vm.createContext({});
vm.runInContext(fs.readFileSync(path.join(__dirname,'../../quickshell/.config/quickshell/labfy-sway',file),'utf8')
.replace(/^\.pragma library\s*\n/,''),context);
return context;
}
const hide=load('dock/AutoHide.js');
let phase='hidden';
const input={suppressed:false,held:false,body:false,edge:false};
const event=(name='input') => phase=hide.next(phase,name,input);
assert.equal(event(),'hidden');
input.edge=true; assert.equal(event(),'arming');
input.edge=false; assert.equal(event(),'hidden');
input.edge=true; event(); assert.equal(event('deadline'),'shown');
input.edge=false; assert.equal(event(),'waiting');
input.body=true; assert.equal(event(),'shown');
input.body=false; event(); input.held=true; assert.equal(event(),'held');
assert.equal(event('deadline'),'held');
input.held=false; assert.equal(event(),'waiting');
assert.equal(event('deadline'),'hidden');
input.held=true; event(); input.suppressed=true; assert.equal(event(),'hidden');
assert.equal(hide.influence(0),1);
assert.ok(hide.influence(55)>hide.influence(100));
assert.equal(hide.influence(115),0);
const model=load('applications/WindowModel.js');
const apps=[{id:'kitty.desktop',aliases:['kitty'],iconSource:'file:///tela/kitty.svg'},
{id:'org.app.desktop',aliases:['startup-class','executable']},
{id:'org.other.desktop',aliases:['executable']}];
assert.equal(model.match(apps,{appId:'kitty'}).id,'kitty.desktop');
assert.equal(model.match(apps,{wmClass:'Startup-Class'}).id,'org.app.desktop');
assert.equal(model.match(apps,{appId:'executable'}),null);
assert.equal(model.match(apps,{title:'kitty'}),null);
let entries=model.entries(apps,['absent.desktop','kitty.desktop'],[{id:'10',appId:'kitty'},{id:'11',appId:'kitty'},
{id:'12',appId:'unknown'}]);
assert.equal(entries[0].missing,true);
assert.equal(entries[1].windows.length,2);
assert.equal(entries[1].iconSource,apps[0].iconSource);
assert.equal(entries[2].pinned,false);
assert.equal(model.entries(apps,[],[]).length,0);
assert.equal(model.entries(apps,[],[{id:'11',appId:'kitty'}])[0].pinned,false);
assert.equal(model.entries(apps,['kitty.desktop'],[])[0].windows.length,0);
console.log('Dock state and window models: PASS');

109
tests/dock/test_shared.py Normal file
View 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()