feat(launcher): splitted in smaller files for easier maintainability. improved record usage.

This commit is contained in:
Lemmy
2026-03-09 21:50:50 -04:00
parent 44045fa020
commit 15decbe053
6 changed files with 734 additions and 684 deletions
+6 -11
View File
@@ -109,18 +109,13 @@ Singleton {
save();
}
// Set a usage count directly (used for migration/merging)
function recordLauncherUsageMerge(key, count) {
// Migrate usage from one key to another, merging counts in a single save
function migrateLauncherUsage(fromKey, toKey) {
let counts = Object.assign({}, adapter.launcherUsage || {});
counts[key] = count;
adapter.launcherUsage = counts;
save();
}
// Remove a usage key (used for cleaning up legacy keys after migration)
function clearLauncherUsage(key) {
let counts = Object.assign({}, adapter.launcherUsage || {});
delete counts[key];
const fromCount = typeof counts[fromKey] === 'number' && isFinite(counts[fromKey]) ? counts[fromKey] : 0;
const toCount = typeof counts[toKey] === 'number' && isFinite(counts[toKey]) ? counts[toKey] : 0;
counts[toKey] = toCount + fromCount;
delete counts[fromKey];
adapter.launcherUsage = counts;
save();
}
@@ -0,0 +1,134 @@
function selectNext(selectedIndex, resultsLength) {
if (resultsLength > 0 && selectedIndex < resultsLength - 1)
return selectedIndex + 1;
return selectedIndex;
}
function selectPrevious(selectedIndex, resultsLength) {
if (resultsLength > 0 && selectedIndex > 0)
return selectedIndex - 1;
return selectedIndex;
}
function selectNextWrapped(selectedIndex, resultsLength, allowWrap) {
if (resultsLength > 0) {
if (allowWrap)
return (selectedIndex + 1) % resultsLength;
return selectNext(selectedIndex, resultsLength);
}
return selectedIndex;
}
function selectPreviousWrapped(selectedIndex, resultsLength, allowWrap) {
if (resultsLength > 0) {
if (allowWrap)
return (((selectedIndex - 1) % resultsLength) + resultsLength) % resultsLength;
return selectPrevious(selectedIndex, resultsLength);
}
return selectedIndex;
}
function selectFirst() {
return 0;
}
function selectLast(resultsLength) {
return resultsLength > 0 ? resultsLength - 1 : 0;
}
function selectNextPage(selectedIndex, resultsLength, entryHeight) {
if (resultsLength > 0) {
var page = Math.max(1, Math.floor(600 / entryHeight));
return Math.min(selectedIndex + page, resultsLength - 1);
}
return selectedIndex;
}
function selectPreviousPage(selectedIndex, resultsLength, entryHeight) {
if (resultsLength > 0) {
var page = Math.max(1, Math.floor(600 / entryHeight));
return Math.max(selectedIndex - page, 0);
}
return selectedIndex;
}
function selectPreviousRow(selectedIndex, resultsLength, gridColumns) {
if (resultsLength <= 0 || gridColumns <= 0)
return selectedIndex;
var currentRow = Math.floor(selectedIndex / gridColumns);
var currentCol = selectedIndex % gridColumns;
if (currentRow > 0) {
var targetRow = currentRow - 1;
var itemsInTargetRow = Math.min(gridColumns, resultsLength - targetRow * gridColumns);
if (currentCol < itemsInTargetRow)
return targetRow * gridColumns + currentCol;
return targetRow * gridColumns + itemsInTargetRow - 1;
}
// Wrap to last row, same column
var totalRows = Math.ceil(resultsLength / gridColumns);
var lastRow = totalRows - 1;
var itemsInLastRow = Math.min(gridColumns, resultsLength - lastRow * gridColumns);
if (currentCol < itemsInLastRow)
return lastRow * gridColumns + currentCol;
return resultsLength - 1;
}
function selectNextRow(selectedIndex, resultsLength, gridColumns) {
if (resultsLength <= 0 || gridColumns <= 0)
return selectedIndex;
var currentRow = Math.floor(selectedIndex / gridColumns);
var currentCol = selectedIndex % gridColumns;
var totalRows = Math.ceil(resultsLength / gridColumns);
if (currentRow < totalRows - 1) {
var targetRow = currentRow + 1;
var targetIndex = targetRow * gridColumns + currentCol;
if (targetIndex < resultsLength)
return targetIndex;
var itemsInTargetRow = resultsLength - targetRow * gridColumns;
if (itemsInTargetRow > 0)
return targetRow * gridColumns + itemsInTargetRow - 1;
return Math.min(currentCol, resultsLength - 1);
}
// Wrap to first row, same column
return Math.min(currentCol, resultsLength - 1);
}
function selectPreviousColumn(selectedIndex, resultsLength, gridColumns) {
if (resultsLength <= 0)
return selectedIndex;
var currentRow = Math.floor(selectedIndex / gridColumns);
var currentCol = selectedIndex % gridColumns;
if (currentCol > 0)
return currentRow * gridColumns + (currentCol - 1);
if (currentRow > 0)
return (currentRow - 1) * gridColumns + (gridColumns - 1);
var totalRows = Math.ceil(resultsLength / gridColumns);
var lastRowIndex = (totalRows - 1) * gridColumns + (gridColumns - 1);
return Math.min(lastRowIndex, resultsLength - 1);
}
function selectNextColumn(selectedIndex, resultsLength, gridColumns) {
if (resultsLength <= 0)
return selectedIndex;
var currentRow = Math.floor(selectedIndex / gridColumns);
var currentCol = selectedIndex % gridColumns;
var itemsInCurrentRow = Math.min(gridColumns, resultsLength - currentRow * gridColumns);
if (currentCol < itemsInCurrentRow - 1)
return currentRow * gridColumns + (currentCol + 1);
var totalRows = Math.ceil(resultsLength / gridColumns);
if (currentRow < totalRows - 1)
return (currentRow + 1) * gridColumns;
return 0;
}
+25 -651
View File
@@ -3,6 +3,7 @@ import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
import "Helpers/LauncherNavigation.js" as LauncherNav
import "Providers"
import qs.Commons
@@ -311,11 +312,14 @@ Rectangle {
let sb = b._score !== undefined ? b._score : 0;
// Boost scores for frequently used items from tracked providers
// _score is normalized 01, so boost is scaled to nudge, not overwhelm
if (boostByUsage) {
if (a.provider && a.provider.trackUsage && a.usageKey)
sa += 100.0 * Math.log2(1 + ShellState.getLauncherUsageCount(a.usageKey));
if (b.provider && b.provider.trackUsage && b.usageKey)
sb += 100.0 * Math.log2(1 + ShellState.getLauncherUsageCount(b.usageKey));
if (a.provider && a.provider.trackUsage && a.usageKey) {
sa += 0.1 * Math.log2(1 + ShellState.getLauncherUsageCount(a.usageKey));
}
if (b.provider && b.provider.trackUsage && b.usageKey) {
sb += 0.1 * Math.log2(1 + ShellState.getLauncherUsageCount(b.usageKey));
}
}
return sb - sa;
@@ -329,149 +333,42 @@ Rectangle {
selectedIndex = 0;
}
// Navigation functions
// Navigation functions (delegated to LauncherNavigation.js)
function selectNext() {
if (results.length > 0 && selectedIndex < results.length - 1) {
selectedIndex++;
}
selectedIndex = LauncherNav.selectNext(selectedIndex, results.length);
}
function selectPrevious() {
if (results.length > 0 && selectedIndex > 0) {
selectedIndex--;
}
selectedIndex = LauncherNav.selectPrevious(selectedIndex, results.length);
}
function selectNextWrapped() {
if (results.length > 0) {
if (allowWrapNavigation) {
selectedIndex = (selectedIndex + 1) % results.length;
} else {
selectNext();
}
}
selectedIndex = LauncherNav.selectNextWrapped(selectedIndex, results.length, allowWrapNavigation);
}
function selectPreviousWrapped() {
if (results.length > 0) {
if (allowWrapNavigation) {
selectedIndex = (((selectedIndex - 1) % results.length) + results.length) % results.length;
} else {
selectPrevious();
}
}
selectedIndex = LauncherNav.selectPreviousWrapped(selectedIndex, results.length, allowWrapNavigation);
}
function selectFirst() {
selectedIndex = 0;
selectedIndex = LauncherNav.selectFirst();
}
function selectLast() {
selectedIndex = results.length > 0 ? results.length - 1 : 0;
selectedIndex = LauncherNav.selectLast(results.length);
}
function selectNextPage() {
if (results.length > 0) {
const page = Math.max(1, Math.floor(600 / entryHeight));
selectedIndex = Math.min(selectedIndex + page, results.length - 1);
}
selectedIndex = LauncherNav.selectNextPage(selectedIndex, results.length, entryHeight);
}
function selectPreviousPage() {
if (results.length > 0) {
const page = Math.max(1, Math.floor(600 / entryHeight));
selectedIndex = Math.max(selectedIndex - page, 0);
}
selectedIndex = LauncherNav.selectPreviousPage(selectedIndex, results.length, entryHeight);
}
// Grid view navigation functions
function selectPreviousRow() {
if (results.length > 0 && isGridView && gridColumns > 0) {
const currentRow = Math.floor(selectedIndex / gridColumns);
const currentCol = selectedIndex % gridColumns;
if (currentRow > 0) {
const targetRow = currentRow - 1;
const targetIndex = targetRow * gridColumns + currentCol;
const itemsInTargetRow = Math.min(gridColumns, results.length - targetRow * gridColumns);
if (currentCol < itemsInTargetRow) {
selectedIndex = targetIndex;
} else {
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;
}
}
}
selectedIndex = LauncherNav.selectPreviousRow(selectedIndex, results.length, gridColumns);
}
function selectNextRow() {
if (results.length > 0 && isGridView && gridColumns > 0) {
const currentRow = Math.floor(selectedIndex / gridColumns);
const currentCol = selectedIndex % gridColumns;
const totalRows = Math.ceil(results.length / gridColumns);
if (currentRow < totalRows - 1) {
const targetRow = currentRow + 1;
const targetIndex = targetRow * gridColumns + currentCol;
if (targetIndex < results.length) {
selectedIndex = targetIndex;
} else {
const itemsInTargetRow = results.length - targetRow * gridColumns;
if (itemsInTargetRow > 0) {
selectedIndex = targetRow * gridColumns + itemsInTargetRow - 1;
} else {
selectedIndex = Math.min(currentCol, results.length - 1);
}
}
} else {
// Wrap to first row, same column
selectedIndex = Math.min(currentCol, results.length - 1);
}
}
selectedIndex = LauncherNav.selectNextRow(selectedIndex, results.length, gridColumns);
}
function selectPreviousColumn() {
if (results.length > 0 && isGridView) {
const currentRow = Math.floor(selectedIndex / gridColumns);
const currentCol = selectedIndex % gridColumns;
if (currentCol > 0) {
selectedIndex = currentRow * gridColumns + (currentCol - 1);
} else if (currentRow > 0) {
selectedIndex = (currentRow - 1) * gridColumns + (gridColumns - 1);
} else {
const totalRows = Math.ceil(results.length / gridColumns);
const lastRowIndex = (totalRows - 1) * gridColumns + (gridColumns - 1);
selectedIndex = Math.min(lastRowIndex, results.length - 1);
}
}
selectedIndex = LauncherNav.selectPreviousColumn(selectedIndex, results.length, gridColumns);
}
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) {
selectedIndex = currentRow * gridColumns + (currentCol + 1);
} else {
const totalRows = Math.ceil(results.length / gridColumns);
if (currentRow < totalRows - 1) {
selectedIndex = (currentRow + 1) * gridColumns;
} else {
selectedIndex = 0;
}
}
}
selectedIndex = LauncherNav.selectNextColumn(selectedIndex, results.length, gridColumns);
}
function activate() {
@@ -818,276 +715,8 @@ Rectangle {
}
onModelChanged: {}
delegate: NBox {
id: entry
property bool isSelected: (!root.ignoreMouseHover && mouseArea.containsMouse) || (index === root.selectedIndex)
width: resultsList.availableWidth
implicitHeight: root.entryHeight
clip: true
color: entry.isSelected ? Color.mHover : Color.mSurface
forceOpaque: entry.isSelected
// Prepare item when it becomes visible (e.g., decode images)
Component.onCompleted: {
var provider = modelData.provider;
if (provider && provider.prepareItem) {
provider.prepareItem(modelData);
}
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.OutCirc
}
}
ColumnLayout {
id: contentLayout
anchors.fill: parent
anchors.margins: root.isCompactDensity ? Style.marginXS : Style.marginM
spacing: root.isCompactDensity ? Style.marginXS : Style.marginM
// Top row - Main entry content with action buttons
RowLayout {
Layout.fillWidth: true
spacing: root.isCompactDensity ? Style.marginS : Style.marginM
// Icon badge or Image preview or Emoji
Item {
visible: !modelData.hideIcon
Layout.preferredWidth: modelData.hideIcon ? 0 : root.badgeSize
Layout.preferredHeight: modelData.hideIcon ? 0 : root.badgeSize
// Icon background
Rectangle {
anchors.fill: parent
radius: Style.radiusXS
color: Color.mSurfaceVariant
visible: Settings.data.appLauncher.showIconBackground && !modelData.isImage
}
// Image preview - uses provider's getImageUrl if available
NImageRounded {
id: imagePreview
anchors.fill: parent
visible: !!modelData.isImage && !modelData.displayString
radius: Style.radiusXS
borderColor: Color.mOnSurface
borderWidth: Style.borderM
imageFillMode: Image.PreserveAspectCrop
// Use provider's image revision for reactive updates
readonly property int _rev: modelData.provider && modelData.provider.imageRevision ? modelData.provider.imageRevision : 0
// Get image URL from provider
imagePath: {
_rev;
var provider = modelData.provider;
if (provider && provider.getImageUrl) {
return provider.getImageUrl(modelData);
}
return "";
}
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.displayString) || (!!modelData.isImage && imagePreview.status === Image.Error)
active: visible
sourceComponent: Component {
Loader {
anchors.fill: parent
sourceComponent: Settings.data.appLauncher.iconMode === "tabler" && modelData.isTablerIcon ? tablerIconComponent : systemIconComponent
}
}
Component {
id: tablerIconComponent
NIcon {
icon: modelData.icon
pointSize: Style.fontSizeXXXL
visible: modelData.icon && !modelData.displayString
color: (entry.isSelected && !Settings.data.appLauncher.showIconBackground) ? Color.mOnHover : Color.mOnSurface
}
}
Component {
id: systemIconComponent
IconImage {
anchors.fill: parent
source: modelData.icon ? ThemeIcons.iconFromName(modelData.icon, "application-x-executable") : ""
visible: modelData.icon && source !== "" && !modelData.displayString
asynchronous: true
}
}
}
// String display - takes precedence when displayString is present
NText {
id: stringDisplay
anchors.centerIn: parent
visible: !!modelData.displayString || (!imagePreview.visible && !iconLoader.visible)
text: modelData.displayString ? modelData.displayString : (modelData.name ? modelData.name.charAt(0).toUpperCase() : "?")
pointSize: modelData.displayString ? (modelData.displayStringSize || Style.fontSizeXXXL) : Style.fontSizeXXL
font.weight: Style.fontWeightBold
color: modelData.displayString ? Color.mOnSurface : Color.mOnPrimary
}
// Image type indicator overlay
Rectangle {
visible: !!modelData.isImage && imagePreview.visible
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.margins: 2
width: formatLabel.width + Style.marginXS
height: formatLabel.height + Style.marginXXS
color: Color.mSurfaceVariant
radius: Style.radiusXXS
NText {
id: formatLabel
anchors.centerIn: parent
text: {
if (!modelData.isImage)
return "";
const desc = modelData.description || "";
const parts = desc.split(" \u2022 ");
return parts[0] || "IMG";
}
pointSize: Style.fontSizeXXS
color: Color.mOnSurfaceVariant
}
}
// Badge icon overlay (generic indicator for any provider)
Rectangle {
visible: !!modelData.badgeIcon
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.margins: 2
width: height
height: Style.fontSizeM + Style.marginXS
color: Color.mSurfaceVariant
radius: Style.radiusXXS
NIcon {
anchors.centerIn: parent
icon: modelData.badgeIcon || ""
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
}
}
}
// 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
maximumLineCount: 1
wrapMode: Text.Wrap
clip: true
Layout.fillWidth: true
}
NText {
text: modelData.description || ""
pointSize: Style.fontSizeS
color: entry.isSelected ? Color.mOnHover : Color.mOnSurfaceVariant
elide: Text.ElideRight
maximumLineCount: 1
Layout.fillWidth: true
visible: text !== "" && !root.isCompactDensity
}
}
// Action buttons row - dynamically populated from provider
RowLayout {
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
spacing: Style.marginXS
visible: entry.isSelected && itemActions.length > 0
property var itemActions: {
if (!entry.isSelected)
return [];
var provider = modelData.provider || root.currentProvider;
if (provider && provider.getItemActions) {
return provider.getItemActions(modelData);
}
return [];
}
Repeater {
model: parent.itemActions
NIconButton {
icon: modelData.icon
baseSize: Style.baseWidgetSize * 0.75
tooltipText: modelData.tooltip
z: 1
handleWheel: true
onClicked: {
if (modelData.action) {
modelData.action();
}
}
}
}
}
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
z: -1
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: !Settings.data.appLauncher.ignoreMouseInput
onEntered: {
if (!root.ignoreMouseHover) {
root.selectedIndex = index;
}
}
onClicked: mouse => {
if (mouse.button === Qt.LeftButton) {
root.selectedIndex = index;
root.activate();
mouse.accepted = true;
}
}
acceptedButtons: Qt.LeftButton
}
delegate: LauncherListDelegate {
launcher: root
}
}
}
@@ -1206,263 +835,8 @@ Rectangle {
}
}
delegate: Item {
id: gridEntryContainer
width: resultsGrid.cellWidth
height: resultsGrid.cellHeight
property bool isSelected: (!root.ignoreMouseHover && mouseArea.containsMouse) || (index === root.selectedIndex)
// Prepare item when it becomes visible (e.g., decode images)
Component.onCompleted: {
var provider = modelData.provider;
if (provider && provider.prepareItem) {
provider.prepareItem(modelData);
}
}
NBox {
id: gridEntry
anchors.fill: parent
anchors.margins: Style.marginXXS
color: gridEntryContainer.isSelected ? Color.mHover : Color.mSurface
forceOpaque: gridEntryContainer.isSelected
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.OutCirc
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: root.isCompactDensity ? Style.marginXS : Style.marginS
anchors.bottomMargin: root.isCompactDensity ? Style.marginXS : Style.marginS
spacing: root.isCompactDensity ? 0 : Style.marginXXS
// Icon badge or Image preview or Emoji
Item {
// Size image at 65% of cell dimensions.
Layout.preferredWidth: Math.round(gridEntry.width * 0.65)
Layout.preferredHeight: Math.round(gridEntry.height * 0.65)
Layout.alignment: Qt.AlignHCenter
// Icon background
Rectangle {
anchors.fill: parent
radius: Style.radiusM
color: Color.mSurfaceVariant
visible: Settings.data.appLauncher.showIconBackground && !modelData.isImage
}
// Image preview - uses provider's getImageUrl if available
NImageRounded {
id: gridImagePreview
anchors.fill: parent
visible: !!modelData.isImage && !modelData.displayString
radius: Style.radiusM
// Use provider's image revision for reactive updates
readonly property int _rev: modelData.provider && modelData.provider.imageRevision ? modelData.provider.imageRevision : 0
// Get image URL from provider
imagePath: {
_rev;
var provider = modelData.provider;
if (provider && provider.getImageUrl) {
return provider.getImageUrl(modelData);
}
return "";
}
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) {
gridIconLoader.visible = true;
gridImagePreview.visible = false;
}
}
}
Loader {
id: gridIconLoader
anchors.fill: parent
anchors.margins: Style.marginXS
visible: (!modelData.isImage && !modelData.displayString) || (!!modelData.isImage && gridImagePreview.status === Image.Error)
active: visible
sourceComponent: Settings.data.appLauncher.iconMode === "tabler" && modelData.isTablerIcon ? gridTablerIconComponent : gridSystemIconComponent
Component {
id: gridTablerIconComponent
NIcon {
icon: modelData.icon
pointSize: Style.fontSizeXXXL
visible: modelData.icon && !modelData.displayString
color: (gridEntryContainer.isSelected && !Settings.data.appLauncher.showIconBackground) ? Color.mOnHover : Color.mOnSurface
}
}
Component {
id: gridSystemIconComponent
IconImage {
anchors.fill: parent
source: modelData.icon ? ThemeIcons.iconFromName(modelData.icon, "application-x-executable") : ""
visible: modelData.icon && source !== "" && !modelData.displayString
asynchronous: true
}
}
}
// String display
NText {
id: gridStringDisplay
anchors.centerIn: parent
visible: !!modelData.displayString || (!gridImagePreview.visible && !gridIconLoader.visible)
text: modelData.displayString ? modelData.displayString : (modelData.name ? modelData.name.charAt(0).toUpperCase() : "?")
pointSize: {
if (modelData.displayString) {
// Use custom size if provided, otherwise default scaling
if (modelData.displayStringSize) {
return modelData.displayStringSize * Style.uiScaleRatio;
}
if (root.providerHasDisplayString) {
// Scale with cell width but cap at reasonable maximum
const cellBasedSize = gridEntry.width * 0.4;
const maxSize = Style.fontSizeXXXL * Style.uiScaleRatio;
return Math.min(cellBasedSize, maxSize);
}
return Style.fontSizeXXL * 2 * Style.uiScaleRatio;
}
// Scale font size relative to cell width for low res, but cap at maximum
const cellBasedSize = gridEntry.width * 0.25;
const baseSize = Style.fontSizeXL * Style.uiScaleRatio;
const maxSize = Style.fontSizeXXL * Style.uiScaleRatio;
return Math.min(Math.max(cellBasedSize, baseSize), maxSize);
}
font.weight: Style.fontWeightBold
color: modelData.displayString ? Color.mOnSurface : Color.mOnPrimary
}
// Badge icon overlay (generic indicator for any provider)
Rectangle {
visible: !!modelData.badgeIcon
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.margins: 2
width: height
height: Style.fontSizeM + Style.marginXS
color: Color.mSurfaceVariant
radius: Style.radiusXXS
NIcon {
anchors.centerIn: parent
icon: modelData.badgeIcon || ""
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
}
}
}
// Text content (hidden when hideLabel is true)
NText {
visible: !modelData.hideLabel
text: modelData.name || "Unknown"
pointSize: {
if (root.providerHasDisplayString && modelData.displayString) {
return Style.fontSizeS * Style.uiScaleRatio;
}
// Scale font size relative to cell width for low res, but cap at maximum
const cellBasedSize = gridEntry.width * 0.1;
const baseSize = Style.fontSizeXS * Style.uiScaleRatio;
const maxSize = Style.fontSizeS * Style.uiScaleRatio;
return Math.min(Math.max(cellBasedSize, baseSize), maxSize);
}
font.weight: Style.fontWeightSemiBold
color: gridEntryContainer.isSelected ? Color.mOnHover : Color.mOnSurface
elide: Text.ElideRight
Layout.fillWidth: true
Layout.maximumWidth: gridEntry.width - 8
Layout.leftMargin: (root.providerHasDisplayString && modelData.displayString) ? Style.marginS : 0
Layout.rightMargin: (root.providerHasDisplayString && modelData.displayString) ? Style.marginS : 0
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.NoWrap
maximumLineCount: 1
}
}
// Action buttons (overlay in top-right corner) - dynamically populated from provider
Row {
visible: gridEntryContainer.isSelected && gridItemActions.length > 0
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: Style.marginXS
z: 10
spacing: Style.marginXXS
property var gridItemActions: {
if (!gridEntryContainer.isSelected)
return [];
var provider = modelData.provider || root.currentProvider;
if (provider && provider.getItemActions) {
return provider.getItemActions(modelData);
}
return [];
}
Repeater {
model: parent.gridItemActions
NIconButton {
icon: modelData.icon
baseSize: Style.baseWidgetSize * 0.75
tooltipText: modelData.tooltip
z: 11
handleWheel: true
onClicked: {
if (modelData.action) {
modelData.action();
}
}
}
}
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
z: -1
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: !Settings.data.appLauncher.ignoreMouseInput
onEntered: {
if (!root.ignoreMouseHover) {
root.selectedIndex = index;
}
}
onClicked: mouse => {
if (mouse.button === Qt.LeftButton) {
root.selectedIndex = index;
root.activate();
mouse.accepted = true;
}
}
acceptedButtons: Qt.LeftButton
}
delegate: LauncherGridDelegate {
launcher: root
}
}
}
@@ -0,0 +1,272 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Widgets
import qs.Commons
import qs.Widgets
Item {
id: gridEntryContainer
required property var modelData
required property int index
required property var launcher
width: GridView.view.cellWidth
height: GridView.view.cellHeight
property bool isSelected: (!launcher.ignoreMouseHover && mouseArea.containsMouse) || (index === launcher.selectedIndex)
// Prepare item when it becomes visible (e.g., decode images)
Component.onCompleted: {
var provider = modelData.provider;
if (provider && provider.prepareItem) {
provider.prepareItem(modelData);
}
}
NBox {
id: gridEntry
anchors.fill: parent
anchors.margins: Style.marginXXS
color: gridEntryContainer.isSelected ? Color.mHover : Color.mSurface
forceOpaque: gridEntryContainer.isSelected
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.OutCirc
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: launcher.isCompactDensity ? Style.marginXS : Style.marginS
anchors.bottomMargin: launcher.isCompactDensity ? Style.marginXS : Style.marginS
spacing: launcher.isCompactDensity ? 0 : Style.marginXXS
// Icon badge or Image preview or Emoji
Item {
// Size image at 65% of cell dimensions.
Layout.preferredWidth: Math.round(gridEntry.width * 0.65)
Layout.preferredHeight: Math.round(gridEntry.height * 0.65)
Layout.alignment: Qt.AlignHCenter
// Icon background
Rectangle {
anchors.fill: parent
radius: Style.radiusM
color: Color.mSurfaceVariant
visible: Settings.data.appLauncher.showIconBackground && !modelData.isImage
}
// Image preview - uses provider's getImageUrl if available
NImageRounded {
id: gridImagePreview
anchors.fill: parent
visible: !!modelData.isImage && !modelData.displayString
radius: Style.radiusM
// Use provider's image revision for reactive updates
readonly property int _rev: modelData.provider && modelData.provider.imageRevision ? modelData.provider.imageRevision : 0
// Get image URL from provider
imagePath: {
_rev;
var provider = modelData.provider;
if (provider && provider.getImageUrl) {
return provider.getImageUrl(modelData);
}
return "";
}
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) {
gridIconLoader.visible = true;
gridImagePreview.visible = false;
}
}
}
Loader {
id: gridIconLoader
anchors.fill: parent
anchors.margins: Style.marginXS
visible: (!modelData.isImage && !modelData.displayString) || (!!modelData.isImage && gridImagePreview.status === Image.Error)
active: visible
sourceComponent: Settings.data.appLauncher.iconMode === "tabler" && modelData.isTablerIcon ? gridTablerIconComponent : gridSystemIconComponent
Component {
id: gridTablerIconComponent
NIcon {
icon: modelData.icon
pointSize: Style.fontSizeXXXL
visible: modelData.icon && !modelData.displayString
color: (gridEntryContainer.isSelected && !Settings.data.appLauncher.showIconBackground) ? Color.mOnHover : Color.mOnSurface
}
}
Component {
id: gridSystemIconComponent
IconImage {
anchors.fill: parent
source: modelData.icon ? ThemeIcons.iconFromName(modelData.icon, "application-x-executable") : ""
visible: modelData.icon && source !== "" && !modelData.displayString
asynchronous: true
}
}
}
// String display
NText {
id: gridStringDisplay
anchors.centerIn: parent
visible: !!modelData.displayString || (!gridImagePreview.visible && !gridIconLoader.visible)
text: modelData.displayString ? modelData.displayString : (modelData.name ? modelData.name.charAt(0).toUpperCase() : "?")
pointSize: {
if (modelData.displayString) {
// Use custom size if provided, otherwise default scaling
if (modelData.displayStringSize) {
return modelData.displayStringSize * Style.uiScaleRatio;
}
if (launcher.providerHasDisplayString) {
// Scale with cell width but cap at reasonable maximum
const cellBasedSize = gridEntry.width * 0.4;
const maxSize = Style.fontSizeXXXL * Style.uiScaleRatio;
return Math.min(cellBasedSize, maxSize);
}
return Style.fontSizeXXL * 2 * Style.uiScaleRatio;
}
// Scale font size relative to cell width for low res, but cap at maximum
const cellBasedSize = gridEntry.width * 0.25;
const baseSize = Style.fontSizeXL * Style.uiScaleRatio;
const maxSize = Style.fontSizeXXL * Style.uiScaleRatio;
return Math.min(Math.max(cellBasedSize, baseSize), maxSize);
}
font.weight: Style.fontWeightBold
color: modelData.displayString ? Color.mOnSurface : Color.mOnPrimary
}
// Badge icon overlay (generic indicator for any provider)
Rectangle {
visible: !!modelData.badgeIcon
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.margins: 2
width: height
height: Style.fontSizeM + Style.marginXS
color: Color.mSurfaceVariant
radius: Style.radiusXXS
NIcon {
anchors.centerIn: parent
icon: modelData.badgeIcon || ""
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
}
}
}
// Text content (hidden when hideLabel is true)
NText {
visible: !modelData.hideLabel
text: modelData.name || "Unknown"
pointSize: {
if (launcher.providerHasDisplayString && modelData.displayString) {
return Style.fontSizeS * Style.uiScaleRatio;
}
// Scale font size relative to cell width for low res, but cap at maximum
const cellBasedSize = gridEntry.width * 0.1;
const baseSize = Style.fontSizeXS * Style.uiScaleRatio;
const maxSize = Style.fontSizeS * Style.uiScaleRatio;
return Math.min(Math.max(cellBasedSize, baseSize), maxSize);
}
font.weight: Style.fontWeightSemiBold
color: gridEntryContainer.isSelected ? Color.mOnHover : Color.mOnSurface
elide: Text.ElideRight
Layout.fillWidth: true
Layout.maximumWidth: gridEntry.width - 8
Layout.leftMargin: (launcher.providerHasDisplayString && modelData.displayString) ? Style.marginS : 0
Layout.rightMargin: (launcher.providerHasDisplayString && modelData.displayString) ? Style.marginS : 0
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.NoWrap
maximumLineCount: 1
}
}
// Action buttons (overlay in top-right corner) - dynamically populated from provider
Row {
visible: gridEntryContainer.isSelected && gridItemActions.length > 0
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: Style.marginXS
z: 10
spacing: Style.marginXXS
property var gridItemActions: {
if (!gridEntryContainer.isSelected)
return [];
var provider = modelData.provider || launcher.currentProvider;
if (provider && provider.getItemActions) {
return provider.getItemActions(modelData);
}
return [];
}
Repeater {
model: parent.gridItemActions
NIconButton {
required property var modelData
icon: modelData.icon
baseSize: Style.baseWidgetSize * 0.75
tooltipText: modelData.tooltip
z: 11
handleWheel: true
onClicked: {
if (modelData.action) {
modelData.action();
}
}
}
}
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
z: -1
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: !Settings.data.appLauncher.ignoreMouseInput
onEntered: {
if (!launcher.ignoreMouseHover) {
launcher.selectedIndex = gridEntryContainer.index;
}
}
onClicked: mouse => {
if (mouse.button === Qt.LeftButton) {
launcher.selectedIndex = gridEntryContainer.index;
launcher.activate();
mouse.accepted = true;
}
}
acceptedButtons: Qt.LeftButton
}
}
@@ -0,0 +1,284 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell.Widgets
import qs.Commons
import qs.Widgets
NBox {
id: entry
required property var modelData
required property int index
required property var launcher
property bool isSelected: (!launcher.ignoreMouseHover && mouseArea.containsMouse) || (index === launcher.selectedIndex)
width: ListView.view.width
implicitHeight: launcher.entryHeight
clip: true
color: entry.isSelected ? Color.mHover : Color.mSurface
forceOpaque: entry.isSelected
// Prepare item when it becomes visible (e.g., decode images)
Component.onCompleted: {
var provider = modelData.provider;
if (provider && provider.prepareItem) {
provider.prepareItem(modelData);
}
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.OutCirc
}
}
ColumnLayout {
id: contentLayout
anchors.fill: parent
anchors.margins: launcher.isCompactDensity ? Style.marginXS : Style.marginM
spacing: launcher.isCompactDensity ? Style.marginXS : Style.marginM
// Top row - Main entry content with action buttons
RowLayout {
Layout.fillWidth: true
spacing: launcher.isCompactDensity ? Style.marginS : Style.marginM
// Icon badge or Image preview or Emoji
Item {
visible: !modelData.hideIcon
Layout.preferredWidth: modelData.hideIcon ? 0 : launcher.badgeSize
Layout.preferredHeight: modelData.hideIcon ? 0 : launcher.badgeSize
// Icon background
Rectangle {
anchors.fill: parent
radius: Style.radiusXS
color: Color.mSurfaceVariant
visible: Settings.data.appLauncher.showIconBackground && !modelData.isImage
}
// Image preview - uses provider's getImageUrl if available
NImageRounded {
id: imagePreview
anchors.fill: parent
visible: !!modelData.isImage && !modelData.displayString
radius: Style.radiusXS
borderColor: Color.mOnSurface
borderWidth: Style.borderM
imageFillMode: Image.PreserveAspectCrop
// Use provider's image revision for reactive updates
readonly property int _rev: modelData.provider && modelData.provider.imageRevision ? modelData.provider.imageRevision : 0
// Get image URL from provider
imagePath: {
_rev;
var provider = modelData.provider;
if (provider && provider.getImageUrl) {
return provider.getImageUrl(modelData);
}
return "";
}
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.displayString) || (!!modelData.isImage && imagePreview.status === Image.Error)
active: visible
sourceComponent: Component {
Loader {
anchors.fill: parent
sourceComponent: Settings.data.appLauncher.iconMode === "tabler" && modelData.isTablerIcon ? tablerIconComponent : systemIconComponent
}
}
Component {
id: tablerIconComponent
NIcon {
icon: modelData.icon
pointSize: Style.fontSizeXXXL
visible: modelData.icon && !modelData.displayString
color: (entry.isSelected && !Settings.data.appLauncher.showIconBackground) ? Color.mOnHover : Color.mOnSurface
}
}
Component {
id: systemIconComponent
IconImage {
anchors.fill: parent
source: modelData.icon ? ThemeIcons.iconFromName(modelData.icon, "application-x-executable") : ""
visible: modelData.icon && source !== "" && !modelData.displayString
asynchronous: true
}
}
}
// String display - takes precedence when displayString is present
NText {
id: stringDisplay
anchors.centerIn: parent
visible: !!modelData.displayString || (!imagePreview.visible && !iconLoader.visible)
text: modelData.displayString ? modelData.displayString : (modelData.name ? modelData.name.charAt(0).toUpperCase() : "?")
pointSize: modelData.displayString ? (modelData.displayStringSize || Style.fontSizeXXXL) : Style.fontSizeXXL
font.weight: Style.fontWeightBold
color: modelData.displayString ? Color.mOnSurface : Color.mOnPrimary
}
// Image type indicator overlay
Rectangle {
visible: !!modelData.isImage && imagePreview.visible
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.margins: 2
width: formatLabel.width + Style.marginXS
height: formatLabel.height + Style.marginXXS
color: Color.mSurfaceVariant
radius: Style.radiusXXS
NText {
id: formatLabel
anchors.centerIn: parent
text: {
if (!modelData.isImage)
return "";
const desc = modelData.description || "";
const parts = desc.split(" \u2022 ");
return parts[0] || "IMG";
}
pointSize: Style.fontSizeXXS
color: Color.mOnSurfaceVariant
}
}
// Badge icon overlay (generic indicator for any provider)
Rectangle {
visible: !!modelData.badgeIcon
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.margins: 2
width: height
height: Style.fontSizeM + Style.marginXS
color: Color.mSurfaceVariant
radius: Style.radiusXXS
NIcon {
anchors.centerIn: parent
icon: modelData.badgeIcon || ""
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
}
}
}
// 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
maximumLineCount: 1
wrapMode: Text.Wrap
clip: true
Layout.fillWidth: true
}
NText {
text: modelData.description || ""
pointSize: Style.fontSizeS
color: entry.isSelected ? Color.mOnHover : Color.mOnSurfaceVariant
elide: Text.ElideRight
maximumLineCount: 1
Layout.fillWidth: true
visible: text !== "" && !launcher.isCompactDensity
}
}
// Action buttons row - dynamically populated from provider
RowLayout {
Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
spacing: Style.marginXS
visible: entry.isSelected && itemActions.length > 0
property var itemActions: {
if (!entry.isSelected)
return [];
var provider = modelData.provider || launcher.currentProvider;
if (provider && provider.getItemActions) {
return provider.getItemActions(modelData);
}
return [];
}
Repeater {
model: parent.itemActions
NIconButton {
required property var modelData
icon: modelData.icon
baseSize: Style.baseWidgetSize * 0.75
tooltipText: modelData.tooltip
z: 1
handleWheel: true
onClicked: {
if (modelData.action) {
modelData.action();
}
}
}
}
}
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
z: -1
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: !Settings.data.appLauncher.ignoreMouseInput
onEntered: {
if (!launcher.ignoreMouseHover) {
launcher.selectedIndex = entry.index;
}
}
onClicked: mouse => {
if (mouse.button === Qt.LeftButton) {
launcher.selectedIndex = entry.index;
launcher.activate();
mouse.accepted = true;
}
}
acceptedButtons: Qt.LeftButton
}
}
@@ -61,6 +61,7 @@ Item {
function init() {
loadApplications();
migrateLegacyUsageKeys();
}
function onOpened() {
@@ -639,32 +640,22 @@ Item {
return String(app && app.name ? app.name : "unknown");
}
// Returns the usage count for an app, checking both the canonical key (app.id)
// and the legacy command-based key. If a legacy key has usage but the canonical
// key doesn't, the counts are migrated automatically.
function getUsageCount(app) {
const key = getAppKey(app);
let count = ShellState.getLauncherUsageCount(key);
return ShellState.getLauncherUsageCount(getAppKey(app));
}
// Check for legacy command-based key if the primary key is the app ID
if (app && app.id && app.command && app.command.join) {
const legacyKey = app.command.join(" ");
if (legacyKey !== key) {
const legacyCount = ShellState.getLauncherUsageCount(legacyKey);
if (legacyCount > 0) {
// Migrate: merge legacy count into the canonical key
count += legacyCount;
ShellState.recordLauncherUsageMerge(key, count);
ShellState.clearLauncherUsage(legacyKey);
Logger.d("ApplicationsProvider", `Migrated usage: "${legacyKey}" (${legacyCount}) "${key}" (${count})`);
// Migrate legacy command-based usage keys to canonical app-id keys at startup
function migrateLegacyUsageKeys() {
for (let i = 0; i < entries.length; i++) {
const app = entries[i];
if (app && app.id && app.command && app.command.join) {
const key = getAppKey(app);
const legacyKey = app.command.join(" ");
if (legacyKey !== key && ShellState.getLauncherUsageCount(legacyKey) > 0) {
ShellState.migrateLauncherUsage(legacyKey, key);
Logger.d("ApplicationsProvider", `Migrated usage: "${legacyKey}" "${key}"`);
}
}
}
return count;
}
function recordUsage(app) {
ShellState.recordLauncherUsage(getAppKey(app));
}
}