add noctalia and fuzzel

This commit is contained in:
2026-05-23 21:20:58 +02:00
parent 364801f1a3
commit 9a3eceb3ab
599 changed files with 204318 additions and 0 deletions
@@ -0,0 +1,195 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Commons
import qs.Services.Keyboard
import qs.Widgets
Item {
id: previewPanel
property var currentItem: null
property string fullContent: ""
property string imageDataUrl: ""
property bool loadingFullContent: false
property bool isImageContent: false
implicitHeight: contentArea.implicitHeight + Style.margin2L
function loadContent() {
if (!currentItem || !currentItem.clipboardId)
return;
if (isImageContent) {
// For images, check cache first then decode
imageDataUrl = ClipboardService.getImageData(currentItem.clipboardId) || "";
loadingFullContent = !imageDataUrl;
if (!imageDataUrl && currentItem.mime) {
ClipboardService.decodeToDataUrl(currentItem.clipboardId, currentItem.mime, null);
}
} else {
// For text, check sync cache first
const cached = ClipboardService.getContent(currentItem.clipboardId);
if (cached) {
fullContent = cached;
loadingFullContent = false;
} else {
// Show preview while loading full content
fullContent = currentItem.preview || "";
loadingFullContent = true;
// Async decode as fallback
var requestedId = currentItem.clipboardId;
ClipboardService.decode(requestedId, function (content) {
if (!previewPanel || !previewPanel.currentItem) {
return;
}
if (previewPanel.currentItem.clipboardId === requestedId) {
var trimmed = content ? content.trim() : "";
if (trimmed !== "") {
previewPanel.fullContent = trimmed;
}
previewPanel.loadingFullContent = false;
}
});
}
}
}
Connections {
target: previewPanel
function onCurrentItemChanged() {
fullContent = "";
imageDataUrl = "";
loadingFullContent = false;
isImageContent = currentItem && currentItem.isImage;
if (currentItem && currentItem.clipboardId) {
loadContent();
}
}
}
readonly property int _rev: ClipboardService.revision
on_RevChanged: {
// When cache updates, try to load content if we're still showing loading or preview
if (currentItem && currentItem.clipboardId && !isImageContent && loadingFullContent) {
const cached = ClipboardService.getContent(currentItem.clipboardId);
if (cached) {
fullContent = cached;
loadingFullContent = false;
}
}
}
Timer {
id: imageUpdateTimer
interval: 200
running: currentItem && currentItem.isImage && imageDataUrl === ""
repeat: currentItem && currentItem.isImage && imageDataUrl === ""
onTriggered: {
if (currentItem && currentItem.clipboardId) {
const newData = ClipboardService.getImageData(currentItem.clipboardId) || "";
if (newData !== imageDataUrl) {
imageDataUrl = newData;
if (newData) {
loadingFullContent = false;
}
}
}
}
}
Item {
id: contentArea
anchors.fill: parent
anchors.margins: Style.marginS
BusyIndicator {
anchors.centerIn: parent
running: loadingFullContent
visible: loadingFullContent
width: Style.baseWidgetSize
height: width
}
// Preview for image entry
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginS
spacing: Style.marginS
visible: isImageContent && !loadingFullContent && imageDataUrl !== ""
NImageRounded {
id: previewImage
Layout.fillWidth: true
Layout.fillHeight: true
radius: Style.marginS
imagePath: imageDataUrl
imageFillMode: Image.PreserveAspectFit
}
NDivider {
Layout.fillWidth: true
Layout.bottomMargin: Style.marginS
}
NText {
Layout.fillWidth: true
text: {
const meta = ClipboardService.parseImageMeta(currentItem?.preview);
if (meta)
return `${meta.fmt} ${meta.w}×${meta.h} ${meta.size}`;
// Fallback to basic info
const format = (currentItem?.mime || "image").split("/")[1]?.toUpperCase() || "Image";
return `${format} ${previewImage.implicitWidth}×${previewImage.implicitHeight}`;
}
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
}
}
// Preview for text entry
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginS
spacing: Style.marginS
visible: !isImageContent && !loadingFullContent
NScrollView {
id: clipboardScrollView
Layout.fillWidth: true
Layout.fillHeight: true
horizontalPolicy: Settings.data.appLauncher.clipboardWrapText ? ScrollBar.AlwaysOff : ScrollBar.AsNeeded
NText {
text: fullContent
width: Settings.data.appLauncher.clipboardWrapText ? clipboardScrollView.availableWidth : implicitWidth
wrapMode: Settings.data.appLauncher.clipboardWrapText ? Text.Wrap : Text.NoWrap
textFormat: Text.PlainText
font.pointSize: Style.fontSizeM
font.family: Settings.data.ui.fontFixed
color: Color.mOnSurface
}
}
NDivider {
Layout.fillWidth: true
Layout.bottomMargin: Style.marginS
}
NText {
Layout.fillWidth: true
visible: fullContent.length > 0
text: {
const chars = fullContent.length;
const words = fullContent.split(/\s+/).filter(w => w.length > 0).length;
const lines = fullContent.split('\n').length;
return `${chars} chars, ${words} words, ${lines} lines`;
}
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
}
}
}
}
@@ -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;
}
@@ -0,0 +1,182 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import qs.Commons
import qs.Modules.MainScreen
import qs.Services.UI
import qs.Widgets
SmartPanel {
id: root
// Disable when overlay mode is enabled (LauncherOverlayWindow handles it)
enabled: !Settings.data.appLauncher.overviewLayer
visible: !Settings.data.appLauncher.overviewLayer
// Reference to core (set after panelContent loads)
property var launcherCoreRef: null
// Expose core launcher for external access (e.g., IPC)
readonly property string searchText: launcherCoreRef ? launcherCoreRef.searchText : ""
readonly property int selectedIndex: launcherCoreRef ? launcherCoreRef.selectedIndex : 0
readonly property var results: launcherCoreRef ? launcherCoreRef.results : []
readonly property var activeProvider: launcherCoreRef ? launcherCoreRef.activeProvider : null
readonly property var currentProvider: launcherCoreRef ? launcherCoreRef.currentProvider : null
readonly property bool isGridView: launcherCoreRef ? launcherCoreRef.isGridView : false
readonly property int gridColumns: launcherCoreRef ? launcherCoreRef.gridColumns : 5
function setSearchText(text) {
if (launcherCoreRef)
launcherCoreRef.setSearchText(text);
}
// Preview panel support
readonly property bool previewActive: {
if (!launcherCoreRef)
return false;
var provider = launcherCoreRef.activeProvider;
if (!provider || !provider.hasPreview)
return false;
if (!Settings.data.appLauncher.enableClipPreview)
return false;
return selectedIndex >= 0 && results && !!results[selectedIndex];
}
readonly property int previewPanelWidth: Math.round(400 * Style.uiScaleRatio)
// Panel sizing
readonly property int listPanelWidth: Math.round(500 * Style.uiScaleRatio)
readonly property int totalBaseWidth: listPanelWidth + Style.margin2L
preferredWidth: totalBaseWidth
preferredHeight: Math.round(600 * Style.uiScaleRatio)
preferredWidthRatio: 0.25
preferredHeightRatio: 0.5
// Positioning
readonly property string screenBarPosition: Settings.getBarPositionForScreen(screen?.name)
readonly property string panelPosition: {
if (Settings.data.appLauncher.position === "follow_bar") {
if (screenBarPosition === "left" || screenBarPosition === "right") {
return `center_${screenBarPosition}`;
} else {
return `${screenBarPosition}_center`;
}
} else {
return Settings.data.appLauncher.position;
}
}
panelAnchorHorizontalCenter: !root.useButtonPosition && (panelPosition === "center" || panelPosition.endsWith("_center"))
panelAnchorVerticalCenter: !root.useButtonPosition && panelPosition === "center"
panelAnchorLeft: !root.useButtonPosition && panelPosition !== "center" && panelPosition.endsWith("_left")
panelAnchorRight: !root.useButtonPosition && panelPosition !== "center" && panelPosition.endsWith("_right")
panelAnchorBottom: !root.useButtonPosition && panelPosition.startsWith("bottom_")
panelAnchorTop: !root.useButtonPosition && panelPosition.startsWith("top_")
panelContent: Rectangle {
id: ui
color: "transparent"
opacity: launcherCore.resultsReady ? 1.0 : 0.0
Component.onCompleted: root.launcherCoreRef = launcherCore
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutCirc
}
}
// Preview Panel (external) - uses provider's preview component
NDropShadow {
source: previewBox
anchors.fill: previewBox
autoPaddingEnabled: true
visible: previewBox.visible
z: previewBox.z - 1
}
NBox {
id: previewBox
visible: root.previewActive
width: root.previewPanelWidth
height: Math.round(400 * Style.uiScaleRatio)
forceOpaque: true // no blur for now
x: root.panelAnchorRight ? -(root.previewPanelWidth + Style.marginM) : ui.width + Style.marginM
y: {
var view = launcherCore.resultsView;
if (!view)
return Style.marginL;
var row = launcherCore.isGridView ? Math.floor(launcherCore.selectedIndex / launcherCore.gridColumns) : launcherCore.selectedIndex;
var gridCellSize = Math.floor((root.listPanelWidth - (2 * Style.marginXS) - ((launcherCore.targetGridColumns - 1) * Style.marginS)) / launcherCore.targetGridColumns);
var itemHeight = launcherCore.isGridView ? (gridCellSize + Style.marginXXS) : (launcherCore.entryHeight + (view.spacing || 0));
var yPos = row * itemHeight - (view.contentY || 0);
var mapped = view.mapToItem(ui, 0, yPos);
return Math.max(Style.marginL, Math.min(mapped.y, ui.height - previewBox.height - Style.marginL));
}
z: -1
opacity: visible ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
}
}
Behavior on y {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutCubic
}
}
Loader {
id: previewLoader
anchors.fill: parent
active: root.previewActive
source: {
if (!active)
return "";
var provider = launcherCore.activeProvider;
if (provider && provider.previewComponentPath)
return provider.previewComponentPath;
return "";
}
onLoaded: updatePreviewItem()
onItemChanged: updatePreviewItem()
function updatePreviewItem() {
if (!item || launcherCore.selectedIndex < 0 || !launcherCore.results[launcherCore.selectedIndex])
return;
var provider = launcherCore.activeProvider;
if (provider && provider.getPreviewData) {
item.currentItem = provider.getPreviewData(launcherCore.results[launcherCore.selectedIndex]);
} else {
item.currentItem = launcherCore.results[launcherCore.selectedIndex];
}
}
}
}
// Core launcher (state, providers, UI)
LauncherCore {
id: launcherCore
anchors.fill: parent
screen: root.screen
isOpen: root.isPanelOpen
onRequestClose: root.close()
// Defer so the signal emission completes before SmartPanel
// sets isPanelOpen=false and the contentLoader destroys us.
onRequestCloseImmediately: Qt.callLater(root.closeImmediately)
}
// Update preview when selection changes
Connections {
target: launcherCore
function onSelectedIndexChanged() {
if (previewLoader.item)
previewLoader.updatePreviewItem();
}
}
}
}
@@ -0,0 +1,909 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
import "Helpers/LauncherNavigation.js" as LauncherNav
import "Providers"
import qs.Commons
import qs.Services.Keyboard
import qs.Services.UI
import qs.Widgets
// Core launcher logic and UI - shared between SmartPanel (Launcher.qml) and overlay (LauncherOverlayWindow.qml)
Rectangle {
id: root
color: "transparent"
// External interface - set by parent
property var screen: null
property bool isOpen: false
signal requestClose
signal requestCloseImmediately
function closeImmediately() {
requestCloseImmediately();
}
// Expose for preview panel positioning
readonly property var resultsView: resultsSwapView.item
// State
property string searchText: ""
property int selectedIndex: 0
property var results: []
property var providers: []
property var activeProvider: null
property bool resultsReady: false
property var pluginProviderInstances: ({})
property bool ignoreMouseHover: true // Transient flag, should always be true on init
// Global mouse tracking for movement detection across delegates
property real globalLastMouseX: 0
property real globalLastMouseY: 0
property bool globalMouseInitialized: false
property bool mouseTrackingReady: false // Delay tracking until panel is settled
readonly property bool animationsDisabled: Settings.data.general.animationDisabled
Timer {
id: mouseTrackingDelayTimer
interval: root.animationsDisabled ? 0 : (Style.animationNormal + 50) // Wait for panel animation to complete + safety margin
repeat: false
onTriggered: {
root.mouseTrackingReady = true;
root.globalMouseInitialized = false; // Reset so we get fresh initial position
}
}
readonly property var defaultProvider: appsProvider
readonly property var currentProvider: activeProvider || defaultProvider
readonly property string launcherDensity: (currentProvider && currentProvider.ignoreDensity === false) ? (Settings.data.appLauncher.density || "default") : "comfortable"
readonly property int effectiveIconSize: launcherDensity === "comfortable" ? 48 : (launcherDensity === "default" ? 36 : 24)
readonly property int badgeSize: Math.round(effectiveIconSize * Style.uiScaleRatio)
readonly property int entryHeight: Math.round(badgeSize + (launcherDensity === "compact" ? (Style.marginL + Style.marginXXS) : (Style.marginXL + Style.marginS)))
readonly property bool providerShowsCategories: (currentProvider.showsCategories !== undefined ? currentProvider.showsCategories : true) && providerCategories.length > 0
readonly property var providerCategories: {
if (currentProvider.availableCategories && currentProvider.availableCategories.length > 0) {
return currentProvider.availableCategories;
}
return currentProvider.categories || [];
}
readonly property bool showProviderCategories: {
if (!providerShowsCategories || providerCategories.length === 0)
return false;
if (currentProvider === defaultProvider)
return Settings.data.appLauncher.showCategories;
return true;
}
readonly property bool providerHasDisplayString: results.length > 0 && !!results[0].displayString
readonly property string providerSupportedLayouts: {
if (activeProvider && activeProvider.supportedLayouts)
return activeProvider.supportedLayouts;
if (results.length > 0 && results[0].provider && results[0].provider.supportedLayouts)
return results[0].provider.supportedLayouts;
if (defaultProvider && defaultProvider.supportedLayouts)
return defaultProvider.supportedLayouts;
return "both";
}
readonly property bool showLayoutToggle: !providerHasDisplayString && providerSupportedLayouts === "both"
readonly property string layoutMode: {
if (searchText === ">")
return "list";
if (providerSupportedLayouts === "grid")
return "grid";
if (providerSupportedLayouts === "list")
return "list";
if (providerSupportedLayouts === "single")
return "single";
if (providerHasDisplayString)
return "grid";
return Settings.data.appLauncher.viewMode;
}
readonly property bool isGridView: layoutMode === "grid"
readonly property bool isSingleView: layoutMode === "single"
readonly property bool isCompactDensity: launcherDensity === "compact"
readonly property int targetGridColumns: {
let base = 5;
if (launcherDensity === "comfortable")
base = 4;
else if (launcherDensity === "compact")
base = 6;
if (!activeProvider || activeProvider === defaultProvider)
return base;
if (activeProvider.preferredGridColumns) {
let multiplier = base / 5.0;
return Math.max(1, Math.round(activeProvider.preferredGridColumns * multiplier));
}
return base;
}
readonly property int listPanelWidth: Math.round(500 * Style.uiScaleRatio)
readonly property int gridContentWidth: listPanelWidth - (2 * Style.marginXS)
readonly property int gridCellSize: Math.floor((gridContentWidth - ((targetGridColumns - 1) * Style.marginS)) / targetGridColumns)
readonly property int gridColumns: targetGridColumns
// Check if current provider allows wrap navigation (default true)
readonly property bool allowWrapNavigation: {
var provider = activeProvider || currentProvider;
return provider && provider.wrapNavigation !== undefined ? provider.wrapNavigation : true;
}
// Listen for plugin provider registry changes
Connections {
target: LauncherProviderRegistry
function onPluginProviderRegistryUpdated() {
root.syncPluginProviders();
}
}
// Lifecycle
onIsOpenChanged: {
if (isOpen) {
onOpened();
} else {
onClosed();
}
}
onSearchTextChanged: {
if (isOpen) {
updateResults();
}
}
function onOpened() {
ignoreMouseHover = true;
globalMouseInitialized = false;
mouseTrackingReady = false;
mouseTrackingDelayTimer.restart();
// Show launcher immediately, results will populate asynchronously
resultsReady = true;
focusSearchInput();
Qt.callLater(() => {
syncPluginProviders();
for (let provider of providers) {
if (provider.onOpened)
provider.onOpened();
}
updateResults();
});
}
function onClosed() {
searchText = "";
ignoreMouseHover = true;
if (resultsSwapView)
resultsSwapView.resetVisuals();
for (let provider of providers) {
if (provider.onClosed)
provider.onClosed();
}
}
function close() {
requestClose();
}
function applyCategorySelection(tabIndex, categories) {
const categoryList = categories || providerCategories;
if (!categoryList || tabIndex < 0 || tabIndex >= categoryList.length)
return false;
currentProvider.selectCategory(categoryList[tabIndex]);
categoryTabs.currentIndex = tabIndex;
return true;
}
function selectCategoryWithSlide(tabIndex) {
if (!showProviderCategories || !currentProvider || !currentProvider.selectCategory)
return;
const cats = providerCategories;
if (!cats || tabIndex < 0 || tabIndex >= cats.length)
return;
const currentIdx = cats.indexOf(currentProvider.selectedCategory);
if (tabIndex === currentIdx)
return;
const canAnimate = !animationsDisabled && resultsSwapView.width > 0 && resultsSwapView.height > 0;
if (!canAnimate) {
applyCategorySelection(tabIndex, cats);
return;
}
const direction = tabIndex > currentIdx ? 1 : -1;
resultsSwapView.swap(direction, () => applyCategorySelection(tabIndex, providerCategories));
}
// Public API
function setSearchText(text) {
searchText = text;
}
function focusSearchInput() {
if (searchInput.inputItem) {
searchInput.inputItem.forceActiveFocus();
}
}
// Provider registration
function registerProvider(provider) {
providers.push(provider);
provider.launcher = root;
if (provider.init)
provider.init();
}
function syncPluginProviders() {
var registeredIds = LauncherProviderRegistry.getPluginProviders();
var changed = false;
// Remove providers that are no longer registered
for (var existingId in pluginProviderInstances) {
if (registeredIds.indexOf(existingId) === -1) {
var idx = providers.indexOf(pluginProviderInstances[existingId]);
if (idx >= 0)
providers.splice(idx, 1);
delete pluginProviderInstances[existingId];
Logger.d("Launcher", "Removed plugin provider:", existingId);
changed = true;
}
}
// Adopt persistent instances from the registry
for (var i = 0; i < registeredIds.length; i++) {
var providerId = registeredIds[i];
if (!pluginProviderInstances[providerId]) {
var instance = LauncherProviderRegistry.getProviderInstance(providerId);
if (instance) {
pluginProviderInstances[providerId] = instance;
providers.push(instance);
instance.launcher = root;
Logger.d("Launcher", "Adopted plugin provider:", providerId);
changed = true;
}
}
}
// Update results only if providers changed
if (changed && root.isOpen) {
updateResults();
}
}
// Search handling
function updateResults() {
results = [];
var newActiveProvider = null;
// Check for command mode
if (searchText.startsWith(">")) {
for (let provider of providers) {
if (provider.handleCommand && provider.handleCommand(searchText)) {
newActiveProvider = provider;
results = provider.getResults(searchText);
break;
}
}
// Show available commands if just ">" or filter commands if partial match
if (!newActiveProvider) {
let allCommands = [];
for (let provider of providers) {
if (provider.commands)
allCommands = allCommands.concat(provider.commands());
}
if (searchText === ">") {
results = allCommands;
} else if (searchText.length > 1) {
const query = searchText.substring(1);
if (typeof FuzzySort !== 'undefined') {
const fuzzyResults = FuzzySort.go(query, allCommands, {
"keys": ["name"],
"limit": 50
});
results = fuzzyResults.map(result => result.obj);
} else {
const queryLower = query.toLowerCase();
results = allCommands.filter(cmd => (cmd.name || "").toLowerCase().includes(queryLower));
}
}
}
} else {
// Regular search - let providers contribute results
let allResults = [];
for (let provider of providers) {
if (provider.handleSearch) {
const providerResults = provider.getResults(searchText);
allResults = allResults.concat(providerResults);
}
}
// Sort by _score (higher = better match), items without _score go first
if (searchText.trim() !== "") {
const boostByUsage = Settings.data.appLauncher.sortByMostUsed;
allResults.sort((a, b) => {
let sa = a._score !== undefined ? a._score : 0;
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 += 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;
});
}
results = allResults;
}
// Update activeProvider only after computing new state to avoid UI flicker
activeProvider = newActiveProvider;
selectedIndex = 0;
}
// Navigation functions (delegated to LauncherNavigation.js)
function selectNext() {
selectedIndex = LauncherNav.selectNext(selectedIndex, results.length);
}
function selectPrevious() {
selectedIndex = LauncherNav.selectPrevious(selectedIndex, results.length);
}
function selectNextWrapped() {
selectedIndex = LauncherNav.selectNextWrapped(selectedIndex, results.length, allowWrapNavigation);
}
function selectPreviousWrapped() {
selectedIndex = LauncherNav.selectPreviousWrapped(selectedIndex, results.length, allowWrapNavigation);
}
function selectFirst() {
selectedIndex = LauncherNav.selectFirst();
}
function selectLast() {
selectedIndex = LauncherNav.selectLast(results.length);
}
function selectNextPage() {
selectedIndex = LauncherNav.selectNextPage(selectedIndex, results.length, entryHeight);
}
function selectPreviousPage() {
selectedIndex = LauncherNav.selectPreviousPage(selectedIndex, results.length, entryHeight);
}
function selectPreviousRow() {
selectedIndex = LauncherNav.selectPreviousRow(selectedIndex, results.length, gridColumns);
}
function selectNextRow() {
selectedIndex = LauncherNav.selectNextRow(selectedIndex, results.length, gridColumns);
}
function selectPreviousColumn() {
selectedIndex = LauncherNav.selectPreviousColumn(selectedIndex, results.length, gridColumns);
}
function selectNextColumn() {
selectedIndex = LauncherNav.selectNextColumn(selectedIndex, results.length, gridColumns);
}
function activate() {
if (results.length > 0 && results[selectedIndex]) {
const item = results[selectedIndex];
const provider = item.provider || currentProvider;
// Track usage for providers that opt in (cross-provider "most used" tracking)
if (Settings.data.appLauncher.sortByMostUsed && provider && provider.trackUsage && item.usageKey) {
ShellState.recordLauncherUsage(item.usageKey);
}
// Check if auto-paste is enabled and provider/item supports it
if (Settings.data.appLauncher.autoPasteClipboard && provider && provider.supportsAutoPaste && item.autoPasteText) {
if (item.onAutoPaste)
item.onAutoPaste();
closeImmediately();
Qt.callLater(() => {
ClipboardService.pasteText(item.autoPasteText);
});
return;
}
if (item.onActivate)
item.onActivate();
}
}
function checkKey(event, settingName) {
return Keybinds.checkKey(event, settingName, Settings);
}
// Keyboard handler
function handleKeyPress(event) {
if (checkKey(event, 'escape')) {
close();
event.accepted = true;
return;
}
if (checkKey(event, 'enter')) {
activate();
event.accepted = true;
return;
}
if (checkKey(event, 'up')) {
if (!isSingleView) {
isGridView ? selectPreviousRow() : selectPreviousWrapped();
}
event.accepted = true;
return;
}
if (checkKey(event, 'down')) {
if (!isSingleView) {
isGridView ? selectNextRow() : selectNextWrapped();
}
event.accepted = true;
return;
}
if (checkKey(event, 'left')) {
if (isGridView) {
selectPreviousColumn();
event.accepted = true;
return;
}
}
if (checkKey(event, 'right')) {
if (isGridView) {
selectNextColumn();
event.accepted = true;
return;
}
}
// Static bindings
switch (event.key) {
case Qt.Key_Tab:
if (showProviderCategories) {
var cats = providerCategories;
var idx = cats.indexOf(currentProvider.selectedCategory);
var nextIdx = (idx + 1) % cats.length;
selectCategoryWithSlide(nextIdx);
} else {
selectNextWrapped();
}
event.accepted = true;
break;
case Qt.Key_Backtab:
if (showProviderCategories) {
var cats2 = providerCategories;
var idx2 = cats2.indexOf(currentProvider.selectedCategory);
var prevIdx = ((idx2 - 1) % cats2.length + cats2.length) % cats2.length;
selectCategoryWithSlide(prevIdx);
} else {
selectPreviousWrapped();
}
event.accepted = true;
break;
case Qt.Key_Home:
selectFirst();
event.accepted = true;
break;
case Qt.Key_End:
selectLast();
event.accepted = true;
break;
case Qt.Key_PageUp:
selectPreviousPage();
event.accepted = true;
break;
case Qt.Key_PageDown:
selectNextPage();
event.accepted = true;
break;
case Qt.Key_Delete:
if (selectedIndex >= 0 && results && results[selectedIndex]) {
var item = results[selectedIndex];
var provider = item.provider || currentProvider;
if (provider && provider.canDeleteItem && provider.canDeleteItem(item))
provider.deleteItem(item);
}
event.accepted = true;
break;
}
}
// -----------------------
// Provider components
// -----------------------
ApplicationsProvider {
id: appsProvider
Component.onCompleted: {
registerProvider(this);
Logger.d("Launcher", "Registered: ApplicationsProvider");
}
}
ClipboardProvider {
id: clipProvider
Component.onCompleted: {
if (Settings.data.appLauncher.enableClipboardHistory) {
registerProvider(this);
Logger.d("Launcher", "Registered: ClipboardProvider");
}
}
}
CommandProvider {
id: cmdProvider
Component.onCompleted: {
registerProvider(this);
Logger.d("Launcher", "Registered: CommandProvider");
}
}
EmojiProvider {
id: emojiProvider
Component.onCompleted: {
registerProvider(this);
Logger.d("Launcher", "Registered: EmojiProvider");
}
}
CalculatorProvider {
id: calcProvider
Component.onCompleted: {
registerProvider(this);
Logger.d("Launcher", "Registered: CalculatorProvider");
}
}
SettingsProvider {
id: settingsProvider
Component.onCompleted: {
registerProvider(this);
Logger.d("Launcher", "Registered: SettingsProvider");
}
}
SessionProvider {
id: sessionProvider
Component.onCompleted: {
registerProvider(this);
Logger.d("Launcher", "Registered: SessionProvider");
}
}
WindowsProvider {
id: windowsProvider
Component.onCompleted: {
registerProvider(this);
Logger.d("Launcher", "Registered: WindowsProvider");
}
}
// ==================== UI Content ====================
opacity: resultsReady ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutCirc
}
}
HoverHandler {
id: globalHoverHandler
enabled: !Settings.data.appLauncher.ignoreMouseInput
onPointChanged: {
if (!root.mouseTrackingReady) {
return;
}
if (!root.globalMouseInitialized) {
root.globalLastMouseX = point.position.x;
root.globalLastMouseY = point.position.y;
root.globalMouseInitialized = true;
return;
}
const deltaX = Math.abs(point.position.x - root.globalLastMouseX);
const deltaY = Math.abs(point.position.y - root.globalLastMouseY);
if (deltaX + deltaY >= 5) {
root.ignoreMouseHover = false;
root.globalLastMouseX = point.position.x;
root.globalLastMouseY = point.position.y;
}
}
}
ColumnLayout {
anchors.fill: parent
anchors.topMargin: Style.marginL
anchors.bottomMargin: Style.marginL
spacing: Style.marginL
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: Style.marginL
Layout.rightMargin: Style.marginL
spacing: Style.marginS
NTextInput {
id: searchInput
Layout.fillWidth: true
radius: Style.iRadiusM
text: root.searchText
placeholderText: I18n.tr("placeholders.search-launcher")
fontSize: Style.fontSizeM
onTextChanged: root.searchText = text
Component.onCompleted: {
if (searchInput.inputItem) {
searchInput.inputItem.forceActiveFocus();
searchInput.inputItem.Keys.onPressed.connect(function (event) {
root.handleKeyPress(event);
});
}
}
}
NIconButton {
visible: root.showLayoutToggle
icon: Settings.data.appLauncher.viewMode === "grid" ? "layout-list" : "layout-grid"
tooltipText: Settings.data.appLauncher.viewMode === "grid" ? I18n.tr("tooltips.list-view") : I18n.tr("tooltips.grid-view")
customRadius: Style.iRadiusM
Layout.preferredWidth: searchInput.height
Layout.preferredHeight: searchInput.height
onClicked: Settings.data.appLauncher.viewMode = Settings.data.appLauncher.viewMode === "grid" ? "list" : "grid"
}
}
// Unified category tabs (works with any provider that has categories)
NTabBar {
id: categoryTabs
visible: root.showProviderCategories
Layout.fillWidth: true
Layout.leftMargin: Style.marginL
Layout.rightMargin: Style.marginL
margins: 0
border.color: Style.boxBorderColor
border.width: Style.borderS
property int computedCurrentIndex: visible && root.providerCategories.length > 0 ? root.providerCategories.indexOf(root.currentProvider.selectedCategory) : 0
currentIndex: computedCurrentIndex
Repeater {
model: root.providerCategories
NTabButton {
required property string modelData
required property int index
icon: root.currentProvider.categoryIcons ? (root.currentProvider.categoryIcons[modelData] || "star") : "star"
tooltipText: root.currentProvider.getCategoryName ? root.currentProvider.getCategoryName(modelData) : modelData
tabIndex: index
checked: categoryTabs.currentIndex === index
onClicked: root.selectCategoryWithSlide(index)
}
}
}
// Results view
NSlideSwapView {
id: resultsSwapView
Layout.fillWidth: true
Layout.leftMargin: Style.marginL
Layout.rightMargin: Style.marginL
Layout.fillHeight: true
animationsEnabled: !root.animationsDisabled
sourceComponent: root.isSingleView ? singleViewComponent : (root.isGridView ? gridViewComponent : listViewComponent)
}
// --------------------------
// LIST VIEW
Component {
id: listViewComponent
NListView {
id: resultsList
horizontalPolicy: ScrollBar.AlwaysOff
verticalPolicy: ScrollBar.AlwaysOff
reserveScrollbarSpace: false
gradientColor: Settings.data.ui.panelBackgroundOpacity < 1 ? "transparent" : Color.mSurface
wheelScrollMultiplier: 4.0
width: parent.width
height: parent.height
spacing: Style.marginS
model: root.results
currentIndex: root.selectedIndex
cacheBuffer: resultsList.height * 2
interactive: !Settings.data.appLauncher.ignoreMouseInput
onCurrentIndexChanged: {
cancelFlick();
if (currentIndex >= 0) {
positionViewAtIndex(currentIndex, ListView.Contain);
}
}
onModelChanged: {}
delegate: LauncherListDelegate {
launcher: root
}
}
}
// --------------------------
// SINGLE ITEM VIEW
Component {
id: singleViewComponent
Item {
Layout.fillWidth: true
Layout.fillHeight: true
NBox {
anchors.fill: parent
color: Color.mSurfaceVariant
forceOpaque: true
Layout.fillWidth: true
Layout.fillHeight: true
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginL
Layout.fillWidth: true
Layout.fillHeight: true
Item {
Layout.alignment: Qt.AlignTop | Qt.AlignLeft
NText {
text: root.results.length > 0 ? root.results[0].name : ""
pointSize: Style.fontSizeL
font.weight: Font.Bold
color: Color.mPrimary
}
}
NScrollView {
id: descriptionScrollView
Layout.alignment: Qt.AlignTop | Qt.AlignLeft
Layout.topMargin: Style.fontSizeL + Style.marginXL
Layout.fillWidth: true
Layout.fillHeight: true
horizontalPolicy: ScrollBar.AlwaysOff
reserveScrollbarSpace: false
NText {
width: descriptionScrollView.availableWidth
text: root.results.length > 0 ? root.results[0].description : ""
pointSize: Style.fontSizeM
font.weight: Font.Bold
color: Color.mOnSurface
horizontalAlignment: Text.AlignHLeft
verticalAlignment: Text.AlignTop
wrapMode: Text.Wrap
markdownTextEnabled: true
}
}
}
}
}
}
// // --------------------------
// GRID VIEW
Component {
id: gridViewComponent
NGridView {
id: resultsGrid
horizontalPolicy: ScrollBar.AlwaysOff
verticalPolicy: ScrollBar.AlwaysOff
reserveScrollbarSpace: false
gradientColor: Settings.data.ui.panelBackgroundOpacity < 1 ? "transparent" : Color.mSurface
wheelScrollMultiplier: 4.0
trackedSelectionIndex: root.selectedIndex
width: parent.width
height: parent.height
cellWidth: parent.width / root.targetGridColumns
cellHeight: {
var cellWidth = parent.width / root.targetGridColumns;
// Use provider's preferred ratio if available
if (root.currentProvider && root.currentProvider.preferredGridCellRatio) {
return cellWidth * root.currentProvider.preferredGridCellRatio;
}
return cellWidth;
}
leftMargin: 0
rightMargin: 0
topMargin: 0
bottomMargin: 0
model: root.results
cacheBuffer: resultsGrid.height * 2
keyNavigationEnabled: false
focus: false
interactive: !Settings.data.appLauncher.ignoreMouseInput
// Completely disable GridView key handling
Keys.enabled: false
// Handle scrolling to show selected item when it changes
Connections {
target: root
enabled: root.isGridView
function onSelectedIndexChanged() {
if (!root.isGridView || root.selectedIndex < 0 || !resultsGrid) {
return;
}
Qt.callLater(() => {
if (root.isGridView && resultsGrid && resultsGrid.cancelFlick) {
resultsGrid.cancelFlick();
resultsGrid.positionViewAtIndex(root.selectedIndex, GridView.Contain);
}
});
}
}
delegate: LauncherGridDelegate {
launcher: root
}
}
}
ColumnLayout {
Layout.leftMargin: Style.marginL
Layout.rightMargin: Style.marginL
NDivider {
Layout.fillWidth: true
Layout.bottomMargin: Style.marginS
}
NText {
Layout.fillWidth: true
text: {
if (root.results.length === 0) {
if (root.searchText) {
return I18n.tr("common.no-results");
}
// Use provider's empty browsing message if available
var provider = root.currentProvider;
if (provider && provider.emptyBrowsingMessage) {
return provider.emptyBrowsingMessage;
}
return "";
}
var prefix = root.activeProvider && root.activeProvider.name ? root.activeProvider.name + ": " : "";
return prefix + I18n.trp("common.result-count", root.results.length);
}
pointSize: Style.fontSizeXS
color: Color.mOnSurfaceVariant
horizontalAlignment: Text.AlignCenter
}
}
}
}
@@ -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.mSurfaceVariant
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.mSurface
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,294 @@
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.mSurfaceVariant
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.mSurface
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;
}
}
}
// Color swatch - shown for clipboard color entries
Rectangle {
anchors.fill: parent
radius: Style.radiusXS
color: modelData.colorHex || "transparent"
visible: !!modelData.colorHex
border.color: Color.mOnSurface
border.width: Style.borderM
}
Loader {
id: iconLoader
anchors.fill: parent
anchors.margins: Style.marginXS
visible: (!modelData.isImage && !modelData.displayString && !modelData.colorHex) || (!!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
}
}
@@ -0,0 +1,426 @@
import QtQuick
import QtQuick.Shapes
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Modules.MainScreen.Backgrounds
import qs.Services.UI
import qs.Widgets
// Standalone launcher window for Overlay layer mode.
// This window appears above fullscreen windows and does not attach to the bar.
Variants {
id: launcherVariants
model: Quickshell.screens.filter(screen => Settings.data.appLauncher.overviewLayer)
delegate: Loader {
id: windowLoader
required property ShellScreen modelData
active: PanelService.overlayLauncherOpen && PanelService.overlayLauncherScreen === modelData
sourceComponent: PanelWindow {
id: launcherWindow
screen: windowLoader.modelData
anchors {
top: true
bottom: true
left: true
right: true
}
color: "transparent"
WlrLayershell.namespace: "noctalia-launcher-overlay-" + (screen?.name || "unknown")
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.exclusionMode: ExclusionMode.Ignore
BackgroundEffect.blurRegion: Settings.data.general.enableBlurBehind ? launcherBlurRegion : null
Region {
id: launcherBlurRegion
Region {
x: Math.round(launcherPanel.x)
y: Math.round(launcherPanel.y)
width: Math.round(launcherPanel.width)
height: Math.round(launcherPanel.height)
radius: Style.radiusL
topLeftCorner: launcherPanel.topLeftCornerState
topRightCorner: launcherPanel.topRightCornerState
bottomLeftCorner: launcherPanel.bottomLeftCornerState
bottomRightCorner: launcherPanel.bottomRightCornerState
}
Region {
x: Math.round(previewBox.visible ? previewBox.x : 0)
y: Math.round(previewBox.visible ? previewBox.y : 0)
width: Math.round(previewBox.visible ? previewBox.width : 0)
height: Math.round(previewBox.visible ? previewBox.height : 0)
radius: Style.radiusL
}
}
// Positioning logic (respects settings but doesn't attach to bar)
readonly property string barPosition: Settings.data.bar.position
readonly property bool barIsVertical: barPosition === "left" || barPosition === "right"
readonly property int barThickness: Math.round(Style.barHeight + Style.marginL)
readonly property string panelPosition: {
var pos = Settings.data.appLauncher.position;
if (pos === "follow_bar") {
if (barIsVertical) {
return "center_" + barPosition;
} else {
return barPosition + "_center";
}
}
return pos;
}
// Preview panel support
readonly property int listPanelWidth: Math.round(500 * Style.uiScaleRatio)
readonly property int previewPanelWidth: Math.round(400 * Style.uiScaleRatio)
readonly property bool previewActive: {
if (!launcherCore)
return false;
var provider = launcherCore.activeProvider;
if (!provider || !provider.hasPreview)
return false;
if (!Settings.data.appLauncher.enableClipPreview)
return false;
return launcherCore.selectedIndex >= 0 && launcherCore.results && !!launcherCore.results[launcherCore.selectedIndex];
}
// Dimmer background (click to close)
Rectangle {
anchors.fill: parent
color: Qt.alpha(Color.mSurface, Settings.data.general.dimmerOpacity)
MouseArea {
anchors.fill: parent
onClicked: PanelService.closeOverlayLauncher()
}
}
// Shadow for launcher panel
NDropShadow {
source: launcherPanel
anchors.fill: launcherPanel
autoPaddingEnabled: true
}
// Launcher panel with position-based anchoring
Item {
id: launcherPanel
width: Math.round(Math.max(parent.width * 0.25, launcherWindow.listPanelWidth + Style.margin2L * 2))
height: Math.round(Math.max(parent.height * 0.5, 600 * Style.uiScaleRatio))
clip: false
// Entrance animation
opacity: 0
transformOrigin: {
if (touchingTop && touchingLeft)
return Item.TopLeft;
if (touchingTop && touchingRight)
return Item.TopRight;
if (touchingBottom && touchingLeft)
return Item.BottomLeft;
if (touchingBottom && touchingRight)
return Item.BottomRight;
if (touchingTop)
return Item.Top;
if (touchingBottom)
return Item.Bottom;
if (touchingLeft)
return Item.Left;
if (touchingRight)
return Item.Right;
return Item.Center;
}
Component.onCompleted: {
opacity = 1;
}
Behavior on opacity {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutCubic
}
}
// Horizontal positioning
anchors.horizontalCenter: (panelPosition === "center" || panelPosition.endsWith("_center")) ? parent.horizontalCenter : undefined
anchors.left: panelPosition.endsWith("_left") ? parent.left : undefined
anchors.right: panelPosition.endsWith("_right") ? parent.right : undefined
// Vertical positioning
anchors.verticalCenter: (panelPosition === "center" || panelPosition.startsWith("center_")) ? parent.verticalCenter : undefined
anchors.top: panelPosition.startsWith("top_") ? parent.top : undefined
anchors.bottom: panelPosition.startsWith("bottom_") ? parent.bottom : undefined
// Margins - only add bar clearance on the bar's edge
anchors.leftMargin: barPosition === "left" ? barThickness : 0
anchors.rightMargin: barPosition === "right" ? barThickness : 0
anchors.topMargin: barPosition === "top" ? barThickness : 0
anchors.bottomMargin: barPosition === "bottom" ? barThickness : 0
// Edge detection - based on position setting and bar location
readonly property bool touchingLeft: panelPosition.endsWith("_left") && barPosition !== "left"
readonly property bool touchingRight: panelPosition.endsWith("_right") && barPosition !== "right"
readonly property bool touchingTop: panelPosition.startsWith("top_") && barPosition !== "top"
readonly property bool touchingBottom: panelPosition.startsWith("bottom_") && barPosition !== "bottom"
// Corner states based on edge touching
// State 0: Normal rounded, State 1: Horizontal inversion, State 2: Vertical inversion
readonly property int topLeftCornerState: {
if (touchingLeft && touchingTop)
return 0;
if (touchingLeft)
return 2;
if (touchingTop)
return 1;
return 0;
}
readonly property int topRightCornerState: {
if (touchingRight && touchingTop)
return 0;
if (touchingRight)
return 2;
if (touchingTop)
return 1;
return 0;
}
readonly property int bottomLeftCornerState: {
if (touchingLeft && touchingBottom)
return 0;
if (touchingLeft)
return 2;
if (touchingBottom)
return 1;
return 0;
}
readonly property int bottomRightCornerState: {
if (touchingRight && touchingBottom)
return 0;
if (touchingRight)
return 2;
if (touchingBottom)
return 1;
return 0;
}
// Background with inverted corners - extends beyond panel for inverted corners
Shape {
id: panelShape
// Extend shape to allow inverted corners to render outside panel bounds
x: -radius
y: -radius
width: launcherPanel.width + radius * 2
height: launcherPanel.height + radius * 2
visible: panelW > 0 && panelH > 0
opacity: launcherPanel.opacity
layer.enabled: true
readonly property real radius: Style.radiusL
// Panel dimensions (for path calculations)
readonly property real panelW: launcherPanel.width
readonly property real panelH: launcherPanel.height
// Helper functions for corner rendering
function getMultX(state) {
return state === 1 ? -1 : 1;
}
function getMultY(state) {
return state === 2 ? -1 : 1;
}
function getArcDir(multX, multY) {
return ((multX < 0) !== (multY < 0)) ? PathArc.Counterclockwise : PathArc.Clockwise;
}
readonly property real tlMultX: getMultX(launcherPanel.topLeftCornerState)
readonly property real tlMultY: getMultY(launcherPanel.topLeftCornerState)
readonly property real trMultX: getMultX(launcherPanel.topRightCornerState)
readonly property real trMultY: getMultY(launcherPanel.topRightCornerState)
readonly property real blMultX: getMultX(launcherPanel.bottomLeftCornerState)
readonly property real blMultY: getMultY(launcherPanel.bottomLeftCornerState)
readonly property real brMultX: getMultX(launcherPanel.bottomRightCornerState)
readonly property real brMultY: getMultY(launcherPanel.bottomRightCornerState)
ShapePath {
strokeWidth: -1
fillColor: Qt.alpha(Color.mSurface, Color.adaptiveOpacity(Settings.data.ui.panelBackgroundOpacity))
// Offset by radius to account for Shape's extended bounds
startX: panelShape.radius + panelShape.radius * panelShape.tlMultX
startY: panelShape.radius
// Top edge
PathLine {
relativeX: panelShape.panelW - panelShape.radius * panelShape.tlMultX - panelShape.radius * panelShape.trMultX
relativeY: 0
}
// Top-right corner
PathArc {
relativeX: panelShape.radius * panelShape.trMultX
relativeY: panelShape.radius * panelShape.trMultY
radiusX: panelShape.radius
radiusY: panelShape.radius
direction: panelShape.getArcDir(panelShape.trMultX, panelShape.trMultY)
}
// Right edge
PathLine {
relativeX: 0
relativeY: panelShape.panelH - panelShape.radius * panelShape.trMultY - panelShape.radius * panelShape.brMultY
}
// Bottom-right corner
PathArc {
relativeX: -panelShape.radius * panelShape.brMultX
relativeY: panelShape.radius * panelShape.brMultY
radiusX: panelShape.radius
radiusY: panelShape.radius
direction: panelShape.getArcDir(panelShape.brMultX, panelShape.brMultY)
}
// Bottom edge
PathLine {
relativeX: -(panelShape.panelW - panelShape.radius * panelShape.brMultX - panelShape.radius * panelShape.blMultX)
relativeY: 0
}
// Bottom-left corner
PathArc {
relativeX: -panelShape.radius * panelShape.blMultX
relativeY: -panelShape.radius * panelShape.blMultY
radiusX: panelShape.radius
radiusY: panelShape.radius
direction: panelShape.getArcDir(panelShape.blMultX, panelShape.blMultY)
}
// Left edge
PathLine {
relativeX: 0
relativeY: -(panelShape.panelH - panelShape.radius * panelShape.blMultY - panelShape.radius * panelShape.tlMultY)
}
// Top-left corner
PathArc {
relativeX: panelShape.radius * panelShape.tlMultX
relativeY: -panelShape.radius * panelShape.tlMultY
radiusX: panelShape.radius
radiusY: panelShape.radius
direction: panelShape.getArcDir(panelShape.tlMultX, panelShape.tlMultY)
}
}
}
// Border
Rectangle {
anchors.fill: parent
color: "transparent"
radius: Style.radiusL
border.color: Style.boxBorderColor
border.width: Style.borderS
visible: !launcherPanel.touchingLeft && !launcherPanel.touchingRight && !launcherPanel.touchingTop && !launcherPanel.touchingBottom
}
LauncherCore {
id: launcherCore
anchors.fill: parent
screen: windowLoader.modelData
isOpen: true
onRequestClose: PanelService.closeOverlayLauncher()
onRequestCloseImmediately: PanelService.closeOverlayLauncherImmediately()
Component.onCompleted: PanelService.overlayLauncherCore = launcherCore
Component.onDestruction: PanelService.overlayLauncherCore = null
}
}
// Preview Panel - positioned as sibling of launcherPanel to avoid shadow bleed
NDropShadow {
source: previewBox
anchors.fill: previewBox
autoPaddingEnabled: true
visible: previewBox.visible
z: previewBox.z - 1
}
NBox {
id: previewBox
visible: launcherWindow.previewActive
width: launcherWindow.previewPanelWidth
height: Math.round(400 * Style.uiScaleRatio)
forceOpaque: true
x: {
if (panelPosition.endsWith("_right"))
return launcherPanel.x - launcherWindow.previewPanelWidth - Style.marginM;
return launcherPanel.x + launcherPanel.width + Style.marginM;
}
y: {
var view = launcherCore.resultsView;
if (!view)
return launcherPanel.y + Style.marginL;
var row = launcherCore.isGridView ? Math.floor(launcherCore.selectedIndex / launcherCore.gridColumns) : launcherCore.selectedIndex;
var gridCellSize = Math.floor((launcherWindow.listPanelWidth - (2 * Style.marginXS) - ((launcherCore.targetGridColumns - 1) * Style.marginS)) / launcherCore.targetGridColumns);
var itemHeight = launcherCore.isGridView ? (gridCellSize + Style.marginXXS) : (launcherCore.entryHeight + (view.spacing || 0));
var yPos = row * itemHeight - (view.contentY || 0);
var mapped = view.mapToItem(launcherWindow.contentItem, 0, yPos);
return Math.max(launcherPanel.y + Style.marginL, Math.min(mapped.y, launcherPanel.y + launcherPanel.height - previewBox.height - Style.marginL));
}
opacity: visible ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
}
}
Behavior on y {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutCubic
}
}
Loader {
id: previewLoader
anchors.fill: parent
active: launcherWindow.previewActive
source: {
if (!active)
return "";
var provider = launcherCore.activeProvider;
if (provider && provider.previewComponentPath)
return provider.previewComponentPath;
return "";
}
onLoaded: updatePreviewItem()
onItemChanged: updatePreviewItem()
function updatePreviewItem() {
if (!item || launcherCore.selectedIndex < 0 || !launcherCore.results[launcherCore.selectedIndex])
return;
var provider = launcherCore.activeProvider;
if (provider && provider.getPreviewData) {
item.currentItem = provider.getPreviewData(launcherCore.results[launcherCore.selectedIndex]);
} else {
item.currentItem = launcherCore.results[launcherCore.selectedIndex];
}
}
}
}
// Update preview when selection changes
Connections {
target: launcherCore
function onSelectedIndexChanged() {
if (previewLoader.item)
previewLoader.updatePreviewItem();
}
}
}
}
}
@@ -0,0 +1,654 @@
import QtQuick
import Quickshell
import qs.Commons
import qs.Services.Compositor
import qs.Services.System
Item {
id: root
property var launcher: null
property string name: I18n.tr("launcher.providers.applications")
property bool handleSearch: true
property var entries: []
property string supportedLayouts: "both"
property bool isDefaultProvider: true // This provider handles empty search
property bool ignoreDensity: false // Apps should scale with launcher density
property bool trackUsage: true // Track usage frequency for "most used" sorting
// Category support
property string selectedCategory: "all"
property bool showsCategories: true // Default to showing categories
property var categories: ["all", "Pinned", "AudioVideo", "Chat", "Development", "Education", "Game", "Graphics", "Network", "Office", "System", "Misc", "WebBrowser"]
property var availableCategories: ["all"] // Reactive property for available categories
property var categoryIcons: ({
"all": "apps",
"Pinned": "pin",
"AudioVideo": "music",
"Chat": "message-circle",
"Development": "code",
"Education": "school" // Includes Science
,
"Game": "device-gamepad",
"Graphics": "brush",
"Network": "wifi",
"Office": "file-text",
"System": "device-desktop" // Includes Settings and Utility
,
"Misc": "dots",
"WebBrowser": "world"
})
function getCategoryName(category) {
const names = {
"all": I18n.tr("launcher.categories.all"),
"Pinned": I18n.tr("launcher.categories.pinned"),
"AudioVideo": I18n.tr("launcher.categories.audiovideo"),
"Chat": I18n.tr("launcher.categories.chat"),
"Development": I18n.tr("launcher.categories.development"),
"Education": I18n.tr("launcher.categories.education"),
"Game": I18n.tr("launcher.categories.game"),
"Graphics": I18n.tr("launcher.categories.graphics"),
"Network": I18n.tr("common.network"),
"Office": I18n.tr("launcher.categories.office"),
"System": I18n.tr("launcher.categories.system"),
"Misc": I18n.tr("launcher.categories.misc"),
"WebBrowser": I18n.tr("launcher.categories.webbrowser")
};
return names[category] || category;
}
function init() {
loadApplications();
migrateLegacyUsageKeys();
}
function onOpened() {
// Just update available categories in case pinned apps changed
updateAvailableCategories();
// Default to Pinned if there are pinned apps, otherwise all
if (availableCategories.includes("Pinned")) {
selectedCategory = "Pinned";
} else {
selectedCategory = "all";
}
// Set category mode initially (will be updated when getResults is called)
showsCategories = true;
}
// Reload applications when desktop entries change on disk
Connections {
target: typeof DesktopEntries !== 'undefined' ? DesktopEntries.applications : null
function onValuesChanged() {
Logger.d("ApplicationsProvider", "Desktop entries changed, reloading applications");
loadApplications();
}
}
function selectCategory(category) {
selectedCategory = category;
if (launcher) {
launcher.updateResults();
}
}
function getAppCategories(app) {
if (!app)
return [];
const result = [];
if (app.categories) {
if (Array.isArray(app.categories)) {
for (let cat of app.categories) {
if (cat && cat.trim && cat.trim() !== '') {
result.push(cat.trim());
} else if (cat && typeof cat === 'string' && cat.trim() !== '') {
result.push(cat.trim());
}
}
} else if (typeof app.categories === 'string') {
const cats = app.categories.split(';').filter(c => c && c.trim() !== '');
for (let cat of cats) {
const trimmed = cat.trim();
if (trimmed && !result.includes(trimmed)) {
result.push(trimmed);
}
}
} else if (app.categories.length !== undefined) {
try {
for (let i = 0; i < app.categories.length; i++) {
const cat = app.categories[i];
if (cat && cat.trim && typeof cat.trim === 'function' && cat.trim() !== '') {
result.push(cat.trim());
} else if (cat && typeof cat === 'string' && cat.trim() !== '') {
result.push(cat.trim());
}
}
} catch (e) {}
}
}
if (app.Categories) {
const cats = app.Categories.split(';').filter(c => c && c.trim() !== '');
for (let cat of cats) {
const trimmed = cat.trim();
if (trimmed && !result.includes(trimmed)) {
result.push(trimmed);
}
}
}
return result;
}
function getAppCategory(app) {
const appCategories = getAppCategories(app);
if (appCategories.length === 0)
return null;
const priorityCategories = ["AudioVideo", "Chat", "WebBrowser", "Game", "Development", "Graphics", "Office", "Education", "System", "Network", "Misc"];
for (let cat of appCategories) {
if (cat === "AudioVideo" || cat === "Audio" || cat === "Video") {
return "AudioVideo";
}
}
if (appCategories.includes("Chat") || appCategories.includes("InstantMessaging")) {
return "Chat";
}
if (appCategories.includes("WebBrowser")) {
return "WebBrowser";
}
// Map Science to Education
if (appCategories.includes("Science")) {
return "Education";
}
// Map Settings to System
if (appCategories.includes("Settings")) {
return "System";
}
// Map Utility to System
if (appCategories.includes("Utility")) {
return "System";
}
for (let priorityCat of priorityCategories) {
if (appCategories.includes(priorityCat) && root.categories.includes(priorityCat)) {
return priorityCat;
}
}
return "Misc";
}
// Helper function to normalize app IDs for case-insensitive matching
function normalizeAppId(appId) {
if (!appId || typeof appId !== 'string')
return "";
return appId.toLowerCase().trim();
}
// Helper function to check if an app is pinned
function isAppPinned(app) {
if (!app)
return false;
const pinnedApps = Settings.data.appLauncher.pinnedApps || [];
const appId = getAppKey(app);
const normalizedId = normalizeAppId(appId);
return pinnedApps.some(pinnedId => normalizeAppId(pinnedId) === normalizedId);
}
function appMatchesCategory(app, category) {
// Check if app matches the selected category
if (category === "all")
return true;
// Handle Pinned category separately
if (category === "Pinned") {
return isAppPinned(app);
}
// Get the primary category for this app (first matching standard category)
const primaryCategory = getAppCategory(app);
// If app has no matching standard category, don't show it in any category (only in "all")
if (!primaryCategory)
return false;
// Map Audio/Video to AudioVideo
if (category === "AudioVideo") {
const appCategories = getAppCategories(app);
// Show if app has AudioVideo, Audio, or Video
return appCategories.includes("AudioVideo") || appCategories.includes("Audio") || appCategories.includes("Video");
}
// Map Science to Education
if (category === "Education") {
const appCategories = getAppCategories(app);
return appCategories.includes("Education") || appCategories.includes("Science");
}
// Map Settings and Utility to System
if (category === "System") {
const appCategories = getAppCategories(app);
return appCategories.includes("System") || appCategories.includes("Settings") || appCategories.includes("Utility");
}
// Only show app in its primary category to avoid overlap
// This ensures each app appears in exactly one category tab
return category === primaryCategory;
}
function getAvailableCategories() {
const categorySet = new Set();
let hasAudioVideo = false;
let hasEducation = false;
let hasSystem = false;
let hasPinned = false;
// Check if there are any pinned apps
const pinnedApps = Settings.data.appLauncher.pinnedApps || [];
if (pinnedApps.length > 0) {
// Verify that at least one pinned app exists in entries
for (let app of entries) {
if (isAppPinned(app)) {
hasPinned = true;
break;
}
}
}
for (let app of entries) {
const appCategories = getAppCategories(app);
const primaryCategory = getAppCategory(app);
if (appCategories.includes("AudioVideo") || appCategories.includes("Audio") || appCategories.includes("Video")) {
hasAudioVideo = true;
} else if (appCategories.includes("Education") || appCategories.includes("Science")) {
hasEducation = true;
} else if (appCategories.includes("System") || appCategories.includes("Settings") || appCategories.includes("Utility")) {
hasSystem = true;
} else if (primaryCategory && root.categories.includes(primaryCategory)) {
categorySet.add(primaryCategory);
}
}
const result = [];
// Add Pinned category first if there are pinned apps
if (hasPinned) {
result.push("Pinned");
}
result.push("all");
if (hasAudioVideo) {
categorySet.add("AudioVideo");
}
if (hasEducation) {
categorySet.add("Education");
}
if (hasSystem) {
categorySet.add("System");
}
for (let cat of root.categories) {
if (cat !== "all" && cat !== "Pinned" && cat !== "Misc" && categorySet.has(cat)) {
result.push(cat);
}
}
if (categorySet.has("Misc")) {
result.push("Misc");
}
if (result.length === 1) {
const fallback = root.categories.filter(c => c !== "Misc");
fallback.push("Misc");
return fallback;
}
return result;
}
function loadApplications() {
if (typeof DesktopEntries === 'undefined') {
Logger.w("ApplicationsProvider", "DesktopEntries service not available");
return;
}
const allApps = DesktopEntries.applications.values || [];
const seen = new Map(); // Map of appId -> exec command
entries = allApps.filter(app => {
if (!app || app.noDisplay || app.hidden)
return false;
const appId = app.id || app.name;
const execCmd = getExecutableName(app);
// Check if we've seen this app ID before
if (seen.has(appId)) {
const previousExec = seen.get(appId);
// If exec is different, it's a legitimate different entry - keep it
if (previousExec !== execCmd) {
Logger.d("ApplicationsProvider", `Keeping variant of ${appId}: ${execCmd} (differs from ${previousExec})`);
// Add with modified ID to make it unique
app.id = `${appId}_${execCmd}`;
seen.set(app.id, execCmd);
return true;
}
// Same appId AND same exec = true duplicate, skip it
Logger.d("ApplicationsProvider", `Skipping duplicate: ${appId}`);
return false;
}
seen.set(appId, execCmd);
return true;
}).map(app => {
app.executableName = getExecutableName(app);
return app;
});
Logger.d("ApplicationsProvider", `Loaded ${entries.length} applications`);
updateAvailableCategories();
}
function updateAvailableCategories() {
availableCategories = getAvailableCategories();
}
Connections {
target: Settings.data.appLauncher
function onPinnedAppsChanged() {
const wasViewingPinned = selectedCategory === "Pinned";
updateAvailableCategories();
// If we were viewing Pinned category and it's no longer available, switch to "all"
if (wasViewingPinned && !availableCategories.includes("Pinned")) {
selectedCategory = "all";
}
// Update results if we're currently viewing the Pinned category
if (selectedCategory === "Pinned" && launcher) {
launcher.updateResults();
} else if (wasViewingPinned && selectedCategory === "all" && launcher) {
// Also update results when switching to "all"
launcher.updateResults();
}
}
}
function getExecutableName(app) {
if (!app)
return "";
// Try to get executable name from command array
if (app.command && Array.isArray(app.command) && app.command.length > 0) {
const cmd = app.command[0];
// Extract just the executable name from the full path
const parts = cmd.split('/');
const executable = parts[parts.length - 1];
// Remove any arguments or parameters
return executable.split(' ')[0];
}
// Try to get from exec property if available
if (app.exec) {
const parts = app.exec.split('/');
const executable = parts[parts.length - 1];
return executable.split(' ')[0];
}
// Fallback to app id (desktop file name without .desktop)
if (app.id) {
return app.id.replace('.desktop', '');
}
return "";
}
function getResults(query) {
if (!entries || entries.length === 0)
return [];
// Set category mode based on whether there's a query
const isSearching = !!(query && query.trim() !== "");
showsCategories = !isSearching;
// Filter by category only when NOT searching
let filteredEntries = entries;
if (!isSearching && selectedCategory && selectedCategory !== "all") {
filteredEntries = entries.filter(app => appMatchesCategory(app, selectedCategory));
}
if (!query || query.trim() === "") {
// Return filtered apps, optionally sorted by usage
let sorted;
if (Settings.data.appLauncher.sortByMostUsed) {
sorted = filteredEntries.slice().sort((a, b) => {
// Pinned first
const aPinned = isAppPinned(a);
const bPinned = isAppPinned(b);
if (aPinned !== bPinned)
return aPinned ? -1 : 1;
const ua = getUsageCount(a);
const ub = getUsageCount(b);
if (ub !== ua)
return ub - ua;
return (a.name || "").toLowerCase().localeCompare((b.name || "").toLowerCase());
});
} else {
sorted = filteredEntries.slice().sort((a, b) => {
const aPinned = isAppPinned(a);
const bPinned = isAppPinned(b);
if (aPinned !== bPinned)
return aPinned ? -1 : 1;
return (a.name || "").toLowerCase().localeCompare((b.name || "").toLowerCase());
});
}
return sorted.map(app => createResultEntry(app));
}
// Use fuzzy search if available, fallback to simple search
if (typeof FuzzySort !== 'undefined') {
const fuzzyResults = FuzzySort.go(query, filteredEntries, {
"keys": ["name", "comment", "genericName", "executableName"],
"limit": 20
});
// Sort pinned first within fuzzy results while preserving fuzzysort order otherwise
const pinned = [];
const nonPinned = [];
for (const r of fuzzyResults) {
const app = r.obj;
if (isAppPinned(app))
pinned.push(r);
else
nonPinned.push(r);
}
return pinned.concat(nonPinned).map(result => createResultEntry(result.obj, result.score));
} else {
// Fallback to simple search
const searchTerm = query.toLowerCase();
return filteredEntries.filter(app => {
const name = (app.name || "").toLowerCase();
const comment = (app.comment || "").toLowerCase();
const generic = (app.genericName || "").toLowerCase();
const executable = getExecutableName(app).toLowerCase();
return name.includes(searchTerm) || comment.includes(searchTerm) || generic.includes(searchTerm) || executable.includes(searchTerm);
}).sort((a, b) => {
// Prioritize name matches, then executable matches
const aName = a.name.toLowerCase();
const bName = b.name.toLowerCase();
const aExecutable = getExecutableName(a).toLowerCase();
const bExecutable = getExecutableName(b).toLowerCase();
const aStarts = aName.startsWith(searchTerm);
const bStarts = bName.startsWith(searchTerm);
const aExecStarts = aExecutable.startsWith(searchTerm);
const bExecStarts = bExecutable.startsWith(searchTerm);
// Prioritize name matches first
if (aStarts && !bStarts)
return -1;
if (!aStarts && bStarts)
return 1;
// Then prioritize executable matches
if (aExecStarts && !bExecStarts)
return -1;
if (!aExecStarts && bExecStarts)
return 1;
return aName.localeCompare(bName);
}).slice(0, 20).map(app => createResultEntry(app));
}
}
function createResultEntry(app, score) {
return {
"appId": getAppKey(app),
"usageKey": getAppKey(app),
"name": app.name || "Unknown",
"description": app.genericName || app.comment || "",
"icon": app.icon || "application-x-executable",
"isImage": false,
"_score": (score !== undefined ? score : 0),
"provider": root,
"onActivate": function () {
// Close the launcher/SmartPanel immediately without any animations.
// Ensures we are not preventing the future focusing of the app
launcher.closeImmediately();
// Defer execution to next event loop iteration to ensure panel is fully closed
Qt.callLater(() => {
Logger.d("ApplicationsProvider", `Launching: ${app.name} (App ID: ${app.id || "unknown"})`);
const execString = (app.exec !== undefined && app.exec !== null) ? String(app.exec) : "";
const commandArgs = Array.isArray(app.command) ? app.command : (app.command && app.command.length !== undefined) ? Array.from(app.command) : [];
let hasQuotedArgs = execString.includes("\"") || execString.includes("'");
let hasSpaceArgs = false;
if (!hasQuotedArgs) {
hasQuotedArgs = commandArgs.some(arg => {
const text = String(arg);
return text.includes("\"") || text.includes("'");
});
}
if (!hasSpaceArgs) {
hasSpaceArgs = commandArgs.some(arg => String(arg).includes(" "));
}
if (app.execute && (hasQuotedArgs || hasSpaceArgs)) {
Logger.d("ApplicationsProvider", `Detected quoted/space arguments in Exec for ${app.name}, using app.execute()`);
app.execute();
return;
}
if (Settings.data.appLauncher.customLaunchPrefixEnabled && Settings.data.appLauncher.customLaunchPrefix) {
// Use custom launch prefix
const prefix = Settings.data.appLauncher.customLaunchPrefix.split(" ");
Logger.d("ApplicationsProvider", `Using custom launch prefix: ${Settings.data.appLauncher.customLaunchPrefix}`);
if (app.runInTerminal) {
const terminal = Settings.data.appLauncher.terminalCommand.split(" ");
const command = prefix.concat(terminal.concat(app.command));
Logger.d("ApplicationsProvider", `Executing command (with prefix and terminal): ${command.join(" ")}`);
Quickshell.execDetached(command);
} else {
const command = prefix.concat(app.command);
Logger.d("ApplicationsProvider", `Executing command (with prefix): ${command.join(" ")}`);
Quickshell.execDetached(command);
}
} else {
if (app.runInTerminal) {
Logger.d("ApplicationsProvider", "Executing terminal app manually: " + app.name);
const terminal = Settings.data.appLauncher.terminalCommand.split(" ");
const command = terminal.concat(app.command);
Logger.d("ApplicationsProvider", "Executing command (manual terminal): " + command.join(" "));
CompositorService.spawn(command);
} else if (app.command && app.command.length > 0) {
Logger.d("ApplicationsProvider", "Executing command: " + app.command.join(" "));
CompositorService.spawn(app.command);
} else if (app.execute) {
Logger.d("ApplicationsProvider", "Calling app.execute() for: " + app.name);
app.execute();
} else {
Logger.w("ApplicationsProvider", `Could not launch: ${app.name}. No valid launch method.`);
}
}
});
}
};
}
// -------------------------
// Item actions for launcher delegate
function getItemActions(item) {
if (!item || !item.appId)
return [];
return [
{
"icon": isAppPinned({
"id": item.appId
}) ? "unpin" : "pin",
"tooltip": isAppPinned({
"id": item.appId
}) ? I18n.tr("common.unpin") : I18n.tr("common.pin"),
"action": function () {
togglePin(item.appId);
}
}
];
}
function togglePin(appId) {
if (!appId)
return;
const normalizedId = normalizeAppId(appId);
let arr = (Settings.data.appLauncher.pinnedApps || []).slice();
const idx = arr.findIndex(pinnedId => normalizeAppId(pinnedId) === normalizedId);
if (idx >= 0)
arr.splice(idx, 1);
else
arr.push(appId);
Settings.data.appLauncher.pinnedApps = arr;
}
// -------------------------
// Usage tracking helpers
function getAppKey(app) {
if (app && app.id)
return String(app.id);
if (app && app.command && app.command.join)
return app.command.join(" ");
return String(app && app.name ? app.name : "unknown");
}
function getUsageCount(app) {
return ShellState.getLauncherUsageCount(getAppKey(app));
}
// 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}"`);
}
}
}
}
}
@@ -0,0 +1,77 @@
import QtQuick
import Quickshell
import "../../../../Helpers/AdvancedMath.js" as AdvancedMath
import qs.Commons
import qs.Services.Keyboard
import qs.Services.UI
Item {
id: root
// Provider metadata
property string name: I18n.tr("launcher.providers.calculator")
property var launcher: null
property string iconMode: Settings.data.appLauncher.iconMode
property bool handleSearch: true // Contribute to regular search
property string supportedLayouts: "list"
// Initialize provider
function init() {
Logger.d("CalculatorProvider", "Initialized");
}
// Get search results - evaluates math expressions inline
function getResults(query) {
if (!query)
return [];
const trimmed = query.trim();
if (!trimmed || !isMathExpression(trimmed))
return [];
try {
const result = AdvancedMath.evaluate(trimmed);
const formattedResult = AdvancedMath.formatResult(result);
return [
{
"name": formattedResult,
"description": I18n.tr("launcher.providers.calculator-press-enter-to-copy"),
"icon": iconMode === "tabler" ? "calculator" : "accessories-calculator",
"isTablerIcon": true,
"isImage": false,
"provider": root,
"onActivate": function () {
// Copy result to clipboard via xclip
Quickshell.execDetached(["sh", "-c", "echo -n '" + formattedResult.replace(/'/g, "'\\''") + "' | wl-copy"]);
ToastService.showNotice(I18n.tr("common.copied-to-clipboard"), formattedResult);
if (launcher)
launcher.close();
}
}
];
} catch (error) {
return [];
}
}
// Check if a string is a valid math expression
function isMathExpression(expr) {
// Allow: digits, operators, parentheses, decimal points, whitespace, letters (for functions), commas
if (!/^[\d\s\+\-\*\/\(\)\.\%\^a-zA-Z,]+$/.test(expr))
return false;
// Must contain at least one operator OR a function call (letter followed by parenthesis)
if (!/[+\-*/%\^]/.test(expr) && !/[a-zA-Z]\s*\(/.test(expr))
return false;
// Reject if ends with an operator (incomplete expression)
if (/[+\-*/%\^]\s*$/.test(expr))
return false;
// Reject if it's just letters (would match app names)
if (/^[a-zA-Z\s]+$/.test(expr))
return false;
return true;
}
}
@@ -0,0 +1,457 @@
import QtQuick
import Quickshell
import qs.Commons
import qs.Services.Keyboard
import qs.Services.Noctalia
Item {
id: root
// Provider metadata
property string name: I18n.tr("launcher.providers.clipboard")
property var launcher: null
property string iconMode: Settings.data.appLauncher.iconMode
property string supportedLayouts: "list" // List view for clipboard content
property bool wrapNavigation: false // Don't wrap at end of list
// Provider capabilities
property bool handleSearch: false // Don't handle regular search
// Preview support
property bool hasPreview: Settings.data.appLauncher.enableClipPreview
property string previewComponentPath: "./ClipboardPreview.qml"
// Image handling - expose revision for reactive updates in delegates
readonly property int imageRevision: ClipboardService.revision
// Categories
property var availableCategories: Settings.data.appLauncher.enableClipboardChips ? ["All", "Images", "Links", "Files", "Code", "Colors"] : []
property string selectedCategory: "All"
function selectCategory(cat) {
if (selectedCategory !== cat) {
selectedCategory = cat;
if (launcher) {
launcher.updateResults();
}
}
}
property var categoryIcons: {
"All": iconMode === "tabler" ? "border-all" : "view-grid",
"Images": iconMode === "tabler" ? "photo" : "image",
"Links": iconMode === "tabler" ? "link" : "insert-link",
"Files": iconMode === "tabler" ? "file" : "text-x-generic",
"Code": iconMode === "tabler" ? "code" : "text-x-script",
"Colors": iconMode === "tabler" ? "palette" : "color-picker"
}
// Internal state
property bool isWaitingForData: false
property bool gotResults: false
property string lastSearchText: ""
// Listen for clipboard data updates
Connections {
target: ClipboardService
function onListCompleted() {
if (gotResults && (lastSearchText === searchText)) {
// Do not update results after the first fetch.
// This will avoid the list resetting every 2seconds when the service updates.
return;
}
// Refresh results if we're waiting for data or if clipboard plugin is active
if (isWaitingForData || (launcher && launcher.searchText.startsWith(">clip"))) {
isWaitingForData = false;
gotResults = true;
if (launcher) {
launcher.updateResults();
}
}
}
function onActiveChanged() {
// When active state changes (e.g. dependency check completes), refresh results
if (ClipboardService.active && launcher && launcher.searchText.startsWith(">clip")) {
isWaitingForData = true;
gotResults = false;
ClipboardService.list(100);
}
}
}
// Initialize provider
function init() {
Logger.d("ClipboardProvider", "Initialized");
// Pre-load clipboard data if service is active
if (ClipboardService.active) {
ClipboardService.list(100);
}
}
// Called when launcher opens
function onOpened() {
isWaitingForData = true;
gotResults = false;
lastSearchText = "";
// Refresh clipboard history when launcher opens
if (ClipboardService.active) {
ClipboardService.list(100);
}
}
// Check if this provider handles the command
function handleCommand(searchText) {
return searchText.startsWith(">clip");
}
// Return available commands when user types ">"
function commands() {
return [
{
"name": ">clip",
"description": I18n.tr("launcher.providers.clipboard-search-description"),
"icon": iconMode === "tabler" ? "clipboard" : "diodon",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {
launcher.setSearchText(">clip ");
}
},
{
"name": ">clip clear",
"description": I18n.tr("launcher.providers.clipboard-clear-description"),
"icon": iconMode === "tabler" ? "trash" : "user-trash",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {
ClipboardService.wipeAll();
launcher.close();
}
}
];
}
// Get search results
function getResults(searchText) {
if (!searchText.startsWith(">clip")) {
return [];
}
lastSearchText = searchText;
const results = [];
const query = searchText.slice(5).trim();
// Check if clipboard service is not active
if (!ClipboardService.active) {
// If dependency check hasn't completed yet, show loading instead of disabled
if (!ClipboardService.dependencyChecked) {
return [
{
"name": I18n.tr("launcher.providers.clipboard-loading"),
"description": I18n.tr("launcher.providers.emoji-loading-description"),
"icon": iconMode === "tabler" ? "refresh" : "view-refresh",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {}
}
];
}
return [
{
"name": I18n.tr("launcher.providers.clipboard-history-disabled"),
"description": I18n.tr("launcher.providers.clipboard-history-disabled-description"),
"icon": iconMode === "tabler" ? "refresh" : "view-refresh",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {}
}
];
}
// Special command: clear
if (query === "clear") {
return [
{
"name": I18n.tr("launcher.providers.clipboard-clear-history"),
"description": I18n.tr("launcher.providers.clipboard-clear-description-full"),
"icon": iconMode === "tabler" ? "trash" : "user-trash",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {
ClipboardService.wipeAll();
launcher.close();
}
}
];
}
// Show loading state if data is being loaded
if (ClipboardService.loading || isWaitingForData) {
return [
{
"name": I18n.tr("launcher.providers.clipboard-loading"),
"description": I18n.tr("launcher.providers.emoji-loading-description"),
"icon": iconMode === "tabler" ? "refresh" : "view-refresh",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {}
}
];
}
// Get clipboard items
const items = ClipboardService.items || [];
// If no items and we haven't tried loading yet, trigger a load
if (items.count === 0 && !ClipboardService.loading) {
isWaitingForData = true;
ClipboardService.list(100);
return [
{
"name": I18n.tr("launcher.providers.clipboard-loading"),
"description": I18n.tr("launcher.providers.emoji-loading-description"),
"icon": iconMode === "tabler" ? "refresh" : "view-refresh",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {}
}
];
}
// Search clipboard items
const searchTerm = query.toLowerCase();
const now = Date.now() / 1000;
const catMap = {
"Images": "image",
"Links": "link",
"Files": "file",
"Code": "code",
"Colors": "color"
};
// Filter and format results
items.forEach(function (item) {
// Category filter
if (Settings.data.appLauncher.enableClipboardChips && root.selectedCategory !== "All") {
if (item.contentType !== catMap[root.selectedCategory]) {
return;
}
}
const preview = (item.preview || "").toLowerCase();
// Skip if search term doesn't match
if (searchTerm && preview.indexOf(searchTerm) === -1) {
return;
}
const firstSeen = ClipboardService.firstSeenById[item.id] || now;
// Format the result based on type
let entry;
if (item.isImage) {
entry = formatImageEntry(item, firstSeen);
} else {
entry = formatTextEntry(item, firstSeen);
}
// Add activation handler
entry.onActivate = function () {
if (Settings.data.appLauncher.autoPasteClipboard) {
launcher.closeImmediately();
Qt.callLater(() => {
ClipboardService.pasteFromClipboard(item.id, item.mime);
});
} else {
ClipboardService.copyToClipboard(item.id);
launcher.close();
}
};
results.push(entry);
});
// Show empty state if no results
if (results.length === 0) {
results.push({
"name": searchTerm ? "No matching clipboard items" : "Clipboard is empty",
"description": searchTerm ? `No items containing "${query}"` : "Copy something to see it here",
"icon": iconMode === "tabler" ? "clipboard" : "text-x-generic",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {// Do nothing
}
});
}
//Logger.i("ClipboardPlugin", `Returning ${results.length} results for query: "${query}"`)
return results;
}
function formatImageEntry(item, firstSeen) {
const meta = ClipboardService.parseImageMeta(item.preview);
const timeStr = Time.formatRelativeTime(new Date(firstSeen * 1000));
let desc = meta ? `${meta.fmt} ${meta.size}` : item.mime || "Image data";
if (timeStr)
desc += ` ${timeStr}`;
return {
"name": meta ? `Image ${meta.w}×${meta.h}` : "Image",
"description": desc,
"icon": iconMode === "tabler" ? "photo" : "image",
"isTablerIcon": true,
"isImage": true,
"imageWidth": meta ? meta.w : 0,
"imageHeight": meta ? meta.h : 0,
"clipboardId": item.id,
"mime": item.mime,
"preview": item.preview,
"provider": root
};
}
function formatTextEntry(item, firstSeen) {
const preview = (item.preview || "").trim();
const lines = preview.split('\n').filter(l => l.trim());
let title = lines[0] || "Empty text";
if (title.length > 60) {
title = title.substring(0, 57) + "...";
}
let description = "";
if (lines.length > 1) {
description = lines[1];
if (description.length > 80) {
description = description.substring(0, 77) + "...";
}
} else {
// Preview is truncated at ~100 chars, so we can't show exact count
if (preview.length >= 100) {
description = I18n.tr("toast.clipboard.long-text");
} else {
const chars = preview.length;
const words = preview.split(/\s+/).length;
description = `${chars} characters, ${words} word${words !== 1 ? 's' : ''}`;
}
}
const timeStr = Time.formatRelativeTime(new Date(firstSeen * 1000));
if (timeStr)
description += ` ${timeStr}`;
let defaultIcon = iconMode === "tabler" ? "clipboard" : "text-x-generic";
let colorHex = "";
if (Settings.data.appLauncher.enableClipboardSmartIcons) {
if (item.contentType === "link")
defaultIcon = iconMode === "tabler" ? "link" : "insert-link";
else if (item.contentType === "file")
defaultIcon = iconMode === "tabler" ? "file" : "text-x-generic";
else if (item.contentType === "code")
defaultIcon = iconMode === "tabler" ? "code" : "text-x-script";
else if (item.contentType === "color") {
defaultIcon = iconMode === "tabler" ? "palette" : "color-picker";
colorHex = preview;
}
}
return {
"name": title,
"description": description,
"icon": defaultIcon,
"isTablerIcon": true,
"isImage": false,
"clipboardId": item.id,
"preview": preview,
"contentType": item.contentType,
"colorHex": colorHex,
"provider": root
};
}
function getImageForItem(clipboardId) {
return ClipboardService.getImageData ? ClipboardService.getImageData(clipboardId) : null;
}
// -------------------------
// Item actions for launcher delegate
function getItemActions(item) {
if (!item || !item.clipboardId)
return [];
var actions = [];
// Annotation tool for images
if (item.isImage && Settings.data.appLauncher.screenshotAnnotationTool !== "") {
actions.push({
"icon": "pencil",
"tooltip": I18n.tr("tooltips.open-annotation-tool"),
"action": function () {
var tool = Settings.data.appLauncher.screenshotAnnotationTool;
Quickshell.execDetached(["sh", "-c", "cliphist decode " + item.clipboardId + " | " + tool]);
if (launcher)
launcher.close();
}
});
}
// Delete action
actions.push({
"icon": "trash",
"tooltip": I18n.tr("launcher.providers.clipboard-delete"),
"action": function () {
deleteItem(item);
}
});
return actions;
}
function canDeleteItem(item) {
return item && !!item.clipboardId;
}
function deleteItem(item) {
if (!item || !item.clipboardId)
return;
// Set provider state before deletion so refresh works
gotResults = false;
isWaitingForData = true;
lastSearchText = launcher ? launcher.searchText : "";
// Delete the item
ClipboardService.deleteById(String(item.clipboardId));
}
// Prepare item for display (handles image decoding)
function prepareItem(item) {
if (item && item.isImage && item.clipboardId) {
if (!ClipboardService.getImageData(item.clipboardId)) {
ClipboardService.decodeToDataUrl(item.clipboardId, item.mime, null);
}
}
}
// Get image URL for item (used by delegates)
function getImageUrl(item) {
if (!item || !item.clipboardId)
return "";
return ClipboardService.getImageData(item.clipboardId) || "";
}
// Get preview data for the preview panel
function getPreviewData(item) {
if (!item)
return null;
return {
"clipboardId": item.clipboardId,
"isImage": item.isImage,
"mime": item.mime,
"preview": item.preview
};
}
}
@@ -0,0 +1,51 @@
import QtQuick
import Quickshell
import qs.Commons
Item {
property var launcher: null
property string name: I18n.tr("common.command")
property string iconMode: Settings.data.appLauncher.iconMode
function handleCommand(query) {
return query.startsWith(">cmd");
}
function commands() {
return [
{
"name": ">cmd",
"description": I18n.tr("launcher.providers.command-description"),
"icon": iconMode === "tabler" ? "terminal" : "utilities-terminal",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {
launcher.setSearchText(">cmd ");
}
}
];
}
function getResults(query) {
if (!query.startsWith(">cmd"))
return [];
let expression = query.substring(4).trim();
return [
{
"name": I18n.tr("common.command"),
"description": I18n.tr("launcher.providers.command-description"),
"icon": iconMode === "tabler" ? "terminal" : "utilities-terminal",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {
launcher.closeImmediately();
Qt.callLater(() => {
Logger.d("CommandProvider", "Executing shell command: " + expression);
Quickshell.execDetached(["sh", "-c", expression]);
});
}
}
];
}
}
@@ -0,0 +1,165 @@
import QtQuick
import Quickshell
import qs.Commons
import qs.Services.Keyboard
Item {
id: root
// Provider metadata
property string name: I18n.tr("launcher.providers.emoji")
property var launcher: null
property string iconMode: Settings.data.appLauncher.iconMode
property bool handleSearch: false
property string supportedLayouts: "grid" // Only grid layout for emoji
property int preferredGridColumns: 7 // More columns for compact emoji display
property real preferredGridCellRatio: 1.15 // Slightly taller than wide to accommodate label
property bool supportsAutoPaste: true // Emoji can be auto-pasted
property bool ignoreDensity: false // Emoji should scale with launcher density
property string selectedCategory: "recent"
property bool showsCategories: true // Default to showing categories
// Empty state message for category view
readonly property string emptyBrowsingMessage: selectedCategory === "recent" ? I18n.tr("launcher.providers.emoji-no-recent") : ""
property var categoryIcons: ({
"all": "apps",
"recent": "clock",
"people": "user",
"animals": "paw",
"nature": "leaf",
"food": "apple",
"activity": "run",
"travel": "plane",
"objects": "home",
"symbols": "star",
"flags": "flag"
})
property var categories: ["all", "recent", "people", "animals", "nature", "food", "activity", "travel", "objects", "symbols", "flags"]
function getCategoryName(category) {
const names = {
"all": I18n.tr("launcher.categories.all"),
"recent": I18n.tr("launcher.categories.emoji-recent"),
"people": I18n.tr("launcher.categories.emoji-people"),
"animals": I18n.tr("launcher.categories.emoji-animals"),
"nature": I18n.tr("launcher.categories.emoji-nature"),
"food": I18n.tr("launcher.categories.emoji-food"),
"activity": I18n.tr("launcher.categories.emoji-activity"),
"travel": I18n.tr("launcher.categories.emoji-travel"),
"objects": I18n.tr("launcher.categories.emoji-objects"),
"symbols": I18n.tr("launcher.categories.emoji-symbols"),
"flags": I18n.tr("launcher.categories.emoji-flags")
};
return names[category] || category;
}
// Force update results when emoji service loads
Connections {
target: EmojiService
function onLoadedChanged() {
if (EmojiService.loaded && root.launcher) {
root.launcher.updateResults();
}
}
}
// Initialize provider
function init() {
Logger.d("EmojiProvider", "Initialized");
}
function selectCategory(category) {
selectedCategory = category;
if (launcher) {
launcher.updateResults();
}
}
function onOpened() {
// Always reset to "recent" category when opening
selectedCategory = "recent";
}
// Check if this provider handles the command
function handleCommand(searchText) {
return searchText.startsWith(">emoji");
}
// Return available commands when user types ">"
function commands() {
return [
{
"name": ">emoji",
"description": I18n.tr("launcher.providers.emoji-search-description"),
"icon": iconMode === "tabler" ? "mood-smile" : "face-smile",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {
launcher.setSearchText(">emoji ");
}
}
];
}
// Get search results
function getResults(searchText) {
if (!searchText.startsWith(">emoji")) {
return [];
}
if (!EmojiService.loaded) {
return [
{
"name": I18n.tr("launcher.providers.emoji-loading"),
"description": I18n.tr("launcher.providers.emoji-loading-description"),
"icon": iconMode === "tabler" ? "refresh" : "view-refresh",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {}
}
];
}
var query = searchText.slice(6).trim();
var emojis = [];
if (query !== "" || selectedCategory === "all") {
emojis = EmojiService.search(query);
} else {
emojis = EmojiService.getEmojisByCategory(selectedCategory);
}
return emojis.map(formatEmojiEntry);
}
// Format an emoji entry for the results list
function formatEmojiEntry(emoji) {
let title = emoji.name;
let description = emoji.keywords.join(", ");
if (emoji.category) {
description += " • Category: " + emoji.category;
}
const emojiChar = emoji.emoji;
return {
"name": title,
"description": description,
"icon": null,
"isImage": false,
"displayString": emojiChar,
"autoPasteText": emojiChar,
"provider": root,
"onAutoPaste": function () {
EmojiService.recordUsage(emojiChar);
},
"onActivate": function () {
EmojiService.copy(emojiChar);
launcher.close();
}
};
}
}
@@ -0,0 +1,194 @@
import QtQuick
import Quickshell
import qs.Commons
import qs.Services.Compositor
import qs.Services.UI
Item {
id: root
// Provider metadata
property string name: I18n.tr("tooltips.session-menu")
property var launcher: null
property bool handleSearch: Settings.data.appLauncher.enableSessionSearch
property string supportedLayouts: "list"
property string iconMode: Settings.data.appLauncher.iconMode
// Session actions with search keywords
readonly property var sessionActions: [
{
"action": "lock",
"labelKey": "common.lock",
"icon": iconMode === "tabler" ? "lock" : "system-lock-screen",
"keywords": ["lock", "screen", "secure"]
},
{
"action": "suspend",
"labelKey": "common.suspend",
"icon": iconMode === "tabler" ? "suspend" : "system-suspend",
"keywords": ["suspend", "sleep", "standby"]
},
{
"action": "hibernate",
"labelKey": "common.hibernate",
"icon": iconMode === "tabler" ? "hibernate" : "system-suspend-hibernate",
"keywords": ["hibernate", "disk"]
},
{
"action": "reboot",
"labelKey": "common.reboot",
"icon": iconMode === "tabler" ? "reboot" : "system-reboot",
"keywords": ["reboot", "restart", "reload"]
},
{
"action": "rebootToUefi",
"labelKey": "common.reboot-to-uefi",
"icon": iconMode === "tabler" ? "reboot" : "system-reboot",
"keywords": ["reboot", "uefi", "firmware", "bios"]
},
{
"action": "userspaceReboot",
"labelKey": "common.userspace-reboot",
"icon": iconMode === "tabler" ? "rotate" : "system-reboot",
"keywords": ["reboot", "restart", "soft", "userspace"]
},
{
"action": "logout",
"labelKey": "common.logout",
"icon": iconMode === "tabler" ? "logout" : "system-log-out",
"keywords": ["logout", "sign out", "exit", "leave"]
},
{
"action": "shutdown",
"labelKey": "common.shutdown",
"icon": iconMode === "tabler" ? "shutdown" : "system-shutdown",
"keywords": ["shutdown", "power off", "turn off", "poweroff"]
}
]
function init() {
Logger.d("SessionProvider", "Initialized");
}
function getEnabledActions() {
var powerOptions = Settings.data.sessionMenu.powerOptions || [];
var enabledSet = {};
for (var i = 0; i < powerOptions.length; i++) {
if (powerOptions[i].enabled) {
enabledSet[powerOptions[i].action] = powerOptions[i];
}
}
var enabled = [];
for (var j = 0; j < sessionActions.length; j++) {
var action = sessionActions[j];
if (enabledSet[action.action]) {
enabled.push({
"action": action.action,
"labelKey": action.labelKey,
"icon": action.icon,
"keywords": action.keywords,
"command": enabledSet[action.action].command || ""
});
}
}
return enabled;
}
function getResults(query) {
if (!query)
return [];
var trimmed = query.trim();
if (!trimmed || trimmed.length < 2)
return [];
var enabledActions = getEnabledActions();
if (enabledActions.length === 0)
return [];
// Build searchable items with resolved translations
var items = [];
for (var i = 0; i < enabledActions.length; i++) {
var action = enabledActions[i];
var label = I18n.tr(action.labelKey);
items.push({
"action": action.action,
"icon": action.icon,
"label": label,
"command": action.command,
"searchText": [label.toLowerCase()].concat(action.keywords).join(" ")
});
}
var results = FuzzySort.go(trimmed, items, {
"keys": ["label", "searchText"],
"limit": 6
});
var launcherItems = [];
for (var j = 0; j < results.length; j++) {
var entry = results[j].obj;
var score = results[j].score;
launcherItems.push({
"name": entry.label,
"description": I18n.tr("tooltips.session-menu"),
"icon": entry.icon,
"isTablerIcon": true,
"isImage": false,
"_score": score - 1,
"provider": root,
"onActivate": createActivateHandler(entry.action, entry.command)
});
}
return launcherItems;
}
function createActivateHandler(action, command) {
return function () {
if (launcher)
launcher.close();
// Execute via Qt.callLater, but reference only singletons
// (root may be destroyed after launcher.close() unloads the panel)
Qt.callLater(() => {
switch (action) {
case "lock":
if (PanelService.lockScreen && !PanelService.lockScreen.active) {
PanelService.lockScreen.active = true;
}
break;
case "suspend":
if (Settings.data.general.lockOnSuspend) {
CompositorService.lockAndSuspend();
} else {
CompositorService.suspend();
}
break;
case "hibernate":
CompositorService.hibernate();
break;
case "reboot":
CompositorService.reboot();
break;
case "rebootToUefi":
CompositorService.rebootToUefi();
break;
case "userspaceReboot":
CompositorService.userspaceReboot();
break;
case "logout":
CompositorService.logout();
break;
case "shutdown":
CompositorService.shutdown();
break;
}
});
};
}
}
@@ -0,0 +1,161 @@
import QtQuick
import qs.Commons
import qs.Services.UI
Item {
id: root
// Provider metadata
property string name: I18n.tr("common.settings")
property var launcher: null
property bool handleSearch: Settings.data.appLauncher.enableSettingsSearch
property string supportedLayouts: "list"
property string iconMode: Settings.data.appLauncher.iconMode
function init() {
Logger.d("SettingsProvider", "Initialized");
}
// Check if this provider handles the command
function handleCommand(searchText) {
return searchText.startsWith(">settings");
}
// Return available commands when user types ">"
function commands() {
return [
{
"name": ">settings",
"description": I18n.tr("launcher.providers.settings-search-description"),
"icon": iconMode === "tabler" ? "settings" : "preferences-system",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {
launcher.setSearchText(">settings ");
}
}
];
}
function getResults(query) {
if (!query || SettingsSearchService.searchIndex.length === 0)
return [];
var trimmed = query.trim();
// Handle command mode: ">settings" or ">settings <search>"
var isCommandMode = trimmed.startsWith(">settings");
if (isCommandMode) {
// Extract search term after ">settings "
var searchTerm = trimmed.substring(9).trim();
// In command mode, show all settings if no search term
if (searchTerm.length === 0) {
return getAllSettings();
}
trimmed = searchTerm;
} else {
// Regular search mode - require at least 2 chars
if (!trimmed || trimmed.length < 2)
return [];
}
// Build searchable items with resolved translations, filtering out invisible entries
let items = [];
for (let j = 0; j < SettingsSearchService.searchIndex.length; j++) {
const entry = SettingsSearchService.searchIndex[j];
if (!SettingsSearchService.isEntryVisible(entry))
continue;
items.push({
"labelKey": entry.labelKey,
"descriptionKey": entry.descriptionKey,
"widget": entry.widget,
"tab": entry.tab,
"tabLabel": entry.tabLabel,
"subTab": entry.subTab,
"subTabLabel": entry.subTabLabel || null,
"label": I18n.tr(entry.labelKey),
"description": entry.descriptionKey ? I18n.tr(entry.descriptionKey) : "",
"subTabName": entry.subTabLabel ? I18n.tr(entry.subTabLabel) : ""
});
}
const results = FuzzySort.go(trimmed, items, {
"keys": ["label", "subTabName", "description"],
"limit": 10,
"scoreFn": function (r) {
const labelScore = r[0].score;
const subTabScore = r[1].score * 1.5;
const descScore = r[2].score;
return Math.max(labelScore, subTabScore, descScore);
}
});
let launcherItems = [];
for (let i = 0; i < results.length; i++) {
const entry = results[i].obj;
const score = results[i].score;
const tabName = I18n.tr(entry.tabLabel);
const subTabName = entry.subTabName || "";
const breadcrumb = subTabName ? (tabName + " " + subTabName) : tabName;
launcherItems.push({
"name": entry.label,
"description": breadcrumb,
"icon": iconMode === "tabler" ? "settings" : "preferences-system",
"isTablerIcon": true,
"isImage": false,
"_score": score - 2,
"provider": root,
"onActivate": createActivateHandler(entry)
});
}
return launcherItems;
}
function getAllSettings() {
var launcherItems = [];
for (var j = 0; j < SettingsSearchService.searchIndex.length; j++) {
var entry = SettingsSearchService.searchIndex[j];
if (!SettingsSearchService.isEntryVisible(entry))
continue;
var label = I18n.tr(entry.labelKey);
var tabName = I18n.tr(entry.tabLabel);
var subTabName = entry.subTabLabel ? I18n.tr(entry.subTabLabel) : "";
var breadcrumb = subTabName ? (tabName + " " + subTabName) : tabName;
launcherItems.push({
"name": label,
"description": breadcrumb,
"icon": iconMode === "tabler" ? "settings" : "preferences-system",
"isTablerIcon": true,
"isImage": false,
"_score": 0,
"provider": root,
"onActivate": createActivateHandler({
"labelKey": entry.labelKey,
"descriptionKey": entry.descriptionKey,
"widget": entry.widget,
"tab": entry.tab,
"tabLabel": entry.tabLabel,
"subTab": entry.subTab,
"subTabLabel": entry.subTabLabel || null
})
});
}
return launcherItems;
}
function createActivateHandler(entry) {
return function () {
if (launcher)
launcher.close();
Qt.callLater(() => {
SettingsPanelService.openToEntry(entry, launcher.screen);
});
};
}
}
@@ -0,0 +1,159 @@
import QtQuick
import Quickshell
import qs.Commons
import qs.Services.Compositor
Item {
id: root
property string name: I18n.tr("common.windows")
property var launcher: null
property bool handleSearch: Settings.data.appLauncher.enableWindowsSearch
property string supportedLayouts: "list"
property string iconMode: Settings.data.appLauncher.iconMode
function init() {
Logger.d("WindowsProvider", "Initialized");
}
// Check if this provider handles the command
function handleCommand(searchText) {
return searchText.startsWith(">win");
}
// Return available commands when user types ">"
function commands() {
return [
{
"name": ">win",
"description": I18n.tr("launcher.providers.windows-search-description"),
"icon": iconMode === "tabler" ? "app-window" : "preferences-system-windows",
"isTablerIcon": true,
"isImage": false,
"onActivate": function () {
launcher.setSearchText(">win ");
}
}
];
}
function getResults(query) {
if (!query)
return [];
var trimmed = query.trim();
// Handle command mode: ">win" or ">win <search>"
var isCommandMode = trimmed.startsWith(">win");
if (isCommandMode) {
// Extract search term after ">win "
var searchTerm = trimmed.substring(4).trim();
// In command mode, show all windows if no search term
if (searchTerm.length === 0) {
return getAllWindows();
}
trimmed = searchTerm;
} else {
// Regular search mode - require at least 2 chars
if (trimmed.length < 2)
return [];
}
var items = [];
// Collect all windows from CompositorService
for (var i = 0; i < CompositorService.windows.count; i++) {
var win = CompositorService.windows.get(i);
items.push({
"id": win.id,
"title": win.title || "",
"appId": win.appId || "",
"workspaceId": win.workspaceId,
"isFocused": win.isFocused,
"searchText": (win.title + " " + win.appId).toLowerCase()
});
}
// Fuzzy search on title and appId
var results = FuzzySort.go(trimmed, items, {
"keys": ["title", "appId"],
"limit": 10
});
// Map to launcher items
var launcherItems = [];
for (var j = 0; j < results.length; j++) {
var entry = results[j].obj;
var score = results[j].score;
// Get icon name from DesktopEntry if available, otherwise use appId
var iconName = entry.appId;
var appEntry = ThemeIcons.findAppEntry(entry.appId);
if (appEntry && appEntry.icon) {
iconName = appEntry.icon;
}
launcherItems.push({
"name": entry.title || entry.appId,
"description": entry.appId,
"icon": iconName || "application-x-executable",
"isTablerIcon": false,
"badgeIcon": "app-window",
"_score": score,
"provider": root,
"windowId": entry.id,
"onActivate": createActivateHandler(entry)
});
}
return launcherItems;
}
function getAllWindows() {
var launcherItems = [];
for (var i = 0; i < CompositorService.windows.count; i++) {
var win = CompositorService.windows.get(i);
var iconName = win.appId;
var appEntry = ThemeIcons.findAppEntry(win.appId);
if (appEntry && appEntry.icon) {
iconName = appEntry.icon;
}
launcherItems.push({
"name": win.title || win.appId,
"description": win.appId,
"icon": iconName || "application-x-executable",
"isTablerIcon": false,
"badgeIcon": "app-window",
"_score": 0,
"provider": root,
"windowId": win.id,
"onActivate": createActivateHandler({
"id": win.id
})
});
}
return launcherItems;
}
function createActivateHandler(windowEntry) {
return function () {
if (launcher)
launcher.close();
Qt.callLater(() => {
// Find the actual window object to pass to focusWindow
for (var i = 0; i < CompositorService.windows.count; i++) {
var win = CompositorService.windows.get(i);
if (win.id === windowEntry.id) {
CompositorService.focusWindow(win);
break;
}
}
});
};
}
}