feat(quickshell): add applications menu and workspace separators
This commit is contained in:
parent
25a2d7b555
commit
8551f8357b
13 changed files with 950 additions and 5 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
import QtQuick
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import Quickshell.I3
|
import Quickshell.I3
|
||||||
|
|
@ -12,6 +13,7 @@ import "status"
|
||||||
import "theme"
|
import "theme"
|
||||||
import "clipboard"
|
import "clipboard"
|
||||||
import "overview"
|
import "overview"
|
||||||
|
import "applications"
|
||||||
|
|
||||||
PanelWindow {
|
PanelWindow {
|
||||||
id: bar
|
id: bar
|
||||||
|
|
@ -22,6 +24,7 @@ PanelWindow {
|
||||||
required property var notificationService
|
required property var notificationService
|
||||||
required property bool startupHost
|
required property bool startupHost
|
||||||
required property bool resizeMode
|
required property bool resizeMode
|
||||||
|
required property var applicationCoordinator
|
||||||
screen: modelData
|
screen: modelData
|
||||||
|
|
||||||
anchors {
|
anchors {
|
||||||
|
|
@ -45,6 +48,87 @@ PanelWindow {
|
||||||
focusable: true
|
focusable: true
|
||||||
color: Theme.panelBackground
|
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() {
|
function closeClipboard() {
|
||||||
if (clipboardLoader.item && !clipboardLoader.item.closing)
|
if (clipboardLoader.item && !clipboardLoader.item.closing)
|
||||||
clipboardLoader.item.dismiss(true);
|
clipboardLoader.item.dismiss(true);
|
||||||
|
|
@ -54,6 +138,7 @@ PanelWindow {
|
||||||
if (clipboardLoader.active) {
|
if (clipboardLoader.active) {
|
||||||
closeClipboard();
|
closeClipboard();
|
||||||
} else {
|
} else {
|
||||||
|
closeApplications();
|
||||||
closeOverview();
|
closeOverview();
|
||||||
controlCenter.visible = false;
|
controlCenter.visible = false;
|
||||||
dateCenter.requestClose();
|
dateCenter.requestClose();
|
||||||
|
|
@ -72,6 +157,7 @@ PanelWindow {
|
||||||
}
|
}
|
||||||
function toggleOverview() {
|
function toggleOverview() {
|
||||||
if (overviewRequested || overviewLoader.active) { closeOverview(); return true; }
|
if (overviewRequested || overviewLoader.active) { closeOverview(); return true; }
|
||||||
|
closeApplications();
|
||||||
closeClipboard();
|
closeClipboard();
|
||||||
controlCenter.visible = false;
|
controlCenter.visible = false;
|
||||||
dateCenter.requestClose();
|
dateCenter.requestClose();
|
||||||
|
|
@ -122,6 +208,7 @@ PanelWindow {
|
||||||
interval: 350; repeat: false
|
interval: 350; repeat: false
|
||||||
onTriggered: {
|
onTriggered: {
|
||||||
if (!overviewCapture.running && !overviewLoader.active && !clipboardLoader.active
|
if (!overviewCapture.running && !overviewLoader.active && !clipboardLoader.active
|
||||||
|
&& !applicationsLoader.active
|
||||||
&& !controlCenter.visible && !dateCenter.visible)
|
&& !controlCenter.visible && !dateCenter.visible)
|
||||||
overviewCapture.running = true;
|
overviewCapture.running = true;
|
||||||
}
|
}
|
||||||
|
|
@ -132,6 +219,7 @@ PanelWindow {
|
||||||
interval: 15000; repeat: true; running: true; triggeredOnStart: true
|
interval: 15000; repeat: true; running: true; triggeredOnStart: true
|
||||||
onTriggered: {
|
onTriggered: {
|
||||||
if (!overviewCapture.running && !overviewLoader.active && !clipboardLoader.active
|
if (!overviewCapture.running && !overviewLoader.active && !clipboardLoader.active
|
||||||
|
&& !applicationsLoader.active
|
||||||
&& !controlCenter.visible && !dateCenter.visible)
|
&& !controlCenter.visible && !dateCenter.visible)
|
||||||
overviewCapture.running = true;
|
overviewCapture.running = true;
|
||||||
}
|
}
|
||||||
|
|
@ -151,6 +239,7 @@ PanelWindow {
|
||||||
target: "appearanceUi-" + bar.screen.name
|
target: "appearanceUi-" + bar.screen.name
|
||||||
function openPage(page: string): bool {
|
function openPage(page: string): bool {
|
||||||
if (controlCenter.pages[page] === undefined) return false;
|
if (controlCenter.pages[page] === undefined) return false;
|
||||||
|
bar.closeApplications();
|
||||||
bar.closeClipboard();
|
bar.closeClipboard();
|
||||||
dateCenter.requestClose();
|
dateCenter.requestClose();
|
||||||
windowStrip.closeMenu();
|
windowStrip.closeMenu();
|
||||||
|
|
@ -161,6 +250,7 @@ PanelWindow {
|
||||||
function close(): bool { controlCenter.visible = false; return true; }
|
function close(): bool { controlCenter.visible = false; return true; }
|
||||||
function openDateCenter(): bool { bar.toggleDateCenter(); return true; }
|
function openDateCenter(): bool { bar.toggleDateCenter(); return true; }
|
||||||
function openWindowMenu(): bool {
|
function openWindowMenu(): bool {
|
||||||
|
bar.closeApplications();
|
||||||
bar.closeClipboard();
|
bar.closeClipboard();
|
||||||
controlCenter.visible = false;
|
controlCenter.visible = false;
|
||||||
dateCenter.requestClose();
|
dateCenter.requestClose();
|
||||||
|
|
@ -197,6 +287,7 @@ PanelWindow {
|
||||||
// CONTRACT: toute la capsule centrale partage le même popup et la même exclusion XOR.
|
// CONTRACT: toute la capsule centrale partage le même popup et la même exclusion XOR.
|
||||||
function toggleDateCenter() {
|
function toggleDateCenter() {
|
||||||
const opening = !dateCenter.visible && !dateCenter.opening;
|
const opening = !dateCenter.visible && !dateCenter.opening;
|
||||||
|
closeApplications();
|
||||||
closeOverview();
|
closeOverview();
|
||||||
closeClipboard();
|
closeClipboard();
|
||||||
controlCenter.visible = false;
|
controlCenter.visible = false;
|
||||||
|
|
@ -215,10 +306,41 @@ PanelWindow {
|
||||||
height: bar.height
|
height: bar.height
|
||||||
clip: true
|
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 {
|
Rectangle {
|
||||||
id: resizeIndicator
|
id: resizeIndicator
|
||||||
visible: bar.resizeMode
|
visible: bar.resizeMode
|
||||||
anchors.left: parent.left
|
anchors.left: applicationsSeparator.right
|
||||||
anchors.leftMargin: 8
|
anchors.leftMargin: 8
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
width: visible ? resizeLabel.implicitWidth + 12 : 0
|
width: visible ? resizeLabel.implicitWidth + 12 : 0
|
||||||
|
|
@ -243,14 +365,24 @@ PanelWindow {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
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 {
|
WindowStrip {
|
||||||
id: windowStrip
|
id: windowStrip
|
||||||
screen: bar.screen
|
screen: bar.screen
|
||||||
anchors.left: workspaces.right
|
anchors.left: workspacesSeparator.right
|
||||||
anchors.leftMargin: 10
|
anchors.leftMargin: 8
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
maxWidth: Math.max(0, leftArea.width - x - 8)
|
maxWidth: Math.max(0, leftArea.width - x - 8)
|
||||||
onMenuOpened: {
|
onMenuOpened: {
|
||||||
|
bar.closeApplications();
|
||||||
bar.closeOverview();
|
bar.closeOverview();
|
||||||
bar.closeClipboard();
|
bar.closeClipboard();
|
||||||
controlCenter.visible = false;
|
controlCenter.visible = false;
|
||||||
|
|
@ -291,6 +423,7 @@ PanelWindow {
|
||||||
audioBrightnessController: controlCenter
|
audioBrightnessController: controlCenter
|
||||||
clipboardOpen: clipboardLoader.active && !!clipboardLoader.item && !clipboardLoader.item.closing
|
clipboardOpen: clipboardLoader.active && !!clipboardLoader.item && !clipboardLoader.item.closing
|
||||||
onOpenPageRequested: page => {
|
onOpenPageRequested: page => {
|
||||||
|
bar.closeApplications();
|
||||||
bar.closeOverview();
|
bar.closeOverview();
|
||||||
bar.closeClipboard();
|
bar.closeClipboard();
|
||||||
dateCenter.requestClose();
|
dateCenter.requestClose();
|
||||||
|
|
@ -300,6 +433,7 @@ PanelWindow {
|
||||||
controlCenter.visible = true;
|
controlCenter.visible = true;
|
||||||
}
|
}
|
||||||
onToggleControlCenterRequested: {
|
onToggleControlCenterRequested: {
|
||||||
|
bar.closeApplications();
|
||||||
bar.closeOverview();
|
bar.closeOverview();
|
||||||
const opening = !controlCenter.visible;
|
const opening = !controlCenter.visible;
|
||||||
bar.closeClipboard();
|
bar.closeClipboard();
|
||||||
|
|
@ -310,6 +444,7 @@ PanelWindow {
|
||||||
controlCenter.visible = opening;
|
controlCenter.visible = opening;
|
||||||
}
|
}
|
||||||
onToggleRemovableMediaRequested: {
|
onToggleRemovableMediaRequested: {
|
||||||
|
bar.closeApplications();
|
||||||
bar.closeOverview();
|
bar.closeOverview();
|
||||||
const opening = !rightStatusArea.removableMediaOpen;
|
const opening = !rightStatusArea.removableMediaOpen;
|
||||||
bar.closeClipboard();
|
bar.closeClipboard();
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
```text
|
||||||
Bar
|
Bar
|
||||||
|
├── Applications
|
||||||
├── Workspaces
|
├── Workspaces
|
||||||
├── Workspace Overview
|
├── Workspace Overview
|
||||||
├── Window/task switcher
|
├── Window/task switcher
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
175
quickshell/.config/quickshell/labfy-sway/applications/backend.py
Normal file
175
quickshell/.config/quickshell/labfy-sway/applications/backend.py
Normal 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:]))
|
||||||
|
|
@ -172,7 +172,8 @@ def state():
|
||||||
by_number = {item["num"]: item for item in workspaces if item.get("num") in range(1, 11)}
|
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")}
|
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")
|
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 [])]
|
for surface in item.get("layer_shell_surfaces") or [])]
|
||||||
workspace_nodes = {}
|
workspace_nodes = {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,15 @@ import "theme"
|
||||||
ShellRoot {
|
ShellRoot {
|
||||||
id: shell
|
id: shell
|
||||||
property bool resizeMode: false
|
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 {
|
IpcHandler {
|
||||||
target: "barMode"
|
target: "barMode"
|
||||||
function active(): bool { return shell.resizeMode; }
|
function active(): bool { return shell.resizeMode; }
|
||||||
|
|
@ -68,6 +77,7 @@ ShellRoot {
|
||||||
model: Quickshell.screens
|
model: Quickshell.screens
|
||||||
|
|
||||||
Bar {
|
Bar {
|
||||||
|
applicationCoordinator: shell
|
||||||
resizeMode: shell.resizeMode
|
resizeMode: shell.resizeMode
|
||||||
notificationService: rootNotificationService
|
notificationService: rootNotificationService
|
||||||
// Une seule barre possède le chooser, même en configuration multi-écran.
|
// Une seule barre possède le chooser, même en configuration multi-écran.
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,7 @@ bindsym $mod+Alt+g exec gimp
|
||||||
bindsym $mod+Alt+m exec limusic-app
|
bindsym $mod+Alt+m exec limusic-app
|
||||||
bindsym $mod+Alt+b exec blueman-manager
|
bindsym $mod+Alt+b exec blueman-manager
|
||||||
bindsym $mod+Alt+o exec swaylock
|
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+p exec proton-pass
|
||||||
bindsym $mod+Alt+t exec ~/.local/bin/thunderbird-proton
|
bindsym $mod+Alt+t exec ~/.local/bin/thunderbird-proton
|
||||||
bindsym $mod+Alt+y exec kitty -- sh -c "yazi"
|
bindsym $mod+Alt+y exec kitty -- sh -c "yazi"
|
||||||
|
|
|
||||||
13
sway/.config/sway/scripts/applications-quickshell
Executable file
13
sway/.config/sway/scripts/applications-quickshell
Executable 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
|
||||||
98
tests/applications/test_backend.py
Normal file
98
tests/applications/test_backend.py
Normal 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()
|
||||||
30
tests/applications/test_model.js
Normal file
30
tests/applications/test_model.js
Normal 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");
|
||||||
|
|
@ -40,6 +40,10 @@ class OverviewBackendTests(unittest.TestCase):
|
||||||
self.assertTrue(result["cards"][0]["windows"][1]["floating"])
|
self.assertTrue(result["cards"][0]["windows"][1]["floating"])
|
||||||
self.assertEqual(result["cards"][0]["windows"][0]["foreignId"], "a" * 32)
|
self.assertEqual(result["cards"][0]["windows"][0]["foreignId"], "a" * 32)
|
||||||
self.assertFalse(result["cards"][1]["exists"])
|
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):
|
def test_move_uses_only_valid_con_id_and_workspace(self):
|
||||||
cards = [{"number": 1, "revision": "before", "windows": [{"id": 42}]},
|
cards = [{"number": 1, "revision": "before", "windows": [{"id": 42}]},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue