1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-05-15 00:32:47 -04:00
Files
DankMaterialShell/quickshell/Modals/AppPickerModal.qml
T
bbedward 018795125e app picker: extend App Picker to integrate with mime overrides
- Adds "DMS Opener" as an option (dms-open.desktop)
- Add mime type GO utils
- Add rememberance to App Picker modal
2026-05-14 13:06:22 -04:00

551 lines
22 KiB
QML

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
property string mimeType: ""
property var rememberMimeTypes: []
property bool rememberChoice: false
property var mimeMatchedAppIds: []
signal applicationSelected(var app, string targetData)
function _normAppId(id) {
if (!id)
return "";
return id.replace(/\.desktop$/, "").toLowerCase();
}
shouldBeVisible: false
allowStacking: true
modalWidth: 520
modalHeight: 500
onBackgroundClicked: close()
onDialogClosed: {
searchQuery = "";
selectedIndex = 0;
keyboardNavigationActive = false;
}
onOpened: {
searchQuery = "";
rememberChoice = false;
fetchMimeMatches();
updateApplicationList();
selectedIndex = 0;
Qt.callLater(() => {
if (contentLoader.item && contentLoader.item.searchField) {
contentLoader.item.searchField.text = "";
contentLoader.item.searchField.forceActiveFocus();
}
});
}
function fetchMimeMatches() {
mimeMatchedAppIds = [];
const queriedMime = mimeType;
if (queriedMime.length === 0)
return;
DMSService.sendRequest("mime.appsForMime", {
"mimeType": queriedMime
}, response => {
if (queriedMime !== root.mimeType)
return;
if (response.error) {
log.warn("mime.appsForMime failed:", response.error);
return;
}
const ids = (response.result && response.result.desktopIds) || [];
mimeMatchedAppIds = ids.map(_normAppId);
updateApplicationList();
});
}
function _appMatchesMime(app, mime) {
const list = app && (app.mimeTypes || app.mimeType);
return !!list && !!list.includes && list.includes(mime);
}
function updateApplicationList() {
applicationsModel.clear();
const apps = AppSearchService.applications;
const usageHistory = usageHistoryKey && SettingsData[usageHistoryKey] ? SettingsData[usageHistoryKey] : {};
const hasCategoryFilter = categoryFilter.length > 0;
const hasMime = mimeType.length > 0;
const hasMimeMatches = mimeMatchedAppIds.length > 0;
const lowerQuery = searchQuery.toLowerCase();
let filteredApps = [];
for (const app of apps) {
if (!app)
continue;
const appId = _normAppId(app.id || app.execString || app.exec || "");
const mimeIdMatch = hasMimeMatches && mimeMatchedAppIds.includes(appId);
const mimeFieldMatch = hasMime && _appMatchesMime(app, mimeType);
const mimeMatch = mimeIdMatch || mimeFieldMatch;
let categoryMatch = false;
if (hasCategoryFilter && app.categories) {
try {
for (const cat of app.categories) {
if (categoryFilter.includes(cat)) {
categoryMatch = true;
break;
}
}
} catch (e) {
log.warn("AppPicker: Error iterating categories for", app.name, ":", e);
continue;
}
}
const include = (!hasCategoryFilter && !hasMime) || mimeMatch || categoryMatch;
if (!include)
continue;
const name = app.name || "";
if (searchQuery !== "" && !name.toLowerCase().includes(lowerQuery))
continue;
filteredApps.push({
name: name,
icon: app.icon || "application-x-executable",
exec: app.exec || app.execString || "",
startupClass: app.startupWMClass || "",
appData: app,
mimeMatch: mimeMatch
});
}
filteredApps.sort((a, b) => {
if (a.mimeMatch !== b.mimeMatch) {
return a.mimeMatch ? -1 : 1;
}
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 (event.key === Qt.Key_Tab && root.mimeType.length > 0) {
root.rememberChoice = !root.rememberChoice;
event.accepted = true;
return;
}
if (applicationsModel.count === 0)
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;
}
if (root.mimeType && root.mimeType.length > 0) {
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
}
}
Item {
width: parent.width
height: 36
visible: root.mimeType.length > 0
DankToggle {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
checked: root.rememberChoice
text: I18n.tr("Always use this app for %1").arg(root.mimeType)
onToggled: checked => {
root.rememberChoice = checked;
}
}
}
}
function launchApplication(app) {
if (!app)
return;
if (root.rememberChoice && app.appId) {
const targets = (root.rememberMimeTypes && root.rememberMimeTypes.length > 0) ? root.rememberMimeTypes : (root.mimeType ? [root.mimeType] : []);
if (targets.length > 0) {
DesktopService.setDefaultAppForMimes(targets, app.appId);
}
}
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();
}
}
}
}