diff --git a/.temp-icon-resolver/ActiveWindow-with-resolver.qml b/.temp-icon-resolver/ActiveWindow-with-resolver.qml new file mode 100644 index 000000000..44a429b60 --- /dev/null +++ b/.temp-icon-resolver/ActiveWindow-with-resolver.qml @@ -0,0 +1,597 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import Quickshell.Widgets +import qs.Commons +import qs.Modules.Bar.Extras +import qs.Services.Compositor +import qs.Services.Icons +import qs.Services.UI +import qs.Widgets + +Item { + id: root + + property ShellScreen screen + + // Widget properties passed from Bar.qml for per-instance settings + property string widgetId: "" + property string section: "" + property int sectionWidgetIndex: -1 + property int sectionWidgetsCount: 0 + property real scaling: 1.0 + + property var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] || {} + property var widgetSettings: { + if (section && sectionWidgetIndex >= 0) { + var widgets = Settings.data.bar.widgets[section]; + if (widgets && sectionWidgetIndex < widgets.length && widgets[sectionWidgetIndex]) { + return widgets[sectionWidgetIndex]; + } + } + return {}; + } + + // Widget settings - matching MediaMini pattern + readonly property bool showIcon: (widgetSettings.showIcon !== undefined) ? widgetSettings.showIcon : (widgetMetadata.showIcon || false) + readonly property string hideMode: (widgetSettings.hideMode !== undefined) ? widgetSettings.hideMode : (widgetMetadata.hideMode || "hidden") + readonly property string scrollingMode: (widgetSettings.scrollingMode !== undefined) ? widgetSettings.scrollingMode : (widgetMetadata.scrollingMode || "hover") + + // Maximum widget width with user settings support + readonly property real maxWidth: (widgetSettings.maxWidth !== undefined) ? widgetSettings.maxWidth : Math.max(widgetMetadata.maxWidth || 0, screen ? screen.width * 0.06 : 0) + readonly property bool useFixedWidth: (widgetSettings.useFixedWidth !== undefined) ? widgetSettings.useFixedWidth : (widgetMetadata.useFixedWidth || false) + + readonly property bool isVerticalBar: (Settings.data.bar.position === "left" || Settings.data.bar.position === "right") + readonly property bool hasFocusedWindow: CompositorService.getFocusedWindow() !== null + readonly property string windowTitle: CompositorService.getFocusedWindowTitle() || "No active window" + readonly property string fallbackIcon: "user-desktop" + + implicitHeight: visible ? (isVerticalBar ? (((!hasFocusedWindow) && hideMode === "hidden") ? 0 : calculatedVerticalDimension()) : Style.capsuleHeight) : 0 + implicitWidth: visible ? (isVerticalBar ? (((!hasFocusedWindow) && hideMode === "hidden") ? 0 : calculatedVerticalDimension()) : (((!hasFocusedWindow) && hideMode === "hidden") ? 0 : dynamicWidth)) : 0 + + // "visible": Always Visible, "hidden": Hide When Empty, "transparent": Transparent When Empty + visible: (hideMode !== "hidden" || hasFocusedWindow) || opacity > 0 + opacity: ((hideMode !== "hidden" || hasFocusedWindow) && (hideMode !== "transparent" || hasFocusedWindow)) ? 1.0 : 0.0 + Behavior on opacity { + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.OutCubic + } + } + + Behavior on implicitWidth { + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.InOutCubic + } + } + + Behavior on implicitHeight { + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.InOutCubic + } + } + + function calculatedVerticalDimension() { + return Math.round((Style.baseWidgetSize - 5) * scaling); + } + + function calculateContentWidth() { + // Calculate the actual content width based on visible elements + var contentWidth = 0; + var margins = Style.marginS * scaling * 2; // Left and right margins + + // Icon width (if visible) + if (showIcon) { + contentWidth += 18 * scaling; + contentWidth += Style.marginS * scaling; // Spacing after icon + } + + // Text width (use the measured width) + contentWidth += fullTitleMetrics.contentWidth; + + // Additional small margin for text + contentWidth += Style.marginXXS * 2; + + // Add container margins + contentWidth += margins; + + return Math.ceil(contentWidth); + } + + // Dynamic width: adapt to content but respect maximum width setting + readonly property real dynamicWidth: { + // If using fixed width mode, always use maxWidth + if (useFixedWidth) { + return maxWidth; + } + // Otherwise, adapt to content + if (!hasFocusedWindow) { + return Math.min(calculateContentWidth(), maxWidth); + } + // Use content width but don't exceed user-set maximum width + return Math.min(calculateContentWidth(), maxWidth); + } + + function getAppIcon() { + try { + const focusedWindow = CompositorService.getFocusedWindow(); + let appId = null; + + if (focusedWindow && focusedWindow.appId) { + const idValue = focusedWindow.appId; + appId = (typeof idValue === 'string') ? idValue : String(idValue); + } else if (CompositorService.isHyprland && ToplevelManager && ToplevelManager.activeToplevel) { + const activeToplevel = ToplevelManager.activeToplevel; + if (activeToplevel.appId) { + const idValue2 = activeToplevel.appId; + appId = (typeof idValue2 === 'string') ? idValue2 : String(idValue2); + } + } + + const iconName = appId ? AppSearch.guessIcon(appId.toLowerCase()) : AppSearch.guessIcon(fallbackIcon); + Logger.d("ActiveWindow", "getAppIcon returning:", iconName) + return iconName; + } catch (e) { + Logger.w("ActiveWindow", "Error in getAppIcon:", e); + const iconName = AppSearch.guessIcon(fallbackIcon); + Logger.d("ActiveWindow", "getAppIcon returning (error fallback):", iconName) + return iconName; + } + } + + // Hidden text element to measure full title width + NText { + id: fullTitleMetrics + visible: false + text: windowTitle + pointSize: Style.fontSizeS * scaling + applyUiScale: false + font.weight: Style.fontWeightMedium + } + + NPopupContextMenu { + id: contextMenu + + model: [ + { + "label": I18n.tr("context-menu.widget-settings"), + "action": "widget-settings", + "icon": "settings" + }, + ] + + onTriggered: action => { + var popupMenuWindow = PanelService.getPopupMenuWindow(screen); + if (popupMenuWindow) { + popupMenuWindow.close(); + } + + if (action === "widget-settings") { + BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings); + } + } + } + + Rectangle { + id: windowActiveRect + visible: root.visible + anchors.verticalCenter: parent.verticalCenter + width: isVerticalBar ? ((!hasFocusedWindow) && hideMode === "hidden" ? 0 : calculatedVerticalDimension()) : ((!hasFocusedWindow) && (hideMode === "hidden") ? 0 : dynamicWidth) + height: isVerticalBar ? ((!hasFocusedWindow) && hideMode === "hidden" ? 0 : calculatedVerticalDimension()) : Style.capsuleHeight + radius: Style.radiusM + color: Style.capsuleColor + border.color: Style.capsuleBorderColor + border.width: Style.capsuleBorderWidth + + // Smooth width transition + Behavior on width { + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.InOutCubic + } + } + + Item { + id: mainContainer + anchors.fill: parent + anchors.leftMargin: isVerticalBar ? 0 : Style.marginS * scaling + anchors.rightMargin: isVerticalBar ? 0 : Style.marginS * scaling + + // Horizontal layout for top/bottom bars + RowLayout { + id: rowLayout + anchors.verticalCenter: parent.verticalCenter + spacing: Style.marginS * scaling + visible: !isVerticalBar + z: 1 + + // Window icon + Item { + Layout.preferredWidth: 18 * scaling + Layout.preferredHeight: 18 * scaling + Layout.alignment: Qt.AlignVCenter + visible: showIcon + + IconImage { + id: windowIcon + anchors.fill: parent + asynchronous: true + smooth: true + visible: source !== "" + + Component.onCompleted: { + var iconName = getAppIcon(); + // Resolve with fallback (caches ThemeIcons results for performance) + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + windowIcon.source = path; + } + // If path is empty, leave blank (resolver not ready yet) + }); + } + + // Refresh icon when IconResolver becomes ready (in case it wasn't ready on first load) + Connections { + target: IconResolver + function onReadyChanged() { + if (IconResolver.ready && (windowIcon.source === "" || !windowIcon.source)) { + // If icon is still empty when resolver becomes ready, try again + var iconName = getAppIcon(); + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + windowIcon.source = path; + } + }); + } + } + function onResolverRestarted() { + // Clear icon source and re-resolve when theme changes + windowIcon.source = ""; + var iconName = getAppIcon(); + // Wait for resolver to become ready, then resolve + Qt.callLater(function() { + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + windowIcon.source = path; + } + }); + }); + } + } + + layer.enabled: widgetSettings.colorizeIcons !== false + layer.effect: ShaderEffect { + property color targetColor: Color.mOnSurface + property real colorizeMode: 0.0 + fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/appicon_colorize.frag.qsb") + } + } + } + + // Title container with scrolling + Item { + id: titleContainer + Layout.preferredWidth: { + // Calculate available width based on other elements + var iconWidth = (showIcon && windowIcon.visible ? (18 + Style.marginS) : 0); + var totalMargins = Style.marginXXS * 2; + var availableWidth = mainContainer.width - iconWidth - totalMargins; + return Math.max(20, availableWidth); + } + Layout.maximumWidth: Layout.preferredWidth + Layout.alignment: Qt.AlignVCenter + Layout.preferredHeight: titleText.height + + clip: true + + property bool isScrolling: false + property bool isResetting: false + property real textWidth: fullTitleMetrics.contentWidth + property real containerWidth: width + property bool needsScrolling: textWidth > containerWidth + + // Timer for "always" mode with delay + Timer { + id: scrollStartTimer + interval: 1000 + repeat: false + onTriggered: { + if (scrollingMode === "always" && titleContainer.needsScrolling) { + titleContainer.isScrolling = true; + titleContainer.isResetting = false; + } + } + } + + // Update scrolling state based on mode + property var updateScrollingState: function () { + if (scrollingMode === "never") { + isScrolling = false; + isResetting = false; + } else if (scrollingMode === "always") { + if (needsScrolling) { + if (mouseArea.containsMouse) { + isScrolling = false; + isResetting = true; + } else { + scrollStartTimer.restart(); + } + } else { + scrollStartTimer.stop(); + isScrolling = false; + isResetting = false; + } + } else if (scrollingMode === "hover") { + if (mouseArea.containsMouse && needsScrolling) { + isScrolling = true; + isResetting = false; + } else { + isScrolling = false; + if (needsScrolling) { + isResetting = true; + } + } + } + } + + onWidthChanged: updateScrollingState() + Component.onCompleted: updateScrollingState() + + // React to hover changes + Connections { + target: mouseArea + function onContainsMouseChanged() { + titleContainer.updateScrollingState(); + } + } + + // Scrolling content with seamless loop + Item { + id: scrollContainer + height: parent.height + width: childrenRect.width + + property real scrollX: 0 + x: scrollX + + RowLayout { + spacing: 50 // Gap between text copies + + NText { + id: titleText + text: windowTitle + pointSize: Style.fontSizeS * scaling + applyUiScale: false + font.weight: Style.fontWeightMedium + verticalAlignment: Text.AlignVCenter + color: Color.mOnSurface + onTextChanged: { + if (root.scrollingMode === "always") { + titleContainer.isScrolling = false; + titleContainer.isResetting = false; + scrollContainer.scrollX = 0; + scrollStartTimer.restart(); + } + } + } + + // Second copy for seamless scrolling + NText { + text: windowTitle + font: titleText.font + pointSize: Style.fontSizeS * scaling + applyUiScale: false + verticalAlignment: Text.AlignVCenter + color: Color.mOnSurface + visible: titleContainer.needsScrolling && titleContainer.isScrolling + } + } + + // Reset animation + NumberAnimation on scrollX { + running: titleContainer.isResetting + to: 0 + duration: 300 + easing.type: Easing.OutQuad + onFinished: { + titleContainer.isResetting = false; + } + } + + // Seamless infinite scroll + NumberAnimation on scrollX { + id: infiniteScroll + running: titleContainer.isScrolling && !titleContainer.isResetting + from: 0 + to: -(titleContainer.textWidth + 50) + duration: Math.max(4000, windowTitle.length * 100) + loops: Animation.Infinite + easing.type: Easing.Linear + } + } + } + } + + // Vertical layout for left/right bars - icon only + Item { + id: verticalLayout + anchors.centerIn: parent + width: parent.width - Style.marginM * 2 + height: parent.height - Style.marginM * 2 + visible: isVerticalBar + z: 1 + + // Window icon + Item { + width: Style.baseWidgetSize * 0.5 * scaling + height: width + anchors.centerIn: parent + visible: windowTitle !== "" + + IconImage { + id: windowIconVertical + anchors.fill: parent + asynchronous: true + smooth: true + visible: source !== "" + + Component.onCompleted: { + var iconName = getAppIcon(); + // Resolve with fallback (caches ThemeIcons results for performance) + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + windowIconVertical.source = path; + } + // If path is empty, leave blank (resolver not ready yet) + }); + } + + // Refresh icon when IconResolver becomes ready (in case it wasn't ready on first load) + Connections { + target: IconResolver + function onReadyChanged() { + if (IconResolver.ready && (windowIconVertical.source === "" || !windowIconVertical.source)) { + // If icon is still empty when resolver becomes ready, try again + var iconName = getAppIcon(); + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + windowIconVertical.source = path; + } + }); + } + } + function onResolverRestarted() { + // Clear icon source and re-resolve when theme changes + windowIconVertical.source = ""; + var iconName = getAppIcon(); + // Wait for resolver to become ready, then resolve + Qt.callLater(function() { + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + windowIconVertical.source = path; + } + }); + }); + } + } + + layer.enabled: widgetSettings.colorizeIcons !== false + layer.effect: ShaderEffect { + property color targetColor: Color.mOnSurface + property real colorizeMode: 0.0 + fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/appicon_colorize.frag.qsb") + } + } + } + } + + // Mouse area for hover detection + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + onEntered: { + if ((windowTitle !== "") && isVerticalBar || (scrollingMode === "never")) { + TooltipService.show(root, windowTitle, BarService.getTooltipDirection()); + } + } + onExited: { + TooltipService.hide(); + } + onClicked: mouse => { + if (mouse.button === Qt.RightButton) { + var popupMenuWindow = PanelService.getPopupMenuWindow(screen); + if (popupMenuWindow) { + popupMenuWindow.showContextMenu(contextMenu); + contextMenu.openAtItem(root, screen); + } + } + } + } + } + } + + Connections { + target: CompositorService + function onActiveWindowChanged() { + try { + // Clear icons immediately to prevent showing stale icons during resolver restart + windowIcon.source = ""; + windowIconVertical.source = ""; + + const iconName = getAppIcon(); + + // If resolver is restarting, wait for it to be ready before resolving + // This prevents mismatched icons when switching windows during theme changes + if (IconResolver.isRestarting || !IconResolver.ready) { + // Wait for resolver to be ready, then resolve + var checkReady = function() { + if (IconResolver.ready && !IconResolver.isRestarting) { + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + windowIcon.source = path; + windowIconVertical.source = path; + } + }); + } else { + // Check again in a moment + Qt.callLater(checkReady); + } + }; + Qt.callLater(checkReady); + } else { + // Resolver is ready, resolve immediately + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + windowIcon.source = path; + windowIconVertical.source = path; + } + }); + } + } catch (e) { + Logger.w("ActiveWindow", "Error in onActiveWindowChanged:", e); + } + } + function onWindowListChanged() { + try { + // Clear icons immediately to prevent showing stale icons during resolver restart + windowIcon.source = ""; + windowIconVertical.source = ""; + + const iconName = getAppIcon(); + + // If resolver is restarting, wait for it to be ready before resolving + if (IconResolver.isRestarting || !IconResolver.ready) { + var checkReady = function() { + if (IconResolver.ready && !IconResolver.isRestarting) { + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + windowIcon.source = path; + windowIconVertical.source = path; + } + }); + } else { + Qt.callLater(checkReady); + } + }; + Qt.callLater(checkReady); + } else { + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + windowIcon.source = path; + windowIconVertical.source = path; + } + }); + } + } catch (e) { + Logger.w("ActiveWindow", "Error in onWindowListChanged:", e); + } + } + } +} diff --git a/.temp-icon-resolver/Dock-with-resolver.qml b/.temp-icon-resolver/Dock-with-resolver.qml new file mode 100644 index 000000000..192603ed6 --- /dev/null +++ b/.temp-icon-resolver/Dock-with-resolver.qml @@ -0,0 +1,784 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import Quickshell.Widgets +import qs.Commons +import qs.Services.Icons +import qs.Services.UI +import qs.Widgets + +Loader { + + active: Settings.data.dock.enabled + sourceComponent: Variants { + model: Quickshell.screens + + delegate: Item { + id: root + + required property ShellScreen modelData + + property bool barIsReady: modelData ? BarService.isBarReady(modelData.name) : false + + Connections { + target: BarService + function onBarReadyChanged(screenName) { + if (screenName === modelData.name) { + barIsReady = true; + } + } + } + + // Update dock apps when toplevels change + Connections { + target: ToplevelManager ? ToplevelManager.toplevels : null + function onValuesChanged() { + updateDockApps(); + } + } + + // Update dock apps when pinned apps change + Connections { + target: Settings.data.dock + function onPinnedAppsChanged() { + updateDockApps(); + } + function onOnlySameOutputChanged() { + updateDockApps(); + } + } + + // Initial update when component is ready + Component.onCompleted: { + if (ToplevelManager) { + updateDockApps(); + } + } + + // Shared properties between peek and dock windows + readonly property string displayMode: Settings.data.dock.displayMode + readonly property bool autoHide: displayMode === "auto_hide" + readonly property bool exclusive: displayMode === "exclusive" + readonly property int hideDelay: 500 + readonly property int showDelay: 100 + readonly property int hideAnimationDuration: Math.max(0, Math.round(Style.animationFast / (Settings.data.dock.animationSpeed || 1.0))) + readonly property int showAnimationDuration: Math.max(0, Math.round(Style.animationFast / (Settings.data.dock.animationSpeed || 1.0))) + readonly property int peekHeight: 1 + readonly property int iconSize: Math.round(12 + 24 * (Settings.data.dock.size ?? 1)) + readonly property int floatingMargin: Settings.data.dock.floatingRatio * Style.marginL + + // Bar detection and positioning properties + readonly property bool hasBar: modelData && modelData.name ? (Settings.data.bar.monitors.includes(modelData.name) || (Settings.data.bar.monitors.length === 0)) : false + readonly property bool barAtBottom: hasBar && Settings.data.bar.position === "bottom" + readonly property int barHeight: Style.barHeight + + // Shared state between windows + property bool dockHovered: false + property bool anyAppHovered: false + property bool menuHovered: false + property bool hidden: autoHide + property bool peekHovered: false + + // Separate property to control Loader - stays true during animations + property bool dockLoaded: !autoHide // Start loaded if autoHide is off + + // Track the currently open context menu + property var currentContextMenu: null + + + // Combined model of running apps and pinned apps + property var dockApps: [] + + // Function to close any open context menu + function closeAllContextMenus() { + if (currentContextMenu && currentContextMenu.visible) { + currentContextMenu.hide(); + } + } + + // 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 ID matches a pinned app (case-insensitive) + function isAppIdPinned(appId, pinnedApps) { + if (!appId || !pinnedApps || pinnedApps.length === 0) + return false; + const normalizedId = normalizeAppId(appId); + return pinnedApps.some(pinnedId => normalizeAppId(pinnedId) === normalizedId); + } + + // Helper function to get app name from desktop entry + function getAppNameFromDesktopEntry(appId) { + if (!appId) + return appId; + + try { + if (typeof DesktopEntries !== 'undefined' && DesktopEntries.heuristicLookup) { + const entry = DesktopEntries.heuristicLookup(appId); + if (entry && entry.name) { + return entry.name; + } + } + + if (typeof DesktopEntries !== 'undefined' && DesktopEntries.byId) { + const entry = DesktopEntries.byId(appId); + if (entry && entry.name) { + return entry.name; + } + } + } catch (e) + // Fall through to return original appId + {} + + // Return original appId if we can't find a desktop entry + return appId; + } + + function getIconForApp(appId) { + if (!appId) return "image-missing"; + return AppSearch.guessIcon(appId); + } + + // Function to update the combined dock apps model + function updateDockApps() { + const runningApps = ToplevelManager ? (ToplevelManager.toplevels.values || []) : []; + const pinnedApps = Settings.data.dock.pinnedApps || []; + const combined = []; + const processedToplevels = new Set(); + const processedPinnedAppIds = new Set(); + + //push an app onto combined with the given appType + function pushApp(appType, toplevel, appId, title) { + // For running apps, track by toplevel object to allow multiple instances + if (toplevel) { + if (processedToplevels.has(toplevel)) { + return; // Already processed this toplevel instance + } + if (Settings.data.dock.onlySameOutput && toplevel.screens && !toplevel.screens.includes(modelData)) { + return; // Filtered out by onlySameOutput setting + } + combined.push({ + "type": appType, + "toplevel": toplevel, + "appId": appId, + "title": title + }); + processedToplevels.add(toplevel); + } else { + // For pinned apps that aren't running, track by appId to avoid duplicates + if (processedPinnedAppIds.has(appId)) { + return; // Already processed this pinned app + } + combined.push({ + "type": appType, + "toplevel": toplevel, + "appId": appId, + "title": title + }); + processedPinnedAppIds.add(appId); + } + } + + function pushRunning(first) { + runningApps.forEach(toplevel => { + if (toplevel) { + // Skip pinned apps if they were already processed (when pinnedStatic is true) + const isPinned = pinnedApps.includes(toplevel.appId); + if (!first && isPinned && processedToplevels.has(toplevel)) { + return; // Already added by pushPinned() + } + pushApp((first && isPinned) ? "pinned-running" : "running", toplevel, toplevel.appId, toplevel.title); + } + }); + } + + function pushPinned() { + pinnedApps.forEach(pinnedAppId => { + // Find all running instances of this pinned app + const matchingToplevels = runningApps.filter(app => app && app.appId === pinnedAppId); + + if (matchingToplevels.length > 0) { + // Add all running instances as pinned-running + matchingToplevels.forEach(toplevel => { + pushApp("pinned-running", toplevel, pinnedAppId, toplevel.title); + }); + } else { + // App is pinned but not running - add once + pushApp("pinned", null, pinnedAppId, pinnedAppId); + } + }); + } + + //if pinnedStatic then push all pinned and then all remaining running apps + if (Settings.data.dock.pinnedStatic) { + pushPinned(); + pushRunning(false); + + //else add all running apps and then remaining pinned apps + } else { + pushRunning(true); + pushPinned(); + } + + dockApps = combined; + } + + // Timer to unload dock after hide animation completes + Timer { + id: unloadTimer + interval: hideAnimationDuration + 50 // Add small buffer + onTriggered: { + if (hidden && autoHide) { + dockLoaded = false; + } + } + } + + // Timer for auto-hide delay + Timer { + id: hideTimer + interval: hideDelay + onTriggered: { + // Force menuHovered to false if no menu is current or visible + if (!root.currentContextMenu || !root.currentContextMenu.visible) { + menuHovered = false; + } + if (autoHide && !dockHovered && !anyAppHovered && !peekHovered && !menuHovered) { + hidden = true; + unloadTimer.restart(); // Start unload timer when hiding + } else if (autoHide && !dockHovered && !peekHovered) { + // Restart timer if menu is closing (handles race condition) + restart(); + } + } + } + + // Timer for show delay + Timer { + id: showTimer + interval: showDelay + onTriggered: { + if (autoHide) { + dockLoaded = true; // Load dock immediately + hidden = false; // Then trigger show animation + unloadTimer.stop(); // Cancel any pending unload + } + } + } + + // Watch for autoHide setting changes + onAutoHideChanged: { + if (!autoHide) { + hidden = false; + dockLoaded = true; + hideTimer.stop(); + showTimer.stop(); + unloadTimer.stop(); + } else { + hidden = true; + unloadTimer.restart(); // Schedule unload after animation + } + } + + // PEEK WINDOW - Always visible when auto-hide is enabled + Loader { + active: (barIsReady || !hasBar) && modelData && (Settings.data.dock.monitors.length === 0 || Settings.data.dock.monitors.includes(modelData.name)) && autoHide + + sourceComponent: PanelWindow { + id: peekWindow + + screen: modelData + anchors.bottom: true + anchors.left: true + anchors.right: true + focusable: false + color: Color.transparent + + WlrLayershell.namespace: "noctalia-dock-peek-" + (screen?.name || "unknown") + WlrLayershell.exclusionMode: ExclusionMode.Ignore + implicitHeight: peekHeight + + MouseArea { + id: peekArea + anchors.fill: parent + hoverEnabled: true + + onEntered: { + peekHovered = true; + if (hidden) { + showTimer.start(); + } + } + + onExited: { + peekHovered = false; + if (!hidden && !dockHovered && !anyAppHovered && !menuHovered) { + hideTimer.restart(); + } + } + } + } + } + + // DOCK WINDOW + Loader { + id: dockWindowLoader + active: Settings.data.dock.enabled && (barIsReady || !hasBar) && modelData && (Settings.data.dock.monitors.length === 0 || Settings.data.dock.monitors.includes(modelData.name)) && dockLoaded && ToplevelManager && (dockApps.length > 0) + + sourceComponent: PanelWindow { + id: dockWindow + + screen: modelData + + focusable: false + color: Color.transparent + + WlrLayershell.namespace: "noctalia-dock-" + (screen?.name || "unknown") + WlrLayershell.exclusionMode: exclusive ? ExclusionMode.Auto : ExclusionMode.Ignore + + // Size to fit the dock container exactly + implicitWidth: dockContainerWrapper.width + implicitHeight: dockContainerWrapper.height + + // Position above the bar if it's at bottom + anchors.bottom: true + + margins.bottom: { + switch (Settings.data.bar.position) { + case "bottom": + return (Style.barHeight + Style.marginM) + (Settings.data.bar.floating ? Settings.data.bar.marginVertical * Style.marginXL + floatingMargin : floatingMargin); + default: + return floatingMargin; + } + } + + // Wrapper item for scale/opacity animations + Item { + id: dockContainerWrapper + width: dockContainer.width + height: dockContainer.height + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + + // Apply animations to this wrapper + opacity: hidden ? 0 : 1 + scale: hidden ? 0.85 : 1 + + Behavior on opacity { + NumberAnimation { + duration: hidden ? hideAnimationDuration : showAnimationDuration + easing.type: Easing.InOutQuad + } + } + + Behavior on scale { + NumberAnimation { + duration: hidden ? hideAnimationDuration : showAnimationDuration + easing.type: hidden ? Easing.InQuad : Easing.OutBack + easing.overshoot: hidden ? 0 : 1.05 + } + } + + Rectangle { + id: dockContainer + width: dockLayout.implicitWidth + Style.marginM * 2 + height: Math.round(iconSize * 1.5) + color: Qt.alpha(Color.mSurface, Settings.data.dock.backgroundOpacity) + anchors.centerIn: parent + radius: Style.radiusL + border.width: Style.borderS + border.color: Qt.alpha(Color.mOutline, Settings.data.dock.backgroundOpacity) + + // Enable layer caching to reduce GPU usage from continuous animations + // (pulse animations on active indicators run infinitely) + layer.enabled: true + + MouseArea { + id: dockMouseArea + anchors.fill: parent + hoverEnabled: true + + onEntered: { + dockHovered = true; + if (autoHide) { + showTimer.stop(); + hideTimer.stop(); + unloadTimer.stop(); // Cancel unload if hovering + } + } + + onExited: { + dockHovered = false; + if (autoHide && !anyAppHovered && !peekHovered && !menuHovered) { + hideTimer.restart(); + } + } + + onClicked: { + // Close any open context menu when clicking on the dock background + closeAllContextMenus(); + } + } + + Item { + id: dock + width: dockLayout.implicitWidth + height: parent.height - (Style.marginM * 2) + anchors.centerIn: parent + + RowLayout { + id: dockLayout + spacing: Style.marginM + Layout.preferredHeight: parent.height + anchors.centerIn: parent + + Repeater { + model: dockApps + + delegate: Item { + id: appButton + Layout.preferredWidth: iconSize + Layout.preferredHeight: iconSize + Layout.alignment: Qt.AlignCenter + + property bool isActive: modelData.toplevel && ToplevelManager.activeToplevel && ToplevelManager.activeToplevel === modelData.toplevel + property bool hovered: appMouseArea.containsMouse + property string appId: modelData ? modelData.appId : "" + property string appTitle: { + if (!modelData) + return ""; + // For running apps, use the toplevel title directly (reactive) + if (modelData.toplevel) { + const toplevelTitle = modelData.toplevel.title || ""; + // If title is "Loading..." or empty, use desktop entry name + if (!toplevelTitle || toplevelTitle === "Loading..." || toplevelTitle.trim() === "") { + return root.getAppNameFromDesktopEntry(modelData.appId) || modelData.appId; + } + return toplevelTitle; + } + // For pinned apps that aren't running, use the stored title + return modelData.title || modelData.appId || ""; + } + property bool isRunning: modelData && (modelData.type === "running" || modelData.type === "pinned-running") + + // Listen for the toplevel being closed + Connections { + target: modelData?.toplevel + function onClosed() { + Qt.callLater(root.updateDockApps); + } + } + + IconImage { + id: appIconImage + anchors.centerIn: parent + width: parent.width * 0.7 + height: parent.height * 0.7 + asynchronous: true + smooth: true + + property string currentAppId: modelData?.appId || "" + + // Dim pinned apps that aren't running + opacity: appButton.isRunning ? 1.0 : Settings.data.dock.deadOpacity + scale: appButton.hovered ? 1.15 : 1.0 + + Component.onCompleted: { + if (currentAppId) { + const iconName = root.getIconForApp(currentAppId); + // Resolve with fallback (caches ThemeIcons results for performance) + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + appIconImage.source = path; + } + // If path is empty, leave blank (resolver not ready yet) + }); + } + } + + onCurrentAppIdChanged: { + if (currentAppId) { + // Clear icon immediately to prevent showing stale icons during resolver restart + appIconImage.source = ""; + const iconName = root.getIconForApp(currentAppId); + // Resolve with fallback when app ID changes + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + appIconImage.source = path; + } + // If path is empty, leave blank (resolver not ready yet) + }); + } + } + + // Refresh icon when IconResolver becomes ready (in case it wasn't ready on first load) + Connections { + target: IconResolver + function onReadyChanged() { + if (IconResolver.ready && appIconImage.currentAppId && (appIconImage.source === "" || !appIconImage.source)) { + // If icon is still empty when resolver becomes ready, try again + const iconName = root.getIconForApp(appIconImage.currentAppId); + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + appIconImage.source = path; + } + }); + } + } + function onResolverRestarted() { + // Clear icon source and re-resolve when theme changes + if (appIconImage.currentAppId) { + appIconImage.source = ""; + const iconName = root.getIconForApp(appIconImage.currentAppId); + + // If resolver is restarting, wait for it to be ready before resolving + // This prevents mismatched icons when switching apps during theme changes + if (IconResolver.isRestarting || !IconResolver.ready) { + // Wait for resolver to be ready, then resolve + var checkReady = function() { + if (IconResolver.ready && !IconResolver.isRestarting) { + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + appIconImage.source = path; + } + }); + } else { + // Check again in a moment + Qt.callLater(checkReady); + } + }; + Qt.callLater(checkReady); + } else { + // Resolver is ready, resolve immediately + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + appIconImage.source = path; + } + }); + } + } + } + } + + Behavior on scale { + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.OutBack + easing.overshoot: 1.2 + } + } + + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutQuad + } + } + + layer.enabled: (Settings.data.dock.grayscaleInactiveIcons || false) && !isActive + layer.effect: ShaderEffect { + property color targetColor: Color.mOnSurface + property real colorizeMode: isActive ? 0.0 : (Settings.data.dock.grayscaleMode === "colorize" ? 1.0 : 0.0) + fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/appicon_colorize.frag.qsb") + } + } + + // Fall back if no icon + NIcon { + anchors.centerIn: parent + visible: !appIconImage.visible + icon: "question-mark" + pointSize: iconSize * 0.7 + color: appButton.isActive ? Color.mPrimary : Color.mOnSurfaceVariant + opacity: appButton.isRunning ? 1.0 : 0.6 + scale: appButton.hovered ? 1.15 : 1.0 + + Behavior on scale { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutBack + easing.overshoot: 1.2 + } + } + + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutQuad + } + } + } + + // Context menu popup + DockMenu { + id: contextMenu + onHoveredChanged: { + // Only update menuHovered if this menu is current and visible + if (root.currentContextMenu === contextMenu && contextMenu.visible) { + menuHovered = hovered; + } else { + menuHovered = false; + } + } + + Connections { + target: contextMenu + function onRequestClose() { + // Clear current menu immediately to prevent hover updates + root.currentContextMenu = null; + hideTimer.stop(); + contextMenu.hide(); + menuHovered = false; + anyAppHovered = false; + } + } + onAppClosed: root.updateDockApps // Force immediate dock update when app is closed + onVisibleChanged: { + if (visible) { + root.currentContextMenu = contextMenu; + anyAppHovered = false; + } else if (root.currentContextMenu === contextMenu) { + root.currentContextMenu = null; + hideTimer.stop(); + menuHovered = false; + anyAppHovered = false; + // Restart hide timer after menu closes + if (autoHide && !dockHovered && !anyAppHovered && !peekHovered && !menuHovered) { + hideTimer.restart(); + } + } + } + } + + MouseArea { + id: appMouseArea + objectName: "appMouseArea" + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton + + onEntered: { + anyAppHovered = true; + const appName = appButton.appTitle || appButton.appId || "Unknown"; + const tooltipText = appName.length > 40 ? appName.substring(0, 37) + "..." : appName; + if (!contextMenu.visible) { + TooltipService.show(appButton, tooltipText, "top"); + } + if (autoHide) { + showTimer.stop(); + hideTimer.stop(); + unloadTimer.stop(); // Cancel unload if hovering app + } + } + + onExited: { + anyAppHovered = false; + TooltipService.hide(); + // Clear menuHovered if no current menu or menu not visible + if (!root.currentContextMenu || !root.currentContextMenu.visible) { + menuHovered = false; + } + if (autoHide && !dockHovered && !peekHovered && !menuHovered) { + hideTimer.restart(); + } + } + + onClicked: function (mouse) { + if (mouse.button === Qt.RightButton) { + // If right-clicking on the same app with an open context menu, close it + if (root.currentContextMenu === contextMenu && contextMenu.visible) { + root.closeAllContextMenus(); + return; + } + // Close any other existing context menu first + root.closeAllContextMenus(); + // Hide tooltip when showing context menu + TooltipService.hideImmediately(); + contextMenu.show(appButton, modelData.toplevel || modelData); + return; + } + + // Close any existing context menu for non-right-click actions + root.closeAllContextMenus(); + + // Check if toplevel is still valid (not a stale reference) + const isValidToplevel = modelData?.toplevel && ToplevelManager && ToplevelManager.toplevels.values.includes(modelData.toplevel); + + if (mouse.button === Qt.MiddleButton && isValidToplevel && modelData.toplevel.close) { + modelData.toplevel.close(); + Qt.callLater(root.updateDockApps); // Force immediate dock update + } else if (mouse.button === Qt.LeftButton) { + if (isValidToplevel && modelData.toplevel.activate) { + // Running app - activate it + modelData.toplevel.activate(); + } else if (modelData?.appId) { + // Pinned app not running - launch it + const app = DesktopEntries.byId(modelData.appId); + + if (Settings.data.appLauncher.customLaunchPrefixEnabled && Settings.data.appLauncher.customLaunchPrefix) { + // Use custom launch prefix + const prefix = Settings.data.appLauncher.customLaunchPrefix.split(" "); + + if (app.runInTerminal) { + const terminal = Settings.data.appLauncher.terminalCommand.split(" "); + const command = prefix.concat(terminal.concat(app.command)); + Quickshell.execDetached(command); + } else { + const command = prefix.concat(app.command); + Quickshell.execDetached(command); + } + } else if (Settings.data.appLauncher.useApp2Unit && app.id) { + Logger.d("Dock", `Using app2unit for: ${app.id}`); + if (app.runInTerminal) + Quickshell.execDetached(["app2unit", "--", app.id + ".desktop"]); + else + Quickshell.execDetached(["app2unit", "--"].concat(app.command)); + } else { + // Fallback logic when app2unit is not used + if (app.runInTerminal) { + // If app.execute() fails for terminal apps, we handle it manually. + Logger.d("Dock", "Executing terminal app manually: " + app.name); + const terminal = Settings.data.appLauncher.terminalCommand.split(" "); + const command = terminal.concat(app.command); + Quickshell.execDetached(command); + } else if (app.execute) { + // Default execution for GUI apps + app.execute(); + } else { + Logger.w("Dock", `Could not launch: ${app.name}. No valid launch method.`); + } + } + } + } + } + } + + // Active indicator + Rectangle { + visible: Settings.data.dock.inactiveIndicators ? isRunning : isActive + width: iconSize * 0.2 + height: iconSize * 0.1 + color: Color.mPrimary + radius: Style.radiusXS + anchors.top: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/.temp-icon-resolver/Launcher-with-resolver.qml b/.temp-icon-resolver/Launcher-with-resolver.qml new file mode 100644 index 000000000..14804a710 --- /dev/null +++ b/.temp-icon-resolver/Launcher-with-resolver.qml @@ -0,0 +1,1522 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import "../../../Helpers/FuzzySort.js" as Fuzzysort + +import "Plugins" +import qs.Commons +import qs.Modules.MainScreen +import qs.Services.Icons +import qs.Services.Keyboard +import qs.Widgets + +SmartPanel { + id: root + + readonly property bool previewActive: !!(searchText && searchText.startsWith(">clip") && Settings.data.appLauncher.enableClipPreview && ClipboardService.items && ClipboardService.items.length > 0 && selectedIndex >= 0 && results && results[selectedIndex] && results[selectedIndex].clipboardId) + + // Panel configuration + readonly property int listPanelWidth: Math.round(500 * Style.uiScaleRatio) + readonly property int previewPanelWidth: Math.round(400 * Style.uiScaleRatio) + readonly property int totalBaseWidth: listPanelWidth + (Style.marginL * 2) + + preferredWidth: totalBaseWidth + preferredHeight: Math.round(600 * Style.uiScaleRatio) + preferredWidthRatio: 0.3 + preferredHeightRatio: 0.5 + + // Positioning + readonly property string panelPosition: { + if (Settings.data.appLauncher.position === "follow_bar") { + if (Settings.data.bar.position === "left" || Settings.data.bar.position === "right") { + return `center_${Settings.data.bar.position}`; + } else { + return `${Settings.data.bar.position}_center`; + } + } else { + return Settings.data.appLauncher.position; + } + } + panelAnchorHorizontalCenter: panelPosition === "center" || panelPosition.endsWith("_center") + panelAnchorVerticalCenter: panelPosition === "center" + panelAnchorLeft: panelPosition !== "center" && panelPosition.endsWith("_left") + panelAnchorRight: panelPosition !== "center" && panelPosition.endsWith("_right") + panelAnchorBottom: panelPosition.startsWith("bottom_") + panelAnchorTop: panelPosition.startsWith("top_") + + // Core state + property string searchText: "" + property int selectedIndex: 0 + property var results: [] + property var plugins: [] + property var activePlugin: null + property bool resultsReady: false + property bool ignoreMouseHover: false + + readonly property int badgeSize: Math.round(Style.baseWidgetSize * 1.6 * Style.uiScaleRatio) + readonly property int entryHeight: Math.round(badgeSize + Style.marginM * 2) + readonly property bool isGridView: { + // Always use list view for clipboard and calculator to better display content + if (searchText.startsWith(">clip") || searchText.startsWith(">calc")) { + return false; + } + if (activePlugin === emojiPlugin && emojiPlugin.isBrowsingMode) { + return true; + } + return Settings.data.appLauncher.viewMode === "grid"; + } + + // Target columns, but actual columns may vary based on available width + // Account for NTabBar margins to match category tabs width + readonly property int targetGridColumns: 5 + readonly property int gridContentWidth: listPanelWidth - (2 * Style.marginXS) + readonly property int gridCellSize: Math.floor((gridContentWidth - ((targetGridColumns - 1) * Style.marginS)) / targetGridColumns) + + // Actual columns that fit in the GridView + // This gets updated dynamically by the GridView when its actual width is known + property int gridColumns: 5 + + // Override keyboard handlers from SmartPanel for navigation. + // Launcher specific: onTabPressed() and onBackTabPressed() are special here. + // They are not coming from SmartPanelWindow as they are consumed by the search field before reaching the panel. + // They are instead being forwared from the search field NTextInput below. + function onTabPressed() { + // In emoji browsing mode, Tab navigates between categories + if (activePlugin === emojiPlugin && emojiPlugin.isBrowsingMode) { + var currentIndex = emojiPlugin.categories.indexOf(emojiPlugin.selectedCategory); + var nextIndex = (currentIndex + 1) % emojiPlugin.categories.length; + emojiPlugin.selectCategory(emojiPlugin.categories[nextIndex]); + } else if ((activePlugin === null || activePlugin === appsPlugin) && appsPlugin.isBrowsingMode && !root.searchText.startsWith(">") && Settings.data.appLauncher.showCategories) { + // In apps browsing mode (no search), Tab navigates between categories + var availableCategories = appsPlugin.availableCategories || ["all"]; + var currentIndex = availableCategories.indexOf(appsPlugin.selectedCategory); + var nextIndex = (currentIndex + 1) % availableCategories.length; + appsPlugin.selectCategory(availableCategories[nextIndex]); + } else { + selectNextWrapped(); + } + } + + function onBackTabPressed() { + if (activePlugin === emojiPlugin && emojiPlugin.isBrowsingMode) { + var currentIndex = emojiPlugin.categories.indexOf(emojiPlugin.selectedCategory); + var previousIndex = ((currentIndex - 1) % emojiPlugin.categories.length + emojiPlugin.categories.length) % emojiPlugin.categories.length; + emojiPlugin.selectCategory(emojiPlugin.categories[previousIndex]); + } else if ((activePlugin === null || activePlugin === appsPlugin) && appsPlugin.isBrowsingMode && !root.searchText.startsWith(">") && Settings.data.appLauncher.showCategories) { + var availableCategories = appsPlugin.availableCategories || ["all"]; + var currentIndex = availableCategories.indexOf(appsPlugin.selectedCategory); + var previousIndex = ((currentIndex - 1) % availableCategories.length + availableCategories.length) % availableCategories.length; + appsPlugin.selectCategory(availableCategories[previousIndex]); + } else { + selectPreviousWrapped(); + } + } + + function onUpPressed() { + if (isGridView) { + selectPreviousRow(); + } else { + selectPreviousWrapped(); + } + } + + function onDownPressed() { + if (isGridView) { + selectNextRow(); + } else { + selectNextWrapped(); + } + } + + function onLeftPressed() { + if (isGridView) { + selectPreviousColumn(); + } else { + // In list view, left = previous item + selectPreviousWrapped(); + } + } + + function onRightPressed() { + if (isGridView) { + selectNextColumn(); + } else { + // In list view, right = next item + selectNextWrapped(); + } + } + + function onReturnPressed() { + activate(); + } + + function onHomePressed() { + selectFirst(); + } + + function onEndPressed() { + selectLast(); + } + + function onPageUpPressed() { + selectPreviousPage(); + } + + function onPageDownPressed() { + selectNextPage(); + } + + function onCtrlJPressed() { + selectNextWrapped(); + } + + function onCtrlKPressed() { + selectPreviousWrapped(); + } + + function onCtrlNPressed() { + selectNextWrapped(); + } + + function onCtrlPPressed() { + selectPreviousWrapped(); + } + + // Public API for plugins + function setSearchText(text) { + searchText = text; + } + + // Plugin registration + function registerPlugin(plugin) { + plugins.push(plugin); + plugin.launcher = root; + if (plugin.init) + plugin.init(); + } + + // Search handling + function updateResults() { + results = []; + activePlugin = null; + + // Check for command mode + if (searchText.startsWith(">")) { + // Find plugin that handles this command + for (let plugin of plugins) { + if (plugin.handleCommand && plugin.handleCommand(searchText)) { + activePlugin = plugin; + results = plugin.getResults(searchText); + break; + } + } + + // Show available commands if just ">" or filter commands if partial match + if (!activePlugin) { + // Collect all commands from all plugins + let allCommands = []; + for (let plugin of plugins) { + if (plugin.commands) { + allCommands = allCommands.concat(plugin.commands()); + } + } + + if (searchText === ">") { + // Show all commands when just ">" + results = allCommands; + } else if (searchText.length > 1) { + // Filter commands using fuzzy search when typing partial command + const query = searchText.substring(1); // Remove the ">" prefix + + if (typeof Fuzzysort !== 'undefined') { + // Use fuzzy search to filter commands + const fuzzyResults = Fuzzysort.go(query, allCommands, { + "keys": ["name"], + "threshold": -1000, + "limit": 50 + }); + + // Convert fuzzy results back to command objects + results = fuzzyResults.map(result => result.obj); + } else { + // Fallback to simple substring matching + const queryLower = query.toLowerCase(); + results = allCommands.filter(cmd => { + const cmdName = (cmd.name || "").toLowerCase(); + return cmdName.includes(queryLower); + }); + } + } + } + } else { + // Regular search - let plugins contribute results + for (let plugin of plugins) { + if (plugin.handleSearch) { + const pluginResults = plugin.getResults(searchText); + results = results.concat(pluginResults); + } + } + } + + selectedIndex = 0; + } + + onSearchTextChanged: updateResults() + + // Lifecycle + onOpened: { + resultsReady = false; + ignoreMouseHover = true; + + // Notify plugins and update results + // Use Qt.callLater to ensure plugins are registered (Component.onCompleted runs first) + Qt.callLater(() => { + for (let plugin of plugins) { + if (plugin.onOpened) + plugin.onOpened(); + } + updateResults(); + resultsReady = true; + }); + } + + onClosed: { + // Reset search text + searchText = ""; + ignoreMouseHover = true; + + // Notify plugins + for (let plugin of plugins) { + if (plugin.onClosed) + plugin.onClosed(); + } + } + + // Plugin components - declared inline so imports work correctly + ApplicationsPlugin { + id: appsPlugin + Component.onCompleted: { + registerPlugin(this); + Logger.d("Launcher", "Registered: ApplicationsPlugin"); + } + } + + CalculatorPlugin { + id: calcPlugin + Component.onCompleted: { + registerPlugin(this); + Logger.d("Launcher", "Registered: CalculatorPlugin"); + } + } + + ClipboardPlugin { + id: clipPlugin + Component.onCompleted: { + if (Settings.data.appLauncher.enableClipboardHistory) { + registerPlugin(this); + Logger.d("Launcher", "Registered: ClipboardPlugin"); + } + } + } + + CommandPlugin { + id: cmdPlugin + Component.onCompleted: { + registerPlugin(this); + Logger.d("Launcher", "Registered: CommandPlugin"); + } + } + + EmojiPlugin { + id: emojiPlugin + Component.onCompleted: { + registerPlugin(this); + Logger.d("Launcher", "Registered: EmojiPlugin"); + } + } + + // Navigation functions + function selectNextWrapped() { + if (results.length > 0) { + selectedIndex = (selectedIndex + 1) % results.length; + } + } + + function selectPreviousWrapped() { + if (results.length > 0) { + selectedIndex = (((selectedIndex - 1) % results.length) + results.length) % results.length; + } + } + + function selectFirst() { + selectedIndex = 0; + } + + function selectLast() { + if (results.length > 0) { + selectedIndex = results.length - 1; + } else { + selectedIndex = 0; + } + } + + function selectNextPage() { + if (results.length > 0) { + const page = Math.max(1, Math.floor(600 / entryHeight)); // Use approximate height + selectedIndex = Math.min(selectedIndex + page, results.length - 1); + } + } + + function selectPreviousPage() { + if (results.length > 0) { + const page = Math.max(1, Math.floor(600 / entryHeight)); // Use approximate height + selectedIndex = Math.max(selectedIndex - page, 0); + } + } + + // Grid view navigation functions + function selectPreviousRow() { + if (results.length > 0 && isGridView && gridColumns > 0) { + const currentRow = Math.floor(selectedIndex / gridColumns); + const currentCol = selectedIndex % gridColumns; + + if (currentRow > 0) { + // Move to previous row, same column + const targetRow = currentRow - 1; + const targetIndex = targetRow * gridColumns + currentCol; + // Check if target column exists in target row + const itemsInTargetRow = Math.min(gridColumns, results.length - targetRow * gridColumns); + if (currentCol < itemsInTargetRow) { + selectedIndex = targetIndex; + } else { + // Target column doesn't exist, go to last item in target row + selectedIndex = targetRow * gridColumns + itemsInTargetRow - 1; + } + } else { + // Wrap to last row, same column + const totalRows = Math.ceil(results.length / gridColumns); + const lastRow = totalRows - 1; + const itemsInLastRow = Math.min(gridColumns, results.length - lastRow * gridColumns); + if (currentCol < itemsInLastRow) { + selectedIndex = lastRow * gridColumns + currentCol; + } else { + selectedIndex = results.length - 1; + } + } + } + } + + function selectNextRow() { + if (results.length > 0 && isGridView && gridColumns > 0) { + const currentRow = Math.floor(selectedIndex / gridColumns); + const currentCol = selectedIndex % gridColumns; + const totalRows = Math.ceil(results.length / gridColumns); + + if (currentRow < totalRows - 1) { + // Move to next row, same column + const targetRow = currentRow + 1; + const targetIndex = targetRow * gridColumns + currentCol; + + // Check if target index is valid + if (targetIndex < results.length) { + selectedIndex = targetIndex; + } else { + // Target column doesn't exist in target row, go to last item in target row + const itemsInTargetRow = results.length - targetRow * gridColumns; + if (itemsInTargetRow > 0) { + selectedIndex = targetRow * gridColumns + itemsInTargetRow - 1; + } else { + // Target row is empty, wrap to first row + selectedIndex = Math.min(currentCol, results.length - 1); + } + } + } else { + // Wrap to first row, same column + selectedIndex = Math.min(currentCol, results.length - 1); + } + } + } + + function selectPreviousColumn() { + if (results.length > 0 && isGridView) { + const currentRow = Math.floor(selectedIndex / gridColumns); + const currentCol = selectedIndex % gridColumns; + if (currentCol > 0) { + // Move left in same row + selectedIndex = currentRow * gridColumns + (currentCol - 1); + } else { + // Wrap to last column of previous row + if (currentRow > 0) { + selectedIndex = (currentRow - 1) * gridColumns + (gridColumns - 1); + } else { + // Wrap to last column of last row + const totalRows = Math.ceil(results.length / gridColumns); + const lastRowIndex = (totalRows - 1) * gridColumns + (gridColumns - 1); + selectedIndex = Math.min(lastRowIndex, results.length - 1); + } + } + } + } + + function selectNextColumn() { + if (results.length > 0 && isGridView) { + const currentRow = Math.floor(selectedIndex / gridColumns); + const currentCol = selectedIndex % gridColumns; + const itemsInCurrentRow = Math.min(gridColumns, results.length - currentRow * gridColumns); + + if (currentCol < itemsInCurrentRow - 1) { + // Move right in same row + selectedIndex = currentRow * gridColumns + (currentCol + 1); + } else { + // Wrap to first column of next row + const totalRows = Math.ceil(results.length / gridColumns); + if (currentRow < totalRows - 1) { + selectedIndex = (currentRow + 1) * gridColumns; + } else { + // Wrap to first item + selectedIndex = 0; + } + } + } + } + + function activate() { + if (results.length > 0 && results[selectedIndex]) { + const item = results[selectedIndex]; + if (item.onActivate) { + item.onActivate(); + } + } + } + + panelContent: Rectangle { + id: ui + color: Color.transparent + opacity: resultsReady ? 1.0 : 0.0 + + // Preview Panel (external) + NBox { + id: previewBox + visible: root.previewActive + width: root.previewPanelWidth + height: Math.round(400 * Style.uiScaleRatio) + x: ui.width + Style.marginM + y: { + if (!resultsViewLoader.item) + return Style.marginL; + const view = resultsViewLoader.item; + const row = root.isGridView ? Math.floor(root.selectedIndex / root.gridColumns) : root.selectedIndex; + const itemHeight = root.isGridView ? (root.gridCellSize + Style.marginXXS) : (root.entryHeight + view.spacing); + const yPos = row * itemHeight - view.contentY; + const mapped = view.mapToItem(ui, 0, yPos); + return Math.max(Style.marginL, Math.min(mapped.y, ui.height - previewBox.height - Style.marginL)); + } + z: -1 // Draw behind main panel content if it ever overlaps + + opacity: visible ? 1.0 : 0.0 + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + } + } + + Loader { + id: clipboardPreviewLoader + anchors.fill: parent + active: root.previewActive + source: active ? "./ClipboardPreview.qml" : "" + + onLoaded: { + if (selectedIndex >= 0 && results[selectedIndex] && item) { + item.currentItem = results[selectedIndex]; + } + } + + onItemChanged: { + if (item && selectedIndex >= 0 && results[selectedIndex]) { + item.currentItem = results[selectedIndex]; + } + } + } + } + + MouseArea { + id: mouseMovementDetector + anchors.fill: parent + z: -999 + hoverEnabled: true + propagateComposedEvents: true + acceptedButtons: Qt.NoButton + + property real lastX: 0 + property real lastY: 0 + property bool initialized: false + + onPositionChanged: mouse => { + if (!initialized) { + lastX = mouse.x; + lastY = mouse.y; + initialized = true; + return; + } + + const deltaX = Math.abs(mouse.x - lastX); + const deltaY = Math.abs(mouse.y - lastY); + if (deltaX > 1 || deltaY > 1) { + root.ignoreMouseHover = false; + lastX = mouse.x; + lastY = mouse.y; + } + } + + Connections { + target: root + function onOpened() { + mouseMovementDetector.initialized = false; + } + } + } + + // Focus management + Connections { + target: root + function onOpened() { + // Delay focus to ensure window has keyboard focus + Qt.callLater(() => { + if (searchInput.inputItem) { + searchInput.inputItem.forceActiveFocus(); + } + }); + } + } + + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutCirc + } + } + + RowLayout { + anchors.fill: parent + anchors.margins: Style.marginL // Apply overall margins here + spacing: Style.marginM // Apply spacing between elements here + + // Left Pane + ColumnLayout { + id: leftPane + Layout.fillHeight: true + Layout.preferredWidth: root.listPanelWidth + spacing: Style.marginM + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + NTextInput { + id: searchInput + Layout.fillWidth: true + + fontSize: Style.fontSizeL + fontWeight: Style.fontWeightSemiBold + + text: searchText + placeholderText: I18n.tr("placeholders.search-launcher") + + onTextChanged: searchText = text + + Component.onCompleted: { + if (searchInput.inputItem) { + searchInput.inputItem.forceActiveFocus(); + // Intercept keys before TextField handles them + searchInput.inputItem.Keys.onPressed.connect(function (event) { + if (event.key === Qt.Key_Tab) { + root.onTabPressed(); + event.accepted = true; + } else if (event.key === Qt.Key_Backtab) { + root.onBackTabPressed(); + event.accepted = true; + } else if (event.key === Qt.Key_Left) { + root.onLeftPressed(); + event.accepted = true; + } else if (event.key === Qt.Key_Right) { + root.onRightPressed(); + event.accepted = true; + } else if (event.key === Qt.Key_Up) { + root.onUpPressed(); + event.accepted = true; + } else if (event.key === Qt.Key_Down) { + root.onDownPressed(); + event.accepted = true; + } else if (event.key === Qt.Key_Enter) { + root.activate(); + event.accepted = true; + } + }); + } + } + } + + NIconButton { + visible: root.activePlugin === null || root.activePlugin === appsPlugin + 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") + Layout.preferredWidth: searchInput.height + Layout.preferredHeight: searchInput.height + onClicked: { + Settings.data.appLauncher.viewMode = Settings.data.appLauncher.viewMode === "grid" ? "list" : "grid"; + } + } + } + + // Emoji category tabs (shown when in browsing mode) + NTabBar { + id: emojiCategoryTabs + visible: root.activePlugin === emojiPlugin && emojiPlugin.isBrowsingMode + Layout.fillWidth: true + margins: Style.marginM + property int computedCurrentIndex: { + if (visible && emojiPlugin.categories) { + return emojiPlugin.categories.indexOf(emojiPlugin.selectedCategory); + } + return 0; + } + currentIndex: computedCurrentIndex + + Repeater { + model: emojiPlugin.categories + NIconTabButton { + required property string modelData + required property int index + icon: emojiPlugin.categoryIcons[modelData] || "star" + tabIndex: index + checked: emojiCategoryTabs.currentIndex === index + onClicked: { + emojiPlugin.selectCategory(modelData); + } + } + } + } + + Connections { + target: emojiPlugin + enabled: emojiCategoryTabs.visible + function onSelectedCategoryChanged() { + // Force update of currentIndex when selectedCategory changes + Qt.callLater(() => { + if (emojiCategoryTabs.visible && emojiPlugin.categories) { + const newIndex = emojiPlugin.categories.indexOf(emojiPlugin.selectedCategory); + if (newIndex >= 0 && emojiCategoryTabs.currentIndex !== newIndex) { + emojiCategoryTabs.currentIndex = newIndex; + } + } + }); + } + } + + // App category tabs (shown when browsing apps without search) + NTabBar { + id: appCategoryTabs + visible: (root.activePlugin === null || root.activePlugin === appsPlugin) && appsPlugin.isBrowsingMode && !root.searchText.startsWith(">") && Settings.data.appLauncher.showCategories + Layout.fillWidth: true + margins: Style.marginM + property int computedCurrentIndex: { + if (visible && appsPlugin.availableCategories) { + return appsPlugin.availableCategories.indexOf(appsPlugin.selectedCategory); + } + return 0; + } + currentIndex: computedCurrentIndex + + Repeater { + model: appsPlugin.availableCategories || [] + NIconTabButton { + required property string modelData + required property int index + icon: appsPlugin.categoryIcons[modelData] || "apps" + tooltipText: appsPlugin.getCategoryName ? appsPlugin.getCategoryName(modelData) : modelData + tabIndex: index + checked: appCategoryTabs.currentIndex === index + onClicked: { + appsPlugin.selectCategory(modelData); + } + } + } + } + + Connections { + target: appsPlugin + enabled: appCategoryTabs.visible + function onSelectedCategoryChanged() { + // Force update of currentIndex when selectedCategory changes + Qt.callLater(() => { + if (appCategoryTabs.visible && appsPlugin.availableCategories) { + const newIndex = appsPlugin.availableCategories.indexOf(appsPlugin.selectedCategory); + if (newIndex >= 0 && appCategoryTabs.currentIndex !== newIndex) { + appCategoryTabs.currentIndex = newIndex; + } + } + }); + } + } + + Loader { + id: resultsViewLoader + Layout.fillWidth: true + Layout.fillHeight: true + sourceComponent: root.isGridView ? gridViewComponent : listViewComponent + } + + Component { + id: listViewComponent + NListView { + id: resultsList + + horizontalPolicy: ScrollBar.AlwaysOff + verticalPolicy: ScrollBar.AsNeeded + + width: parent.width + height: parent.height + spacing: Style.marginXXS + model: results + currentIndex: selectedIndex + cacheBuffer: resultsList.height * 2 + onCurrentIndexChanged: { + cancelFlick(); + if (currentIndex >= 0) { + positionViewAtIndex(currentIndex, ListView.Contain); + } + if (clipboardPreviewLoader.item) { + clipboardPreviewLoader.item.currentItem = results[currentIndex] || null; + } + } + onModelChanged: {} + + delegate: Rectangle { + id: entry + + property bool isSelected: (!root.ignoreMouseHover && mouseArea.containsMouse) || (index === selectedIndex) + property string appId: (modelData && modelData.appId) ? String(modelData.appId) : "" + + // Helper function to normalize app IDs for case-insensitive matching + function normalizeAppId(appId) { + if (!appId || typeof appId !== 'string') + return ""; + return appId.toLowerCase().trim(); + } + + // Pin helpers + function togglePin(appId) { + if (!appId) + return; + const normalizedId = normalizeAppId(appId); + let arr = (Settings.data.dock.pinnedApps || []).slice(); + const idx = arr.findIndex(pinnedId => normalizeAppId(pinnedId) === normalizedId); + if (idx >= 0) + arr.splice(idx, 1); + else + arr.push(appId); + Settings.data.dock.pinnedApps = arr; + } + + function isPinned(appId) { + if (!appId) + return false; + const arr = Settings.data.dock.pinnedApps || []; + const normalizedId = normalizeAppId(appId); + return arr.some(pinnedId => normalizeAppId(pinnedId) === normalizedId); + } + + // Property to reliably track the current item's ID. + // This changes whenever the delegate is recycled for a new item. + property var currentClipboardId: modelData.isImage ? modelData.clipboardId : "" + + // When this delegate is assigned a new image item, trigger the decode. + onCurrentClipboardIdChanged: { + // Check if it's a valid ID and if the data isn't already cached. + if (currentClipboardId && !ClipboardService.getImageData(currentClipboardId)) { + ClipboardService.decodeToDataUrl(currentClipboardId, modelData.mime, null); + } + } + + width: resultsList.width - Style.marginS + implicitHeight: entryHeight + radius: Style.radiusM + color: entry.isSelected ? Color.mHover : Color.mSurface + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + easing.type: Easing.OutCirc + } + } + + ColumnLayout { + id: contentLayout + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginM + + // Top row - Main entry content with pin button + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + + // Icon badge or Image preview or Emoji + Rectangle { + Layout.preferredWidth: badgeSize + Layout.preferredHeight: badgeSize + radius: Style.radiusM + color: Color.mSurfaceVariant + + // Image preview for clipboard images + NImageRounded { + id: imagePreview + anchors.fill: parent + visible: modelData.isImage && !modelData.emojiChar + radius: Style.radiusM + + // This property creates a dependency on the service's revision counter + readonly property int _rev: ClipboardService.revision + + // Fetches from the service's cache. + // The dependency on `_rev` ensures this binding is re-evaluated when the cache is updated. + imagePath: { + _rev; + return ClipboardService.getImageData(modelData.clipboardId) || ""; + } + + Rectangle { + anchors.fill: parent + visible: parent.status === Image.Loading + color: Color.mSurfaceVariant + + BusyIndicator { + anchors.centerIn: parent + running: true + width: Style.baseWidgetSize * 0.5 + height: width + } + } + + onStatusChanged: status => { + if (status === Image.Error) { + iconLoader.visible = true; + imagePreview.visible = false; + } + } + } + + Loader { + id: iconLoader + anchors.fill: parent + anchors.margins: Style.marginXS + + visible: !modelData.isImage && !modelData.emojiChar || (modelData.isImage && imagePreview.status === Image.Error) + active: visible + + onActiveChanged: { + Logger.d("Launcher", "iconLoader active changed | index:", index, "| iconName:", modelData?.icon, "| active:", active, "| visible:", visible) + } + onVisibleChanged: { + Logger.d("Launcher", "iconLoader visible changed | index:", index, "| iconName:", modelData?.icon, "| visible:", visible, "| active:", active) + } + + sourceComponent: Component { + Item { + id: iconContainer + anchors.fill: parent + + property string iconKey: modelData.icon || "application-x-executable" + property string iconSrc: "" + property string _requestKey: "" + + function updateIcon() { + iconSrc = "" + _requestKey = iconKey + + if (!iconKey || iconKey.length === 0) { + return + } + + AppSearch.resolveIconWithFallback(iconKey, function(path) { + // Guard against delegate reuse + if (_requestKey === iconKey && path && path.length > 0) { + iconSrc = path + } + }) + } + + onIconKeyChanged: updateIcon() + Component.onCompleted: updateIcon() + + // Use IconImage for icon names (theme-based icons) + IconImage { + id: listIcon + anchors.centerIn: parent + width: parent.width + height: parent.height + asynchronous: true + smooth: true + source: iconContainer.iconSrc && !iconContainer.iconSrc.startsWith("image://icon/") && !iconContainer.iconSrc.startsWith("file://") && !iconContainer.iconSrc.startsWith("/") ? iconContainer.iconSrc : "" + visible: source !== "" + } + + // Use regular Image for file:// URLs and image://icon/ URLs (IconImage doesn't support them) + Image { + id: listIconFallback + anchors.centerIn: parent + width: parent.width + height: parent.height + asynchronous: true + smooth: true + source: iconContainer.iconSrc && (iconContainer.iconSrc.startsWith("image://icon/") || iconContainer.iconSrc.startsWith("file://") || iconContainer.iconSrc.startsWith("/")) ? iconContainer.iconSrc : "" + visible: source !== "" && status === Image.Ready + } + } + } + } + + // Emoji display - takes precedence when emojiChar is present + NText { + id: emojiDisplay + anchors.centerIn: parent + visible: modelData.emojiChar || (!imagePreview.visible && !iconLoader.visible) + text: modelData.emojiChar ? modelData.emojiChar : modelData.name.charAt(0).toUpperCase() + pointSize: modelData.emojiChar ? Style.fontSizeXXXL : Style.fontSizeXXL // Larger font for emojis + font.weight: Style.fontWeightBold + color: modelData.emojiChar ? Color.mOnSurface : Color.mOnPrimary // Different color for emojis + } + + // Image type indicator overlay + Rectangle { + visible: modelData.isImage && imagePreview.visible + anchors.bottom: parent.bottom + anchors.right: parent.right + anchors.margins: 2 + width: formatLabel.width + 6 + height: formatLabel.height + 2 + radius: Style.radiusM + color: Color.mSurfaceVariant + + NText { + id: formatLabel + anchors.centerIn: parent + text: { + if (!modelData.isImage) + return ""; + const desc = modelData.description || ""; + const parts = desc.split(" • "); + return parts[0] || "IMG"; + } + pointSize: Style.fontSizeXXS + color: Color.mPrimary + } + } + } + + // Text content + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + NText { + text: modelData.name || "Unknown" + pointSize: Style.fontSizeL + font.weight: Style.fontWeightBold + color: entry.isSelected ? Color.mOnHover : Color.mOnSurface + elide: Text.ElideRight + Layout.fillWidth: true + } + + NText { + text: modelData.description || "" + pointSize: Style.fontSizeS + color: entry.isSelected ? Color.mOnHover : Color.mOnSurfaceVariant + elide: Text.ElideRight + Layout.fillWidth: true + visible: text !== "" + } + } + + // Action buttons row + RowLayout { + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter + spacing: Style.marginXS + visible: (!!entry.appId && entry.isSelected) || (!!modelData.clipboardId && entry.isSelected) + + // Pin/Unpin action icon button + NIconButton { + visible: !!entry.appId && !modelData.isImage && entry.isSelected + icon: entry.isPinned(entry.appId) ? "unpin" : "pin" + tooltipText: entry.isPinned(entry.appId) ? I18n.tr("launcher.unpin") : I18n.tr("launcher.pin") + onClicked: entry.togglePin(entry.appId) + } + + // Delete action icon button for clipboard entries + NIconButton { + visible: !!modelData.clipboardId && entry.isSelected + icon: "trash" + tooltipText: I18n.tr("plugins.clipboard-delete") + z: 1 + onClicked: { + if (modelData.clipboardId) { + // Set plugin state before deletion so refresh works + clipPlugin.gotResults = false; + clipPlugin.isWaitingForData = true; + clipPlugin.lastSearchText = root.searchText; + // Delete the item - deleteById now uses Process and will refresh automatically + ClipboardService.deleteById(String(modelData.clipboardId)); + } + } + } + } + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + z: -1 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: { + if (!root.ignoreMouseHover) { + selectedIndex = index; + } + } + onClicked: mouse => { + if (mouse.button === Qt.LeftButton) { + selectedIndex = index; + root.activate(); + mouse.accepted = true; + } + } + acceptedButtons: Qt.LeftButton + } + } + } + } + + Component { + id: gridViewComponent + NGridView { + id: resultsGrid + + horizontalPolicy: ScrollBar.AlwaysOff + verticalPolicy: ScrollBar.AsNeeded + + width: parent.width + height: parent.height + cellWidth: { + if (root.activePlugin === emojiPlugin && emojiPlugin.isBrowsingMode) { + return parent.width / root.targetGridColumns; + } + // Make cells fit exactly like the tab bar + // Cell width scales automatically as parent.width scales with uiScaleRatio + return parent.width / root.targetGridColumns; + } + cellHeight: { + if (root.activePlugin === emojiPlugin && emojiPlugin.isBrowsingMode) { + return (parent.width / root.targetGridColumns) * 1.2; + } + // Cell height scales automatically as parent.width scales with uiScaleRatio + // Content (badge, text) scales via badgeSize which now uses uiScaleRatio + return parent.width / root.targetGridColumns; + } + leftMargin: 0 + rightMargin: 0 + topMargin: 0 + bottomMargin: 0 + model: results + cacheBuffer: resultsGrid.height * 2 + keyNavigationEnabled: false + focus: false + interactive: true + + Component.onCompleted: { + // Initialize gridColumns when grid view is created + updateGridColumns(); + } + + function updateGridColumns() { + // Update gridColumns based on actual GridView width + // This ensures navigation works correctly regardless of panel size + if (root.activePlugin === emojiPlugin && emojiPlugin.isBrowsingMode) { + // Always 5 columns for emoji browsing mode + root.gridColumns = 5; + } else { + // Since cellWidth = width / targetGridColumns, the number of columns is always targetGridColumns + // Just use targetGridColumns directly + root.gridColumns = root.targetGridColumns; + } + } + + onWidthChanged: { + updateGridColumns(); + } + + // Completely disable GridView key handling + Keys.enabled: false + + // Don't sync selectedIndex to GridView's currentIndex + // The visual selection is handled by the delegate based on selectedIndex + // We only need to position the view to show the selected item + + onModelChanged: {} + + // Update gridColumns when entering/exiting emoji browsing mode + Connections { + target: emojiPlugin + function onIsBrowsingModeChanged() { + if (emojiPlugin.isBrowsingMode) { + root.gridColumns = 5; + } + } + } + + // Handle scrolling to show selected item when it changes + Connections { + target: root + enabled: root.isGridView + function onSelectedIndexChanged() { + // Only process if we're still in grid view and component exists + if (!root.isGridView || root.selectedIndex < 0 || !resultsGrid) { + return; + } + + Qt.callLater(() => { + // Double-check we're still in grid view mode + if (root.isGridView && resultsGrid && resultsGrid.cancelFlick) { + resultsGrid.cancelFlick(); + resultsGrid.positionViewAtIndex(root.selectedIndex, GridView.Contain); + } + }); + + // Update preview + if (clipboardPreviewLoader.item && root.selectedIndex >= 0) { + clipboardPreviewLoader.item.currentItem = results[root.selectedIndex] || null; + } + } + } + + delegate: Rectangle { + id: gridEntry + + property bool isSelected: (!root.ignoreMouseHover && mouseArea.containsMouse) || (index === selectedIndex) + property string appId: (modelData && modelData.appId) ? String(modelData.appId) : "" + + // Helper function to normalize app IDs for case-insensitive matching + function normalizeAppId(appId) { + if (!appId || typeof appId !== 'string') + return ""; + return appId.toLowerCase().trim(); + } + + // Pin helpers + function togglePin(appId) { + if (!appId) + return; + const normalizedId = normalizeAppId(appId); + let arr = (Settings.data.dock.pinnedApps || []).slice(); + const idx = arr.findIndex(pinnedId => normalizeAppId(pinnedId) === normalizedId); + if (idx >= 0) + arr.splice(idx, 1); + else + arr.push(appId); + Settings.data.dock.pinnedApps = arr; + } + + function isPinned(appId) { + if (!appId) + return false; + const arr = Settings.data.dock.pinnedApps || []; + const normalizedId = normalizeAppId(appId); + return arr.some(pinnedId => normalizeAppId(pinnedId) === normalizedId); + } + + width: resultsGrid.cellWidth + height: resultsGrid.cellHeight + radius: Style.radiusM + color: gridEntry.isSelected ? Color.mHover : Color.mSurface + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + easing.type: Easing.OutCirc + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: (root.activePlugin === emojiPlugin && emojiPlugin.isBrowsingMode) ? 4 : Style.marginM + anchors.bottomMargin: (root.activePlugin === emojiPlugin && emojiPlugin.isBrowsingMode) ? Style.marginL : Style.marginM + spacing: Style.marginS + + // Icon badge or Image preview or Emoji + Rectangle { + Layout.preferredWidth: { + if (root.activePlugin === emojiPlugin && emojiPlugin.isBrowsingMode && modelData.emojiChar) { + return gridEntry.width - 8; + } + // Scale badge relative to cell size for proper scaling on all resolutions + // Use 60% of cell width, ensuring it scales down on low res and up on high res + return Math.round(gridEntry.width * 0.6); + } + Layout.preferredHeight: { + if (root.activePlugin === emojiPlugin && emojiPlugin.isBrowsingMode && modelData.emojiChar) { + return gridEntry.width - 8; + } + // Scale badge relative to cell size for proper scaling on all resolutions + // Use 60% of cell width, ensuring it scales down on low res and up on high res + return Math.round(gridEntry.width * 0.6); + } + Layout.alignment: Qt.AlignHCenter + radius: Style.radiusM + color: Color.mSurfaceVariant + + // Image preview for clipboard images + NImageRounded { + id: gridImagePreview + anchors.fill: parent + visible: modelData.isImage && !modelData.emojiChar + radius: Style.radiusM + + readonly property int _rev: ClipboardService.revision + + imagePath: { + _rev; + return ClipboardService.getImageData(modelData.clipboardId) || ""; + } + + Rectangle { + anchors.fill: parent + visible: parent.status === Image.Loading + color: Color.mSurfaceVariant + + BusyIndicator { + anchors.centerIn: parent + running: true + width: Style.baseWidgetSize * 0.5 + height: width + } + } + + onStatusChanged: status => { + if (status === Image.Error) { + gridIconLoader.visible = true; + gridImagePreview.visible = false; + } + } + } + + Loader { + id: gridIconLoader + anchors.fill: parent + anchors.margins: Style.marginXS + + visible: !modelData.isImage && !modelData.emojiChar || (modelData.isImage && gridImagePreview.status === Image.Error) + active: visible + + onActiveChanged: { + Logger.d("Launcher", "gridIconLoader active changed | index:", index, "| iconName:", modelData?.icon, "| active:", active, "| visible:", visible) + } + onVisibleChanged: { + Logger.d("Launcher", "gridIconLoader visible changed | index:", index, "| iconName:", modelData?.icon, "| visible:", visible, "| active:", active) + } + + sourceComponent: Component { + Item { + id: gridIconContainer + anchors.fill: parent + + property string iconKey: modelData.icon || "application-x-executable" + property string iconSrc: "" + property string _requestKey: "" + + function updateIcon() { + iconSrc = "" + _requestKey = iconKey + + if (!iconKey || iconKey.length === 0) { + return + } + + AppSearch.resolveIconWithFallback(iconKey, function(path) { + // Guard against delegate reuse + if (_requestKey === iconKey && path && path.length > 0) { + iconSrc = path + } + }) + } + + onIconKeyChanged: updateIcon() + Component.onCompleted: updateIcon() + + // Use IconImage for icon names (theme-based icons) + IconImage { + id: gridIcon + anchors.centerIn: parent + width: parent.width + height: parent.height + asynchronous: true + smooth: true + source: gridIconContainer.iconSrc && !gridIconContainer.iconSrc.startsWith("image://icon/") && !gridIconContainer.iconSrc.startsWith("file://") && !gridIconContainer.iconSrc.startsWith("/") ? gridIconContainer.iconSrc : "" + visible: source !== "" + } + + // Use regular Image for file:// URLs and image://icon/ URLs (IconImage doesn't support them) + Image { + id: gridIconFallback + anchors.centerIn: parent + width: parent.width + height: parent.height + asynchronous: true + smooth: true + source: gridIconContainer.iconSrc && (gridIconContainer.iconSrc.startsWith("image://icon/") || gridIconContainer.iconSrc.startsWith("file://") || gridIconContainer.iconSrc.startsWith("/")) ? gridIconContainer.iconSrc : "" + visible: source !== "" && status === Image.Ready + } + } + } + } + + // Emoji display + NText { + id: gridEmojiDisplay + anchors.centerIn: parent + visible: modelData.emojiChar || (!gridImagePreview.visible && !gridIconLoader.visible) + text: modelData.emojiChar ? modelData.emojiChar : modelData.name.charAt(0).toUpperCase() + pointSize: { + if (modelData.emojiChar) { + if (root.activePlugin === emojiPlugin && emojiPlugin.isBrowsingMode) { + // 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.emojiChar ? Color.mOnSurface : Color.mOnPrimary + } + } + + // Text content + NText { + text: modelData.name || "Unknown" + pointSize: { + if (root.activePlugin === emojiPlugin && emojiPlugin.isBrowsingMode && modelData.emojiChar) { + return Style.fontSizeS * Style.uiScaleRatio; + } + // Scale font size relative to cell width for low res, but cap at maximum + const cellBasedSize = gridEntry.width * 0.12; + const baseSize = Style.fontSizeS * Style.uiScaleRatio; + const maxSize = Style.fontSizeM * Style.uiScaleRatio; + return Math.min(Math.max(cellBasedSize, baseSize), maxSize); + } + font.weight: Style.fontWeightSemiBold + color: gridEntry.isSelected ? Color.mOnHover : Color.mOnSurface + elide: Text.ElideRight + Layout.fillWidth: true + Layout.maximumWidth: gridEntry.width - 8 + Layout.leftMargin: (root.activePlugin === emojiPlugin && emojiPlugin.isBrowsingMode && modelData.emojiChar) ? Style.marginS : 0 + Layout.rightMargin: (root.activePlugin === emojiPlugin && emojiPlugin.isBrowsingMode && modelData.emojiChar) ? Style.marginS : 0 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.NoWrap + maximumLineCount: 1 + } + } + + // Action buttons (overlay in top-right corner) + Row { + visible: (!!gridEntry.appId && gridEntry.isSelected) || (!!modelData.clipboardId && gridEntry.isSelected) + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: Style.marginXS + z: 10 + spacing: Style.marginXXS + + // Pin/Unpin action icon button + NIconButton { + visible: !!gridEntry.appId && !modelData.isImage && gridEntry.isSelected + icon: gridEntry.isPinned(gridEntry.appId) ? "unpin" : "pin" + tooltipText: gridEntry.isPinned(gridEntry.appId) ? I18n.tr("launcher.unpin") : I18n.tr("launcher.pin") + onClicked: gridEntry.togglePin(gridEntry.appId) + } + + // Delete action icon button for clipboard entries + NIconButton { + visible: !!modelData.clipboardId && gridEntry.isSelected + icon: "trash" + tooltipText: I18n.tr("plugins.clipboard-delete") + z: 11 + onClicked: { + if (modelData.clipboardId) { + // Set plugin state before deletion so refresh works + clipPlugin.gotResults = false; + clipPlugin.isWaitingForData = true; + clipPlugin.lastSearchText = root.searchText; + // Delete the item - deleteById now uses Process and will refresh automatically + ClipboardService.deleteById(String(modelData.clipboardId)); + } + } + } + } + + MouseArea { + id: mouseArea + anchors.fill: parent + z: -1 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: { + root.ignoreMouseHover = false; + selectedIndex = index; + } + onClicked: mouse => { + if (mouse.button === Qt.LeftButton) { + selectedIndex = index; + root.activate(); + mouse.accepted = true; + } + } + acceptedButtons: Qt.LeftButton + } + } + } + } + + NDivider { + Layout.fillWidth: true + } + + NText { + Layout.fillWidth: true + text: { + if (results.length === 0) { + if (searchText) { + return "No results"; + } else if (activePlugin === emojiPlugin && emojiPlugin.isBrowsingMode && emojiPlugin.selectedCategory === "recent") { + return "No recently used emoji"; + } + return ""; + } + var prefix = activePlugin && activePlugin.name ? activePlugin.name + ": " : ""; + return prefix + results.length + " result" + (results.length !== 1 ? 's' : ''); + } + pointSize: Style.fontSizeXS + color: Color.mOnSurfaceVariant + horizontalAlignment: Text.AlignCenter + } + } + } + } +} diff --git a/Modules/Bar/Widgets/ActiveWindow.qml b/Modules/Bar/Widgets/ActiveWindow.qml index d96622527..9d44ff3a4 100644 --- a/Modules/Bar/Widgets/ActiveWindow.qml +++ b/Modules/Bar/Widgets/ActiveWindow.qml @@ -7,6 +7,7 @@ import Quickshell.Widgets import qs.Commons import qs.Modules.Bar.Extras import qs.Services.Compositor +import qs.Services.Icons import qs.Services.UI import qs.Widgets @@ -117,44 +118,28 @@ Item { function getAppIcon() { try { - // Try CompositorService first const focusedWindow = CompositorService.getFocusedWindow(); + let appId = null; + if (focusedWindow && focusedWindow.appId) { - try { - const idValue = focusedWindow.appId; - const normalizedId = (typeof idValue === 'string') ? idValue : String(idValue); - const iconResult = ThemeIcons.iconForAppId(normalizedId.toLowerCase()); - if (iconResult && iconResult !== "") { - return iconResult; - } - } catch (iconError) { - Logger.w("ActiveWindow", "Error getting icon from CompositorService:", iconError); - } - } - - if (CompositorService.isHyprland) { - // Fallback to ToplevelManager - if (ToplevelManager && ToplevelManager.activeToplevel) { - try { - const activeToplevel = ToplevelManager.activeToplevel; - if (activeToplevel.appId) { - const idValue2 = activeToplevel.appId; - const normalizedId2 = (typeof idValue2 === 'string') ? idValue2 : String(idValue2); - const iconResult2 = ThemeIcons.iconForAppId(normalizedId2.toLowerCase()); - if (iconResult2 && iconResult2 !== "") { - return iconResult2; - } - } - } catch (fallbackError) { - Logger.w("ActiveWindow", "Error getting icon from ToplevelManager:", fallbackError); - } + const idValue = focusedWindow.appId; + appId = (typeof idValue === 'string') ? idValue : String(idValue); + } else if (CompositorService.isHyprland && ToplevelManager && ToplevelManager.activeToplevel) { + const activeToplevel = ToplevelManager.activeToplevel; + if (activeToplevel.appId) { + const idValue2 = activeToplevel.appId; + appId = (typeof idValue2 === 'string') ? idValue2 : String(idValue2); } } - - return ThemeIcons.iconFromName(fallbackIcon); + + const iconName = appId ? AppSearch.guessIcon(appId.toLowerCase()) : AppSearch.guessIcon(fallbackIcon); + Logger.d("ActiveWindow", "getAppIcon returning:", iconName) + return iconName; } catch (e) { Logger.w("ActiveWindow", "Error in getAppIcon:", e); - return ThemeIcons.iconFromName(fallbackIcon); + const iconName = AppSearch.guessIcon(fallbackIcon); + Logger.d("ActiveWindow", "getAppIcon returning (error fallback):", iconName) + return iconName; } } @@ -234,10 +219,49 @@ Item { IconImage { id: windowIcon anchors.fill: parent - source: getAppIcon() asynchronous: true smooth: true visible: source !== "" + + Component.onCompleted: { + var iconName = getAppIcon(); + // Resolve with fallback (caches ThemeIcons results for performance) + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + windowIcon.source = path; + } + // If path is empty, leave blank (resolver not ready yet) + }); + } + + // Refresh icon when IconResolver becomes ready (in case it wasn't ready on first load) + Connections { + target: IconResolver + function onReadyChanged() { + if (IconResolver.ready && (windowIcon.source === "" || !windowIcon.source)) { + // If icon is still empty when resolver becomes ready, try again + var iconName = getAppIcon(); + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + windowIcon.source = path; + } + }); + } + } + function onResolverRestarted() { + // Clear icon source and re-resolve when theme changes + windowIcon.source = ""; + var iconName = getAppIcon(); + // Wait for resolver to become ready, then resolve + Qt.callLater(function() { + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + windowIcon.source = path; + } + }); + }); + } + } // Apply dock shader to active window icon (always themed) layer.enabled: widgetSettings.colorizeIcons !== false @@ -413,10 +437,49 @@ Item { IconImage { id: windowIconVertical anchors.fill: parent - source: getAppIcon() asynchronous: true smooth: true visible: source !== "" + + Component.onCompleted: { + var iconName = getAppIcon(); + // Resolve with fallback (caches ThemeIcons results for performance) + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + windowIconVertical.source = path; + } + // If path is empty, leave blank (resolver not ready yet) + }); + } + + // Refresh icon when IconResolver becomes ready (in case it wasn't ready on first load) + Connections { + target: IconResolver + function onReadyChanged() { + if (IconResolver.ready && (windowIconVertical.source === "" || !windowIconVertical.source)) { + // If icon is still empty when resolver becomes ready, try again + var iconName = getAppIcon(); + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + windowIconVertical.source = path; + } + }); + } + } + function onResolverRestarted() { + // Clear icon source and re-resolve when theme changes + windowIconVertical.source = ""; + var iconName = getAppIcon(); + // Wait for resolver to become ready, then resolve + Qt.callLater(function() { + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + windowIconVertical.source = path; + } + }); + }); + } + } // Apply dock shader to active window icon (always themed) layer.enabled: widgetSettings.colorizeIcons !== false @@ -462,16 +525,74 @@ Item { target: CompositorService function onActiveWindowChanged() { try { - windowIcon.source = Qt.binding(getAppIcon); - windowIconVertical.source = Qt.binding(getAppIcon); + // Clear icons immediately to prevent showing stale icons during resolver restart + windowIcon.source = ""; + windowIconVertical.source = ""; + + const iconName = getAppIcon(); + + // If resolver is restarting, wait for it to be ready before resolving + // This prevents mismatched icons when switching windows during theme changes + if (IconResolver.isRestarting || !IconResolver.ready) { + // Wait for resolver to be ready, then resolve + var checkReady = function() { + if (IconResolver.ready && !IconResolver.isRestarting) { + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + windowIcon.source = path; + windowIconVertical.source = path; + } + }); + } else { + // Check again in a moment + Qt.callLater(checkReady); + } + }; + Qt.callLater(checkReady); + } else { + // Resolver is ready, resolve immediately + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + windowIcon.source = path; + windowIconVertical.source = path; + } + }); + } } catch (e) { Logger.w("ActiveWindow", "Error in onActiveWindowChanged:", e); } } function onWindowListChanged() { try { - windowIcon.source = Qt.binding(getAppIcon); - windowIconVertical.source = Qt.binding(getAppIcon); + // Clear icons immediately to prevent showing stale icons during resolver restart + windowIcon.source = ""; + windowIconVertical.source = ""; + + const iconName = getAppIcon(); + + // If resolver is restarting, wait for it to be ready before resolving + if (IconResolver.isRestarting || !IconResolver.ready) { + var checkReady = function() { + if (IconResolver.ready && !IconResolver.isRestarting) { + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + windowIcon.source = path; + windowIconVertical.source = path; + } + }); + } else { + Qt.callLater(checkReady); + } + }; + Qt.callLater(checkReady); + } else { + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + windowIcon.source = path; + windowIconVertical.source = path; + } + }); + } } catch (e) { Logger.w("ActiveWindow", "Error in onWindowListChanged:", e); } diff --git a/Modules/Dock/Dock.qml b/Modules/Dock/Dock.qml index ac64b79d0..fcb1b15a6 100644 --- a/Modules/Dock/Dock.qml +++ b/Modules/Dock/Dock.qml @@ -6,6 +6,7 @@ import Quickshell import Quickshell.Wayland import Quickshell.Widgets import qs.Commons +import qs.Services.Icons import qs.Services.UI import qs.Widgets @@ -139,6 +140,11 @@ Loader { return appId; } + function getIconForApp(appId) { + if (!appId) return "image-missing"; + return AppSearch.guessIcon(appId); + } + // Function to update the combined dock apps model function updateDockApps() { const runningApps = ToplevelManager ? (ToplevelManager.toplevels.values || []) : []; @@ -426,12 +432,6 @@ Loader { height: parent.height - (Style.marginM * 2) anchors.centerIn: parent - function getAppIcon(appData): string { - if (!appData || !appData.appId) - return ""; - return ThemeIcons.iconForAppId(appData.appId?.toLowerCase()); - } - RowLayout { id: dockLayout spacing: Style.marginM @@ -475,35 +475,97 @@ Loader { } } - Image { - id: appIcon - width: iconSize - height: iconSize + IconImage { + id: appIconImage anchors.centerIn: parent - source: dock.getAppIcon(modelData) - visible: source.toString() !== "" - sourceSize.width: iconSize * 2 - sourceSize.height: iconSize * 2 + width: parent.width * 0.7 + height: parent.height * 0.7 + asynchronous: true smooth: true - mipmap: true - antialiasing: true - fillMode: Image.PreserveAspectFit - cache: true - + + property string currentAppId: modelData?.appId || "" + // Dim pinned apps that aren't running opacity: appButton.isRunning ? 1.0 : Settings.data.dock.deadOpacity - scale: appButton.hovered ? 1.15 : 1.0 - - // Apply dock-specific colorization shader only to non-focused apps - layer.enabled: !appButton.isActive && Settings.data.dock.colorizeIcons - layer.effect: ShaderEffect { - property color targetColor: Settings.data.colorSchemes.darkMode ? Color.mOnSurface : Color.mSurfaceVariant - property real colorizeMode: 0.0 // Dock mode (grayscale) - - fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/appicon_colorize.frag.qsb") + + Component.onCompleted: { + if (currentAppId) { + const iconName = root.getIconForApp(currentAppId); + // Resolve with fallback (caches ThemeIcons results for performance) + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + appIconImage.source = path; + } + // If path is empty, leave blank (resolver not ready yet) + }); + } } - + + onCurrentAppIdChanged: { + if (currentAppId) { + // Clear icon immediately to prevent showing stale icons during resolver restart + appIconImage.source = ""; + const iconName = root.getIconForApp(currentAppId); + // Resolve with fallback when app ID changes + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + appIconImage.source = path; + } + // If path is empty, leave blank (resolver not ready yet) + }); + } + } + + // Refresh icon when IconResolver becomes ready (in case it wasn't ready on first load) + Connections { + target: IconResolver + function onReadyChanged() { + if (IconResolver.ready && appIconImage.currentAppId && (appIconImage.source === "" || !appIconImage.source)) { + // If icon is still empty when resolver becomes ready, try again + const iconName = root.getIconForApp(appIconImage.currentAppId); + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + appIconImage.source = path; + } + }); + } + } + function onResolverRestarted() { + // Clear icon source and re-resolve when theme changes + if (appIconImage.currentAppId) { + appIconImage.source = ""; + const iconName = root.getIconForApp(appIconImage.currentAppId); + + // If resolver is restarting, wait for it to be ready before resolving + // This prevents mismatched icons when switching apps during theme changes + if (IconResolver.isRestarting || !IconResolver.ready) { + // Wait for resolver to be ready, then resolve + var checkReady = function() { + if (IconResolver.ready && !IconResolver.isRestarting) { + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + appIconImage.source = path; + } + }); + } else { + // Check again in a moment + Qt.callLater(checkReady); + } + }; + Qt.callLater(checkReady); + } else { + // Resolver is ready, resolve immediately + AppSearch.resolveIconWithFallback(iconName, function(path) { + if (path && path.length > 0) { + appIconImage.source = path; + } + }); + } + } + } + } + Behavior on scale { NumberAnimation { duration: Style.animationNormal @@ -518,12 +580,21 @@ Loader { easing.type: Easing.OutQuad } } + + // Apply dock-specific colorization shader only to non-focused apps + layer.enabled: !appButton.isActive && Settings.data.dock.colorizeIcons + layer.effect: ShaderEffect { + property color targetColor: Settings.data.colorSchemes.darkMode ? Color.mOnSurface : Color.mSurfaceVariant + property real colorizeMode: 0.0 // Dock mode (grayscale) + + fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/appicon_colorize.frag.qsb") + } } // Fall back if no icon NIcon { anchors.centerIn: parent - visible: !appIcon.visible + visible: !appIconImage.visible icon: "question-mark" pointSize: iconSize * 0.7 color: appButton.isActive ? Color.mPrimary : Color.mOnSurfaceVariant diff --git a/Modules/Panels/Launcher/Launcher.qml b/Modules/Panels/Launcher/Launcher.qml index 660ddd91e..0529ed299 100644 --- a/Modules/Panels/Launcher/Launcher.qml +++ b/Modules/Panels/Launcher/Launcher.qml @@ -8,6 +8,7 @@ import "../../../Helpers/FuzzySort.js" as Fuzzysort import "Plugins" import qs.Commons import qs.Modules.MainScreen +import qs.Services.Icons import qs.Services.Keyboard import qs.Widgets @@ -930,30 +931,65 @@ SmartPanel { visible: !modelData.isImage && !modelData.emojiChar || (modelData.isImage && imagePreview.status === Image.Error) active: visible - - sourceComponent: Component { - Loader { - anchors.fill: parent - sourceComponent: Settings.data.appLauncher.iconMode === "tabler" && modelData.isTablerIcon ? tablerIconComponent : systemIconComponent - } + + onActiveChanged: { + Logger.d("Launcher", "iconLoader active changed | index:", index, "| iconName:", modelData?.icon, "| active:", active, "| visible:", visible) } - - Component { - id: tablerIconComponent - NIcon { - icon: modelData.icon - pointSize: Style.fontSizeXXXL - visible: modelData.icon && !modelData.emojiChar - } + onVisibleChanged: { + Logger.d("Launcher", "iconLoader visible changed | index:", index, "| iconName:", modelData?.icon, "| visible:", visible, "| active:", active) } - Component { - id: systemIconComponent - IconImage { + sourceComponent: Component { + Item { + id: iconContainer anchors.fill: parent - source: modelData.icon ? ThemeIcons.iconFromName(modelData.icon, "application-x-executable") : "" - visible: modelData.icon && source !== "" && !modelData.emojiChar - asynchronous: true + + property string iconKey: modelData.icon || "application-x-executable" + property string iconSrc: "" + property string _requestKey: "" + + function updateIcon() { + iconSrc = "" + _requestKey = iconKey + + if (!iconKey || iconKey.length === 0) { + return + } + + AppSearch.resolveIconWithFallback(iconKey, function(path) { + // Guard against delegate reuse + if (_requestKey === iconKey && path && path.length > 0) { + iconSrc = path + } + }) + } + + onIconKeyChanged: updateIcon() + Component.onCompleted: updateIcon() + + // Use IconImage for icon names (theme-based icons) + IconImage { + id: listIcon + anchors.centerIn: parent + width: parent.width + height: parent.height + asynchronous: true + smooth: true + source: iconContainer.iconSrc && !iconContainer.iconSrc.startsWith("image://icon/") && !iconContainer.iconSrc.startsWith("file://") && !iconContainer.iconSrc.startsWith("/") ? iconContainer.iconSrc : "" + visible: source !== "" + } + + // Use regular Image for file:// URLs and image://icon/ URLs (IconImage doesn't support them) + Image { + id: listIconFallback + anchors.centerIn: parent + width: parent.width + height: parent.height + asynchronous: true + smooth: true + source: iconContainer.iconSrc && (iconContainer.iconSrc.startsWith("image://icon/") || iconContainer.iconSrc.startsWith("file://") || iconContainer.iconSrc.startsWith("/")) ? iconContainer.iconSrc : "" + visible: source !== "" && status === Image.Ready + } } } } @@ -1298,30 +1334,65 @@ SmartPanel { visible: !modelData.isImage && !modelData.emojiChar || (modelData.isImage && gridImagePreview.status === Image.Error) active: visible - - sourceComponent: Component { - Loader { - anchors.fill: parent - sourceComponent: Settings.data.appLauncher.iconMode === "tabler" && modelData.isTablerIcon ? gridTablerIconComponent : gridSystemIconComponent - } + + onActiveChanged: { + Logger.d("Launcher", "gridIconLoader active changed | index:", index, "| iconName:", modelData?.icon, "| active:", active, "| visible:", visible) } - - Component { - id: gridTablerIconComponent - NIcon { - icon: modelData.icon - pointSize: Style.fontSizeXXXL - visible: modelData.icon && !modelData.emojiChar - } + onVisibleChanged: { + Logger.d("Launcher", "gridIconLoader visible changed | index:", index, "| iconName:", modelData?.icon, "| visible:", visible, "| active:", active) } - Component { - id: gridSystemIconComponent - IconImage { + sourceComponent: Component { + Item { + id: gridIconContainer anchors.fill: parent - source: modelData.icon ? ThemeIcons.iconFromName(modelData.icon, "application-x-executable") : "" - visible: modelData.icon && source !== "" && !modelData.emojiChar - asynchronous: true + + property string iconKey: modelData.icon || "application-x-executable" + property string iconSrc: "" + property string _requestKey: "" + + function updateIcon() { + iconSrc = "" + _requestKey = iconKey + + if (!iconKey || iconKey.length === 0) { + return + } + + AppSearch.resolveIconWithFallback(iconKey, function(path) { + // Guard against delegate reuse + if (_requestKey === iconKey && path && path.length > 0) { + iconSrc = path + } + }) + } + + onIconKeyChanged: updateIcon() + Component.onCompleted: updateIcon() + + // Use IconImage for icon names (theme-based icons) + IconImage { + id: gridIcon + anchors.centerIn: parent + width: parent.width + height: parent.height + asynchronous: true + smooth: true + source: gridIconContainer.iconSrc && !gridIconContainer.iconSrc.startsWith("image://icon/") && !gridIconContainer.iconSrc.startsWith("file://") && !gridIconContainer.iconSrc.startsWith("/") ? gridIconContainer.iconSrc : "" + visible: source !== "" + } + + // Use regular Image for file:// URLs and image://icon/ URLs (IconImage doesn't support them) + Image { + id: gridIconFallback + anchors.centerIn: parent + width: parent.width + height: parent.height + asynchronous: true + smooth: true + source: gridIconContainer.iconSrc && (gridIconContainer.iconSrc.startsWith("image://icon/") || gridIconContainer.iconSrc.startsWith("file://") || gridIconContainer.iconSrc.startsWith("/")) ? gridIconContainer.iconSrc : "" + visible: source !== "" && status === Image.Ready + } } } } diff --git a/Services/Icons/AppSearch.qml b/Services/Icons/AppSearch.qml new file mode 100644 index 000000000..e062172b9 --- /dev/null +++ b/Services/Icons/AppSearch.qml @@ -0,0 +1,487 @@ +pragma Singleton +// Icon name guessing and resolution - Based on End4's AppSearch + +import QtQuick +import Quickshell +import qs.Commons +import "../../Helpers/FuzzySort.js" as FuzzySort + +Singleton { + id: root + property real scoreThreshold: 0.2 + + Component.onCompleted: { + Logger.i("AppSearch", "Singleton created/loaded") + } + + // Normalization cache - stores recently normalized strings (LRU-style, max 50 entries) + property var normalizeCache: ({}) + property int normalizeCacheMaxSize: 50 + property var normalizeCacheKeys: [] // Track insertion order for LRU eviction + + property var substitutions: ({ + "code-url-handler": "visual-studio-code", + "Code": "visual-studio-code", + "gnome-tweaks": "org.gnome.tweaks", + "pavucontrol-qt": "pavucontrol", + "wps": "wps-office2019-kprometheus", + "wpsoffice": "wps-office2019-kprometheus", + "footclient": "foot", + }) + property var regexSubstitutions: [ + { + "regex": /^steam_app_(\d+)$/, + "replace": "steam_icon_$1" + }, + { + "regex": /minecraft.*/i, + "replace": "minecraft" + }, + { + "regex": /.*polkit.*/, + "replace": "system-lock-screen" + }, + { + "regex": /gcr.prompter/, + "replace": "system-lock-screen" + } + ] + + // Lazy-loaded list (computed on first access) + function getList() { + if (typeof DesktopEntries === 'undefined' || !DesktopEntries.applications) { + return []; + } + var values = Array.from(DesktopEntries.applications.values); + return values.filter(function(app, index, self) { + return index === self.findIndex(function(t) { + return t.id === app.id; + }); + }); + } + + // Lazy-loaded prepped names + function getPreppedNames() { + var list = getList(); + return list.map(function(a) { + return { + name: FuzzySort.prepare(a.name + " "), + entry: a + }; + }); + } + + // Lazy-loaded prepped icons + function getPreppedIcons() { + var list = getList(); + return list.map(function(a) { + return { + name: FuzzySort.prepare(a.icon + " "), + entry: a + }; + }); + } + + function fuzzyQuery(search: string): var { // Idk why list doesn't work + // Apply scoreThreshold to filter low-quality matches + // fuzzysort's threshold option expects normalized score (0-1), which scoreThreshold already is + var results = FuzzySort.go(search, getPreppedNames(), { + all: true, + key: "name", + threshold: root.scoreThreshold + }); + return results.filter(function(r) { + // Double-check score threshold (results have normalized scores) + return r.score >= root.scoreThreshold; + }).map(function(r) { + return r.obj.entry; + }); + } + + // Fuzzy search with highlighted results for UI display + // Returns results with text, highlightedText, and score + // Parameters: search (string), highlightOpen (string, optional), highlightClose (string, optional) + // Returns: Array of {entry, text, highlightedText, score} objects + function fuzzyQueryWithHighlight(search: string, highlightOpen, highlightClose): var { + if (!search || search.length === 0) return [] + + // Use fuzzysort for proper highlighting + var results = FuzzySort.go(search, getPreppedNames(), { + all: true, + key: "name", + threshold: root.scoreThreshold + }); + results = results.filter(function(r) { + return r.score >= root.scoreThreshold; + }); + + var openTag = highlightOpen !== undefined ? highlightOpen : ''; + var closeTag = highlightClose !== undefined ? highlightClose : ''; + + return results.map(function(r) { + return { + entry: r.obj.entry, + text: r.obj.entry.name, + highlightedText: r.highlight ? r.highlight(openTag, closeTag) : r.target || "", + score: r.score + }; + }); + } + + // Cleans and formats display names for consistent UI presentation + // Handles common edge cases: .desktop suffix, dashes/underscores, extra whitespace + // Parameter: name (string) - Raw name (e.g., "app-name.desktop", "App Name", "app_name", etc.) + // Returns: Cleaned display name ready for UI display + function cleanDisplayName(name) { + if (!name || name.length === 0) return "" + + // Remove .desktop suffix if present + var cleaned = name.replace(/\.desktop$/i, "") + + // Replace dashes/underscores with spaces for readability + cleaned = cleaned.replace(/[-_]/g, " ") + + // Trim extra whitespace and normalize multiple spaces to single space + cleaned = cleaned.trim().replace(/\s+/g, " ") + + return cleaned + } + + // Normalizes a string and returns all variant forms + // Caches results for performance (50 most recent entries) + // Parameter: str (string) - The string to normalize + // Returns: Object with normalized variants: + // - lowercase: lowercase version + // - reverseDomain: last part of domain (e.g., "com.app.name" -> "name") + // - reverseDomainLower: reverseDomain.toLowerCase() + // - kebab: spaces to dashes, lowercase + // - underscoreToKebab: underscores to dashes, lowercase + function normalize(str) { + if (!str || str.length === 0) { + return { + lowercase: "", + reverseDomain: "", + reverseDomainLower: "", + kebab: "", + underscoreToKebab: "" + } + } + + // Check cache first + if (root.normalizeCache.hasOwnProperty(str)) { + // Move to end (most recently used) + var index = root.normalizeCacheKeys.indexOf(str) + if (index !== -1) { + root.normalizeCacheKeys.splice(index, 1) + root.normalizeCacheKeys.push(str) + } + return root.normalizeCache[str] + } + + // Compute all variants + var lowercase = str.toLowerCase() + var domainParts = str.split('.') + var reverseDomain = domainParts.length > 0 ? domainParts[domainParts.length - 1] : str + var reverseDomainLower = reverseDomain.toLowerCase() + var kebab = lowercase.replace(/\s+/g, "-") + var underscoreToKebab = lowercase.replace(/_/g, "-") + + var result = { + lowercase: lowercase, + reverseDomain: reverseDomain, + reverseDomainLower: reverseDomainLower, + kebab: kebab, + underscoreToKebab: underscoreToKebab + } + + // Cache the result (LRU eviction) + root.normalizeCache[str] = result + root.normalizeCacheKeys.push(str) + + // Evict oldest if cache is full + if (root.normalizeCacheKeys.length > root.normalizeCacheMaxSize) { + var oldest = root.normalizeCacheKeys.shift() + delete root.normalizeCache[oldest] + } + + return result + } + + // Legacy functions for backward compatibility (now use normalize cache) + function getReverseDomainNameAppName(str) { + return normalize(str).reverseDomain + } + + function getKebabNormalizedAppName(str) { + return normalize(str).kebab + } + + function getUndescoreToKebabAppName(str) { + return normalize(str).underscoreToKebab + } + + // Cache for resolved icon paths (LRU cache, max 200 entries) + property var iconPathCache: ({}) + property var iconPathCacheKeys: [] // Track insertion order for LRU eviction + property int iconPathCacheMaxSize: 200 + + // Resolves an icon name to a file path asynchronously using IconResolver + // Parameters: iconName (string) - The icon name (e.g., "com.mitchellh.ghostty") + // callback (function) - Function called with the resolved path (or empty string if not found) + function resolveIconAsync(iconName, callback) { + if (!iconName || iconName.length === 0) { + if (typeof callback === "function") { + callback("") + } + return + } + + // If it's already a file path, return immediately + if (iconName.startsWith("/")) { + if (typeof callback === "function") { + callback(iconName) + } + return + } + + // Use IconResolver to resolve + IconResolver.resolveIcon(iconName, function(resolvedPath) { + // Cache the result with LRU eviction + if (resolvedPath && resolvedPath.length > 0) { + if (root.iconPathCache.hasOwnProperty(iconName)) { + root.iconPathCache[iconName] = resolvedPath + var index = root.iconPathCacheKeys.indexOf(iconName) + if (index !== -1) { + root.iconPathCacheKeys.splice(index, 1) + root.iconPathCacheKeys.push(iconName) + } + } else { + root.iconPathCache[iconName] = resolvedPath + root.iconPathCacheKeys.push(iconName) + if (root.iconPathCacheKeys.length > root.iconPathCacheMaxSize) { + var oldest = root.iconPathCacheKeys.shift() + delete root.iconPathCache[oldest] + } + } + } + + if (typeof callback === "function") { + callback(resolvedPath) + } + }) + } + + // Clears the icon path cache (called when IconResolver restarts) + function clearIconCache() { + root.iconPathCache = {}; + root.iconPathCacheKeys = []; + Logger.d("AppSearch", "Icon path cache cleared"); + } + + // Resolves icon with ThemeIcons fallback (caches fallback results for performance) + // Parameters: iconName (string) - The icon name + // callback (function) - Function called with the resolved path (or ThemeIcons fallback if not found) + function resolveIconWithFallback(iconName, callback) { + if (!iconName || iconName.length === 0) { + Logger.w("AppSearch", "resolveIconWithFallback: empty iconName") + if (typeof callback === "function") { + callback("") + } + return + } + + // Check cache first + var cacheHit = root.iconPathCache.hasOwnProperty(iconName) + if (cacheHit) { + var cached = root.iconPathCache[iconName] + if (cached && cached.length > 0) { + Logger.d("AppSearch", "resolveIconWithFallback:", iconName, "→ CACHE HIT →", cached) + if (typeof callback === "function") { + callback(cached) + } + return + } + } + + // Try IconResolver first + resolveIconAsync(iconName, function(resolvedPath) { + if (resolvedPath && resolvedPath.length > 0) { + // IconResolver found it + Logger.d("AppSearch", "resolveIconWithFallback:", iconName, "→ IconResolver →", resolvedPath) + if (typeof callback === "function") { + callback(resolvedPath) + } + } else { + // IconResolver didn't find it - try ThemeIcons fallback + var fallbackPath = ThemeIcons.iconFromName(iconName, "application-x-executable"); + var finalPath = fallbackPath || ""; + + // Cache the fallback result + if (finalPath && finalPath.length > 0) { + if (root.iconPathCache.hasOwnProperty(iconName)) { + root.iconPathCache[iconName] = finalPath + var index = root.iconPathCacheKeys.indexOf(iconName) + if (index !== -1) { + root.iconPathCacheKeys.splice(index, 1) + root.iconPathCacheKeys.push(iconName) + } + } else { + root.iconPathCache[iconName] = finalPath + root.iconPathCacheKeys.push(iconName) + if (root.iconPathCacheKeys.length > root.iconPathCacheMaxSize) { + var oldest = root.iconPathCacheKeys.shift() + delete root.iconPathCache[oldest] + } + } + } + + Logger.d("AppSearch", "resolveIconWithFallback:", iconName, "→ ThemeIcons fallback →", finalPath || "(empty)") + if (typeof callback === "function") { + callback(finalPath) + } + } + }) + } + + // Gets the cached icon path for an icon name (synchronous) + // Returns the cached path if available, otherwise returns the icon name + // Parameter: iconName (string) - The icon name + // Returns: The cached path or icon name + function getIconPath(iconName) { + if (!iconName || iconName.length === 0) return "" + + // If it's already a file path, return as-is + if (iconName.startsWith("/")) { + return iconName + } + + // Check cache and update LRU order + if (root.iconPathCache.hasOwnProperty(iconName)) { + // Move to end (most recently used) + var index = root.iconPathCacheKeys.indexOf(iconName) + if (index !== -1) { + root.iconPathCacheKeys.splice(index, 1) + root.iconPathCacheKeys.push(iconName) + } + return root.iconPathCache[iconName] + } + + // Not cached yet, return icon name for fallback + return iconName + } + + // Guesses the best icon name for a given window class or app identifier + // Returns icon names only - does NOT check file existence + // All actual path resolution should go through IconResolver.resolveIcon() + // Parameters: str (string) - Window class or app identifier + // Returns: Best guess icon name (e.g., "com.mitchellh.ghostty", "zen-browser") + function guessIcon(str) { + Logger.d("AppSearch", "guessIcon called with:", str) + if (!str || str.length == 0) return "image-missing"; + + // 1. Canonical substitutions (app-specific mappings) + if (substitutions[str]) { + Logger.d("AppSearch", "Using substitution:", str, "→", substitutions[str]) + return substitutions[str]; + } + if (substitutions[str.toLowerCase()]) { + Logger.d("AppSearch", "Using substitution:", str, "→", substitutions[str.toLowerCase()]) + return substitutions[str.toLowerCase()]; + } + + // 2. Regex substitutions (pattern-based mappings) + for (var i = 0; i < regexSubstitutions.length; i++) { + var substitution = regexSubstitutions[i]; + var replacedName = str.replace( + substitution.regex, + substitution.replace + ); + if (replacedName != str) { + var result = replacedName; + Logger.d("AppSearch", "guessIcon result:", str, "→", result) + return result; + } + } + + // 3. Desktop entry lookup (metadata fallback) + var entry = DesktopEntries.byId(str); + if (entry && entry.icon) { + var result = entry.icon; + Logger.d("AppSearch", "guessIcon result:", str, "→", result) + return result; + } + + // 4. Try normalized variants (return best guess, let Rust resolver check existence) + var normalized = normalize(str); + + // Try variants in order of likelihood + if (normalized.reverseDomain && normalized.reverseDomain !== str) { + var result = normalized.reverseDomain; + Logger.d("AppSearch", "guessIcon result:", str, "→", result) + return result; + } + + if (normalized.reverseDomainLower && normalized.reverseDomainLower !== str) { + var result = normalized.reverseDomainLower; + Logger.d("AppSearch", "guessIcon result:", str, "→", result) + return result; + } + + if (normalized.lowercase && normalized.lowercase !== str) { + var result = normalized.lowercase; + Logger.d("AppSearch", "guessIcon result:", str, "→", result) + return result; + } + + if (normalized.kebab && normalized.kebab !== str) { + var result = normalized.kebab; + Logger.d("AppSearch", "guessIcon result:", str, "→", result) + return result; + } + + if (normalized.underscoreToKebab && normalized.underscoreToKebab !== str) { + var result = normalized.underscoreToKebab; + Logger.d("AppSearch", "guessIcon result:", str, "→", result) + return result; + } + + // 5. Fuzzy search in desktop entries (for icon names) + var iconSearchResults = FuzzySort.go(str, getPreppedIcons(), { + all: true, + key: "name", + threshold: root.scoreThreshold + }); + iconSearchResults = iconSearchResults.filter(function(r) { + return r.score >= root.scoreThreshold; + }); + iconSearchResults = iconSearchResults.map(function(r) { + return r.obj.entry; + }); + if (iconSearchResults.length > 0) { + var result = iconSearchResults[0].icon; + Logger.d("AppSearch", "guessIcon result:", str, "→", result) + return result; + } + + // 6. Fuzzy search in app names (fallback to their icon) + var nameSearchResults = root.fuzzyQuery(str); + if (nameSearchResults.length > 0) { + var result = nameSearchResults[0].icon; + Logger.d("AppSearch", "guessIcon result:", str, "→", result) + return result; + } + + // 7. Heuristic desktop entry lookup + var heuristicEntry = DesktopEntries.heuristicLookup(str); + if (heuristicEntry) { + var result = heuristicEntry.icon; + Logger.d("AppSearch", "guessIcon result:", str, "→", result) + return result; + } + + // 8. Return original string as fallback (let Rust resolver try variations) + var result = str; + Logger.d("AppSearch", "guessIcon result:", str, "→", result) + return result; + } +} diff --git a/Services/Icons/IconResolver.qml b/Services/Icons/IconResolver.qml new file mode 100644 index 000000000..7df3b055b --- /dev/null +++ b/Services/Icons/IconResolver.qml @@ -0,0 +1,358 @@ +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick +import qs.Commons + +Singleton { + id: root + + property bool ready: false + property var cache: ({}) + property var pending: ({}) + property var requestQueue: [] // Queue of icon names waiting for response + property bool initialized: false + property string currentTheme: "" // Track current icon theme + property bool isRestarting: false // True when intentionally restarting (vs unexpected crash) + + // Signal emitted when resolver restarts (e.g., on theme change) + signal resolverRestarted() + + Component.onCompleted: { + // Ensure objects are initialized synchronously + root.cache = {} + root.pending = {} + root.requestQueue = [] + root.initialized = true + Logger.i("IconResolver", "Singleton created/loaded") + + // Get initial theme value + root.getInitialTheme(); + } + + // Get initial icon theme from GSettings + function getInitialTheme() { + var initialThemeProcess = Qt.createQmlObject(` + import QtQuick + import Quickshell.Io + Process { + command: ["gsettings", "get", "org.gnome.desktop.interface", "icon-theme"] + stdout: StdioCollector {} + } + `, root, "InitialThemeProcess"); + + initialThemeProcess.exited.connect(function(exitCode) { + if (exitCode === 0 && initialThemeProcess.stdout.text) { + var match = initialThemeProcess.stdout.text.match(/'([^']+)'/); + if (match && match.length > 1) { + root.currentTheme = match[1]; + Logger.i("IconResolver", "Initial icon theme:", root.currentTheme); + // Start resolver with initial theme + root.startResolver(); + } else { + // Fallback if parsing fails + Logger.w("IconResolver", "Failed to parse initial theme, using default"); + root.currentTheme = "Papirus-Dark"; + root.startResolver(); + } + } else { + // Fallback if gsettings fails + Logger.w("IconResolver", "Failed to get initial theme, using default"); + root.currentTheme = "Papirus-Dark"; + root.startResolver(); + } + initialThemeProcess.destroy(); + }); + + initialThemeProcess.running = true; + } + + // GSettings monitor for icon theme changes + property Process themeMonitorProcess: Process { + id: themeMonitorProcess + command: ["gsettings", "monitor", "org.gnome.desktop.interface", "icon-theme"] + running: root.initialized + + stdout: SplitParser { + splitMarker: "\n" + onRead: function(line) { + if (!line || line.trim() === "") return + + // Parse: "icon-theme: 'Papirus-Dark'" + var match = line.match(/icon-theme:\s*'([^']+)'/); + if (match && match.length > 1) { + var newTheme = match[1]; + if (newTheme !== root.currentTheme) { + Logger.i("IconResolver", "Icon theme changed:", root.currentTheme, "→", newTheme); + root.currentTheme = newTheme; + root.restartResolver(); + } + } + } + } + + onExited: { + Logger.w("IconResolver", "Theme monitor process exited, restarting..."); + // Restart the monitor if it exits unexpectedly + Qt.callLater(() => { + if (themeMonitorProcess && root.initialized) { + themeMonitorProcess.running = true; + } + }); + } + } + + // Start the resolver process with current theme + function startResolver() { + if (!root.currentTheme) { + root.currentTheme = "Papirus-Dark"; // Fallback + } + + // Build command with explicit QT_ICON_THEME env var + var resolverPath = Quickshell.shellPath("Services/Icons/icon-resolver/target/release/icon-resolver"); + iconResolverProcess.command = ["sh", "-c", "env QT_ICON_THEME='" + root.currentTheme + "' " + resolverPath]; + iconResolverProcess.running = true; + } + + // Restart resolver when theme changes + function restartResolver() { + Logger.i("IconResolver", "Restarting icon resolver due to theme change"); + + // Set restarting flag so onExited knows this is intentional + root.isRestarting = true; + + // Clear all JS-side caches + root.cache = {}; + root.pending = {}; + root.requestQueue = []; + root.ready = false; + + // Clear AppSearch's icon path cache as well + if (typeof AppSearch !== 'undefined' && AppSearch.clearIconCache) { + AppSearch.clearIconCache(); + } + + // Emit signal for components that need to refresh (e.g., dock) + root.resolverRestarted(); + + // Stop the resolver process - restart will happen in onExited + if (iconResolverProcess && iconResolverProcess.running) { + iconResolverProcess.running = false; + } else { + // Process not running, start immediately + root.isRestarting = false; + root.startResolver(); + } + } + + // Helper to ensure path has file:// prefix for QML Image components + function ensureFileUrl(path) { + if (!path || path.length === 0) return "" + if (path.startsWith("file://")) return path + if (path.startsWith("/")) return "file://" + path + return path + } + + property Process iconResolverProcess: Process { + id: iconResolverProcess + // Command will be set by startResolver() with explicit QT_ICON_THEME + running: false // Start manually after getting initial theme + + Component.onCompleted: { + Logger.i("IconResolver", "Icon resolver process component ready") + } + + onStarted: { + // Send reload request when process starts to get ready signal + Logger.d("IconResolver", "Process started, sending reload request") + var request = JSON.stringify({ type: "reload" }); + iconResolverProcess.write(request + "\n"); + } + + stdout: SplitParser { + splitMarker: "\n" + onRead: function(data) { + if (!data || data.trim() === "") return + + // Silently ignore if not initialized yet (early responses before Component.onCompleted) + if (!root || !root.initialized) return + try { + if (typeof root.cache !== "object" || root.cache === null) return + if (typeof root.pending !== "object" || root.pending === null) return + if (!Array.isArray(root.requestQueue)) return + } catch (e) { + // Properties not ready yet, silently ignore + return + } + + try { + var response = JSON.parse(data.trim()) + if (!response) return + + if (response.path !== undefined && response.name !== undefined) { + // Resolve response - match by icon name from response (not queue order) + // This prevents mismatches when responses come back out of order + var iconName = response.name + + if (iconName && typeof iconName === "string") { + try { + // Remove from request queue if present (for cleanup, but not required for matching) + if (root.requestQueue && root.requestQueue.length > 0) { + var index = root.requestQueue.indexOf(iconName) + if (index !== -1) { + root.requestQueue.splice(index, 1) + } + } + + // Store in cache (store without file:// prefix) + root.cache[iconName] = response.path + Logger.d("IconResolver", "Resolved", iconName, "→", response.path) + + // Call pending callbacks with file:// prefix + if (root.pending[iconName] && Array.isArray(root.pending[iconName])) { + var callbacks = root.pending[iconName] + delete root.pending[iconName] + + var fileUrl = ensureFileUrl(response.path) + for (var i = 0; i < callbacks.length; i++) { + if (callbacks[i] && typeof callbacks[i] === "function") { + callbacks[i](fileUrl) + } + } + } + } catch (e) { + // Silently ignore property access errors during initialization + } + } + + if (root && !root.ready) { + root.ready = true + Logger.i("IconResolver", "Icon resolver ready!") + // Process any queued requests that were waiting + root.processQueuedRequests() + } + } else if (response.status === "ok") { + // Reload response + var count = response.count || 0 + Logger.i("IconResolver", "Cache reloaded, " + count + " icons cached") + if (root && !root.ready) { + root.ready = true + Logger.i("IconResolver", "Icon resolver ready!") + // Process any queued requests that were waiting + root.processQueuedRequests() + } + } + } catch (e) { + // Silently ignore parsing/processing errors during early initialization + // Only log if we're past initialization phase + if (root && root.initialized) { + Logger.e("IconResolver", "Error parsing response:", e, "data:", data) + } + } + } + } + + onExited: { + if (root.isRestarting) { + Logger.i("IconResolver", "Process stopped for restart") + root.isRestarting = false + // Restart with new theme + Qt.callLater(() => { + if (iconResolverProcess) { + root.startResolver(); + } + }); + } else { + // Unexpected exit - log error + Logger.e("IconResolver", "Process exited unexpectedly") + root.ready = false + } + } + } + + // Resolves an icon name to a file path asynchronously + // Parameters: iconName (string) - The icon name (e.g., "com.mitchellh.ghostty") + // callback (function) - Function called with the resolved path (or empty string if not found) + function resolveIcon(iconName, callback) { + if (!iconName || iconName.length === 0) { + if (typeof callback === "function") { + callback("") + } + return + } + + // If in cache, return immediately (including absolute paths that were validated before) + if (root.cache.hasOwnProperty(iconName)) { + if (typeof callback === "function") { + callback(ensureFileUrl(root.cache[iconName])) + } + return + } + + // For absolute paths, send to Rust resolver to validate existence + // (Rust resolver will check if file exists and return path or empty string) + // For icon names, send to Rust resolver for theme-based lookup + + // Add callback to pending + if (!root.pending[iconName]) { + root.pending[iconName] = [] + // Add to request queue (only once per icon name) + root.requestQueue.push(iconName) + + // Only send request if process is running and not restarting + if (iconResolverProcess && iconResolverProcess.running && !root.isRestarting) { + Logger.d("IconResolver", "Requesting icon:", iconName) + var request = JSON.stringify({ type: "resolve", name: iconName }) + iconResolverProcess.write(request + "\n") + } else { + Logger.d("IconResolver", "Process not ready, queuing icon:", iconName) + // Request will be sent when process becomes ready (handled in onReadyChanged or processQueuedRequests) + } + } + if (callback && typeof callback === "function") { + root.pending[iconName].push(callback) + } + } + + // Process queued requests that were waiting for the process to be ready + function processQueuedRequests() { + if (!iconResolverProcess || !iconResolverProcess.running) return + + // Send all pending requests + var iconNames = Object.keys(root.pending) + for (var i = 0; i < iconNames.length; i++) { + var iconName = iconNames[i] + if (iconName && !root.cache.hasOwnProperty(iconName)) { + Logger.d("IconResolver", "Processing queued icon request:", iconName) + var request = JSON.stringify({ type: "resolve", name: iconName }) + iconResolverProcess.write(request + "\n") + } + } + } + + // Reloads the icon cache + function reload() { + if (!iconResolverProcess || !iconResolverProcess.running) { + Logger.w("IconResolver", "Cannot reload: process not running") + return + } + var request = JSON.stringify({ type: "reload" }) + iconResolverProcess.write(request + "\n") + } + + // Gets cached path for an icon name (synchronous, returns empty if not cached) + function getCachedPath(iconName) { + if (!iconName || iconName.length === 0) return "" + if (iconName.startsWith("/")) { + if (iconName.startsWith("file://")) return iconName + return "file://" + iconName + } + var cached = root.cache[iconName] || "" + if (cached && cached.startsWith("/")) { + return "file://" + cached + } + return cached + } +} + diff --git a/Services/Icons/icon-resolver/Cargo.lock b/Services/Icons/icon-resolver/Cargo.lock new file mode 100644 index 000000000..a1ba6333f --- /dev/null +++ b/Services/Icons/icon-resolver/Cargo.lock @@ -0,0 +1,329 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "icon-resolver" +version = "0.1.0" +dependencies = [ + "dirs", + "rayon", + "serde", + "serde_json", +] + +[[package]] +name = "itoa" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "libredox" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df15f6eac291ed1cf25865b1ee60399f57e7c227e7f51bdbd4c5270396a9ed50" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.147" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af14725505314343e673e9ecb7cd7e8a36aa9791eb936235a3567cc31447ae4" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "zmij" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e404bcd8afdaf006e529269d3e85a743f9480c3cef60034d77860d02964f3ba" diff --git a/Services/Icons/icon-resolver/Cargo.toml b/Services/Icons/icon-resolver/Cargo.toml new file mode 100644 index 000000000..1067f4aed --- /dev/null +++ b/Services/Icons/icon-resolver/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "icon-resolver" +version = "0.1.0" +edition = "2021" + +[lib] +name = "icon_resolver" +path = "src/lib.rs" + +[[bin]] +name = "icon-resolver" +path = "src/main.rs" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +dirs = "5.0" +rayon = "1.8" diff --git a/Services/Icons/icon-resolver/README.md b/Services/Icons/icon-resolver/README.md new file mode 100644 index 000000000..c9891a468 --- /dev/null +++ b/Services/Icons/icon-resolver/README.md @@ -0,0 +1,232 @@ +# icon-resolver + +A Rust service for resolving freedesktop icon theme icons. Resolves icon names to file paths following the XDG Icon Theme Specification. + +## Problem + +Qt's icon lookup searches `XDG_DATA_DIRS` in order and may find fallback icons (like hicolor) before themed icons, causing incorrect icons to be displayed. Additionally, building an icon cache from scratch takes several seconds on first run (varies by theme size), but subsequent runs load from persistent cache in milliseconds. + +## Solution + +This service: +1. Reads `QT_ICON_THEME` and `USER` environment variables at startup +2. Loads persistent cache from disk (~8ms) if available, or builds cache if missing/stale (varies by theme size) +3. Builds icon cache by scanning theme directories in priority order +4. Follows the theme's `Inherits=` chain from `index.theme` files +5. Maps icon names to full file paths with variation matching +6. Provides IPC via stdin/stdout JSON +7. Saves cache to disk for faster subsequent runs + +## Performance + +- **Cache load**: ~8ms (from disk, when cache exists for current theme) +- **Cache build**: Varies by theme size (parallelized with Rayon): + - Small themes (hicolor, ~34 icons): <50ms + - Medium themes (Adwaita, ~1000 icons): ~50-100ms + - Large themes (Papirus-Dark, ~18000 icons): ~300-400ms +- **Runtime lookup**: ~150ns - 8µs per icon (in-memory hash map lookup) +- **Theme switching**: Cache is per-theme, so switching themes rebuilds the cache for the new theme + +**Note**: The cache file stores one theme at a time. When switching themes, the cache for the previous theme is invalidated and rebuilt for the new theme. This means theme switches trigger a cache rebuild, but subsequent uses of the same theme load instantly from cache. + +**Optimization**: Directory scanning is parallelized using Rayon, providing 5-10x speedup on multi-core systems. Large themes that previously took 2-4 seconds now build in under 400ms. + +## Architecture + +``` +src/ +├── lib.rs # Icon resolution logic +└── main.rs # IPC wrapper that handles stdin/stdout JSON protocol +``` + +**Why separate lib.rs and main.rs?** +- `lib.rs`: Contains all icon resolution logic +- `main.rs`: Thin IPC wrapper that handles stdin/stdout JSON protocol +- This separation keeps code organized and enables reuse + +## Persistent Cache + +**Cache Location:** `~/.cache/noctalia/icon-cache.json` + +**Cache Format:** +```json +{ + "metadata": { + "theme": "Papirus-Dark", + "base_path": "/usr/share/icons", + "additional_paths": ["/home/user/.local/share/icons"], + "cache_time": 1702345678, + "icon_count": 21643 + }, + "icons": { + "zen-browser": { + "path": "/path/to/zen-browser.svg", + "size": 128, + "is_svg": true, + "theme_priority": 0 + } + } +} +``` + +**Cache Invalidation:** +Cache is automatically invalidated and rebuilt if: +- Theme name changes (`QT_ICON_THEME` environment variable) +- Base path changes +- Additional paths (`XDG_DATA_DIRS`) change +- Theme's `index.theme` file is modified after cache creation + +## Building + +```bash +cd Services/Icons/icon-resolver +cargo build --release +``` + +The binary will be at `target/release/icon-resolver`. + +## IPC Protocol + +The service communicates via stdin/stdout using JSON messages. Each request is a single JSON object on one line, and each response is a single JSON object on one line. + +### Resolve Request + +**Input:** +```json +{"type": "resolve", "name": "com.mitchellh.ghostty"} +``` + +**Output:** +```json +{"path": "/usr/share/icons/Papirus-Dark/128x128/apps/com.mitchellh.ghostty.svg"} +``` + +If the icon is not found: +```json +{"path": ""} +``` + +### Reload Request + +**Input:** +```json +{"type": "reload"} +``` + +**Output:** +```json +{"status": "ok", "count": 21643} +``` + +The `count` field indicates how many icons were cached after reloading. Cache is automatically saved to disk after reload. + +### Search Request (Debug) + +**Input:** +```json +{"type": "search", "pattern": "zen"} +``` + +**Output:** +```json +{"matches": ["zen", "zen-browser", "zenity", ...]} +``` + +Returns all icon names containing the pattern (case-insensitive, alphabetically sorted). + +## Icon Resolution Priority + +When resolving an icon name, the service uses this priority order: + +### 1. Theme Priority + +Follows the `Inherits=` chain from `index.theme`: +- Papirus-Dark → Papirus → breeze-dark → hicolor +- Earlier themes in the chain have higher priority +- System themes always win over hicolor fallback + +### 2. Format Priority (Within Same Theme) + +- SVG files are preferred over PNG files +- Fixed-size SVGs are preferred over scalable SVGs (when both exist) + +### 3. Size Priority (Within Same Format) + +- Larger sizes preferred (128x128 > 64x64 > 48x48 > ...) +- Scalable SVGs have lowest priority (size = 0) + +### 4. Fallback Variations (If Exact Match Not Found) + +If the exact icon name isn't in the cache, the service tries these variations in order: + +1. **Browser/Client Suffixes**: `name + "-browser"`, `name + "-client"` +2. **Prefix Failover**: `"preferences-" + name`, `"utilities-" + name`, `"accessories-" + name` +3. **Case Normalization**: Lowercase version of name +4. **Reverse Domain**: Last part of domain (e.g., "com.mitchellh.ghostty" → "ghostty") +5. **Kebab/Underscore Conversion**: "visual-studio-code" ↔ "visual_studio_code" +6. **Org Prefix Variations**: `"org." + name + "." + Name`, `"org." + name` + +Returns the first matching variation found, or empty string if none match. + +## Directory Structure + +Icons are expected at: +``` +////. +``` + +Where: +- `` is typically `/usr/share/icons` or `~/.local/share/icons` +- `` is the theme name (e.g., "Papirus-Dark") +- `` is the size directory (e.g., "48x48", "scalable") +- `` is the icon category: `apps`, `actions`, `devices`, `places`, `status`, `mimetypes`, `categories` +- `` is the icon name without extension +- `` is either "svg" or "png" + +The service dynamically discovers directories from `index.theme` files (XDG Icon Theme Specification compliant). + +## Environment Variables + +- `QT_ICON_THEME`: Icon theme name (default: "Papirus-Dark") +- `USER`: Username for profile path detection (default: "user") +- `XDG_DATA_DIRS`: Colon-separated paths for additional icon roots (optional) +- `RESOLVER_DEBUG`: If set, enables debug logging to stderr + +## Error Handling + +- The service never crashes - it always returns valid JSON +- Errors are logged to stderr (only if `RESOLVER_DEBUG` is set) +- Invalid requests return empty path responses +- If environment variables are missing, sensible defaults are used +- If icon directories don't exist, falls back to `/usr/share/icons` + +## Integration + +This service is integrated into the shell through `IconResolver.qml` singleton, which manages communication and caching. Components use `AppSearch.resolveIconAsync()` for async resolution. + +### Integration Architecture + +``` +Component + ↓ +AppSearch.guessIcon(windowClass) → icon name (synchronous) + ↓ +AppSearch.resolveIconAsync(iconName, callback) → themed path (async) + ↓ +IconResolver.qml (QML singleton, manages IPC + caching) + ↓ +icon-resolver (Rust binary, persistent cache) + ↓ +Theme directories +``` + +## Dependencies + +- `serde` - Serialization framework (for JSON IPC + cache) +- `serde_json` - JSON support +- `dirs` - XDG directory discovery (for cache location) + +## References + +- [XDG Icon Theme Specification](https://specifications.freedesktop.org/icon-theme-spec/icon-theme-spec-latest.html) +- [Freedesktop Icon Naming Specification](https://specifications.freedesktop.org/icon-naming-spec/icon-naming-spec-latest.html) diff --git a/Services/Icons/icon-resolver/src/lib.rs b/Services/Icons/icon-resolver/src/lib.rs new file mode 100644 index 000000000..9886c65da --- /dev/null +++ b/Services/Icons/icon-resolver/src/lib.rs @@ -0,0 +1,739 @@ +// lib.rs +// Library module exposing IconResolver for benchmarking + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::env; +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; +use rayon::prelude::*; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IconEntry { + pub path: PathBuf, + pub size: u32, + pub is_svg: bool, + pub theme_priority: usize, +} + +#[derive(Debug, Serialize, Deserialize)] +struct CacheMetadata { + // Cache format version - old caches without this field will deserialize as 0 + #[serde(default)] + cache_version: u32, + theme: String, + base_path: PathBuf, + additional_paths: Vec, + cache_time: u64, + icon_count: usize, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PersistentCache { + metadata: CacheMetadata, + icons: HashMap, +} + +pub struct IconResolver { + pub cache: HashMap, + base_path: PathBuf, + additional_paths: Vec, + theme: String, + debug: bool, +} + +impl IconResolver { + // Current cache format version - increment when cache structure changes + const CACHE_VERSION: u32 = 1; + + pub fn new() -> Self { + let user = env::var("USER").unwrap_or_else(|_| "user".to_string()); + let theme = env::var("QT_ICON_THEME").unwrap_or_else(|_| "Papirus-Dark".to_string()); + let debug = env::var("RESOLVER_DEBUG").is_ok(); + + let mut base_path = PathBuf::from(format!("/etc/profiles/per-user/{}/share/icons", user)); + + if !base_path.exists() { + if debug { + eprintln!( + "[icon-resolver] Warning: Base path does not exist: {:?}. Trying fallback paths...", + base_path + ); + } + + let fallback_paths = vec![ + dirs::home_dir() + .map(|h| h.join(".nix-profile").join("share").join("icons")) + .unwrap_or_else(|| PathBuf::from("")), + PathBuf::from("/run/current-system/sw/share/icons"), + PathBuf::from("/usr/share/icons"), + ]; + + let mut found_path = false; + for fallback in fallback_paths { + if !fallback.as_os_str().is_empty() && fallback.exists() { + base_path = fallback; + found_path = true; + if debug { + eprintln!("[icon-resolver] Using fallback path: {:?}", base_path); + } + break; + } + } + + if !found_path { + eprintln!( + "[icon-resolver] Error: No valid icon directory found. Using default: /usr/share/icons" + ); + base_path = PathBuf::from("/usr/share/icons"); + } + } + + let mut additional_paths = Vec::new(); + if let Ok(xdg_data_dirs) = env::var("XDG_DATA_DIRS") { + for path_str in xdg_data_dirs.split(':') { + let path = PathBuf::from(path_str.trim()).join("icons"); + if path.exists() { + if debug { + eprintln!("[icon-resolver] Added XDG_DATA_DIRS path: {:?}", path); + } + additional_paths.push(path); + } + } + } + + let mut resolver = Self { + cache: HashMap::new(), + base_path: base_path.clone(), + additional_paths: additional_paths.clone(), + theme: theme.clone(), + debug, + }; + + // Try to load cache from disk first + if let Some(cache_path) = resolver.get_cache_path() { + if let Ok(loaded_cache) = resolver.load_cache_from_disk(&cache_path) { + if resolver.is_cache_valid(&loaded_cache.metadata, &base_path, &additional_paths, &theme) { + if debug { + eprintln!("[icon-resolver] Loaded cache from disk: {} icons", loaded_cache.icons.len()); + } + resolver.cache = loaded_cache.icons; + return resolver; + } else if debug { + eprintln!("[icon-resolver] Cache is stale, rebuilding..."); + } + } + } + + // Cache not available or invalid, build it + resolver.build_cache(); + + // Save cache to disk + if let Some(cache_path) = resolver.get_cache_path() { + if let Err(e) = resolver.save_cache_to_disk(&cache_path) { + if debug { + eprintln!("[icon-resolver] Failed to save cache: {}", e); + } + } + } + + resolver + } + + fn get_cache_path(&self) -> Option { + // Use XDG cache directory: ~/.cache/noctalia/icon-cache.json + if let Some(cache_dir) = dirs::cache_dir() { + let cache_file = cache_dir.join("noctalia").join("icon-cache.json"); + // Ensure directory exists + if let Some(parent) = cache_file.parent() { + let _ = fs::create_dir_all(parent); + } + Some(cache_file) + } else { + None + } + } + + fn load_cache_from_disk(&self, cache_path: &PathBuf) -> Result> { + let content = fs::read_to_string(cache_path)?; + let cache: PersistentCache = serde_json::from_str(&content)?; + Ok(cache) + } + + fn save_cache_to_disk(&self, cache_path: &PathBuf) -> Result<(), Box> { + let cache_time = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let cache = PersistentCache { + metadata: CacheMetadata { + cache_version: Self::CACHE_VERSION, + theme: self.theme.clone(), + base_path: self.base_path.clone(), + additional_paths: self.additional_paths.clone(), + cache_time, + icon_count: self.cache.len(), + }, + icons: self.cache.clone(), + }; + + let json = serde_json::to_string_pretty(&cache)?; + + // Atomic write: write to temp file, then rename + // This prevents corruption if the process is killed mid-write + let temp_path = cache_path.with_extension("json.tmp"); + fs::write(&temp_path, json)?; + fs::rename(&temp_path, cache_path)?; + + Ok(()) + } + + fn is_cache_valid( + &self, + metadata: &CacheMetadata, + current_base_path: &PathBuf, + current_additional_paths: &[PathBuf], + current_theme: &str, + ) -> bool { + // Check cache version - reject old cache formats + // Old caches without cache_version will deserialize as 0 (default) + if metadata.cache_version != Self::CACHE_VERSION { + return false; + } + + // Check if theme changed + if metadata.theme != *current_theme { + return false; + } + + // Check if base path changed + if metadata.base_path != *current_base_path { + return false; + } + + // Check if additional paths changed + if metadata.additional_paths.len() != current_additional_paths.len() { + return false; + } + for (a, b) in metadata.additional_paths.iter().zip(current_additional_paths.iter()) { + if a != b { + return false; + } + } + + // Check if theme directory was modified (simple check: index.theme modification time) + let theme_path = current_base_path.join(&metadata.theme).join("index.theme"); + if let Ok(theme_meta) = fs::metadata(&theme_path) { + if let Ok(theme_mtime) = theme_meta.modified() { + if let Ok(theme_duration) = theme_mtime.duration_since(UNIX_EPOCH) { + let theme_secs = theme_duration.as_secs(); + if theme_secs > metadata.cache_time { + // Theme was modified after cache was created + return false; + } + } + } + } + + true + } + + pub fn build_cache(&mut self) { + self.cache.clear(); + if self.debug { + eprintln!("[icon-resolver] Building icon cache for theme: {}", self.theme); + } + + let mut themes = Vec::new(); + let mut all_base_paths = vec![self.base_path.clone()]; + all_base_paths.extend(self.additional_paths.clone()); + + self.collect_theme_chain(&self.theme, &mut themes, &self.base_path, &all_base_paths); + + if !themes.contains(&"hicolor".to_string()) { + themes.push("hicolor".to_string()); + if self.debug { + eprintln!("[icon-resolver] Added hicolor as guaranteed fallback"); + } + } + + let base_path = self.base_path.clone(); + for (priority, theme) in themes.iter().enumerate() { + self.scan_theme(theme, &base_path, priority); + } + + let additional_paths = self.additional_paths.clone(); + let base_priority = themes.len(); + for additional_path in &additional_paths { + for (priority, theme) in themes.iter().enumerate() { + self.scan_theme(theme, additional_path, base_priority + priority); + } + } + + if self.debug { + eprintln!("[icon-resolver] Cache built with {} icons", self.cache.len()); + } + + // Save cache to disk after building + if let Some(cache_path) = self.get_cache_path() { + if let Err(e) = self.save_cache_to_disk(&cache_path) { + if self.debug { + eprintln!("[icon-resolver] Failed to save cache: {}", e); + } + } + } + } + + fn collect_theme_chain(&self, theme: &str, themes: &mut Vec, primary_base: &PathBuf, all_bases: &[PathBuf]) { + if themes.contains(&theme.to_string()) { + return; + } + themes.push(theme.to_string()); + + let mut found_index = false; + for base in all_bases { + let index_path = base.join(theme).join("index.theme"); + if let Ok(content) = fs::read_to_string(&index_path) { + found_index = true; + let mut in_icon_theme_section = false; + for line in content.lines() { + let line = line.trim(); + + if line.starts_with('[') && line.ends_with(']') { + in_icon_theme_section = line == "[Icon Theme]"; + continue; + } + + if in_icon_theme_section && line.starts_with("Inherits=") { + if let Some(inherits_str) = line.split('=').nth(1) { + for inherited_theme in inherits_str.split(',') { + let inherited_theme = inherited_theme.trim(); + if !inherited_theme.is_empty() { + self.collect_theme_chain(inherited_theme, themes, primary_base, all_bases); + } + } + } + } + } + break; + } + } + + if !found_index && self.debug { + eprintln!("[icon-resolver] Warning: Could not find index.theme for theme '{}' in any base path", theme); + } + } + + fn scan_theme(&mut self, theme: &str, base_path: &PathBuf, theme_priority: usize) { + let theme_path = base_path.join(theme); + if !theme_path.exists() { + return; + } + + let mut directories = self.get_theme_directories(&theme_path); + + if directories.is_empty() { + if self.debug { + eprintln!("[icon-resolver] No directories found in index.theme for {}, using fallback sizes", theme); + } + directories = vec![ + ("48x48".to_string(), 48), + ("64x64".to_string(), 64), + ("32x32".to_string(), 32), + ("24x24".to_string(), 24), + ("22x22".to_string(), 22), + ("16x16".to_string(), 16), + ("scalable".to_string(), 0), + ]; + } + + let subdirectories = vec!["apps", "actions", "devices", "places", "status", "mimetypes", "categories"]; + + // Collect all directories to scan (no I/O yet) + let mut scan_tasks = Vec::new(); + for (size_str, size_val) in directories { + for subdir in &subdirectories { + let icon_path = if size_str.contains('/') { + theme_path.join(&size_str) + } else { + theme_path.join(&size_str).join(subdir) + }; + + if icon_path.exists() { + for ext in &["svg", "png"] { + scan_tasks.push((icon_path.clone(), *ext, size_val, theme_priority)); + } + } + } + } + + // Parallelize directory scanning + let scanned_icons: Vec<(String, IconEntry)> = scan_tasks + .par_iter() + .flat_map(|(icon_path, ext, size_val, theme_priority)| { + let mut results = Vec::new(); + + if let Ok(entries) = fs::read_dir(icon_path) { + for entry in entries.flatten() { + let path = entry.path(); + if let Some(file_ext) = path.extension() { + if file_ext.to_string_lossy().to_lowercase() == *ext { + if let Some(stem) = path.file_stem() { + let icon_name = stem.to_string_lossy().to_string(); + let is_svg = *ext == "svg"; + + results.push(( + icon_name, + IconEntry { + path: path.clone(), + size: *size_val, + is_svg, + theme_priority: *theme_priority, + }, + )); + } + } + } + } + } + + results + }) + .collect(); + + // Merge results sequentially (preserves priority ordering) + for (icon_name, entry) in scanned_icons { + let should_add = match self.cache.get(&icon_name) { + None => true, + Some(existing) => { + if entry.theme_priority < existing.theme_priority { + true + } else if entry.theme_priority > existing.theme_priority { + false + } else { + if entry.is_svg && !existing.is_svg { + true + } else if entry.is_svg == existing.is_svg { + if entry.is_svg { + if existing.size == 0 && entry.size > 0 { + true + } else if entry.size == 0 && existing.size > 0 { + false + } else { + entry.size > existing.size + } + } else { + entry.size > existing.size + } + } else { + false + } + } + } + }; + + if should_add { + self.cache.insert(icon_name, entry); + } + } + } + + fn get_theme_directories(&self, theme_path: &PathBuf) -> Vec<(String, u32)> { + let index_path = theme_path.join("index.theme"); + let mut directories = Vec::new(); + let mut in_icon_theme_section = false; + let mut directory_names = Vec::new(); + let mut directory_sizes: HashMap = HashMap::new(); + let mut current_section = String::new(); + + if let Ok(content) = fs::read_to_string(&index_path) { + for line in content.lines() { + let line = line.trim(); + + if line.starts_with('[') && line.ends_with(']') { + in_icon_theme_section = line == "[Icon Theme]"; + current_section = line[1..line.len()-1].to_string(); + continue; + } + + if in_icon_theme_section && line.starts_with("Directories=") { + if let Some(dirs_str) = line.split('=').nth(1) { + for dir_name in dirs_str.split(',') { + let dir_name = dir_name.trim().to_string(); + if !dir_name.is_empty() { + directory_names.push(dir_name); + } + } + } + } + + if !current_section.is_empty() && line.starts_with("Size=") { + if let Ok(size_val) = line.split('=').nth(1).unwrap_or("").trim().parse::() { + directory_sizes.insert(current_section.clone(), size_val); + } + } + } + + for dir_name_full in directory_names { + let size = if let Some(&explicit_size) = directory_sizes.get(&dir_name_full) { + explicit_size + } else { + let size_part = if let Some(slash_pos) = dir_name_full.find('/') { + &dir_name_full[..slash_pos] + } else { + &dir_name_full + }; + + if size_part == "scalable" { + 0 + } else if let Some(x_pos) = size_part.find('x') { + if let Ok(size_val) = size_part[..x_pos].parse::() { + size_val + } else { + continue; + } + } else { + continue; + } + }; + + directories.push((dir_name_full, size)); + } + } + + directories.sort_by(|a, b| { + match (a.1 == 0, b.1 == 0) { + (true, true) => std::cmp::Ordering::Equal, + (true, false) => std::cmp::Ordering::Greater, + (false, true) => std::cmp::Ordering::Less, + (false, false) => b.1.cmp(&a.1), + } + }); + directories + } + + pub fn try_variations(&self, name: &str) -> Option { + let variation = format!("{}-browser", name); + if self.cache.contains_key(&variation) { + return Some(variation); + } + + let variation = format!("{}-client", name); + if self.cache.contains_key(&variation) { + return Some(variation); + } + + let prefixes = vec!["preferences-", "utilities-", "accessories-"]; + for prefix in &prefixes { + let variation = format!("{}{}", prefix, name); + if self.cache.contains_key(&variation) { + return Some(variation); + } + } + + let variation = name.to_lowercase(); + if variation != name && self.cache.contains_key(&variation) { + return Some(variation); + } + + if name.contains('.') { + if let Some(reverse_domain) = name.split('.').last() { + if reverse_domain != name && self.cache.contains_key(reverse_domain) { + return Some(reverse_domain.to_string()); + } + let reverse_domain_lower = reverse_domain.to_lowercase(); + if reverse_domain_lower != name && self.cache.contains_key(&reverse_domain_lower) { + return Some(reverse_domain_lower); + } + } + } + + if name.contains(' ') { + let variation = name.to_lowercase().replace(' ', "-"); + if variation != name && self.cache.contains_key(&variation) { + return Some(variation); + } + } + + if name.contains('-') { + let variation = name.replace('-', "_"); + if self.cache.contains_key(&variation) { + return Some(variation); + } + let variation_lower = name.to_lowercase().replace('-', "_"); + if variation_lower != name && self.cache.contains_key(&variation_lower) { + return Some(variation_lower); + } + } + + if name.contains('_') { + let variation = name.replace('_', "-"); + if self.cache.contains_key(&variation) { + return Some(variation); + } + let variation_lower = name.to_lowercase().replace('_', "-"); + if variation_lower != name && self.cache.contains_key(&variation_lower) { + return Some(variation_lower); + } + } + + if !name.is_empty() { + let mut chars = name.chars(); + if let Some(first_char) = chars.next() { + let capitalized = first_char.to_uppercase().collect::() + &chars.as_str(); + let variation = format!("org.{}.{}", name, capitalized); + if self.cache.contains_key(&variation) { + return Some(variation); + } + + let variation = format!("org.{}.{}", name, name); + if self.cache.contains_key(&variation) { + return Some(variation); + } + + let variation = format!("org.{}", name); + if self.cache.contains_key(&variation) { + return Some(variation); + } + } + } + + None + } + + pub fn resolve(&self, name: &str) -> String { + // Handle absolute file paths as a special case + if name.starts_with('/') { + let path = std::path::Path::new(name); + if path.exists() && path.is_file() { + // Return absolute path as-is if file exists + return name.to_string(); + } else { + // File doesn't exist, return empty string (fallback handled by caller) + if self.debug { + eprintln!("[icon-resolver] Absolute path does not exist: {}", name); + } + return String::new(); + } + } + + // Preprocess: Strip version numbers and metadata from window classes + // This is defense-in-depth in case QML layer doesn't catch all cases + if name.contains(' ') || name.contains('*') || name.contains('-') || name.contains('_') { + // Split on common delimiters and try the first part (base application name) + let parts: Vec<&str> = name.split(&[' ', '*', '-', '_'][..]) + .filter(|s| !s.is_empty()) + .collect(); + + if !parts.is_empty() { + let first_part = parts[0]; + + // Try resolving the base name first (before version numbers) + if let Some(entry) = self.cache.get(first_part) { + return entry.path.to_string_lossy().to_string(); + } + + // Also try lowercase version + let first_part_lower = first_part.to_lowercase(); + if let Some(entry) = self.cache.get(&first_part_lower) { + return entry.path.to_string_lossy().to_string(); + } + + // Try variations on the base name + if let Some(variation) = self.try_variations(first_part) { + if let Some(entry) = self.cache.get(&variation) { + return entry.path.to_string_lossy().to_string(); + } + } + + // Try variations on lowercase base name + if let Some(variation) = self.try_variations(&first_part_lower) { + if let Some(entry) = self.cache.get(&variation) { + return entry.path.to_string_lossy().to_string(); + } + } + } + } + + // Continue with normal icon theme resolution for non-absolute paths + let mut best_entry: Option<&IconEntry> = None; + + if let Some(entry) = self.cache.get(name) { + best_entry = Some(entry); + } + + if let Some(variation) = self.try_variations(name) { + if let Some(entry) = self.cache.get(&variation) { + let is_better = match best_entry { + None => true, + Some(best) => { + if entry.theme_priority < best.theme_priority { + true + } else if entry.theme_priority > best.theme_priority { + false + } else { + if entry.is_svg && !best.is_svg { + true + } else if entry.is_svg == best.is_svg { + if entry.is_svg { + if best.size == 0 && entry.size > 0 { + true + } else if entry.size == 0 && best.size > 0 { + false + } else { + entry.size > best.size + } + } else { + entry.size > best.size + } + } else { + false + } + } + } + }; + + if is_better { + best_entry = Some(entry); + } + } + } + + if let Some(entry) = best_entry { + return entry.path.to_string_lossy().to_string(); + } + + String::new() + } + + pub fn reload(&mut self) -> usize { + self.build_cache(); + + // Save cache to disk after reload + if let Some(cache_path) = self.get_cache_path() { + if let Err(e) = self.save_cache_to_disk(&cache_path) { + if self.debug { + eprintln!("[icon-resolver] Failed to save cache after reload: {}", e); + } + } + } + + self.cache.len() + } + + pub fn search(&self, pattern: &str) -> Vec { + let pattern_lower = pattern.to_lowercase(); + let mut matches: Vec = self + .cache + .keys() + .filter(|icon_name| icon_name.to_lowercase().contains(&pattern_lower)) + .cloned() + .collect(); + + matches.sort(); + matches + } +} + diff --git a/Services/Icons/icon-resolver/src/main.rs b/Services/Icons/icon-resolver/src/main.rs new file mode 100644 index 000000000..eadb25b5a --- /dev/null +++ b/Services/Icons/icon-resolver/src/main.rs @@ -0,0 +1,97 @@ +use icon_resolver::IconResolver; + +use serde::{Deserialize, Serialize}; +use std::io::{self, BufRead, Write}; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type")] +enum Request { + #[serde(rename = "resolve")] + Resolve { name: String }, + #[serde(rename = "reload")] + Reload, + #[serde(rename = "search")] + Search { pattern: String }, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ResolveResponse { + name: String, + path: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ReloadResponse { + status: String, + count: usize, +} + +#[derive(Debug, Serialize, Deserialize)] +struct SearchResponse { + matches: Vec, +} + +fn main() { + let mut resolver = IconResolver::new(); + + let stdin = io::stdin(); + let mut handle = stdin.lock(); + + loop { + let mut line = String::new(); + match handle.read_line(&mut line) { + Ok(0) => break, // EOF + Ok(_) => { + let line = line.trim(); + if line.is_empty() { + continue; + } + + match serde_json::from_str::(line) { + Ok(request) => { + let response: String = match request { + Request::Resolve { name } => { + let path = resolver.resolve(&name); + serde_json::to_string(&ResolveResponse { name: name.clone(), path }) + .unwrap_or_else(|_| r#"{"name":"","path":""}"#.to_string()) + } + Request::Reload => { + let count = resolver.reload(); + let reload_resp = ReloadResponse { + status: "ok".to_string(), + count, + }; + serde_json::to_string(&reload_resp) + .unwrap_or_else(|_| r#"{"status":"error","count":0}"#.to_string()) + } + Request::Search { pattern } => { + let matches = resolver.search(&pattern); + let search_resp = SearchResponse { matches }; + serde_json::to_string(&search_resp) + .unwrap_or_else(|_| r#"{"matches":[]}"#.to_string()) + } + }; + + println!("{}", response); + io::stdout().flush().unwrap_or(()); + } + Err(_e) => { + // Return error response + let error_resp = ResolveResponse { + name: String::new(), + path: String::new(), + }; + if let Ok(json) = serde_json::to_string(&error_resp) { + println!("{}", json); + io::stdout().flush().unwrap_or(()); + } + } + } + } + Err(e) => { + eprintln!("[icon-resolver] Error reading stdin: {}", e); + break; + } + } + } +} diff --git a/shell.qml b/shell.qml index f64859368..af7116f50 100644 --- a/shell.qml +++ b/shell.qml @@ -26,6 +26,7 @@ import qs.Modules.Panels.Settings import qs.Modules.Toast import qs.Services.Control import qs.Services.Hardware +import qs.Services.Icons import qs.Services.Location import qs.Services.Networking import qs.Services.Noctalia