LauncherTab: add grid view option

Launcher: force clipboard history to list view
NGridView: created
This commit is contained in:
Ly-sec
2025-11-23 21:51:14 +01:00
parent 2f92445e8a
commit 04c8f5b54e
15 changed files with 760 additions and 159 deletions

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -1450,6 +1450,10 @@
"description": "Выберите, где появляется панель запуска.",
"label": "Положение"
},
"grid-view": {
"description": "Показывать элементы в виде сетки вместо списка.",
"label": "Вид сетки"
},
"section": {
"description": "Настройка поведения и внешнего вида запуска.",
"label": "Внешний вид"

View File

@@ -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"

View File

@@ -1450,6 +1450,10 @@
"description": "Виберіть, де з'являється панель запускача.",
"label": "Положення"
},
"grid-view": {
"description": "Показувати елементи у вигляді сітки замість списку.",
"label": "Вигляд сітки"
},
"section": {
"description": "Налаштуйте поведінку та зовнішній вигляд запускача.",
"label": "Зовнішній вигляд"

View File

@@ -1450,6 +1450,10 @@
"description": "选择启动器面板出现的位置。",
"label": "位置"
},
"grid-view": {
"description": "以网格布局而非列表显示项目。",
"label": "网格视图"
},
"section": {
"description": "自定义启动器的行为和外观。",
"label": "外观"

View File

@@ -148,7 +148,8 @@
"sortByMostUsed": true,
"terminalCommand": "xterm -e",
"customLaunchPrefixEnabled": false,
"customLaunchPrefix": ""
"customLaunchPrefix": "",
"viewMode": "list"
},
"controlCenter": {
"position": "close_to_bar_button",

View File

@@ -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

View File

@@ -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
}
}
}
}

View File

@@ -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")

186
Widgets/NGridView.qml Normal file
View File

@@ -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
}
}
}
}
}
}