feat(quickshell): add applications menu and workspace separators

This commit is contained in:
fy59 2026-10-08 22:00:58 +02:00
parent 25a2d7b555
commit 8551f8357b
13 changed files with 950 additions and 5 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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 -- <identifiant>` 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.

View file

@ -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"(?<!%)%k", _exec_quoted(filename), original)
executable = os.path.basename(app.get_executable() or "")
prefix = "uwsm app -t service -- "
if executable != "kitty":
prefix += "kitty -- "
keyfile.set_string("Desktop Entry", "Exec", prefix + original)
keyfile.set_boolean("Desktop Entry", "Terminal", False)
keyfile.set_boolean("Desktop Entry", "DBusActivatable", False)
return GioUnix.DesktopAppInfo.new_from_keyfile(keyfile)
def launch(identifier, runner=subprocess.run, loader=GioUnix.DesktopAppInfo.new):
# CONTRACT: accept an installed desktop ID only; search text and Exec never
# cross this boundary. UWSM/GIO interpret field codes without a shell.
if not valid_id(identifier):
raise ValueError("Identifiant d'application invalide")
app = loader(identifier)
if app is None or app.get_is_hidden() or app.get_nodisplay() or not app.should_show():
raise ValueError("Application indisponible")
executable = app.get_executable()
if not app.get_boolean("DBusActivatable") and executable and not shutil.which(executable):
raise ValueError("Exécutable introuvable")
if app.get_boolean("Terminal"):
if not shutil.which("kitty") or not shutil.which("uwsm"):
raise ValueError("Kitty ou UWSM indisponible")
if not terminal_launch_info(app).launch([], None):
raise ValueError("Demande de lancement refusée")
return
if app.get_boolean("DBusActivatable"):
if not app.launch([], None):
raise ValueError("Activation D-Bus refusée")
return
result = runner(["uwsm", "app", "-t", "service", "--", identifier],
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
timeout=10, check=False)
if result.returncode:
raise ValueError("Demande de lancement UWSM refusée")
def main(argv):
try:
if len(argv) == 2 and argv == ["favorites", "read"]:
exists, ids = read_favorites(state_path())
result = {"exists": exists, "ids": ids}
elif len(argv) >= 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:]))

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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");

View file

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