import QtQuick import qs.Common import qs.Modals.Common import qs.Widgets import qs.Services DankModal { id: root readonly property var log: Log.scoped("AppPickerModal") property string title: I18n.tr("Select Application") property string targetData: "" property string targetDataLabel: "" property string searchQuery: "" property int selectedIndex: 0 property int gridColumns: SettingsData.appLauncherGridColumns property bool keyboardNavigationActive: false property string viewMode: "grid" property var categoryFilter: [] property var usageHistoryKey: "" property bool showTargetData: true signal applicationSelected(var app, string targetData) shouldBeVisible: false allowStacking: true modalWidth: 520 modalHeight: 500 onBackgroundClicked: close() onDialogClosed: { searchQuery = ""; selectedIndex = 0; keyboardNavigationActive = false; } onOpened: { searchQuery = ""; updateApplicationList(); selectedIndex = 0; Qt.callLater(() => { if (contentLoader.item && contentLoader.item.searchField) { contentLoader.item.searchField.text = ""; contentLoader.item.searchField.forceActiveFocus(); } }); } function updateApplicationList() { applicationsModel.clear(); const apps = AppSearchService.applications; const usageHistory = usageHistoryKey && SettingsData[usageHistoryKey] ? SettingsData[usageHistoryKey] : {}; let filteredApps = []; for (const app of apps) { if (!app || !app.categories) continue; let matchesCategory = categoryFilter.length === 0; if (categoryFilter.length > 0) { try { for (const cat of app.categories) { if (categoryFilter.includes(cat)) { matchesCategory = true; break; } } } catch (e) { log.warn("AppPicker: Error iterating categories for", app.name, ":", e); continue; } } if (matchesCategory) { const name = app.name || ""; const lowerName = name.toLowerCase(); const lowerQuery = searchQuery.toLowerCase(); if (searchQuery === "" || lowerName.includes(lowerQuery)) { filteredApps.push({ name: name, icon: app.icon || "application-x-executable", exec: app.exec || app.execString || "", startupClass: app.startupWMClass || "", appData: app }); } } } filteredApps.sort((a, b) => { const aId = a.appData.id || a.appData.execString || a.appData.exec || ""; const bId = b.appData.id || b.appData.execString || b.appData.exec || ""; const aUsage = usageHistory[aId] ? usageHistory[aId].count : 0; const bUsage = usageHistory[bId] ? usageHistory[bId].count : 0; if (aUsage !== bUsage) { return bUsage - aUsage; } return (a.name || "").localeCompare(b.name || ""); }); filteredApps.forEach(app => { applicationsModel.append({ name: app.name, icon: app.icon, exec: app.exec, startupClass: app.startupClass, appId: app.appData.id || app.appData.execString || app.appData.exec || "" }); }); log.debug("AppPicker: Found " + filteredApps.length + " applications"); } onSearchQueryChanged: updateApplicationList() ListModel { id: applicationsModel } content: Component { FocusScope { id: appContent property alias searchField: searchField anchors.fill: parent focus: true Keys.onEscapePressed: event => { root.close(); event.accepted = true; } Keys.onPressed: event => { if (applicationsModel.count === 0) return; // Toggle view mode with Tab key if (event.key === Qt.Key_Tab) { root.viewMode = root.viewMode === "grid" ? "list" : "grid"; event.accepted = true; return; } if (root.viewMode === "grid") { if (event.key === Qt.Key_Left) { root.keyboardNavigationActive = true; root.selectedIndex = Math.max(0, root.selectedIndex - 1); event.accepted = true; } else if (event.key === Qt.Key_Right) { root.keyboardNavigationActive = true; root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + 1); event.accepted = true; } else if (event.key === Qt.Key_Up) { root.keyboardNavigationActive = true; root.selectedIndex = Math.max(0, root.selectedIndex - root.gridColumns); event.accepted = true; } else if (event.key === Qt.Key_Down) { root.keyboardNavigationActive = true; root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + root.gridColumns); event.accepted = true; } } else { if (event.key === Qt.Key_Up) { root.keyboardNavigationActive = true; root.selectedIndex = Math.max(0, root.selectedIndex - 1); event.accepted = true; } else if (event.key === Qt.Key_Down) { root.keyboardNavigationActive = true; root.selectedIndex = Math.min(applicationsModel.count - 1, root.selectedIndex + 1); event.accepted = true; } } if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { if (root.selectedIndex >= 0 && root.selectedIndex < applicationsModel.count) { const app = applicationsModel.get(root.selectedIndex); launchApplication(app); } event.accepted = true; } } Column { width: parent.width - Theme.spacingS * 2 height: parent.height - Theme.spacingS * 2 x: Theme.spacingS y: Theme.spacingS spacing: Theme.spacingS Item { width: parent.width height: 40 StyledText { anchors.left: parent.left anchors.leftMargin: Theme.spacingS anchors.verticalCenter: parent.verticalCenter text: root.title font.pixelSize: Theme.fontSizeLarge + 4 font.weight: Font.Bold color: Theme.surfaceText } Row { spacing: 4 anchors.right: parent.right anchors.rightMargin: Theme.spacingS anchors.verticalCenter: parent.verticalCenter DankActionButton { buttonSize: 36 circular: false iconName: "view_list" iconSize: 20 iconColor: root.viewMode === "list" ? Theme.primary : Theme.surfaceText backgroundColor: root.viewMode === "list" ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent" onClicked: { root.viewMode = "list"; } } DankActionButton { buttonSize: 36 circular: false iconName: "grid_view" iconSize: 20 iconColor: root.viewMode === "grid" ? Theme.primary : Theme.surfaceText backgroundColor: root.viewMode === "grid" ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent" onClicked: { root.viewMode = "grid"; } } } } DankTextField { id: searchField width: parent.width - Theme.spacingS * 2 anchors.horizontalCenter: parent.horizontalCenter height: 52 cornerRadius: Theme.cornerRadius backgroundColor: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) normalBorderColor: Theme.outlineMedium focusedBorderColor: Theme.primary leftIconName: "search" leftIconSize: Theme.iconSize leftIconColor: Theme.surfaceVariantText leftIconFocusedColor: Theme.primary showClearButton: true font.pixelSize: Theme.fontSizeLarge enabled: root.shouldBeVisible ignoreLeftRightKeys: root.viewMode !== "list" ignoreTabKeys: true keyForwardTargets: [appContent] onTextEdited: { root.searchQuery = text; } Keys.onPressed: function (event) { if (event.key === Qt.Key_Escape) { root.close(); event.accepted = true; return; } const isEnterKey = [Qt.Key_Return, Qt.Key_Enter].includes(event.key); const hasText = text.length > 0; if (isEnterKey && hasText) { if (root.keyboardNavigationActive && applicationsModel.count > 0) { const app = applicationsModel.get(root.selectedIndex); launchApplication(app); } else if (applicationsModel.count > 0) { const app = applicationsModel.get(0); launchApplication(app); } event.accepted = true; return; } const navigationKeys = [Qt.Key_Down, Qt.Key_Up, Qt.Key_Left, Qt.Key_Right, Qt.Key_Tab, Qt.Key_Backtab]; const isNavigationKey = navigationKeys.includes(event.key); const isEmptyEnter = isEnterKey && !hasText; event.accepted = !(isNavigationKey || isEmptyEnter); } Connections { function onShouldBeVisibleChanged() { if (!root.shouldBeVisible) { searchField.focus = false; } } target: root } } Rectangle { width: parent.width height: { let usedHeight = 40 + Theme.spacingS; usedHeight += 52 + Theme.spacingS; if (root.showTargetData) { usedHeight += 36 + Theme.spacingS; } return parent.height - usedHeight; } radius: Theme.cornerRadius color: "transparent" DankListView { id: appList property int itemHeight: 60 property int itemSpacing: Theme.spacingS function ensureVisible(index) { if (index < 0 || index >= count) return; const itemY = index * (itemHeight + itemSpacing); const itemBottom = itemY + itemHeight; if (itemY < contentY) { contentY = itemY; } else if (itemBottom > contentY + height) { contentY = itemBottom - height; } } anchors.fill: parent anchors.leftMargin: Theme.spacingS anchors.rightMargin: Theme.spacingS anchors.bottomMargin: Theme.spacingS visible: root.viewMode === "list" model: applicationsModel currentIndex: root.selectedIndex clip: true spacing: itemSpacing onCurrentIndexChanged: { root.selectedIndex = currentIndex; if (root.keyboardNavigationActive) { ensureVisible(currentIndex); } } delegate: AppLauncherListDelegate { listView: appList itemHeight: 60 iconSize: 40 showDescription: false isCurrentItem: index === root.selectedIndex keyboardNavigationActive: root.keyboardNavigationActive hoverUpdatesSelection: true onItemClicked: (idx, modelData) => { launchApplication(modelData); } onKeyboardNavigationReset: { root.keyboardNavigationActive = false; } } } DankGridView { id: appGrid function ensureVisible(index) { if (index < 0 || index >= count) return; const itemY = Math.floor(index / root.gridColumns) * cellHeight; const itemBottom = itemY + cellHeight; if (itemY < contentY) { contentY = itemY; } else if (itemBottom > contentY + height) { contentY = itemBottom - height; } } anchors.fill: parent anchors.leftMargin: Theme.spacingS anchors.rightMargin: Theme.spacingS anchors.bottomMargin: Theme.spacingS visible: root.viewMode === "grid" model: applicationsModel cellWidth: width / root.gridColumns cellHeight: 120 clip: true currentIndex: root.selectedIndex onCurrentIndexChanged: { root.selectedIndex = currentIndex; if (root.keyboardNavigationActive) { ensureVisible(currentIndex); } } delegate: AppLauncherGridDelegate { gridView: appGrid cellWidth: appGrid.cellWidth cellHeight: appGrid.cellHeight currentIndex: root.selectedIndex keyboardNavigationActive: root.keyboardNavigationActive hoverUpdatesSelection: true onItemClicked: (idx, modelData) => { launchApplication(modelData); } onKeyboardNavigationReset: { root.keyboardNavigationActive = false; } } } } Rectangle { width: parent.width height: 36 radius: Theme.cornerRadius color: Theme.withAlpha(Theme.surfaceContainerHigh, 0.5) border.color: Theme.outlineMedium border.width: 1 visible: root.showTargetData && root.targetData.length > 0 StyledText { anchors.left: parent.left anchors.leftMargin: Theme.spacingM anchors.right: parent.right anchors.rightMargin: Theme.spacingM anchors.verticalCenter: parent.verticalCenter text: root.targetDataLabel.length > 0 ? root.targetDataLabel + ": " + root.targetData : root.targetData font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceTextMedium elide: Text.ElideMiddle wrapMode: Text.NoWrap maximumLineCount: 1 } } } function launchApplication(app) { if (!app) return; root.applicationSelected(app, root.targetData); if (usageHistoryKey && app.appId) { const usageHistory = SettingsData[usageHistoryKey] || {}; const currentCount = usageHistory[app.appId] ? usageHistory[app.appId].count : 0; usageHistory[app.appId] = { count: currentCount + 1, lastUsed: Date.now(), name: app.name }; SettingsData.set(usageHistoryKey, usageHistory); } root.close(); } } } }