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.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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
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 = {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
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.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}]},
|
||||
|
|
|
|||
Loading…
Reference in a new issue