From 04c8f5b54e15932b81a761124c5626b3fcc3a0ba Mon Sep 17 00:00:00 2001 From: Ly-sec Date: Sun, 23 Nov 2025 21:51:14 +0100 Subject: [PATCH] LauncherTab: add grid view option Launcher: force clipboard history to list view NGridView: created --- Assets/Translations/de.json | 4 + Assets/Translations/en.json | 4 + Assets/Translations/es.json | 4 + Assets/Translations/fr.json | 4 + Assets/Translations/nl.json | 4 + Assets/Translations/pt.json | 4 + Assets/Translations/ru.json | 4 + Assets/Translations/tr.json | 4 + Assets/Translations/uk-UA.json | 4 + Assets/Translations/zh-CN.json | 4 + Assets/settings-default.json | 3 +- Commons/Settings.qml | 2 + Modules/Panels/Launcher/Launcher.qml | 681 ++++++++++++++----- Modules/Panels/Settings/Tabs/LauncherTab.qml | 7 + Widgets/NGridView.qml | 186 +++++ 15 files changed, 760 insertions(+), 159 deletions(-) create mode 100644 Widgets/NGridView.qml diff --git a/Assets/Translations/de.json b/Assets/Translations/de.json index 26e45bc19..5c2a47f89 100644 --- a/Assets/Translations/de.json +++ b/Assets/Translations/de.json @@ -1450,6 +1450,10 @@ "description": "Wählen Sie, wo das Starter-Panel erscheint.", "label": "Position" }, + "grid-view": { + "description": "Elemente in einem Raster statt in einer Liste anzeigen.", + "label": "Rasteransicht" + }, "section": { "description": "Verhalten und Erscheinungsbild des Starters anpassen.", "label": "Erscheinungsbild" diff --git a/Assets/Translations/en.json b/Assets/Translations/en.json index 88a8b69c6..f2ad165d4 100644 --- a/Assets/Translations/en.json +++ b/Assets/Translations/en.json @@ -1450,6 +1450,10 @@ "description": "Choose where the launcher panel appears.", "label": "Position" }, + "grid-view": { + "description": "Display items in a grid layout instead of a list.", + "label": "Grid view" + }, "section": { "description": "Customize the launcher's behavior and appearance.", "label": "Appearance" diff --git a/Assets/Translations/es.json b/Assets/Translations/es.json index 52e538e7e..1501ab1c2 100644 --- a/Assets/Translations/es.json +++ b/Assets/Translations/es.json @@ -1450,6 +1450,10 @@ "description": "Elige dónde aparece el panel del lanzador.", "label": "Posición" }, + "grid-view": { + "description": "Mostrar elementos en una cuadrícula en lugar de una lista.", + "label": "Vista de cuadrícula" + }, "section": { "description": "Personaliza el comportamiento y la apariencia del lanzador.", "label": "Apariencia" diff --git a/Assets/Translations/fr.json b/Assets/Translations/fr.json index ce37824ad..2e14fb634 100644 --- a/Assets/Translations/fr.json +++ b/Assets/Translations/fr.json @@ -1450,6 +1450,10 @@ "description": "Choisissez où le panneau du lanceur apparaît.", "label": "Position" }, + "grid-view": { + "description": "Afficher les éléments dans une grille au lieu d'une liste.", + "label": "Vue grille" + }, "section": { "description": "Personnalisez le comportement et l'apparence du lanceur.", "label": "Apparence" diff --git a/Assets/Translations/nl.json b/Assets/Translations/nl.json index 36352925b..d6bc56047 100644 --- a/Assets/Translations/nl.json +++ b/Assets/Translations/nl.json @@ -1450,6 +1450,10 @@ "description": "Kies waar het launcher-paneel verschijnt.", "label": "Positie" }, + "grid-view": { + "description": "Items in een raster weergeven in plaats van een lijst.", + "label": "Rasterweergave" + }, "section": { "description": "Pas het gedrag en uiterlijk van de launcher aan.", "label": "Uiterlijk" diff --git a/Assets/Translations/pt.json b/Assets/Translations/pt.json index 1048696be..18ae19b73 100644 --- a/Assets/Translations/pt.json +++ b/Assets/Translations/pt.json @@ -1450,6 +1450,10 @@ "description": "Escolha onde o painel do lançador aparece.", "label": "Posição" }, + "grid-view": { + "description": "Exibir itens em uma grade em vez de uma lista.", + "label": "Visualização em grade" + }, "section": { "description": "Personalize o comportamento e a aparência do lançador.", "label": "Aparência" diff --git a/Assets/Translations/ru.json b/Assets/Translations/ru.json index 2215bbded..bf6fd0002 100644 --- a/Assets/Translations/ru.json +++ b/Assets/Translations/ru.json @@ -1450,6 +1450,10 @@ "description": "Выберите, где появляется панель запуска.", "label": "Положение" }, + "grid-view": { + "description": "Показывать элементы в виде сетки вместо списка.", + "label": "Вид сетки" + }, "section": { "description": "Настройка поведения и внешнего вида запуска.", "label": "Внешний вид" diff --git a/Assets/Translations/tr.json b/Assets/Translations/tr.json index a5fa3e546..f5f3ea4e4 100644 --- a/Assets/Translations/tr.json +++ b/Assets/Translations/tr.json @@ -1450,6 +1450,10 @@ "description": "Başlatıcı panelinin nerede görüneceğini seçin.", "label": "Konum" }, + "grid-view": { + "description": "Öğeleri liste yerine ızgara düzeninde görüntüle.", + "label": "Izgara görünümü" + }, "section": { "description": "Başlatıcının davranışını ve görünümünü özelleştirin.", "label": "Görünüm" diff --git a/Assets/Translations/uk-UA.json b/Assets/Translations/uk-UA.json index ae5963f1a..e224716d9 100644 --- a/Assets/Translations/uk-UA.json +++ b/Assets/Translations/uk-UA.json @@ -1450,6 +1450,10 @@ "description": "Виберіть, де з'являється панель запускача.", "label": "Положення" }, + "grid-view": { + "description": "Показувати елементи у вигляді сітки замість списку.", + "label": "Вигляд сітки" + }, "section": { "description": "Налаштуйте поведінку та зовнішній вигляд запускача.", "label": "Зовнішній вигляд" diff --git a/Assets/Translations/zh-CN.json b/Assets/Translations/zh-CN.json index 4d5b858f3..8dd03d332 100644 --- a/Assets/Translations/zh-CN.json +++ b/Assets/Translations/zh-CN.json @@ -1450,6 +1450,10 @@ "description": "选择启动器面板出现的位置。", "label": "位置" }, + "grid-view": { + "description": "以网格布局而非列表显示项目。", + "label": "网格视图" + }, "section": { "description": "自定义启动器的行为和外观。", "label": "外观" diff --git a/Assets/settings-default.json b/Assets/settings-default.json index 11031e130..ba40bd02c 100644 --- a/Assets/settings-default.json +++ b/Assets/settings-default.json @@ -148,7 +148,8 @@ "sortByMostUsed": true, "terminalCommand": "xterm -e", "customLaunchPrefixEnabled": false, - "customLaunchPrefix": "" + "customLaunchPrefix": "", + "viewMode": "list" }, "controlCenter": { "position": "close_to_bar_button", diff --git a/Commons/Settings.qml b/Commons/Settings.qml index 02fe889fc..940837df8 100644 --- a/Commons/Settings.qml +++ b/Commons/Settings.qml @@ -306,6 +306,8 @@ Singleton { property string terminalCommand: "xterm -e" property bool customLaunchPrefixEnabled: false property string customLaunchPrefix: "" + // View mode: "list" or "grid" + property string viewMode: "list" } // control center diff --git a/Modules/Panels/Launcher/Launcher.qml b/Modules/Panels/Launcher/Launcher.qml index 459bf90da..e1cc31edc 100644 --- a/Modules/Panels/Launcher/Launcher.qml +++ b/Modules/Panels/Launcher/Launcher.qml @@ -55,6 +55,21 @@ SmartPanel { readonly property int badgeSize: Math.round(Style.baseWidgetSize * 1.6) readonly property int entryHeight: Math.round(badgeSize + Style.marginM * 2) + readonly property bool isGridView: { + // Always use list view for clipboard to better display text content and previews + if (searchText.startsWith(">clip")) { + return false; + } + return Settings.data.appLauncher.viewMode === "grid"; + } + + // Target columns, but actual columns may vary based on available width + readonly property int targetGridColumns: 5 + readonly property int gridCellSize: Math.floor((listPanelWidth - Style.marginS - (targetGridColumns * Style.marginXXS)) / targetGridColumns) + + // Actual columns that fit in the GridView + // This gets updated dynamically by the GridView when its actual width is known + property int gridColumns: 5 // Override keyboard handlers from SmartPanel for navigation. // Launcher specific: onTabPressed() and onBackTabPressed() are special here. @@ -69,11 +84,43 @@ SmartPanel { } function onUpPressed() { - selectPreviousWrapped(); + if (isGridView) { + // Force update to prevent GridView interference + Qt.callLater(() => { + selectPreviousRow(); + }); + } else { + selectPreviousWrapped(); + } } function onDownPressed() { - selectNextWrapped(); + if (isGridView) { + // Force update to prevent GridView interference + Qt.callLater(() => { + selectNextRow(); + }); + } else { + selectNextWrapped(); + } + } + + function onLeftPressed() { + if (isGridView) { + selectPreviousColumn(); + } else { + // In list view, left = previous item + selectPreviousWrapped(); + } + } + + function onRightPressed() { + if (isGridView) { + selectNextColumn(); + } else { + // In list view, right = next item + selectNextWrapped(); + } } function onReturnPressed() { @@ -267,6 +314,112 @@ SmartPanel { } } + // Grid view navigation functions + function selectPreviousRow() { + if (results.length > 0 && isGridView) { + const currentRow = Math.floor(selectedIndex / gridColumns); + const currentCol = selectedIndex % gridColumns; + + if (currentRow > 0) { + // Move to previous row, same column + const targetRow = currentRow - 1; + const targetIndex = targetRow * gridColumns + currentCol; + // Check if target column exists in target row + const itemsInTargetRow = Math.min(gridColumns, results.length - targetRow * gridColumns); + if (currentCol < itemsInTargetRow) { + selectedIndex = targetIndex; + } else { + // Target column doesn't exist, go to last item in target row + selectedIndex = targetRow * gridColumns + itemsInTargetRow - 1; + } + } else { + // Wrap to last row, same column + const totalRows = Math.ceil(results.length / gridColumns); + const lastRow = totalRows - 1; + const itemsInLastRow = Math.min(gridColumns, results.length - lastRow * gridColumns); + if (currentCol < itemsInLastRow) { + selectedIndex = lastRow * gridColumns + currentCol; + } else { + selectedIndex = results.length - 1; + } + } + } + } + + function selectNextRow() { + if (results.length > 0 && isGridView) { + const currentRow = Math.floor(selectedIndex / gridColumns); + const currentCol = selectedIndex % gridColumns; + const totalRows = Math.ceil(results.length / gridColumns); + + if (currentRow < totalRows - 1) { + // Move to next row, same column + const targetRow = currentRow + 1; + const targetIndex = targetRow * gridColumns + currentCol; + + // Check if target index is valid + if (targetIndex < results.length) { + selectedIndex = targetIndex; + } else { + // Target column doesn't exist in target row, go to last item in target row + const itemsInTargetRow = results.length - targetRow * gridColumns; + if (itemsInTargetRow > 0) { + selectedIndex = targetRow * gridColumns + itemsInTargetRow - 1; + } else { + // Target row is empty, wrap to first row + selectedIndex = Math.min(currentCol, results.length - 1); + } + } + } else { + // Wrap to first row, same column + selectedIndex = Math.min(currentCol, results.length - 1); + } + } + } + + function selectPreviousColumn() { + if (results.length > 0 && isGridView) { + const currentRow = Math.floor(selectedIndex / gridColumns); + const currentCol = selectedIndex % gridColumns; + if (currentCol > 0) { + // Move left in same row + selectedIndex = currentRow * gridColumns + (currentCol - 1); + } else { + // Wrap to last column of previous row + if (currentRow > 0) { + selectedIndex = (currentRow - 1) * gridColumns + (gridColumns - 1); + } else { + // Wrap to last column of last row + const totalRows = Math.ceil(results.length / gridColumns); + const lastRowIndex = (totalRows - 1) * gridColumns + (gridColumns - 1); + selectedIndex = Math.min(lastRowIndex, results.length - 1); + } + } + } + } + + function selectNextColumn() { + if (results.length > 0 && isGridView) { + const currentRow = Math.floor(selectedIndex / gridColumns); + const currentCol = selectedIndex % gridColumns; + const itemsInCurrentRow = Math.min(gridColumns, results.length - currentRow * gridColumns); + + if (currentCol < itemsInCurrentRow - 1) { + // Move right in same row + selectedIndex = currentRow * gridColumns + (currentCol + 1); + } else { + // Wrap to first column of next row + const totalRows = Math.ceil(results.length / gridColumns); + if (currentRow < totalRows - 1) { + selectedIndex = (currentRow + 1) * gridColumns; + } else { + // Wrap to first item + selectedIndex = 0; + } + } + } + } + function activate() { if (results.length > 0 && results[selectedIndex]) { const item = results[selectedIndex]; @@ -288,9 +441,16 @@ SmartPanel { width: root.previewPanelWidth height: Math.round(400 * Style.uiScaleRatio) x: ui.width + Style.marginM - y: Math.max(Style.marginL // Minimum y is the top margin of the content area - , Math.min(resultsList.mapToItem(ui, 0, (root.selectedIndex * (root.entryHeight + resultsList.spacing)) - resultsList.contentY).y, ui.height - previewBox.height - Style.marginL // Maximum y, considering bottom margin - )) + y: { + if (!resultsViewLoader.item) + return Style.marginL; + const view = resultsViewLoader.item; + const row = root.isGridView ? Math.floor(root.selectedIndex / root.gridColumns) : root.selectedIndex; + const itemHeight = root.isGridView ? (root.gridCellSize + Style.marginXXS) : (root.entryHeight + view.spacing); + const yPos = row * itemHeight - view.contentY; + const mapped = view.mapToItem(ui, 0, yPos); + return Math.max(Style.marginL, Math.min(mapped.y, ui.height - previewBox.height - Style.marginL)); + } z: -1 // Draw behind main panel content if it ever overlaps opacity: visible ? 1.0 : 0.0 @@ -404,7 +564,7 @@ SmartPanel { Component.onCompleted: { if (searchInput.inputItem) { searchInput.inputItem.forceActiveFocus(); - // Intercept Tab keys before TextField handles them + // Intercept keys before TextField handles them searchInput.inputItem.Keys.onPressed.connect(function (event) { if (event.key === Qt.Key_Tab) { root.onTabPressed(); @@ -412,113 +572,365 @@ SmartPanel { } else if (event.key === Qt.Key_Backtab) { root.onBackTabPressed(); event.accepted = true; + } else if (event.key === Qt.Key_Left && root.isGridView) { + // In grid view, left arrow navigates the grid + root.onLeftPressed(); + event.accepted = true; + } else if (event.key === Qt.Key_Right && root.isGridView) { + // In grid view, right arrow navigates the grid + root.onRightPressed(); + event.accepted = true; } }); } } } - NListView { - id: resultsList - - horizontalPolicy: ScrollBar.AlwaysOff - verticalPolicy: ScrollBar.AsNeeded - + Loader { + id: resultsViewLoader Layout.fillWidth: true Layout.fillHeight: true - spacing: Style.marginXXS - model: results - currentIndex: selectedIndex - cacheBuffer: resultsList.height * 2 - onCurrentIndexChanged: { - cancelFlick(); - if (currentIndex >= 0) { - positionViewAtIndex(currentIndex, ListView.Contain); + sourceComponent: root.isGridView ? gridViewComponent : listViewComponent + } + + Component { + id: listViewComponent + NListView { + id: resultsList + + horizontalPolicy: ScrollBar.AlwaysOff + verticalPolicy: ScrollBar.AsNeeded + + width: parent.width + height: parent.height + spacing: Style.marginXXS + model: results + currentIndex: selectedIndex + cacheBuffer: resultsList.height * 2 + onCurrentIndexChanged: { + cancelFlick(); + if (currentIndex >= 0) { + positionViewAtIndex(currentIndex, ListView.Contain); + } + if (clipboardPreviewLoader.item) { + clipboardPreviewLoader.item.currentItem = results[currentIndex] || null; + } } - if (clipboardPreviewLoader.item) { - clipboardPreviewLoader.item.currentItem = results[currentIndex] || null; + onModelChanged: {} + + delegate: Rectangle { + id: entry + + property bool isSelected: (!root.ignoreMouseHover && mouseArea.containsMouse) || (index === selectedIndex) + 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. + property var currentClipboardId: modelData.isImage ? modelData.clipboardId : "" + + // When this delegate is assigned a new image item, trigger the decode. + onCurrentClipboardIdChanged: { + // Check if it's a valid ID and if the data isn't already cached. + if (currentClipboardId && !ClipboardService.getImageData(currentClipboardId)) { + ClipboardService.decodeToDataUrl(currentClipboardId, modelData.mime, null); + } + } + + width: resultsList.width - Style.marginS + implicitHeight: entryHeight + radius: Style.radiusM + color: entry.isSelected ? Color.mHover : Color.mSurface + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + easing.type: Easing.OutCirc + } + } + + ColumnLayout { + id: contentLayout + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginM + + // Top row - Main entry content with pin button + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + + // Icon badge or Image preview or Emoji + Rectangle { + Layout.preferredWidth: badgeSize + Layout.preferredHeight: badgeSize + radius: Style.radiusM + color: Color.mSurfaceVariant + + // Image preview for clipboard images + NImageRounded { + id: imagePreview + anchors.fill: parent + visible: modelData.isImage && !modelData.emojiChar + imageRadius: Style.radiusM + + // 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) || ""; + } + + Rectangle { + anchors.fill: parent + visible: parent.status === Image.Loading + color: Color.mSurfaceVariant + + BusyIndicator { + anchors.centerIn: parent + running: true + width: Style.baseWidgetSize * 0.5 + height: width + } + } + + onStatusChanged: status => { + if (status === Image.Error) { + iconLoader.visible = true; + imagePreview.visible = false; + } + } + } + + Loader { + id: iconLoader + anchors.fill: parent + anchors.margins: Style.marginXS + + visible: !modelData.isImage && !modelData.emojiChar || (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 !== "" && !modelData.emojiChar + asynchronous: true + } + } + } + + // Emoji display - takes precedence when emojiChar is present + NText { + id: emojiDisplay + anchors.centerIn: parent + visible: modelData.emojiChar ? true : (!imagePreview.visible && !iconLoader.visible) + text: modelData.emojiChar ? modelData.emojiChar : (modelData.name ? modelData.name.charAt(0).toUpperCase() : "?") + pointSize: modelData.emojiChar ? Style.fontSizeXXXL : Style.fontSizeXXL // Larger font for emojis + font.weight: Style.fontWeightBold + color: modelData.emojiChar ? Color.mOnSurface : Color.mOnPrimary // Different color for emojis + } + + // Image type indicator overlay + Rectangle { + visible: modelData.isImage && imagePreview.visible + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.margins: 2 + width: formatLabel.width + 6 + height: formatLabel.height + 2 + radius: Style.radiusM + 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"; + } + pointSize: Style.fontSizeXXS + color: Color.mPrimary + } + } + } + + // Text content + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + NText { + text: modelData.name || "Unknown" + pointSize: Style.fontSizeL + font.weight: Style.fontWeightBold + color: entry.isSelected ? Color.mOnHover : Color.mOnSurface + elide: Text.ElideRight + Layout.fillWidth: true + } + + NText { + text: modelData.description || "" + pointSize: Style.fontSizeS + color: entry.isSelected ? Color.mOnHover : 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) + } + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + z: -1 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: { + if (!root.ignoreMouseHover) { + selectedIndex = index; + } + } + onClicked: mouse => { + if (mouse.button === Qt.LeftButton) { + selectedIndex = index; + root.activate(); + mouse.accepted = true; + } + } + acceptedButtons: Qt.LeftButton + } } } - onModelChanged: {} + } - delegate: Rectangle { - id: entry + Component { + id: gridViewComponent + NGridView { + id: resultsGrid - property bool isSelected: (!root.ignoreMouseHover && mouseArea.containsMouse) || (index === selectedIndex) - property string appId: (modelData && modelData.appId) ? String(modelData.appId) : "" + horizontalPolicy: ScrollBar.AlwaysOff + verticalPolicy: ScrollBar.AsNeeded - // 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; - } + width: parent.width + height: parent.height + cellWidth: gridCellSize + Style.marginXXS + cellHeight: gridCellSize + Style.marginXXS + model: results + cacheBuffer: resultsGrid.height * 2 + keyNavigationEnabled: false + focus: false + interactive: true - 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. - property var currentClipboardId: modelData.isImage ? modelData.clipboardId : "" - - // When this delegate is assigned a new image item, trigger the decode. - onCurrentClipboardIdChanged: { - // Check if it's a valid ID and if the data isn't already cached. - if (currentClipboardId && !ClipboardService.getImageData(currentClipboardId)) { - ClipboardService.decodeToDataUrl(currentClipboardId, modelData.mime, null); + onWidthChanged: { + // Update gridColumns based on actual GridView width + // This ensures navigation works correctly regardless of panel size + const actualCols = Math.floor(width / cellWidth); + if (actualCols > 0 && actualCols !== root.gridColumns) { + root.gridColumns = actualCols; } } - width: resultsList.width - Style.marginS - implicitHeight: entryHeight - radius: Style.radiusM - color: entry.isSelected ? Color.mHover : Color.mSurface + // Completely disable GridView key handling + Keys.enabled: false - Behavior on color { - ColorAnimation { - duration: Style.animationFast - easing.type: Easing.OutCirc + // Don't sync selectedIndex to GridView's currentIndex + // The visual selection is handled by the delegate based on selectedIndex + // We only need to position the view to show the selected item + + onModelChanged: {} + + // Handle scrolling to show selected item when it changes + Connections { + target: root + function onSelectedIndexChanged() { + if (root.selectedIndex >= 0) { + Qt.callLater(() => { + resultsGrid.cancelFlick(); + resultsGrid.positionViewAtIndex(root.selectedIndex, GridView.Contain); + }); + } + + // Update preview + if (clipboardPreviewLoader.item && root.selectedIndex >= 0) { + clipboardPreviewLoader.item.currentItem = results[root.selectedIndex] || null; + } } } - ColumnLayout { - id: contentLayout - anchors.fill: parent - anchors.margins: Style.marginM - spacing: Style.marginM + delegate: Rectangle { + id: gridEntry - // Top row - Main entry content with pin button - RowLayout { - Layout.fillWidth: true - spacing: Style.marginM + property bool isSelected: (!root.ignoreMouseHover && mouseArea.containsMouse) || (index === selectedIndex) + property string appId: (modelData && modelData.appId) ? String(modelData.appId) : "" + + width: gridCellSize + height: gridCellSize + radius: Style.radiusM + color: gridEntry.isSelected ? Color.mHover : Color.mSurface + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + easing.type: Easing.OutCirc + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginS // Icon badge or Image preview or Emoji Rectangle { - Layout.preferredWidth: badgeSize - Layout.preferredHeight: badgeSize + Layout.preferredWidth: badgeSize * 1.5 + Layout.preferredHeight: badgeSize * 1.5 + Layout.alignment: Qt.AlignHCenter radius: Style.radiusM color: Color.mSurfaceVariant // Image preview for clipboard images NImageRounded { - id: imagePreview + id: gridImagePreview anchors.fill: parent visible: modelData.isImage && !modelData.emojiChar imageRadius: Style.radiusM - // 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) || ""; @@ -539,18 +951,18 @@ SmartPanel { onStatusChanged: status => { if (status === Image.Error) { - iconLoader.visible = true; - imagePreview.visible = false; + gridIconLoader.visible = true; + gridImagePreview.visible = false; } } } Loader { - id: iconLoader + id: gridIconLoader anchors.fill: parent anchors.margins: Style.marginXS - visible: !modelData.isImage && !modelData.emojiChar || (modelData.isImage && imagePreview.status === Image.Error) + visible: !modelData.isImage && !modelData.emojiChar || (modelData.isImage && gridImagePreview.status === Image.Error) active: visible sourceComponent: Component { @@ -563,98 +975,51 @@ SmartPanel { } } - // Emoji display - takes precedence when emojiChar is present + // Emoji display NText { - id: emojiDisplay + id: gridEmojiDisplay anchors.centerIn: parent - visible: modelData.emojiChar ? true : (!imagePreview.visible && !iconLoader.visible) + visible: modelData.emojiChar ? true : (!gridImagePreview.visible && !gridIconLoader.visible) text: modelData.emojiChar ? modelData.emojiChar : (modelData.name ? modelData.name.charAt(0).toUpperCase() : "?") - pointSize: modelData.emojiChar ? Style.fontSizeXXXL : Style.fontSizeXXL // Larger font for emojis + pointSize: modelData.emojiChar ? Style.fontSizeXXL : Style.fontSizeXL font.weight: Style.fontWeightBold - color: modelData.emojiChar ? Color.mOnSurface : Color.mOnPrimary // Different color for emojis - } - - // Image type indicator overlay - Rectangle { - visible: modelData.isImage && imagePreview.visible - anchors.bottom: parent.bottom - anchors.right: parent.right - anchors.margins: 2 - width: formatLabel.width + 6 - height: formatLabel.height + 2 - radius: Style.radiusM - 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"; - } - pointSize: Style.fontSizeXXS - color: Color.mPrimary - } + color: modelData.emojiChar ? Color.mOnSurface : Color.mOnPrimary } } // Text content - ColumnLayout { + NText { + text: modelData.name || "Unknown" + pointSize: Style.fontSizeS + font.weight: Style.fontWeightSemiBold + color: gridEntry.isSelected ? Color.mOnHover : Color.mOnSurface + elide: Text.ElideRight Layout.fillWidth: true - spacing: 0 - - NText { - text: modelData.name || "Unknown" - pointSize: Style.fontSizeL - font.weight: Style.fontWeightBold - color: entry.isSelected ? Color.mOnHover : Color.mOnSurface - elide: Text.ElideRight - Layout.fillWidth: true - } - - NText { - text: modelData.description || "" - pointSize: Style.fontSizeS - color: entry.isSelected ? Color.mOnHover : 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) + Layout.maximumWidth: gridCellSize - Style.marginM * 2 + horizontalAlignment: Text.AlignHCenter } } - } - MouseArea { - id: mouseArea - anchors.fill: parent - z: -1 - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onEntered: { - if (!root.ignoreMouseHover) { - selectedIndex = index; + MouseArea { + id: mouseArea + anchors.fill: parent + z: -1 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: { + if (!root.ignoreMouseHover) { + selectedIndex = index; + } } - } - onClicked: mouse => { - if (mouse.button === Qt.LeftButton) { - selectedIndex = index; - root.activate(); - mouse.accepted = true; + onClicked: mouse => { + if (mouse.button === Qt.LeftButton) { + selectedIndex = index; + root.activate(); + mouse.accepted = true; + } } - } - acceptedButtons: Qt.LeftButton + acceptedButtons: Qt.LeftButton + } } } } diff --git a/Modules/Panels/Settings/Tabs/LauncherTab.qml b/Modules/Panels/Settings/Tabs/LauncherTab.qml index 2edbbf62c..ded58b119 100644 --- a/Modules/Panels/Settings/Tabs/LauncherTab.qml +++ b/Modules/Panels/Settings/Tabs/LauncherTab.qml @@ -58,6 +58,13 @@ ColumnLayout { } } + NToggle { + label: I18n.tr("settings.launcher.settings.grid-view.label") + description: I18n.tr("settings.launcher.settings.grid-view.description") + checked: Settings.data.appLauncher.viewMode === "grid" + onToggled: checked => Settings.data.appLauncher.viewMode = checked ? "grid" : "list" + } + NToggle { label: I18n.tr("settings.launcher.settings.clipboard-history.label") description: I18n.tr("settings.launcher.settings.clipboard-history.description") diff --git a/Widgets/NGridView.qml b/Widgets/NGridView.qml new file mode 100644 index 000000000..8f588c3ea --- /dev/null +++ b/Widgets/NGridView.qml @@ -0,0 +1,186 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Templates as T +import qs.Commons + +Item { + id: root + + // Intercept all key events at the root level to prevent GridView from handling them + Keys.onPressed: event => { + // Don't let this event reach the GridView + event.accepted = false; + } + + Keys.onReleased: event => { + event.accepted = false; + } + + property color handleColor: Qt.alpha(Color.mHover, 0.8) + property color handleHoverColor: handleColor + property color handlePressedColor: handleColor + property color trackColor: Color.transparent + property real handleWidth: 6 + property real handleRadius: Style.radiusM + property int verticalPolicy: ScrollBar.AsNeeded + property int horizontalPolicy: ScrollBar.AlwaysOff + readonly property bool verticalScrollBarActive: { + if (gridView.ScrollBar.vertical.policy === ScrollBar.AlwaysOff) + return false; + return gridView.contentHeight > gridView.height; + } + readonly property real scrollBarWidth: verticalScrollBarActive ? handleWidth : 0 + + // Forward GridView properties + property alias model: gridView.model + property alias delegate: gridView.delegate + property alias cellWidth: gridView.cellWidth + property alias cellHeight: gridView.cellHeight + property alias currentIndex: gridView.currentIndex + property alias count: gridView.count + property alias contentHeight: gridView.contentHeight + property alias contentWidth: gridView.contentWidth + property alias contentY: gridView.contentY + property alias contentX: gridView.contentX + property alias currentItem: gridView.currentItem + property alias highlightItem: gridView.highlightItem + property alias highlightFollowsCurrentItem: gridView.highlightFollowsCurrentItem + property alias preferredHighlightBegin: gridView.preferredHighlightBegin + property alias preferredHighlightEnd: gridView.preferredHighlightEnd + property alias highlightRangeMode: gridView.highlightRangeMode + property alias snapMode: gridView.snapMode + property alias keyNavigationEnabled: gridView.keyNavigationEnabled + property alias keyNavigationWraps: gridView.keyNavigationWraps + property alias cacheBuffer: gridView.cacheBuffer + property alias displayMarginBeginning: gridView.displayMarginBeginning + property alias displayMarginEnd: gridView.displayMarginEnd + property alias layoutDirection: gridView.layoutDirection + property alias effectiveLayoutDirection: gridView.effectiveLayoutDirection + property alias flow: gridView.flow + property alias boundsBehavior: gridView.boundsBehavior + property alias flickableDirection: gridView.flickableDirection + property alias interactive: gridView.interactive + property alias moving: gridView.moving + property alias flicking: gridView.flicking + property alias dragging: gridView.dragging + property alias horizontalVelocity: gridView.horizontalVelocity + property alias verticalVelocity: gridView.verticalVelocity + + // Forward GridView methods + function positionViewAtIndex(index, mode) { + gridView.positionViewAtIndex(index, mode); + } + + function positionViewAtBeginning() { + gridView.positionViewAtBeginning(); + } + + function positionViewAtEnd() { + gridView.positionViewAtEnd(); + } + + function forceLayout() { + gridView.forceLayout(); + } + + function cancelFlick() { + gridView.cancelFlick(); + } + + function flick(xVelocity, yVelocity) { + gridView.flick(xVelocity, yVelocity); + } + + function incrementCurrentIndex() { + gridView.incrementCurrentIndex(); + } + + function decrementCurrentIndex() { + gridView.decrementCurrentIndex(); + } + + function indexAt(x, y) { + return gridView.indexAt(x, y); + } + + function itemAt(x, y) { + return gridView.itemAt(x, y); + } + + function itemAtIndex(index) { + return gridView.itemAtIndex(index); + } + + // Set reasonable implicit sizes for Layout usage + implicitWidth: 200 + implicitHeight: 200 + + GridView { + id: gridView + anchors.fill: parent + + // Enable clipping to keep content within bounds + clip: true + + // Enable flickable for smooth scrolling + boundsBehavior: Flickable.StopAtBounds + + // Completely disable focus to prevent any keyboard interaction + focus: false + activeFocusOnTab: false + enabled: true // Still enabled for mouse interaction + + // Override key navigation - do nothing + Keys.onPressed: event => { + // Consume the event here so GridView doesn't process it + // but don't actually do anything + event.accepted = true; + } + + Keys.onReleased: event => { + event.accepted = true; + } + + ScrollBar.vertical: ScrollBar { + parent: gridView + x: gridView.mirrored ? 0 : gridView.width - width + y: 0 + height: gridView.height + policy: root.verticalPolicy + + contentItem: Rectangle { + implicitWidth: root.handleWidth + implicitHeight: 100 + radius: root.handleRadius + color: parent.pressed ? root.handlePressedColor : parent.hovered ? root.handleHoverColor : root.handleColor + opacity: parent.policy === ScrollBar.AlwaysOn ? 1.0 : root.verticalScrollBarActive ? (parent.active ? 1.0 : 0.0) : 0.0 + + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + } + } + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + } + } + + background: Rectangle { + implicitWidth: root.handleWidth + implicitHeight: 100 + color: root.trackColor + opacity: parent.policy === ScrollBar.AlwaysOn ? 0.3 : root.verticalScrollBarActive ? (parent.active ? 0.3 : 0.0) : 0.0 + radius: root.handleRadius / 2 + + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + } + } + } + } + } +}