diff --git a/Assets/Translations/de.json b/Assets/Translations/de.json index 03f22602..5a69f240 100644 --- a/Assets/Translations/de.json +++ b/Assets/Translations/de.json @@ -1155,6 +1155,10 @@ "close": "Schließen" } }, + "launcher": { + "pin": "An das Dock anheften", + "unpin": "Vom Dock lösen" + }, "placeholders": { "search-icons": "z.B. noctalia, niri, battery, cloud", "profile-picture-path": "/home/benutzer/.face", diff --git a/Assets/Translations/en.json b/Assets/Translations/en.json index 29ed9c5c..1915dcca 100644 --- a/Assets/Translations/en.json +++ b/Assets/Translations/en.json @@ -1138,6 +1138,10 @@ "close": "Close" } }, + "launcher": { + "pin": "Pin to dock", + "unpin": "Unpin from dock" + }, "placeholders": { "search-icons": "e.g., noctalia, niri, battery, cloud", "profile-picture-path": "/home/user/.face", diff --git a/Assets/Translations/es.json b/Assets/Translations/es.json index 29065f8a..398a5935 100644 --- a/Assets/Translations/es.json +++ b/Assets/Translations/es.json @@ -1136,6 +1136,10 @@ "close": "Cerrar" } }, + "launcher": { + "pin": "Anclar al dock", + "unpin": "Desanclar del dock" + }, "placeholders": { "search-icons": "ej., noctalia, niri, battery, cloud", "profile-picture-path": "/home/usuario/.face", diff --git a/Assets/Translations/fr.json b/Assets/Translations/fr.json index fab353cc..15aecc4f 100644 --- a/Assets/Translations/fr.json +++ b/Assets/Translations/fr.json @@ -1136,6 +1136,10 @@ "close": "Fermer" } }, + "launcher": { + "pin": "Épingler au dock", + "unpin": "Retirer du dock" + }, "placeholders": { "search-icons": "ex: noctalia, niri, batterie, nuage", "profile-picture-path": "/home/user/.face", diff --git a/Assets/Translations/pt.json b/Assets/Translations/pt.json index 8b3002ae..b37bd547 100644 --- a/Assets/Translations/pt.json +++ b/Assets/Translations/pt.json @@ -1136,6 +1136,10 @@ "close": "Fechar" } }, + "launcher": { + "pin": "Fixar no dock", + "unpin": "Desafixar do dock" + }, "placeholders": { "search-icons": "ex., noctalia, niri, battery, cloud", "profile-picture-path": "/home/usuario/.face", diff --git a/Assets/Translations/zh-CN.json b/Assets/Translations/zh-CN.json index d88f4eba..2fd17a68 100644 --- a/Assets/Translations/zh-CN.json +++ b/Assets/Translations/zh-CN.json @@ -1136,6 +1136,10 @@ "close": "关闭" } }, + "launcher": { + "pin": "固定到 Dock", + "unpin": "从 Dock 取消固定" + }, "placeholders": { "search-icons": "例如:noctalia, niri, battery, cloud", "profile-picture-path": "/home/user/.face", diff --git a/Commons/TablerIcons.qml b/Commons/TablerIcons.qml index b10e66f8..39743adb 100644 --- a/Commons/TablerIcons.qml +++ b/Commons/TablerIcons.qml @@ -71,6 +71,8 @@ Singleton { "chevron-down": "chevron-down", "caret-up": "caret-up-filled", "caret-down": "caret-down-filled", + "star": "star", + "star-off": "star-off", "battery-exclamation": "battery-exclamation", "battery-charging": "battery-charging", "battery-4": "battery-4", diff --git a/Modules/Launcher/Launcher.qml b/Modules/Launcher/Launcher.qml index 0714530f..5b705bd4 100644 --- a/Modules/Launcher/Launcher.qml +++ b/Modules/Launcher/Launcher.qml @@ -302,11 +302,29 @@ NPanel { positionViewAtIndex(currentIndex, ListView.Contain) } } + onModelChanged: {} delegate: Rectangle { id: entry property bool isSelected: mouseArea.containsMouse || (index === selectedIndex) + // Accessor for app id + property string appId: (modelData && modelData.appId) ? String(modelData.appId) : "" + + // Pin helpers + function togglePin(appId) { + if (!appId) return + let arr = (Settings.data.dock.pinnedApps || []).slice() + const idx = arr.indexOf(appId) + if (idx >= 0) arr.splice(idx, 1) + else arr.push(appId) + Settings.data.dock.pinnedApps = arr + } + + function isPinned(appId) { + const arr = Settings.data.dock.pinnedApps || [] + return appId && arr.indexOf(appId) >= 0 + } // Property to reliably track the current item's ID. // This changes whenever the delegate is recycled for a new item. @@ -321,7 +339,7 @@ NPanel { } width: resultsList.width - Style.marginS * scaling - height: entryHeight + implicitHeight: entryHeight radius: Style.radiusM * scaling color: entry.isSelected ? Color.mTertiary : Color.mSurface @@ -332,136 +350,152 @@ NPanel { } } - RowLayout { + ColumnLayout { + id: contentLayout anchors.fill: parent anchors.margins: Style.marginM * scaling spacing: Style.marginM * scaling - // Icon badge or Image preview - Rectangle { - Layout.preferredWidth: badgeSize - Layout.preferredHeight: badgeSize - radius: Style.radiusM * scaling - color: Color.mSurfaceVariant - clip: true + // Top row - Main entry content with pin button + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM * scaling - // Image preview for clipboard images - NImageRounded { - id: imagePreview - anchors.fill: parent - visible: modelData.isImage - imageRadius: Style.radiusM * scaling - - // This property creates a dependency on the service's revision counter - readonly property int _rev: ClipboardService.revision - - // Fetches from the service's cache. - // The dependency on `_rev` ensures this binding is re-evaluated when the cache is updated. - imagePath: { - _rev - return ClipboardService.getImageData(modelData.clipboardId) || "" - } - - // Loading indicator - Rectangle { - anchors.fill: parent - visible: parent.status === Image.Loading - color: Color.mSurfaceVariant - - BusyIndicator { - anchors.centerIn: parent - running: true - width: Style.baseWidgetSize * 0.5 * scaling - height: width - } - } - - // Error fallback - onStatusChanged: status => { - if (status === Image.Error) { - iconLoader.visible = true - imagePreview.visible = false - } - } - } - - // Icon fallback - Loader { - id: iconLoader - anchors.fill: parent - anchors.margins: Style.marginXS * scaling - - visible: !modelData.isImage || imagePreview.status === Image.Error - active: visible - - sourceComponent: Component { - IconImage { - anchors.fill: parent - source: modelData.icon ? ThemeIcons.iconFromName(modelData.icon, "application-x-executable") : "" - visible: modelData.icon && source !== "" - asynchronous: true - } - } - } - - // Fallback text if no icon and no image - NText { - anchors.centerIn: parent - visible: !imagePreview.visible && !iconLoader.visible - text: modelData.name ? modelData.name.charAt(0).toUpperCase() : "?" - font.pointSize: Style.fontSizeXXL * scaling - font.weight: Style.fontWeightBold - color: Color.mOnPrimary - } - - // Image type indicator overlay + // Icon badge or Image preview Rectangle { - visible: modelData.isImage && imagePreview.visible - anchors.bottom: parent.bottom - anchors.right: parent.right - anchors.margins: 2 * scaling - width: formatLabel.width + 6 * scaling - height: formatLabel.height + 2 * scaling + Layout.preferredWidth: badgeSize + Layout.preferredHeight: badgeSize radius: Style.radiusM * scaling color: Color.mSurfaceVariant + clip: true + // Image preview for clipboard images + NImageRounded { + id: imagePreview + anchors.fill: parent + visible: modelData.isImage + imageRadius: Style.radiusM * scaling + + // This property creates a dependency on the service's revision counter + readonly property int _rev: ClipboardService.revision + + // Fetches from the service's cache. + // The dependency on `_rev` ensures this binding is re-evaluated when the cache is updated. + imagePath: { + _rev + return ClipboardService.getImageData(modelData.clipboardId) || "" + } + + // Loading indicator + Rectangle { + anchors.fill: parent + visible: parent.status === Image.Loading + color: Color.mSurfaceVariant + + BusyIndicator { + anchors.centerIn: parent + running: true + width: Style.baseWidgetSize * 0.5 * scaling + height: width + } + } + + // Error fallback + onStatusChanged: status => { + if (status === Image.Error) { + iconLoader.visible = true + imagePreview.visible = false + } + } + } + + // Icon fallback + Loader { + id: iconLoader + anchors.fill: parent + anchors.margins: Style.marginXS * scaling + + visible: !modelData.isImage || imagePreview.status === Image.Error + active: visible + + sourceComponent: Component { + IconImage { + anchors.fill: parent + source: modelData.icon ? ThemeIcons.iconFromName(modelData.icon, "application-x-executable") : "" + visible: modelData.icon && source !== "" + asynchronous: true + } + } + } + + // Fallback text if no icon and no image NText { - id: formatLabel anchors.centerIn: parent - text: { - if (!modelData.isImage) - return "" - const desc = modelData.description || "" - const parts = desc.split(" • ") - return parts[0] || "IMG" + visible: !imagePreview.visible && !iconLoader.visible + text: modelData.name ? modelData.name.charAt(0).toUpperCase() : "?" + font.pointSize: Style.fontSizeXXL * scaling + font.weight: Style.fontWeightBold + color: Color.mOnPrimary + } + + // Image type indicator overlay + Rectangle { + visible: modelData.isImage && imagePreview.visible + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.margins: 2 * scaling + width: formatLabel.width + 6 * scaling + height: formatLabel.height + 2 * scaling + radius: Style.radiusM * scaling + color: Color.mSurfaceVariant + + NText { + id: formatLabel + anchors.centerIn: parent + text: { + if (!modelData.isImage) + return "" + const desc = modelData.description || "" + const parts = desc.split(" • ") + return parts[0] || "IMG" + } + font.pointSize: Style.fontSizeXXS * scaling + color: Color.mPrimary } - font.pointSize: Style.fontSizeXXS * scaling - color: Color.mPrimary } } - } - // Text content - ColumnLayout { - Layout.fillWidth: true - spacing: 0 * scaling - - NText { - text: modelData.name || "Unknown" - font.pointSize: Style.fontSizeL * scaling - font.weight: Style.fontWeightBold - color: entry.isSelected ? Color.mOnTertiary : Color.mOnSurface - elide: Text.ElideRight + // Text content + ColumnLayout { Layout.fillWidth: true + spacing: 0 * scaling + + NText { + text: modelData.name || "Unknown" + font.pointSize: Style.fontSizeL * scaling + font.weight: Style.fontWeightBold + color: entry.isSelected ? Color.mOnTertiary : Color.mOnSurface + elide: Text.ElideRight + Layout.fillWidth: true + } + + NText { + text: modelData.description || "" + font.pointSize: Style.fontSizeS * scaling + color: entry.isSelected ? Color.mOnTertiary : Color.mOnSurfaceVariant + elide: Text.ElideRight + Layout.fillWidth: true + visible: text !== "" + } } - NText { - text: modelData.description || "" - font.pointSize: Style.fontSizeS * scaling - color: entry.isSelected ? Color.mOnTertiary : Color.mOnSurfaceVariant - elide: Text.ElideRight - Layout.fillWidth: true - visible: text !== "" + // Pin/Unpin action icon button + NIconButton { + visible: !!entry.appId && !modelData.isImage && entry.isSelected && (Settings.data.dock.monitors && Settings.data.dock.monitors.length > 0) + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter + icon: entry.isPinned(entry.appId) ? "unpin" : "pin" + tooltipText: entry.isPinned(entry.appId) ? I18n.tr("launcher.unpin") : I18n.tr("launcher.pin") + onClicked: entry.togglePin(entry.appId) } } } @@ -469,12 +503,17 @@ NPanel { MouseArea { id: mouseArea anchors.fill: parent + z: -1 hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: { - selectedIndex = index - ui.activate() + onClicked: (mouse) => { + if (mouse.button === Qt.LeftButton) { + selectedIndex = index + ui.activate() + mouse.accepted = true + } } + acceptedButtons: Qt.LeftButton } } } @@ -498,4 +537,4 @@ NPanel { } } } -} +} \ No newline at end of file diff --git a/Modules/Launcher/Plugins/ApplicationsPlugin.qml b/Modules/Launcher/Plugins/ApplicationsPlugin.qml index 46d83094..4cbd7f1a 100644 --- a/Modules/Launcher/Plugins/ApplicationsPlugin.qml +++ b/Modules/Launcher/Plugins/ApplicationsPlugin.qml @@ -69,9 +69,14 @@ Item { if (!query || query.trim() === "") { // Return all apps, optionally sorted by usage + const favoriteApps = Settings.data.appLauncher.favoriteApps || [] let sorted if (Settings.data.appLauncher.sortByMostUsed) { sorted = entries.slice().sort((a, b) => { + // Favorites first + const aFav = favoriteApps.includes(getAppKey(a)) + const bFav = favoriteApps.includes(getAppKey(b)) + if (aFav !== bFav) return aFav ? -1 : 1 const ua = getUsageCount(a) const ub = getUsageCount(b) if (ub !== ua) @@ -79,7 +84,12 @@ Item { return (a.name || "").toLowerCase().localeCompare((b.name || "").toLowerCase()) }) } else { - sorted = entries.slice().sort((a, b) => (a.name || "").toLowerCase().localeCompare((b.name || "").toLowerCase())) + sorted = entries.slice().sort((a, b) => { + const aFav = favoriteApps.includes(getAppKey(a)) + const bFav = favoriteApps.includes(getAppKey(b)) + if (aFav !== bFav) return aFav ? -1 : 1 + return (a.name || "").toLowerCase().localeCompare((b.name || "").toLowerCase()) + }) } return sorted.map(app => createResultEntry(app)) } @@ -92,7 +102,16 @@ Item { "limit": 20 }) - return fuzzyResults.map(result => createResultEntry(result.obj)) + // Sort favorites first within fuzzy results while preserving fuzzysort order otherwise + const favoriteApps = Settings.data.appLauncher.favoriteApps || [] + const fav = [] + const nonFav = [] + for (const r of fuzzyResults) { + const app = r.obj + if (favoriteApps.includes(getAppKey(app))) fav.push(r) + else nonFav.push(r) + } + return fav.concat(nonFav).map(result => createResultEntry(result.obj)) } else { // Fallback to simple search const searchTerm = query.toLowerCase() @@ -118,6 +137,7 @@ Item { function createResultEntry(app) { return { + "appId": getAppKey(app), "name": app.name || "Unknown", "description": app.genericName || app.comment || "", "icon": app.icon || "application-x-executable",