import QtQuick import QtQuick.Controls import QtQuick.Effects import QtQuick.Layouts import Quickshell import Quickshell.Services.SystemTray import Quickshell.Widgets import qs.Commons import qs.Modules.Bar.Extras import qs.Services.UI import qs.Widgets Item { id: root property ShellScreen screen // Trigger re-evaluation when window is registered property int popupMenuUpdateTrigger: 0 // Get shared popup menu window from PanelService (reactive to trigger changes) readonly property var popupMenuWindow: { // Reference trigger to force re-evaluation var popupMenuUpdateTriggerRef = popupMenuUpdateTrigger; return PanelService.getPopupMenuWindow(screen); } readonly property var trayMenu: popupMenuWindow ? popupMenuWindow.trayMenuLoader : null Connections { target: PanelService function onPopupMenuWindowRegistered(registeredScreen) { if (registeredScreen === screen) { root.popupMenuUpdateTrigger++; } } } // 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 var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId] ?? {} // Explicit screenName property ensures reactive binding when screen changes readonly property string screenName: screen ? screen.name : "" property var widgetSettings: { if (section && sectionWidgetIndex >= 0 && screenName) { var widgets = Settings.getBarWidgetsForScreen(screenName)[section]; if (widgets && sectionWidgetIndex < widgets.length) { return widgets[sectionWidgetIndex]; } } return {}; } readonly property string barPosition: Settings.getBarPositionForScreen(screenName) readonly property bool isVertical: barPosition === "left" || barPosition === "right" readonly property real barHeight: Style.getBarHeightForScreen(screenName) readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName) readonly property bool density: Settings.data.bar.density readonly property int iconSize: Style.toOdd(capsuleHeight * 0.65) property var blacklist: widgetSettings.blacklist || widgetMetadata.blacklist || [] // Read from settings property var pinned: widgetSettings.pinned || widgetMetadata.pinned || [] // Pinned items (shown inline) property bool drawerEnabled: widgetSettings.drawerEnabled !== undefined ? widgetSettings.drawerEnabled : (widgetMetadata.drawerEnabled !== undefined ? widgetMetadata.drawerEnabled : true) // Enable drawer panel property bool hidePassive: widgetSettings.hidePassive !== undefined ? widgetSettings.hidePassive : true // Hide passive status items readonly property string chevronColorKey: widgetSettings.chevronColor !== undefined ? widgetSettings.chevronColor : widgetMetadata.chevronColor readonly property color chevronColor: Color.resolveColorKey(chevronColorKey) property var filteredItems: [] // Items to show inline (pinned) property var dropdownItems: [] // Items to show in drawer (unpinned) property int hoveredItemIndex: -1 // Track hovered item for dot indicator Timer { id: updateDebounceTimer interval: 100 // milliseconds running: false repeat: false onTriggered: _performFilteredItemsUpdate() } readonly property var statusSignature: { if (!SystemTray.items || !SystemTray.items.values) { return ""; } var sig = ""; var items = SystemTray.items.values; for (var i = 0; i < items.length; i++) { var item = items[i]; if (item) { // Direct property access creates reactive binding var s = item.status; sig += (item.id || i) + ":" + (s !== undefined ? s : -1); } } // Trigger update when signature changes (status changed) if (root.hidePassive) { Qt.callLater(root.updateFilteredItems); } return sig; } Repeater { id: statusConnectionsRepeater model: SystemTray.items && SystemTray.items.values ? SystemTray.items.values : [] delegate: Item { Connections { target: modelData enabled: modelData !== null && modelData !== undefined function onStatusChanged() { if (root.hidePassive) { root.updateFilteredItems(); } } } } } function _performFilteredItemsUpdate() { // Force a fresh read of settings to ensure we have the latest blacklist var currentSettings = {}; if (section && sectionWidgetIndex >= 0 && screenName) { var w = Settings.getBarWidgetsForScreen(screenName)[section]; if (w && sectionWidgetIndex < w.length) { currentSettings = w[sectionWidgetIndex]; } } // Update local properties with fresh data if (currentSettings.blacklist !== undefined) root.blacklist = currentSettings.blacklist; if (currentSettings.pinned !== undefined) root.pinned = currentSettings.pinned; let newItems = []; if (SystemTray.items && SystemTray.items.values) { const trayItems = SystemTray.items.values; for (var i = 0; i < trayItems.length; i++) { const item = trayItems[i]; if (!item) { continue; } const title = item.tooltipTitle || item.name || item.id || ""; // Skip passive items if hidePassive is enabled if (root.hidePassive && item.status !== undefined && (item.status === SystemTray.Passive || item.status === 0)) { continue; } // Check if blacklisted let isBlacklisted = false; if (root.blacklist && root.blacklist.length > 0) { for (var j = 0; j < root.blacklist.length; j++) { const rule = root.blacklist[j]; if (wildCardMatch(title, rule)) { isBlacklisted = true; break; } } } if (!isBlacklisted) { newItems.push(item); } } } // If drawer is disabled, show all items inline if (!root.drawerEnabled) { filteredItems = newItems; dropdownItems = []; } else { // Build inline (pinned) and drawer (unpinned) lists // If pinned list is empty, all items go to drawer (none inline) // If pinned list has items, pinned items are inline, rest go to drawer if (pinned && pinned.length > 0) { let pinnedItems = []; for (var k = 0; k < newItems.length; k++) { const item2 = newItems[k]; const title2 = item2.tooltipTitle || item2.name || item2.id || ""; for (var m = 0; m < pinned.length; m++) { const rule2 = pinned[m]; if (wildCardMatch(title2, rule2)) { pinnedItems.push(item2); break; } } } filteredItems = pinnedItems; // Unpinned items go to drawer let unpinnedItems = []; for (var v = 0; v < newItems.length; v++) { const cand = newItems[v]; let isPinned = false; for (var f = 0; f < filteredItems.length; f++) { if (filteredItems[f] === cand) { isPinned = true; break; } } if (!isPinned) unpinnedItems.push(cand); } dropdownItems = unpinnedItems; } else { // No pinned items: all items go to drawer (none inline) filteredItems = []; dropdownItems = newItems; } } } function updateFilteredItems() { updateDebounceTimer.restart(); } function wildCardMatch(str, rule) { if (!str || !rule) { return false; } // First, convert '*' to a placeholder to preserve it, then escape other special regex characters // Use a unique placeholder that won't appear in normal strings const placeholder = '\uE000'; // Private use character let processedRule = rule.replace(/\*/g, placeholder); // Escape all special regex characters (but placeholder won't match this) let escapedRule = processedRule.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); // Convert placeholder back to '.*' for wildcard matching let pattern = escapedRule.replace(new RegExp(placeholder, 'g'), '.*'); // Add ^ and $ to match the entire string pattern = '^' + pattern + '$'; try { const regex = new RegExp(pattern, 'i'); // 'i' for case-insensitive return regex.test(str); } catch (e) { Logger.w("Tray", "Invalid regex pattern for wildcard match:", rule, e.message); return false; // If regex is invalid, it won't match } } function toggleDrawer(button) { TooltipService.hideImmediately(); // Close the popup menu if it's open if (popupMenuWindow && popupMenuWindow.visible) { popupMenuWindow.close(); } const panel = PanelService.getPanel("trayDrawerPanel", root.screen); if (panel) { panel.widgetSection = root.section; panel.widgetIndex = root.sectionWidgetIndex; panel.toggle(this); } } function onLoaded() { // When the widget is fully initialized with its props set the screen for the trayMenu if (trayMenu && trayMenu.item) { trayMenu.item.screen = screen; } } Connections { target: SystemTray.items function onValuesChanged() { root.updateFilteredItems(); // Repeater will automatically update when items change } } Connections { target: Settings function onSettingsSaved() { root.updateFilteredItems(); } } // Watch for hidePassive changes to update filtering immediately onHidePassiveChanged: { root.updateFilteredItems(); } Component.onCompleted: { root.updateFilteredItems(); // Initial update } // Content dimensions for implicit sizing readonly property int visibleItemCount: (root.drawerEnabled && dropdownItems.length > 0 ? 1 : 0) + filteredItems.length readonly property real capsulePadding: 0 readonly property real capsuleWidth: isVertical ? capsuleHeight : Math.round(trayFlow.implicitWidth + capsulePadding * 2) readonly property real capsuleContentHeight: isVertical ? Math.round(trayFlow.implicitHeight + capsulePadding * 2) : capsuleHeight implicitWidth: isVertical ? barHeight : Math.round(trayFlow.implicitWidth + capsulePadding * 2) implicitHeight: isVertical ? Math.round(trayFlow.implicitHeight + capsulePadding * 2) : barHeight visible: filteredItems.length > 0 || dropdownItems.length > 0 opacity: (filteredItems.length > 0 || dropdownItems.length > 0) ? 1.0 : 0.0 // Visual capsule centered in parent Rectangle { id: visualCapsule width: capsuleWidth height: capsuleContentHeight x: Style.pixelAlignCenter(parent.width, width) y: Style.pixelAlignCenter(parent.height, height) radius: Style.radiusM color: Style.capsuleColor border.color: Style.capsuleBorderColor border.width: Style.capsuleBorderWidth } NPopupContextMenu { id: chevronContextMenu model: [ { "label": I18n.tr("actions.widget-settings"), "action": "widget-settings", "icon": "settings" }, ] onTriggered: action => { chevronContextMenu.close(); PanelService.closeContextMenu(screen); if (action === "widget-settings") { BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings); } } } Flow { id: trayFlow spacing: 0 flow: isVertical ? Flow.TopToBottom : Flow.LeftToRight // Position centered in capsule anchors.centerIn: visualCapsule // Drawer opener (before items if opposite direction) NIconButton { id: chevronIconBefore visible: root.drawerEnabled && dropdownItems.length > 0 && BarService.getPillDirection(root) width: isVertical ? barHeight : capsuleHeight height: isVertical ? capsuleHeight : barHeight tooltipText: { if (PanelService.getPanel("trayDrawerPanel", root.screen)?.isPanelOpen) { return ""; } else { return I18n.tr("tooltips.open-tray-dropdown"); } } tooltipDirection: BarService.getTooltipDirection(root.screen?.name) baseSize: capsuleHeight applyUiScale: false customRadius: Style.radiusL colorBg: "transparent" colorFg: root.chevronColor colorBorder: "transparent" colorBorderHover: "transparent" icon: { switch (barPosition) { case "bottom": return "caret-up"; case "left": return "caret-right"; case "right": return "caret-left"; case "top": default: return "caret-down"; } } onClicked: toggleDrawer(this) onRightClicked: PanelService.showContextMenu(chevronContextMenu, this, screen) } // Pinned items Repeater { id: repeater model: root.filteredItems delegate: Item { id: trayDelegate required property var modelData required property int index width: isVertical ? barHeight : capsuleHeight height: isVertical ? capsuleHeight : barHeight visible: modelData readonly property bool isHovered: root.hoveredItemIndex === index // Tooltip anchor representing the visual area (for proper tooltip positioning) Item { id: tooltipAnchor width: capsuleHeight height: capsuleHeight x: Style.pixelAlignCenter(parent.width, width) y: Style.pixelAlignCenter(parent.height, height) } IconImage { id: trayIcon width: iconSize height: iconSize x: Style.pixelAlignCenter(parent.width, width) y: Style.pixelAlignCenter(parent.height, height) asynchronous: true backer.fillMode: Image.PreserveAspectFit source: { let icon = modelData?.icon || ""; if (!icon) { return ""; } // Process icon path if (icon.includes("?path=")) { const chunks = icon.split("?path="); const name = chunks[0]; const path = chunks[1]; const fileName = name.substring(name.lastIndexOf("/") + 1); return `file://${path}/${fileName}`; } return icon; } opacity: status === Image.Ready ? 1 : 0 layer.enabled: widgetSettings.colorizeIcons !== false layer.effect: ShaderEffect { property color targetColor: Settings.data.colorSchemes.darkMode ? Color.mOnSurface : Color.mSurfaceVariant property real colorizeMode: 1.0 fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/appicon_colorize.frag.qsb") } } Rectangle { id: hoverIndicator anchors.bottom: trayIcon.bottom anchors.bottomMargin: -2 anchors.horizontalCenter: trayIcon.horizontalCenter width: Style.toOdd(iconSize * 0.25) height: 4 color: trayDelegate.isHovered ? Color.mHover : "transparent" radius: Math.min(Style.radiusXXS, width / 2) Behavior on color { ColorAnimation { duration: Style.animationFast easing.type: Easing.OutCubic } } } MouseArea { id: itemMouseArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton onContainsMouseChanged: { if (containsMouse) { if (popupMenuWindow) { popupMenuWindow.close(); } root.hoveredItemIndex = trayDelegate.index; TooltipService.show(tooltipAnchor, modelData.tooltipTitle || modelData.name || modelData.id || "Tray Item", BarService.getTooltipDirection(root.screen?.name)); } else if (root.hoveredItemIndex === trayDelegate.index) { root.hoveredItemIndex = -1; TooltipService.hide(tooltipAnchor); } } onClicked: mouse => { if (!modelData) { return; } if (mouse.button === Qt.LeftButton) { // Close any open menu first if (popupMenuWindow) { popupMenuWindow.close(); } if (!modelData.onlyMenu) { modelData.activate(); } } else if (mouse.button === Qt.MiddleButton) { // Close the menu if it was visible if (popupMenuWindow && popupMenuWindow.visible) { popupMenuWindow.close(); return; } modelData.secondaryActivate && modelData.secondaryActivate(); } else if (mouse.button === Qt.RightButton) { TooltipService.hideImmediately(); // Close the menu if it was visible if (popupMenuWindow && popupMenuWindow.visible) { popupMenuWindow.close(); return; } // Close any opened panel if ((PanelService.openedPanel !== null) && !PanelService.openedPanel.isClosing) { PanelService.openedPanel.close(); } if (modelData.hasMenu && modelData.menu && trayMenu && trayMenu.item) { // Calculate menu position after ensuring menu is loaded const calculateAndShow = () => { // Position menu based on bar position, using tooltipAnchor for proper positioning // Increased spacing for better alignment with other context menus let menuX, menuY; if (barPosition === "left") { // For left bar: position menu to the right of the visual area menuX = tooltipAnchor.width + Style.marginL; menuY = 0; } else if (barPosition === "right") { // For right bar: position menu to the left of the visual area menuX = -trayMenu.item.implicitWidth - Style.marginL; menuY = 0; } else { // For horizontal bars: center horizontally and position below visual area menuX = (tooltipAnchor.width / 2) - (trayMenu.item.implicitWidth / 2); menuY = tooltipAnchor.height + Style.marginS; } PanelService.showTrayMenu(root.screen, modelData, trayMenu.item, tooltipAnchor, menuX, menuY, root.section, root.sectionWidgetIndex); }; // Use Qt.callLater to ensure menu dimensions are calculated Qt.callLater(calculateAndShow); } else { Logger.d("Tray", "No menu available for", modelData.id, "or trayMenu not set"); } } } } } } // Drawer opener (after items if normal direction) NIconButton { id: chevronIconAfter visible: root.drawerEnabled && dropdownItems.length > 0 && !BarService.getPillDirection(root) width: isVertical ? barHeight : capsuleHeight height: isVertical ? capsuleHeight : barHeight tooltipText: I18n.tr("tooltips.open-tray-dropdown") tooltipDirection: BarService.getTooltipDirection(root.screen?.name) baseSize: capsuleHeight applyUiScale: false customRadius: Style.radiusL colorBg: "transparent" colorFg: root.chevronColor colorBorder: "transparent" colorBorderHover: "transparent" icon: { switch (barPosition) { case "bottom": return "caret-up"; case "left": return "caret-right"; case "right": return "caret-left"; case "top": default: return "caret-down"; } } onClicked: toggleDrawer(this) onRightClicked: PanelService.showContextMenu(chevronContextMenu, this, screen) } } // closes Flow }