From 65b87cb9c66b16228162e8da0f8be3e87178b934 Mon Sep 17 00:00:00 2001 From: Kris Date: Thu, 5 Feb 2026 18:43:54 +0200 Subject: [PATCH] feat: app launcher --- Shell/Overlay.qml | 6 + Shell/ShellInputManager.qml | 10 +- Shell/ShellStateManager.qml | 83 ++++++ Shell/Topbar/Topbar.qml | 13 +- Shell/Windows/AppLauncher/AppLauncher.qml | 44 +++ Shell/Windows/AppLauncher/AppLauncherApp.qml | 287 +++++++++++++++++++ 6 files changed, 440 insertions(+), 3 deletions(-) create mode 100644 Shell/Windows/AppLauncher/AppLauncher.qml create mode 100644 Shell/Windows/AppLauncher/AppLauncherApp.qml diff --git a/Shell/Overlay.qml b/Shell/Overlay.qml index e0b6bd6..df94929 100644 --- a/Shell/Overlay.qml +++ b/Shell/Overlay.qml @@ -4,6 +4,7 @@ import Quickshell.Wayland import Quickshell.Hyprland import "Windows/QuickSettings" +import "Windows/AppLauncher" PanelWindow { id: overlay @@ -70,4 +71,9 @@ PanelWindow { id: quickSettingsWindow manager: ShellStateManager } + + AppLauncher { + id: appLauncherWindow + manager: ShellStateManager + } } diff --git a/Shell/ShellInputManager.qml b/Shell/ShellInputManager.qml index f4a346d..732456f 100644 --- a/Shell/ShellInputManager.qml +++ b/Shell/ShellInputManager.qml @@ -30,7 +30,11 @@ QtObject { if (!ShellStateManager.shellOpen) return false; - var context = ShellStateManager.quickSettingsOpen ? "quickSettings" : "topbar"; + var context = "topbar"; + if (ShellStateManager.appLauncherOpen) + context = "appLauncher"; + else if (ShellStateManager.quickSettingsOpen) + context = "quickSettings"; var handler = handlers[context]; if (handler) { var handled = handler(key); @@ -39,7 +43,9 @@ QtObject { } if (key === Qt.Key_Escape || key === Qt.Key_Shift || key === Qt.Key_X) { - if (context === "quickSettings") { + if (context === "appLauncher") { + ShellStateManager.closeAppLauncher(); + } else if (context === "quickSettings") { ShellStateManager.closeQuickSettings(); } else if (context === "topbar") { ShellStateManager.closeShell(); diff --git a/Shell/ShellStateManager.qml b/Shell/ShellStateManager.qml index a5786bc..e1c1821 100644 --- a/Shell/ShellStateManager.qml +++ b/Shell/ShellStateManager.qml @@ -1,12 +1,21 @@ pragma Singleton import QtQuick +import Quickshell import Quickshell.Hyprland +import Quickshell.Io QtObject { id: manager property bool shellOpen: false property bool quickSettingsOpen: false + property bool appLauncherOpen: false + property var appUsageCounts: ({}) + property string appUsagePath: { + var homeDir = Quickshell.env("HOME"); + return homeDir ? homeDir + "/.local/share/deltarunequickshell/apps.json" : ""; + } + property bool appUsageLoaded: false property var windowRequests: ({}) property var quickSettingsPayload: ({}) property var globals: ({}) @@ -15,6 +24,8 @@ QtObject { signal shellClosed signal quickSettingsOpened signal quickSettingsClosed + signal appLauncherOpened + signal appLauncherClosed signal windowRequested(string name, var payload) function openShell() { @@ -33,6 +44,9 @@ QtObject { if (quickSettingsOpen) { closeQuickSettings(); } + if (appLauncherOpen) { + closeAppLauncher(); + } shellOpen = false; shellClosed(); } @@ -55,6 +69,9 @@ QtObject { var normalizedPayload = payload || ({}); quickSettingsPayload = normalizedPayload; console.log("ShellStateManager: openQuickSettings", normalizedPayload); + if (appLauncherOpen) { + closeAppLauncher(); + } if (!quickSettingsOpen) { quickSettingsOpen = true; quickSettingsOpened(); @@ -74,6 +91,72 @@ QtObject { quickSettingsOpen ? closeQuickSettings() : openQuickSettings(payload); } + function openAppLauncher(payload) { + var normalizedPayload = payload || ({}); + console.log("ShellStateManager: openAppLauncher", normalizedPayload); + if (quickSettingsOpen) { + closeQuickSettings(); + } + if (!appLauncherOpen) { + appLauncherOpen = true; + appLauncherOpened(); + } + requestWindow("appLauncher", normalizedPayload); + } + + function closeAppLauncher() { + if (appLauncherOpen) { + console.log("ShellStateManager: closeAppLauncher"); + appLauncherOpen = false; + appLauncherClosed(); + } + } + + function toggleAppLauncher(payload) { + appLauncherOpen ? closeAppLauncher() : openAppLauncher(payload); + } + + function bumpAppUsage(id) { + if (!id) + return; + var updated = Object.assign({}, appUsageCounts); + updated[id] = (updated[id] || 0) + 1; + appUsageCounts = updated; + } + + function persistAppUsage() { + if (!appUsageLoaded) + return; + if (!appUsageFile || !appUsageAdapter) + return; + appUsageAdapter.counts = appUsageCounts || ({}); + appUsageFile.writeAdapter(); + } + + onAppUsageCountsChanged: persistAppUsage() + + property FileView appUsageFile: FileView { + id: appUsageFile + path: manager.appUsagePath + watchChanges: true + onFileChanged: reload() + onLoaded: { + if (appUsageAdapter) { + manager.appUsageCounts = appUsageAdapter.counts || ({}); + } + manager.appUsageLoaded = true; + } + onLoadFailed: { + manager.appUsageLoaded = true; + manager.persistAppUsage(); + } + + JsonAdapter { + id: appUsageAdapter + property var counts: ({}) + } + } + function setGlobal(key, value) { globals[key] = value; } diff --git a/Shell/Topbar/Topbar.qml b/Shell/Topbar/Topbar.qml index a0a07d2..96bfb8b 100644 --- a/Shell/Topbar/Topbar.qml +++ b/Shell/Topbar/Topbar.qml @@ -49,6 +49,12 @@ Item { }); return true; } + if (iconSources[selectedIndex] === "item.png") { + ShellStateManager.openAppLauncher({ + source: "Topbar" + }); + return true; + } } return false; } @@ -84,7 +90,7 @@ Item { anchors.centerIn: parent selected: topbar.selectedIndex == repeatitem.index iconSource: topbar.iconSources[repeatitem.index] - showSoul: !topbar.manager.quickSettingsOpen + showSoul: !topbar.manager.quickSettingsOpen && !topbar.manager.appLauncherOpen } MouseArea { @@ -96,6 +102,11 @@ Item { source: "Topbar" }); } + if (topbar.iconSources[repeatitem.index] === "item.png") { + ShellStateManager.openAppLauncher({ + source: "Topbar" + }); + } } } } diff --git a/Shell/Windows/AppLauncher/AppLauncher.qml b/Shell/Windows/AppLauncher/AppLauncher.qml new file mode 100644 index 0000000..19a1d81 --- /dev/null +++ b/Shell/Windows/AppLauncher/AppLauncher.qml @@ -0,0 +1,44 @@ +import QtQuick +import "../.." +import "../../Window" as ShellWindow + +ShellWindow.Window { + id: appLauncherWindow + property var manager: ShellStateManager + + property var appLauncherApp: null + + width: 1217 + 52 + height: 767 + 52 + visible: manager ? manager.appLauncherOpen : false + anchors.centerIn: parent + + Loader { + id: appLauncherLoader + anchors.fill: parent + asynchronous: true + source: "./AppLauncherApp.qml" + onLoaded: { + appLauncherApp = item; + if (appLauncherApp) + appLauncherApp.manager = manager; + } + } + + onManagerChanged: { + if (appLauncherApp) + appLauncherApp.manager = manager; + } + + QtObject { + id: appLauncherKeyHandler + function handle(event) { + if (appLauncherApp && appLauncherApp.handleKey) + return appLauncherApp.handleKey(event.key); + return false; + } + + Component.onCompleted: ShellInputManager.registerHandler("appLauncher", handle) + Component.onDestruction: ShellInputManager.unregisterHandler("appLauncher") + } +} diff --git a/Shell/Windows/AppLauncher/AppLauncherApp.qml b/Shell/Windows/AppLauncher/AppLauncherApp.qml new file mode 100644 index 0000000..9388e9f --- /dev/null +++ b/Shell/Windows/AppLauncher/AppLauncherApp.qml @@ -0,0 +1,287 @@ +import QtQuick +import Quickshell +import Quickshell.Widgets +import "../.." + +Item { + id: root + width: parent ? parent.width : 1280 + height: parent ? parent.height : 820 + focus: true + + /* ------------------------------ + PIXEL CONSTANTS (DO NOT TOUCH) + ------------------------------ */ + + property int menuLeft: 64 + property int menuTop: 140 + property int lineHeight: 38 + 40 + 1 + + property int nameFontSize: 32 + property int stateFontSize: 28 + + property int stateColumnX: 824 + + property int soulOffsetX: -36 - 32 + property int soulOffsetY: -26 + + /* ------------------------------ */ + + property int iconSize: 36 + property int textStartX: 239 + property int textRightPadding: 96 + property int visibleRows: Math.max(1, Math.floor((height - menuTop - 64) / lineHeight)) + property int scrollOffset: 0 + + property ShellStateManager manager: null + property int activeSelection: 0 + property bool isSelected: true + + ListModel { + id: appEntriesModel + } + + function clamp(v, lo, hi) { + return Math.max(lo, Math.min(hi, v)); + } + + function menuLength() { + return appEntriesModel.count; + } + + function menuAt(index) { + if (index < 0 || index >= appEntriesModel.count) + return null; + var row = appEntriesModel.get(index); + return row ? row.entry : null; + } + + function wrapIndex(i) { + const length = menuLength(); + if (length === 0) + return 0; + return (i + length) % length; + } + + function clampSelection() { + const length = menuLength(); + if (length === 0) { + activeSelection = 0; + return; + } + activeSelection = clamp(activeSelection, 0, length - 1); + } + + function ensureVisible() { + const length = menuLength(); + if (length === 0) + return; + if (activeSelection < scrollOffset) + scrollOffset = activeSelection; + else if (activeSelection >= scrollOffset + visibleRows) + scrollOffset = Math.max(0, activeSelection - visibleRows + 1); + } + + function appName(entry) { + if (!entry) + return ""; + if (entry.name && entry.name.length > 0) + return entry.name; + return entry.id || ""; + } + + function appUsageCount(entry) { + if (!entry || !manager || !manager.appUsageCounts) + return 0; + var key = entry.id || entry.name; + return manager.appUsageCounts[key] || 0; + } + + function applicationsToArray() { + var apps = DesktopEntries.applications; + var list = []; + if (!apps) + return list; + if (apps.values && apps.values.length !== undefined) { + for (var i = 0; i < apps.values.length; i++) { + list.push(apps.values[i]); + } + } else if (apps.length !== undefined) { + for (var j = 0; j < apps.length; j++) { + list.push(apps[j]); + } + } else if (apps.count !== undefined && apps.get) { + for (var k = 0; k < apps.count; k++) { + list.push(apps.get(k)); + } + } + return list; + } + + function rebuildAppEntries() { + var list = applicationsToArray(); + console.log("AppLauncher: rebuilding entries", list.length); + var filtered = []; + for (var i = 0; i < list.length; i++) { + var entry = list[i]; + if (!entry) + continue; + if (entry.noDisplay) + continue; + filtered.push(entry); + } + console.log("AppLauncher: filtered entries", filtered.length); + filtered.sort(function (a, b) { + var usageA = appUsageCount(a); + var usageB = appUsageCount(b); + if (usageA !== usageB) + return usageB - usageA; + var nameA = appName(a); + var nameB = appName(b); + return nameA.localeCompare(nameB); + }); + appEntriesModel.clear(); + for (var i = 0; i < filtered.length; i++) { + appEntriesModel.append({ + entry: filtered[i] + }); + } + clampSelection(); + ensureVisible(); + } + + Connections { + target: DesktopEntries + function onApplicationsChanged() { + rebuildAppEntries(); + } + } + + Connections { + target: manager + ignoreUnknownSignals: true + function onAppUsageCountsChanged() { + rebuildAppEntries(); + } + function onAppLauncherOpened() { + activeSelection = 0; + isSelected = true; + clampSelection(); + } + } + + Text { + text: "APPS" + font.family: "8bitoperator JVE" + font.pixelSize: 71 + renderType: Text.NativeRendering + font.hintingPreference: Font.PreferNoHinting + smooth: false + antialiasing: false + anchors.horizontalCenter: parent.horizontalCenter + color: "#ffffff" + y: 32 + } + + Repeater { + model: appEntriesModel + + delegate: Item { + width: root.width + height: lineHeight + x: 0 + y: menuTop + (index - root.scrollOffset) * lineHeight + visible: index >= root.scrollOffset && index < root.scrollOffset + root.visibleRows + + Text { + x: root.textStartX + y: 0 + text: root.appName(model.entry) + width: root.width - root.textStartX - root.textRightPadding + font.family: "8bitoperator JVE" + font.pixelSize: 71 + font.letterSpacing: 1 + renderType: Text.NativeRendering + font.hintingPreference: Font.PreferNoHinting + smooth: false + antialiasing: false + wrapMode: Text.NoWrap + elide: Text.ElideRight + color: (root.activeSelection == index && root.isSelected == true) ? "#fefe00" : "#ffffff" + } + + IconImage { + id: appIcon + x: 182 + y: 8 + 14 + implicitSize: root.iconSize + source: Quickshell.iconPath(model.entry && model.entry.icon ? model.entry.icon : "") + asynchronous: true + } + + Image { + source: "../QuickSettings/soul.png" + width: 36 + height: 36 + x: appIcon.x + y: appIcon.y + opacity: 0.7 + visible: root.activeSelection == index + } + } + } + + /* ------------------------------ + INPUT HANDLING + ------------------------------ */ + + function handleKey(key) { + switch (key) { + case Qt.Key_Up: + activeSelection = wrapIndex(activeSelection - 1); + ensureVisible(); + return true; + case Qt.Key_Down: + activeSelection = wrapIndex(activeSelection + 1); + ensureVisible(); + return true; + case Qt.Key_Z: + case Qt.Key_Return: + case Qt.Key_Enter: + { + var entry = menuAt(activeSelection); + if (entry) { + if (manager && manager.bumpAppUsage) { + manager.bumpAppUsage(entry.id || entry.name); + } + entry.execute(); + } + if (manager && manager.closeShell) { + manager.closeShell(); + } + return true; + } + case Qt.Key_X: + case Qt.Key_Shift: + case Qt.Key_Escape: + if (manager && manager.closeAppLauncher) { + manager.closeAppLauncher(); + } + return true; + } + return false; + } + + Component.onCompleted: { + activeSelection = 0; + isSelected = true; + scrollOffset = 0; + rebuildAppEntries(); + Qt.callLater(rebuildAppEntries); + ShellInputManager.registerHandler("appLauncher", handleKey); + } + + Component.onDestruction: { + ShellInputManager.unregisterHandler("appLauncher"); + } +}