From 8551f8357b88cb395caec33f066196226a4f5430 Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 8 Oct 2026 22:00:58 +0200 Subject: [PATCH] feat(quickshell): add applications menu and workspace separators --- .../.config/quickshell/labfy-sway/Bar.qml | 141 ++++++- .../.config/quickshell/labfy-sway/README.md | 5 + .../applications/ApplicationsMenu.qml | 355 ++++++++++++++++++ .../applications/ApplicationsModel.js | 60 +++ .../labfy-sway/applications/README.md | 59 +++ .../labfy-sway/applications/backend.py | 175 +++++++++ .../quickshell/labfy-sway/overview/backend.py | 3 +- .../.config/quickshell/labfy-sway/shell.qml | 10 + sway/.config/sway/bind | 2 +- .../sway/scripts/applications-quickshell | 13 + tests/applications/test_backend.py | 98 +++++ tests/applications/test_model.js | 30 ++ tests/overview/test_backend.py | 4 + 13 files changed, 950 insertions(+), 5 deletions(-) create mode 100644 quickshell/.config/quickshell/labfy-sway/applications/ApplicationsMenu.qml create mode 100644 quickshell/.config/quickshell/labfy-sway/applications/ApplicationsModel.js create mode 100644 quickshell/.config/quickshell/labfy-sway/applications/README.md create mode 100644 quickshell/.config/quickshell/labfy-sway/applications/backend.py create mode 100755 sway/.config/sway/scripts/applications-quickshell create mode 100644 tests/applications/test_backend.py create mode 100644 tests/applications/test_model.js diff --git a/quickshell/.config/quickshell/labfy-sway/Bar.qml b/quickshell/.config/quickshell/labfy-sway/Bar.qml index b8f55075a..3814725b3 100644 --- a/quickshell/.config/quickshell/labfy-sway/Bar.qml +++ b/quickshell/.config/quickshell/labfy-sway/Bar.qml @@ -1,4 +1,5 @@ import QtQuick +import QtQuick.Controls import Quickshell import Quickshell.Io import Quickshell.I3 @@ -12,6 +13,7 @@ import "status" import "theme" import "clipboard" import "overview" +import "applications" PanelWindow { id: bar @@ -22,6 +24,7 @@ PanelWindow { required property var notificationService required property bool startupHost required property bool resizeMode + required property var applicationCoordinator screen: modelData anchors { @@ -45,6 +48,87 @@ PanelWindow { focusable: true color: Theme.panelBackground + 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" + function closeApplications() { applicationCoordinator.applicationsOutput = ""; } + function toggleApplications() { + if (applicationCoordinator.applicationsOutput !== screen.name) applicationsError = ""; + applicationCoordinator.toggleApplications(screen.name); + return true; + } + function launchApplication(id) { + if (applicationLaunch.running || launchDelay.running) return; + queuedApplicationId = id; + closeApplications(); + // WHY: release exclusive layer focus before the new client requests it. + launchDelay.start(); + } + Timer { + id: launchDelay + interval: 140; repeat: false + onTriggered: { + applicationLaunch.command = ["python3", "-B", bar.applicationsBackend, + "launch", bar.queuedApplicationId]; + applicationLaunch.running = true; + } + } + Process { + id: applicationLaunch + stdout: StdioCollector { id: launchOutput; waitForEnd: true } + onExited: (code, status) => { + let result = {}; + try { result = JSON.parse(launchOutput.text); } catch (_) {} + if (code !== 0 || result.error) { + bar.applicationsError = result.error || "Lancement impossible"; + if (!bar.applicationCoordinator.applicationsOutput) + bar.applicationCoordinator.toggleApplications(bar.screen.name); + } + bar.queuedApplicationId = ""; + } + } + Connections { + target: bar.applicationCoordinator + function onApplicationsOpening(output) { + bar.closeOverview(); + bar.closeClipboard(); + controlCenter.visible = false; + dateCenter.requestClose(); + windowStrip.closeMenu(); + rightStatusArea.closeRemovableMedia(); + } + } + IpcHandler { + target: "applicationsUi-" + bar.screen.name + function toggle(): bool { return bar.toggleApplications(); } + function state(): string { + return JSON.stringify({ loaded: applicationsLoader.active, + visible: !!applicationsLoader.item && applicationsLoader.item.visible, + searchFocused: !!applicationsLoader.item && applicationsLoader.item.searchFocused, + navigationFocused: !!applicationsLoader.item && applicationsLoader.item.navigationFocused, + gridFocused: !!applicationsLoader.item && applicationsLoader.item.gridFocused, + category: applicationsLoader.item ? applicationsLoader.item.selectedCategory : "", + resultCount: applicationsLoader.item ? applicationsLoader.item.visibleApps.length : 0, + firstIds: applicationsLoader.item ? applicationsLoader.item.visibleApps.slice(0, 4).map(item => item.id) : [], + bounds: applicationsLoader.item ? { + x: applicationsLoader.item.cardX, y: applicationsLoader.item.cardY, + width: applicationsLoader.item.cardWidth, + height: applicationsLoader.item.cardHeight + } : null }); + } + } + LazyLoader { + id: applicationsLoader + active: bar.applicationCoordinator.applicationsOutput === bar.screen.name + ApplicationsMenu { + barWindow: bar + errorMessage: bar.applicationsError + onMenuDismissed: bar.closeApplications() + onLaunchRequested: id => bar.launchApplication(id) + } + } + function closeClipboard() { if (clipboardLoader.item && !clipboardLoader.item.closing) clipboardLoader.item.dismiss(true); @@ -54,6 +138,7 @@ PanelWindow { if (clipboardLoader.active) { closeClipboard(); } else { + closeApplications(); closeOverview(); controlCenter.visible = false; dateCenter.requestClose(); @@ -72,6 +157,7 @@ PanelWindow { } function toggleOverview() { if (overviewRequested || overviewLoader.active) { closeOverview(); return true; } + closeApplications(); closeClipboard(); controlCenter.visible = false; dateCenter.requestClose(); @@ -122,6 +208,7 @@ PanelWindow { interval: 350; repeat: false onTriggered: { if (!overviewCapture.running && !overviewLoader.active && !clipboardLoader.active + && !applicationsLoader.active && !controlCenter.visible && !dateCenter.visible) overviewCapture.running = true; } @@ -132,6 +219,7 @@ PanelWindow { interval: 15000; repeat: true; running: true; triggeredOnStart: true onTriggered: { if (!overviewCapture.running && !overviewLoader.active && !clipboardLoader.active + && !applicationsLoader.active && !controlCenter.visible && !dateCenter.visible) overviewCapture.running = true; } @@ -151,6 +239,7 @@ PanelWindow { target: "appearanceUi-" + bar.screen.name function openPage(page: string): bool { if (controlCenter.pages[page] === undefined) return false; + bar.closeApplications(); bar.closeClipboard(); dateCenter.requestClose(); windowStrip.closeMenu(); @@ -161,6 +250,7 @@ PanelWindow { function close(): bool { controlCenter.visible = false; return true; } function openDateCenter(): bool { bar.toggleDateCenter(); return true; } function openWindowMenu(): bool { + bar.closeApplications(); bar.closeClipboard(); controlCenter.visible = false; dateCenter.requestClose(); @@ -197,6 +287,7 @@ PanelWindow { // CONTRACT: toute la capsule centrale partage le même popup et la même exclusion XOR. function toggleDateCenter() { const opening = !dateCenter.visible && !dateCenter.opening; + closeApplications(); closeOverview(); closeClipboard(); controlCenter.visible = false; @@ -215,10 +306,41 @@ PanelWindow { height: bar.height clip: true + Rectangle { + id: applicationsButton + anchors.left: parent.left + anchors.leftMargin: 8 + anchors.verticalCenter: parent.verticalCenter + width: 30; height: 28; radius: 4 + color: bar.applicationCoordinator.applicationsOutput === bar.screen.name + ? Theme.buttonPressed : applicationsPointer.containsMouse + ? Theme.buttonHover : "transparent" + Text { + anchors.centerIn: parent + text: "󰣇"; color: Theme.lavender + font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 25 + } + MouseArea { + id: applicationsPointer + anchors.fill: parent; hoverEnabled: true + onClicked: bar.toggleApplications() + } + } + + // Même séparation neutre que les groupes de droite, sans zone de clic. + Rectangle { + id: applicationsSeparator + anchors.left: applicationsButton.right + anchors.leftMargin: 8 + anchors.verticalCenter: parent.verticalCenter + width: 1; height: 14 + color: Theme.separator + } + Rectangle { id: resizeIndicator visible: bar.resizeMode - anchors.left: parent.left + anchors.left: applicationsSeparator.right anchors.leftMargin: 8 anchors.verticalCenter: parent.verticalCenter width: visible ? resizeLabel.implicitWidth + 12 : 0 @@ -243,14 +365,24 @@ PanelWindow { anchors.verticalCenter: parent.verticalCenter } + Rectangle { + id: workspacesSeparator + anchors.left: workspaces.right + anchors.leftMargin: 8 + anchors.verticalCenter: parent.verticalCenter + width: 1; height: 14 + color: Theme.separator + } + WindowStrip { id: windowStrip screen: bar.screen - anchors.left: workspaces.right - anchors.leftMargin: 10 + anchors.left: workspacesSeparator.right + anchors.leftMargin: 8 anchors.verticalCenter: parent.verticalCenter maxWidth: Math.max(0, leftArea.width - x - 8) onMenuOpened: { + bar.closeApplications(); bar.closeOverview(); bar.closeClipboard(); controlCenter.visible = false; @@ -291,6 +423,7 @@ PanelWindow { audioBrightnessController: controlCenter clipboardOpen: clipboardLoader.active && !!clipboardLoader.item && !clipboardLoader.item.closing onOpenPageRequested: page => { + bar.closeApplications(); bar.closeOverview(); bar.closeClipboard(); dateCenter.requestClose(); @@ -300,6 +433,7 @@ PanelWindow { controlCenter.visible = true; } onToggleControlCenterRequested: { + bar.closeApplications(); bar.closeOverview(); const opening = !controlCenter.visible; bar.closeClipboard(); @@ -310,6 +444,7 @@ PanelWindow { controlCenter.visible = opening; } onToggleRemovableMediaRequested: { + bar.closeApplications(); bar.closeOverview(); const opening = !rightStatusArea.removableMediaOpen; bar.closeClipboard(); diff --git a/quickshell/.config/quickshell/labfy-sway/README.md b/quickshell/.config/quickshell/labfy-sway/README.md index f60dfc42c..cc521312c 100644 --- a/quickshell/.config/quickshell/labfy-sway/README.md +++ b/quickshell/.config/quickshell/labfy-sway/README.md @@ -5,8 +5,13 @@ Le [moteur de thème commun](theme/README.md) fournit les couleurs effectives Barre principale et serveur de notifications de la session SwayFX. +Le bouton Arch **Applications**, premier élément à gauche même en mode Resize, +ouvre le [menu d'applications](applications/README.md) sous la barre. +`Mod+Alt+L` ouvre ce menu sur la sortie focalisée. + ```text Bar +├── Applications ├── Workspaces ├── Workspace Overview ├── Window/task switcher diff --git a/quickshell/.config/quickshell/labfy-sway/applications/ApplicationsMenu.qml b/quickshell/.config/quickshell/labfy-sway/applications/ApplicationsMenu.qml new file mode 100644 index 000000000..cb58f0dae --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/applications/ApplicationsMenu.qml @@ -0,0 +1,355 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import "../theme" +import "ApplicationsModel.js" as ApplicationsModel + +PanelWindow { + id: menu + required property var barWindow + signal menuDismissed() + signal launchRequested(string desktopId) + property string errorMessage: "" + property var allApps: [] + property var favoriteIds: [] + property string selectedCategory: "Toutes" + property int selectedIndex: 0 + property int navIndex: 0 + property bool closing: false + readonly property var categoryItems: ApplicationsModel.categories(allApps) + readonly property var navigation: [{ key: "Favoris", icon: "󰓎" }, + { key: "Toutes", icon: "󰀻" }].concat(categoryItems) + readonly property var visibleApps: ApplicationsModel.results( + allApps, selectedCategory, search.text, favoriteIds) + readonly property bool searchFocused: search.activeFocus + readonly property bool navigationFocused: navigationList.activeFocus + readonly property bool gridFocused: grid.activeFocus + readonly property real cardX: card.x + readonly property real cardY: card.y + readonly property real cardWidth: card.width + readonly property real cardHeight: card.height + + // WHY: an IPC-opened PopupWindow cannot acquire a reliable parent grab here. + // CONTRACT: this transient layer surface owns focus only while visible, + // and the transparent outside area consumes the closing click. + screen: barWindow.screen + anchors { top: true; bottom: true; left: true; right: true } + exclusionMode: ExclusionMode.Ignore + exclusiveZone: 0 + aboveWindows: true + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + WlrLayershell.namespace: "labfy-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); + selectedIndex = 0; + grid.positionViewAtBeginning(); + } + function currentApp() { + return selectedIndex >= 0 && selectedIndex < visibleApps.length + ? visibleApps[selectedIndex] : null; + } + function chooseApp() { + const app = currentApp(); + 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"); + } + function dismiss() { + if (closing) return; + // INVARIANT: keep this surface alive through the pointer release so + // an outside click cannot land on an application or the bar beneath. + closing = true; + dismissDelay.start(); + } + Timer { + id: dismissDelay + 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; + } + // The Wayland surface exists after completion; focus at the next frame. + Timer { interval: 120; running: true; repeat: false; onTriggered: search.forceActiveFocus() } + + Item { + anchors.fill: parent + Keys.onPressed: event => { + if (event.key === Qt.Key_Escape) { menu.dismiss(); event.accepted = true; } + } + MouseArea { anchors.fill: parent; onClicked: menu.dismiss() } + Rectangle { + id: card + x: 16 + y: menu.barWindow.height + 12 + width: Math.max(0, Math.min(680, parent.width - x - 16)) + height: Math.max(0, Math.min(560, parent.height - y - 16)) + radius: 4 + color: Theme.popupBackground + border.color: Theme.outline + border.width: 1 + MouseArea { anchors.fill: parent; onClicked: {} } + + Column { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + Row { + width: parent.width; height: 26 + spacing: 8 + Text { + text: "󰀻"; color: Theme.lavender + font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 24 + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: "Applications"; color: Theme.foreground + font.pixelSize: 17; font.bold: true + anchors.verticalCenter: parent.verticalCenter + } + } + Rectangle { + width: parent.width; height: 40; radius: 4 + color: Theme.inputBackground + border.color: search.activeFocus ? Theme.lavender : Theme.inputBorder + TextField { + id: search + anchors.fill: parent; anchors.margins: 2 + placeholderText: "Rechercher une application…" + color: Theme.foreground + placeholderTextColor: Theme.secondaryForeground + font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 13 + background: Item {} + onTextChanged: { menu.selectedIndex = 0; grid.positionViewAtBeginning(); } + Keys.onPressed: event => { + if (event.key === Qt.Key_Escape) { menu.dismiss(); event.accepted = true; } + else if (event.key === Qt.Key_Backtab || + (event.key === Qt.Key_Tab && (event.modifiers & Qt.ShiftModifier))) { + grid.forceActiveFocus(); event.accepted = true; + } else if (event.key === Qt.Key_Down) { + grid.forceActiveFocus(); menu.selectedIndex = 0; event.accepted = true; + } else if (event.key === Qt.Key_Tab) { + navigationList.forceActiveFocus(); event.accepted = true; + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + menu.chooseApp(); event.accepted = true; + } + } + } + } + Text { + visible: menu.errorMessage !== "" + width: parent.width; text: menu.errorMessage + color: Theme.danger; font.pixelSize: 12; elide: Text.ElideRight + } + Item { + width: parent.width + height: Math.max(0, parent.height - y) + Rectangle { + id: sidebar + anchors.left: parent.left; anchors.top: parent.top; anchors.bottom: parent.bottom + width: Math.min(170, Math.max(126, parent.width * 0.28)) + radius: 4; color: Theme.buttonBackground + ListView { + id: navigationList + anchors.fill: parent; anchors.margins: 5 + clip: true; spacing: 3 + model: menu.navigation + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + Keys.onPressed: event => { + if (event.key === Qt.Key_Up || event.key === Qt.Key_Down) { + menu.navIndex = Math.max(0, Math.min(menu.navigation.length - 1, + menu.navIndex + (event.key === Qt.Key_Down ? 1 : -1))); + positionViewAtIndex(menu.navIndex, ListView.Contain); + event.accepted = true; + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + menu.chooseCategory(menu.navigation[menu.navIndex].key); event.accepted = true; + } else if (event.key === Qt.Key_Backtab || + (event.key === Qt.Key_Tab && (event.modifiers & Qt.ShiftModifier))) { + search.forceActiveFocus(); event.accepted = true; + } else if (event.key === Qt.Key_Tab) { + grid.forceActiveFocus(); event.accepted = true; + } else if (event.key === Qt.Key_Escape) { + menu.dismiss(); event.accepted = true; + } + } + delegate: Rectangle { + required property var modelData + required property int index + width: navigationList.width; height: 37; radius: 4 + color: menu.selectedCategory === modelData.key ? Theme.buttonPressed + : navPointer.containsMouse ? Theme.buttonHover : "transparent" + border.color: navigationList.activeFocus && menu.navIndex === index + ? Theme.lavender : "transparent" + Row { + anchors.fill: parent; anchors.leftMargin: 10; spacing: 8 + Text { text: modelData.icon; color: Theme.lavender + font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 16 + anchors.verticalCenter: parent.verticalCenter } + Text { text: modelData.key === "Toutes" ? "Toutes les applications" : modelData.key + width: Math.max(0, parent.width - x - 5); elide: Text.ElideRight + color: Theme.foreground; font.pixelSize: 12 + anchors.verticalCenter: parent.verticalCenter } + } + MouseArea { id: navPointer; anchors.fill: parent; hoverEnabled: true + onClicked: { menu.chooseCategory(modelData.key); navigationList.forceActiveFocus(); } } + } + } + } + Item { + anchors.left: sidebar.right; anchors.leftMargin: 12 + anchors.right: parent.right; anchors.top: parent.top; anchors.bottom: parent.bottom + Text { + anchors.centerIn: parent + visible: menu.visibleApps.length === 0 + text: search.text ? "Aucun résultat" : "Aucune application" + color: Theme.secondaryForeground; font.pixelSize: 14 + } + GridView { + id: grid + anchors.fill: parent + visible: menu.visibleApps.length > 0 + clip: true + model: menu.visibleApps + cellWidth: Math.max(80, Math.floor(width / (width >= 420 ? 4 : 3))) + cellHeight: 104 + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + Keys.onPressed: event => { + const columns = Math.max(1, Math.floor(width / cellWidth)); + let next = menu.selectedIndex; + if (event.key === Qt.Key_Left) next--; + else if (event.key === Qt.Key_Right) next++; + else if (event.key === Qt.Key_Up) next -= columns; + else if (event.key === Qt.Key_Down) next += columns; + else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + menu.chooseApp(); event.accepted = true; return; + } else if (event.key === Qt.Key_Space) { + const app = menu.currentApp(); if (app) menu.toggleFavorite(app.id); + event.accepted = true; return; + } else if (event.key === Qt.Key_Backtab || + (event.key === Qt.Key_Tab && (event.modifiers & Qt.ShiftModifier))) { + navigationList.forceActiveFocus(); event.accepted = true; return; + } else if (event.key === Qt.Key_Tab) { + search.forceActiveFocus(); event.accepted = true; return; + } else if (event.key === Qt.Key_Escape) { + menu.dismiss(); event.accepted = true; return; + } else return; + menu.selectedIndex = Math.max(0, Math.min(menu.visibleApps.length - 1, next)); + positionViewAtIndex(menu.selectedIndex, GridView.Contain); + event.accepted = true; + } + delegate: Item { + required property var modelData + required property int index + width: grid.cellWidth; height: grid.cellHeight + Rectangle { + anchors.fill: parent; anchors.margins: 3; radius: 4 + color: menu.selectedIndex === index && grid.activeFocus + ? Theme.buttonPressed : appPointer.containsMouse ? Theme.buttonHover : "transparent" + border.color: menu.selectedIndex === index && grid.activeFocus + ? Theme.lavender : "transparent" + } + Image { + 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 + } + Text { + anchors.top: appIcon.bottom; anchors.topMargin: 5 + anchors.left: parent.left; anchors.right: parent.right + anchors.leftMargin: 6; anchors.rightMargin: 6 + text: modelData.name; color: Theme.foreground + font.pixelSize: 11; horizontalAlignment: Text.AlignHCenter + maximumLineCount: 2; wrapMode: Text.Wrap; elide: Text.ElideRight + } + MouseArea { + id: appPointer; anchors.fill: parent; hoverEnabled: true + onClicked: { + menu.selectedIndex = index; + menu.launchRequested(modelData.id); + } + ToolTip.visible: containsMouse && modelData.name.length > 18 + ToolTip.text: modelData.name + ToolTip.delay: 600 + } + Rectangle { + anchors.right: parent.right; anchors.top: parent.top + anchors.rightMargin: 5; anchors.topMargin: 4 + width: 24; height: 24; radius: 4 + color: starPointer.containsMouse ? Theme.buttonPressed : Theme.buttonBackground + Text { anchors.centerIn: parent + text: menu.favoriteIds.includes(modelData.id) ? "" : "󰓏" + color: Theme.lavender + font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 13 } + MouseArea { id: starPointer; anchors.fill: parent; hoverEnabled: true + onClicked: menu.toggleFavorite(modelData.id) + ToolTip.visible: containsMouse + ToolTip.text: menu.favoriteIds.includes(modelData.id) + ? "Retirer des favoris" : "Ajouter aux favoris" + ToolTip.delay: 600 } + } + } + } + } + } + } + } + } +} diff --git a/quickshell/.config/quickshell/labfy-sway/applications/ApplicationsModel.js b/quickshell/.config/quickshell/labfy-sway/applications/ApplicationsModel.js new file mode 100644 index 000000000..36267f092 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/applications/ApplicationsModel.js @@ -0,0 +1,60 @@ +.pragma library + +const categoryRules = [ + { key: "Internet", icon: "󰖟", tags: ["Network", "WebBrowser", "Email", "InstantMessaging"] }, + { key: "Multimédia", icon: "󰎆", tags: ["AudioVideo", "Audio", "Video", "Player", "Recorder"] }, + { key: "Développement", icon: "󰅩", tags: ["Development", "IDE", "GUIDesigner"] }, + { key: "Bureautique", icon: "󰈙", tags: ["Office", "WordProcessor", "Spreadsheet", "Presentation"] }, + { key: "Graphisme", icon: "󰏘", tags: ["Graphics", "Photography", "2DGraphics", "3DGraphics"] }, + { key: "Système", icon: "󰒓", tags: ["System", "Settings", "Monitor"] }, + { key: "Utilitaires", icon: "󰐱", tags: ["Utility", "FileManager", "TextEditor"] }, + { key: "Jeux", icon: "󰊗", tags: ["Game"] } +]; + +function fold(value) { + return String(value || "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLocaleLowerCase(); +} + +function classify(entry) { + const tags = entry.categories || []; + for (const rule of categoryRules) + if (rule.tags.some(tag => tags.includes(tag))) return rule.key; + return "Autres"; +} + +function catalog(entries) { + const seen = new Set(); + return entries.filter(entry => entry && entry.id && !entry.noDisplay && !seen.has(entry.id) + && seen.add(entry.id)) + // QuickShell 0.3.1 exposes the stem; Gio launch and durable favorites + // use the full desktop ID. Normalize once before crossing that boundary. + .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) })) + .sort((a, b) => a.name.localeCompare(b.name, "fr", { sensitivity: "base" }) || a.id.localeCompare(b.id)); +} + +function categories(entries) { + const present = new Set(entries.map(entry => entry.category)); + return categoryRules.filter(rule => present.has(rule.key)) + .concat(present.has("Autres") ? [{ key: "Autres", icon: "󰌗" }] : []); +} + +function results(entries, selected, query, favoriteIds) { + const needle = fold(query.trim()); + if (needle) return entries.filter(entry => + [entry.name, entry.genericName].concat(entry.keywords).some(value => fold(value).includes(needle))); + if (selected === "Favoris") { + const byId = new Map(entries.map(entry => [entry.id, entry])); + return favoriteIds.map(id => byId.get(id)).filter(Boolean); + } + if (selected === "Toutes") return entries; + return entries.filter(entry => entry.category === selected); +} + +function initialFavorites(entries) { + const names = ["firefox", "kitty", "yazi file manager", "neovim"]; + return names.map(name => entries.find(entry => fold(entry.name) === name)) + .filter(Boolean).map(entry => entry.id); +} diff --git a/quickshell/.config/quickshell/labfy-sway/applications/README.md b/quickshell/.config/quickshell/labfy-sway/applications/README.md new file mode 100644 index 000000000..fcb261a42 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/applications/README.md @@ -0,0 +1,59 @@ +# Menu Applications + +Le bouton Arch est le premier élément de la barre, avant Resize et les +workspaces. Il ouvre un `PanelWindow` layer-shell sur la sortie de la barre +cliquée, à 16 px du bord gauche et 12 px sous la barre. La carte mesure au plus +680 × 560 px logiques et se réduit aux dimensions disponibles. La surface ne +réserve aucune place dans Sway ; sa zone transparente absorbe le clic extérieur +jusqu'à la fin de l'événement souris. Le raccourci `Mod+Alt+L`, auparavant le +lanceur Wofi drun, choisit la sortie focalisée via +`~/.config/sway/scripts/applications-quickshell`. Les autres usages de Wofi et +Rofi restent en place. Une seule sortie possède le menu à la fois. + +`ApplicationsMenu.qml` montre la recherche, une navigation par catégories et +une grille d'icônes. `ApplicationsModel.js` classe les entrées fournies par +`DesktopEntries.applications` ; l'événement `applicationsChanged` actualise le +catalogue après une installation ou désinstallation sans relancer Sway. La +recherche compare noms, noms génériques et mots-clés sans tenir compte des +majuscules et accents, dans toutes les catégories. Les catégories vides sont +masquées. Les icônes d'application conservent leur thème et leurs couleurs ; +une icône générique est utilisée en dernier recours. Le menu utilise les +couleurs du thème commun Catppuccin Mocha et donne le focus initial à la +recherche. `Tab` et `Shift+Tab` passent entre recherche, catégories et grille ; +les flèches choisissent, `Entrée` lance, `Espace` modifie le favori de la +sélection, `Échap` ferme. + +Les favoris ordonnés sont enregistrés sous +`$XDG_STATE_HOME/labfy-sway/applications-favorites.json` (par défaut +`~/.local/state/labfy-sway/`), hors du dépôt. Le fichier privé (`0600`) ne +contient que la version et des identifiants `.desktop`. L'écriture est atomique +et protégée des mises à jour concurrentes. Au premier usage, Firefox, Kitty, +Yazi et Neovim sont proposés seulement si leurs entrées sont présentes. Une +liste existante n'est jamais écrasée ; les entrées désinstallées restent dans +l'état mais sont ignorées par la grille. + +Le menu transmet uniquement un identifiant desktop validé à +`applications/backend.py`. Pour une entrée graphique ordinaire, ce helper +appelle `uwsm app -t service -- ` afin de préserver l'intégration +de la session et de détacher l'application de QuickShell. Une entrée +`DBusActivatable` utilise `GioUnix.DesktopAppInfo.launch`. Pour `Terminal=true`, +le helper construit une entrée temporaire en mémoire et laisse Gio interpréter +`Exec`, ses arguments et codes de champ, ainsi que `Path` ; la commande est +encadrée par `uwsm app -t service -- kitty --`. Une entrée lançant déjà Kitty +ne reçoit pas de second terminal. Les véritables entrées `yazi.desktop` et +`nvim.desktop` ont été vérifiées dans Kitty. Le succès du helper confirme la +demande de lancement, sans prétendre qu'une fenêtre est déjà apparue. + +Dépendances : QuickShell 0.3.1 avec `DesktopEntries`, Python 3 avec PyGObject +(`GioUnix`), UWSM, Kitty, Sway IPC, `jq`, `quickshell ipc`, la Nerd Font +existante et le thème d'icônes système. Aucune base de données ni daemon n'est +ajouté. Les limites : une seule sortie physique a été testée ; certaines +icônes peuvent manquer dans les thèmes installés ; le résultat d'une demande +UWSM ne garantit pas le démarrage complet de l'application. Les entrées +desktop masquées ou exclues par l'environnement sont aussi refusées par Gio au +moment du lancement. + +Tests isolés : `python3 -B -m unittest discover -s tests/applications -p +'test_*.py' -v` et `node tests/applications/test_model.js` depuis la racine du +dépôt. `qmllint` vérifie le composant et la barre. Les tests du backend +emploient des entrées desktop synthétiques, sans lancer leurs commandes. diff --git a/quickshell/.config/quickshell/labfy-sway/applications/backend.py b/quickshell/.config/quickshell/labfy-sway/applications/backend.py new file mode 100644 index 000000000..e0c640791 --- /dev/null +++ b/quickshell/.config/quickshell/labfy-sway/applications/backend.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Favorites persistence and desktop-entry launch requests for the bar menu.""" + +import fcntl +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile + +import gi + +gi.require_version("Gio", "2.0") +gi.require_version("GioUnix", "2.0") +from gi.repository import GioUnix, GLib + + +IDENT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,199}\.desktop$") +MAX_FAVORITES = 64 +MAX_BYTES = 16384 + + +def state_path(): + root = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state") + return root / "labfy-sway/applications-favorites.json" + + +def valid_id(value): + return isinstance(value, str) and IDENT.fullmatch(value) is not None + + +def read_favorites(path): + try: + if path.is_symlink(): + raise ValueError("État des favoris invalide") + with path.open("rb") as stream: + raw = stream.read(MAX_BYTES + 1) + except FileNotFoundError: + return False, [] + if len(raw) > MAX_BYTES: + raise ValueError("État des favoris trop volumineux") + data = json.loads(raw) + ids = data.get("ids") if isinstance(data, dict) and data.get("version") == 1 else None + if (not isinstance(ids, list) or len(ids) > MAX_FAVORITES + or any(not valid_id(item) for item in ids) or len(set(ids)) != len(ids)): + raise ValueError("État des favoris invalide") + return True, ids + + +def write_favorites(path, action, ids): + # CONTRACT: lock the state directory across the read/replace transaction; + # concurrent clicks cannot silently lose another favorite update. + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + fcntl.flock(directory, fcntl.LOCK_EX) + exists, current = read_favorites(path) + if action == "seed": + if exists: + return current + current = list(dict.fromkeys(ids))[:MAX_FAVORITES] + elif action == "add": + if ids[0] not in current: + if len(current) >= MAX_FAVORITES: + raise ValueError("Trop de favoris") + current.append(ids[0]) + elif action == "remove": + current = [item for item in current if item != ids[0]] + else: + raise ValueError("Action de favoris invalide") + if any(not valid_id(item) for item in current): + raise ValueError("Identifiant de favori invalide") + # INVARIANT: the only durable payload is version and ordered IDs. + payload = (json.dumps({"version": 1, "ids": current}, ensure_ascii=False) + "\n").encode() + fd, temporary = tempfile.mkstemp(prefix=".favorites-", dir=path.parent) + try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "wb") as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + os.fsync(directory) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + return current + finally: + os.close(directory) + + +def _exec_quoted(value): + # Desktop Exec uses double quotes, not shell single-quote syntax. + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def terminal_launch_info(app): + filename = app.get_filename() + if not filename: + raise ValueError("Entrée terminal sans fichier desktop") + keyfile = GLib.KeyFile() + keyfile.load_from_file(filename, GLib.KeyFileFlags.NONE) + original = keyfile.get_string("Desktop Entry", "Exec") + # WHY: a keyfile-created DesktopAppInfo has no source filename for %k. + # Preserve that field while GLib expands the remaining desktop field codes. + original = re.sub(r"(?= 3 and argv[:2] == ["favorites", "seed"]: + ids = argv[2:] + 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"]): + 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) == 2 and argv[0] == "launch": + launch(argv[1]) + result = {"ok": True} + else: + raise ValueError("Commande invalide") + print(json.dumps(result, ensure_ascii=False)) + return 0 + except (OSError, ValueError, GLib.Error, json.JSONDecodeError, subprocess.TimeoutExpired) as error: + print(json.dumps({"error": str(error)}, ensure_ascii=False)) + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/quickshell/.config/quickshell/labfy-sway/overview/backend.py b/quickshell/.config/quickshell/labfy-sway/overview/backend.py index 574bc1849..91b6d161a 100644 --- a/quickshell/.config/quickshell/labfy-sway/overview/backend.py +++ b/quickshell/.config/quickshell/labfy-sway/overview/backend.py @@ -172,7 +172,8 @@ def state(): by_number = {item["num"]: item for item in workspaces if item.get("num") in range(1, 11)} output_info = {item["name"]: item for item in outputs if item.get("active")} overlay_outputs = [item["name"] for item in outputs if item.get("active") - and any(surface.get("namespace") == "labfy-workspace-overview" + and any(surface.get("namespace") in { + "labfy-workspace-overview", "labfy-applications-menu"} for surface in item.get("layer_shell_surfaces") or [])] workspace_nodes = {} diff --git a/quickshell/.config/quickshell/labfy-sway/shell.qml b/quickshell/.config/quickshell/labfy-sway/shell.qml index 71abbb43d..69ef22a91 100644 --- a/quickshell/.config/quickshell/labfy-sway/shell.qml +++ b/quickshell/.config/quickshell/labfy-sway/shell.qml @@ -9,6 +9,15 @@ import "theme" ShellRoot { id: shell property bool resizeMode: false + // CONTRACT: one owner across all output bars; opening a menu asks every + // bar to release its other transient panels before the overlay appears. + property string applicationsOutput: "" + signal applicationsOpening(string output) + function toggleApplications(output) { + if (applicationsOutput === output) { applicationsOutput = ""; return; } + applicationsOpening(output); + applicationsOutput = output; + } IpcHandler { target: "barMode" function active(): bool { return shell.resizeMode; } @@ -68,6 +77,7 @@ ShellRoot { model: Quickshell.screens Bar { + applicationCoordinator: shell resizeMode: shell.resizeMode notificationService: rootNotificationService // Une seule barre possède le chooser, même en configuration multi-écran. diff --git a/sway/.config/sway/bind b/sway/.config/sway/bind index 0e800f1f8..63340289c 100644 --- a/sway/.config/sway/bind +++ b/sway/.config/sway/bind @@ -209,7 +209,7 @@ bindsym $mod+Alt+g exec gimp bindsym $mod+Alt+m exec limusic-app bindsym $mod+Alt+b exec blueman-manager bindsym $mod+Alt+o exec swaylock -bindsym $mod+Alt+l exec wofi --cache-file=/dev/null -Dimage_size=26 -Dynamic_lines=true --location top --yoffset=10 --width=600 --height=300 --gtk-dark --show drun +bindsym $mod+Alt+l exec ~/.config/sway/scripts/applications-quickshell bindsym $mod+Alt+p exec proton-pass bindsym $mod+Alt+t exec ~/.local/bin/thunderbird-proton bindsym $mod+Alt+y exec kitty -- sh -c "yazi" diff --git a/sway/.config/sway/scripts/applications-quickshell b/sway/.config/sway/scripts/applications-quickshell new file mode 100755 index 000000000..de3b2ae28 --- /dev/null +++ b/sway/.config/sway/scripts/applications-quickshell @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +# CONTRACT: keyboard opening targets Sway's focused output, never a fixed name. +output="$(swaymsg -t get_outputs -r | jq -r '.[] | select(.focused == true) | .name' | head -n 1)" +[[ -n "$output" ]] || exit 1 +for attempt in 1 2 3 4 5; do + if quickshell ipc -c labfy-sway call "applicationsUi-$output" toggle >/dev/null 2>&1; then + exit 0 + fi + sleep 0.1 +done +exit 1 diff --git a/tests/applications/test_backend.py b/tests/applications/test_backend.py new file mode 100644 index 000000000..61c0b03dd --- /dev/null +++ b/tests/applications/test_backend.py @@ -0,0 +1,98 @@ +import importlib.util +import json +import os +from pathlib import Path +import tempfile +import time +import unittest + +import gi +gi.require_version("GioUnix", "2.0") +from gi.repository import GioUnix + + +BACKEND = Path(__file__).resolve().parents[2] / "quickshell/.config/quickshell/labfy-sway/applications/backend.py" +SPEC = importlib.util.spec_from_file_location("applications_backend", BACKEND) +backend = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(backend) + + +def desktop(path, *, command, terminal=False, extra=""): + path.write_text("[Desktop Entry]\nType=Application\nName=Fixture\n" + f"Exec={command}\nTerminal={'true' if terminal else 'false'}\n{extra}") + return GioUnix.DesktopAppInfo.new_from_filename(str(path)) + + +class FavoritesTests(unittest.TestCase): + def test_seed_order_atomic_update_and_missing_entry(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "state/favorites.json" + self.assertEqual(backend.read_favorites(path), (False, [])) + backend.write_favorites(path, "seed", ["firefox.desktop", "removed.desktop"]) + backend.write_favorites(path, "seed", ["other.desktop"]) + backend.write_favorites(path, "add", ["kitty.desktop"]) + self.assertEqual(backend.read_favorites(path)[1], + ["firefox.desktop", "removed.desktop", "kitty.desktop"]) + backend.write_favorites(path, "remove", ["firefox.desktop"]) + self.assertEqual(backend.read_favorites(path)[1], + ["removed.desktop", "kitty.desktop"]) + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + self.assertEqual(set(json.loads(path.read_text())), {"version", "ids"}) + + def test_bad_state_does_not_get_overwritten(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "favorites.json" + path.write_text('{"version":1,"ids":["bad/path.desktop"]}') + with self.assertRaises(ValueError): + backend.write_favorites(path, "add", ["kitty.desktop"]) + self.assertIn("bad/path.desktop", path.read_text()) + + +class LaunchTests(unittest.TestCase): + def test_graphical_uses_desktop_id_and_uwsm_service(self): + with tempfile.TemporaryDirectory() as temporary: + app = desktop(Path(temporary) / "fixture.desktop", command="/usr/bin/true %F") + calls = [] + class Result: + returncode = 0 + backend.launch("fixture.desktop", loader=lambda _id: app, + runner=lambda *args, **kwargs: calls.append((args, kwargs)) or Result()) + self.assertEqual(calls[0][0][0], + ["uwsm", "app", "-t", "service", "--", "fixture.desktop"]) + + def test_terminal_preserves_desktop_exec_fields_and_kitty(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + app = desktop(root / "fixture.desktop", + command='/usr/bin/printf "argument avec espace" %c %k', + terminal=True, extra=f"Path={root}\n") + transformed = backend.terminal_launch_info(app) + self.assertFalse(transformed.get_boolean("Terminal")) + self.assertEqual(transformed.get_executable(), "uwsm") + self.assertIn("kitty -- /usr/bin/printf", transformed.get_commandline()) + self.assertIn(str(root / "fixture.desktop"), transformed.get_commandline()) + self.assertEqual(transformed.get_string("Path"), str(root)) + + def test_terminal_entry_already_running_kitty_is_not_wrapped_twice(self): + with tempfile.TemporaryDirectory() as temporary: + app = desktop(Path(temporary) / "kitty-fixture.desktop", + command="kitty --hold", terminal=True) + self.assertEqual(backend.terminal_launch_info(app).get_commandline().count("kitty"), 1) + + def test_invalid_id_and_missing_entry(self): + with self.assertRaises(ValueError): + backend.launch("/tmp/arbitrary.desktop") + with self.assertRaises(ValueError): + backend.launch("missing-fixture.desktop", loader=lambda _id: None) + + def test_nodisplay_entry_is_refused_before_launch(self): + with tempfile.TemporaryDirectory() as temporary: + app = desktop(Path(temporary) / "hidden.desktop", command="/usr/bin/true", + extra="NoDisplay=true\n") + with self.assertRaisesRegex(ValueError, "indisponible"): + backend.launch("hidden.desktop", loader=lambda _id: app, + runner=lambda *_args, **_kwargs: self.fail("launch called")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/applications/test_model.js b/tests/applications/test_model.js new file mode 100644 index 000000000..104cc68a8 --- /dev/null +++ b/tests/applications/test_model.js @@ -0,0 +1,30 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const vm = require("node:vm"); +const path = require("node:path"); + +const source = fs.readFileSync(path.join(__dirname, + "../../quickshell/.config/quickshell/labfy-sway/applications/ApplicationsModel.js"), "utf8") + .replace(/^\.pragma library\s*\n/, ""); +const context = vm.createContext({}); +vm.runInContext(source, context); +const entries = context.catalog([ + { id: "firefox.desktop", name: "Firefox", genericName: "Navigateur Web", keywords: ["Internet"], categories: ["Network"], icon: "firefox" }, + { id: "nvim.desktop", name: "Éditeur", genericName: "Texte", keywords: ["Code"], categories: ["Development"] }, + { id: "hidden.desktop", name: "Hidden", noDisplay: true, categories: ["Game"] }, + { id: "firefox.desktop", name: "Duplicate", categories: ["Utility"] }, + { id: "other.desktop", name: "Mystère", categories: [] } +]); +assert.equal(entries.length, 3); +assert.equal(context.catalog([{ id: "fixture", name: "Fixture" }])[0].id, + "fixture.desktop"); +assert.equal(context.categories(entries).length, 3); +assert.equal(context.results(entries, "Développement", "", []).length, 1); +assert.equal(context.results(entries, "Développement", "navigateur", []).length, 1); +assert.equal(context.results(entries, "Toutes", "editeur", []).length, 1); +assert.equal(context.results(entries, "Toutes", "absent", []).length, 0); +assert.equal(context.results(entries, "Favoris", "", ["removed.desktop", "firefox.desktop"])[0].id, + "firefox.desktop"); +assert.equal(context.initialFavorites([{ id: "kitty.desktop", name: "kitty" }])[0], + "kitty.desktop"); +console.log("ApplicationsModel: PASS"); diff --git a/tests/overview/test_backend.py b/tests/overview/test_backend.py index febe2ce31..af97fecf2 100644 --- a/tests/overview/test_backend.py +++ b/tests/overview/test_backend.py @@ -40,6 +40,10 @@ class OverviewBackendTests(unittest.TestCase): self.assertTrue(result["cards"][0]["windows"][1]["floating"]) self.assertEqual(result["cards"][0]["windows"][0]["foreignId"], "a" * 32) self.assertFalse(result["cards"][1]["exists"]) + outputs[0]["layer_shell_surfaces"].append({"namespace": "labfy-applications-menu"}) + with patch.object(backend, "sway", side_effect=lambda kind: { + "get_tree": tree, "get_workspaces": ws, "get_outputs": outputs}[kind]): + self.assertEqual(backend.state()["overlayOutputs"], ["eDP-1"]) def test_move_uses_only_valid_con_id_and_workspace(self): cards = [{"number": 1, "revision": "before", "windows": [{"id": 42}]},