diff --git a/Assets/Translations/en.json b/Assets/Translations/en.json index 5a77e12eb..aa30c5f69 100644 --- a/Assets/Translations/en.json +++ b/Assets/Translations/en.json @@ -771,7 +771,9 @@ "description": "Add tray exclusion rules, supports wildcards (*).", "label": "Blacklist", "placeholder": "e.g., nm-applet, Fcitx*" - } + }, + "add-as-favorite": "Add as Favorite", + "remove-from-favorites": "Remove from Favorites" }, "widgets": { "section": { diff --git a/Assets/settings-default.json b/Assets/settings-default.json index 8162e4106..9a37fa8ab 100644 --- a/Assets/settings-default.json +++ b/Assets/settings-default.json @@ -1,5 +1,5 @@ { - "settingsVersion": 16, + "settingsVersion": 18, "setupCompleted": false, "bar": { "position": "top", @@ -70,6 +70,9 @@ "compactLockScreen": false, "lockOnSuspend": true, "enableShadows": true, + "shadowDirection": "bottom_right", + "shadowOffsetX": 2, + "shadowOffsetY": 3, "language": "" }, "ui": { @@ -118,11 +121,11 @@ "transitionType": "random", "transitionEdgeSmoothness": 0.05, "monitors": [], - "selectorPosition": "follow_bar" + "panelPosition": "folow_bar" }, "appLauncher": { "enableClipboardHistory": false, - "position": "follow_bar", + "position": "center", "backgroundOpacity": 1, "pinnedExecs": [], "useApp2Unit": false, @@ -249,6 +252,7 @@ "kitty": false, "ghostty": false, "foot": false, + "wezterm": false, "fuzzel": false, "discord": false, "discord_vesktop": false, diff --git a/Modules/Bar/Extras/TrayDropdownPanel.qml b/Modules/Bar/Extras/TrayDropdownPanel.qml new file mode 100644 index 000000000..963394711 --- /dev/null +++ b/Modules/Bar/Extras/TrayDropdownPanel.qml @@ -0,0 +1,172 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.SystemTray +import qs.Commons +import qs.Services +import qs.Widgets + +// A compact grid panel listing all tray items, opened from the Tray widget +NPanel { + id: root + + objectName: "trayDropdownPanel" + + // Widget info for menu functionality + property string widgetSection: "" + property int widgetIndex: -1 + + // Trigger refresh when settings change + property int settingsVersion: 0 + + // Read favorites directly from settings for reactivity + readonly property var favoritesList: { + // Reference settingsVersion to force recalculation when it changes + var _ = root.settingsVersion + if (widgetSection === "" || widgetIndex < 0) return [] + var widgets = Settings.data.bar.widgets[widgetSection] + if (!widgets || widgetIndex >= widgets.length) return [] + var widgetSettings = widgets[widgetIndex] + if (!widgetSettings || widgetSettings.id !== "Tray") return [] + return widgetSettings.favorites || [] + } + + function wildCardMatch(str, rule) { + if (!str || !rule) return false + let escaped = rule.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + let pattern = '^' + escaped.replace(/\\\*/g, '.*') + '$' + try { return new RegExp(pattern, 'i').test(str) } catch(e) { return false } + } + + function isFavorite(item) { + if (!favoritesList || favoritesList.length === 0) return false + const title = item?.tooltipTitle || item?.name || item?.id || "" + for (var i = 0; i < favoritesList.length; i++) { + if (wildCardMatch(title, favoritesList[i])) return true + } + return false + } + + // Dynamic sizing based on item count + // Show items that are NOT favorites (non-favorites go to dropdown) + readonly property var trayValuesAll: (SystemTray.items && SystemTray.items.values) ? SystemTray.items.values : [] + readonly property var trayValues: trayValuesAll.filter(function(it){ return !root.isFavorite(it) }) + readonly property int itemCount: trayValues.length + readonly property int maxColumns: 8 + readonly property real cellSize: Math.round(Style.capsuleHeight * 0.65) + readonly property real outerPadding: Style.marginM + readonly property real innerSpacing: Style.marginM + readonly property int columns: Math.max(1, Math.min(maxColumns, itemCount)) + readonly property int rows: Math.max(1, Math.ceil(itemCount / Math.max(1, columns))) + + // Add 2*gap margins around the grid + preferredWidth: (columns * cellSize) + ((columns - 1) * innerSpacing) + (2 * outerPadding) + preferredHeight: (rows * cellSize) + ((rows - 1) * innerSpacing) + (2 * outerPadding) + + // Positioning is handled automatically by NPanel when toggle(buttonItem) is called + + // Watch for settings changes to refresh the dropdown + Connections { + target: Settings + function onSettingsSaved() { + // Force refresh by incrementing settingsVersion, which triggers recalculation of favoritesList + root.settingsVersion++ + } + } + + panelContent: Item { + id: content + + Grid { + id: grid + anchors.fill: parent + anchors.margins: outerPadding + spacing: innerSpacing + columns: root.columns + rowSpacing: innerSpacing + columnSpacing: innerSpacing + + Repeater { + id: repeater + model: root.trayValues + + delegate: Item { + width: root.cellSize + height: root.cellSize + + IconImage { + id: trayIcon + anchors.fill: parent + asynchronous: true + backer.fillMode: Image.PreserveAspectFit + source: { + let icon = modelData?.icon || "" + if (!icon) + return "" + 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 + } + + layer.enabled: true + 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") + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + + onClicked: (mouse) => { + if (!modelData) + return + if (mouse.button === Qt.RightButton && modelData.hasMenu && modelData.menu && trayMenu.item) { + trayMenu.item.menu = modelData.menu + trayMenu.item.screen = root.screen + trayMenu.item.trayItem = modelData + trayMenu.item.widgetSection = root.widgetSection + trayMenu.item.widgetIndex = root.widgetIndex + const menuX = (root.columns > 1) ? (trayIcon.width / 2) : 0 + const menuY = trayIcon.height + trayMenu.item.showAt(trayIcon, menuX, menuY) + } else if (mouse.button === Qt.LeftButton) { + modelData.activate?.() + // Close the dropdown after activation + PanelService.getPanel("trayDropdownPanel", root.screen)?.close() + } else if (mouse.button === Qt.MiddleButton) { + modelData.secondaryActivate?.() + PanelService.getPanel("trayDropdownPanel", root.screen)?.close() + } + } + + onWheel: (wheel) => { + if (wheel.angleDelta.y > 0) modelData?.scrollUp?.() + else if (wheel.angleDelta.y < 0) modelData?.scrollDown?.() + } + + onEntered: TooltipService.show(Screen, trayIcon, modelData.tooltipTitle || modelData.name || modelData.id || "Tray Item", BarService.getTooltipDirection()) + onExited: TooltipService.hide() + } + } + } + } + } + + // Tray menu host + Loader { + id: trayMenu + asynchronous: false + active: true + source: "TrayMenu.qml" + } + } +} \ No newline at end of file diff --git a/Modules/Bar/Extras/TrayMenu.qml b/Modules/Bar/Extras/TrayMenu.qml index 228600790..63909569a 100644 --- a/Modules/Bar/Extras/TrayMenu.qml +++ b/Modules/Bar/Extras/TrayMenu.qml @@ -15,8 +15,12 @@ PopupWindow { property bool isSubMenu: false property bool isHovered: rootMouseArea.containsMouse property ShellScreen screen + // Properties for adding tray item to favorites + property var trayItem: null + property string widgetSection: "" + property int widgetIndex: -1 - readonly property int menuWidth: 180 + readonly property int menuWidth: 240 implicitWidth: menuWidth @@ -117,9 +121,7 @@ PopupWindow { if (modelData?.isSeparator) { return 8 } else { - // Calculate based on text content - const textHeight = text.contentHeight || (Style.fontSizeS * 1.2) - return Math.max(28, textHeight + (Style.marginS * 2)) + return 28 } } @@ -151,7 +153,7 @@ PopupWindow { text: modelData?.text !== "" ? modelData?.text.replace(/[\n\r]+/g, ' ') : "..." pointSize: Style.fontSizeS verticalAlignment: Text.AlignVCenter - wrapMode: Text.WordWrap + elide: Text.ElideRight } Image { @@ -276,6 +278,190 @@ PopupWindow { } } } + + // Separator before custom menu item + Rectangle { + visible: !root.isSubMenu && root.trayItem !== null && root.widgetSection !== "" && root.widgetIndex >= 0 + Layout.preferredWidth: parent.width + Layout.preferredHeight: visible ? 8 : 0 + color: Color.transparent + + NDivider { + anchors.centerIn: parent + width: parent.width - (Style.marginM * 2) + visible: parent.visible + } + } + + // Custom "Add/Remove Favorite" menu item (only for non-submenus with tray item info) + Rectangle { + id: addToFavoriteEntry + visible: !root.isSubMenu && root.trayItem !== null && root.widgetSection !== "" && root.widgetIndex >= 0 + Layout.preferredWidth: parent.width + Layout.preferredHeight: visible ? 28 : 0 + color: Color.transparent + + // Check if item is already a favorite + readonly property bool isFavorite: { + if (!root.trayItem || root.widgetSection === "" || root.widgetIndex < 0) return false + const itemName = root.trayItem.tooltipTitle || root.trayItem.name || root.trayItem.id || "" + if (!itemName) return false + + var widgets = Settings.data.bar.widgets[root.widgetSection] + if (!widgets || root.widgetIndex >= widgets.length) return false + var widgetSettings = widgets[root.widgetIndex] + if (!widgetSettings || widgetSettings.id !== "Tray") return false + + var favorites = widgetSettings.favorites || [] + for (var i = 0; i < favorites.length; i++) { + if (favorites[i] === itemName) return true + } + return false + } + + Rectangle { + anchors.fill: parent + color: addToFavoriteMouseArea.containsMouse ? Qt.alpha(Color.mPrimary, 0.2) : Qt.alpha(Color.mPrimary, 0.08) + radius: Style.radiusS + border.color: Qt.alpha(Color.mPrimary, addToFavoriteMouseArea.containsMouse ? 0.4 : 0.2) + border.width: Style.borderS + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Style.marginM + anchors.rightMargin: Style.marginM + spacing: Style.marginS + + NIcon { + icon: addToFavoriteEntry.isFavorite ? "star" : "star-outline" + pointSize: Style.fontSizeS + applyUiScale: false + verticalAlignment: Text.AlignVCenter + color: Color.mPrimary + } + + NText { + Layout.fillWidth: true + color: Color.mPrimary + text: addToFavoriteEntry.isFavorite ? I18n.tr("settings.bar.tray.remove-from-favorites") : I18n.tr("settings.bar.tray.add-as-favorite") + pointSize: Style.fontSizeS + font.weight: Font.Medium + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + + MouseArea { + id: addToFavoriteMouseArea + anchors.fill: parent + hoverEnabled: true + enabled: root.visible + + onClicked: { + if (addToFavoriteEntry.isFavorite) { + root.removeFromFavorites() + } else { + root.addToFavorites() + } + root.hideMenu() + } + } + } + } } } + + function addToFavorites() { + if (!trayItem || widgetSection === "" || widgetIndex < 0) { + Logger.w("TrayMenu", "Cannot add as favorite: missing tray item or widget info") + return + } + + // Get the tray item name + const itemName = trayItem.tooltipTitle || trayItem.name || trayItem.id || "" + if (!itemName) { + Logger.w("TrayMenu", "Cannot add as favorite: tray item has no name") + return + } + + // Get current widget settings + var widgets = Settings.data.bar.widgets[widgetSection] + if (!widgets || widgetIndex >= widgets.length) { + Logger.w("TrayMenu", "Cannot add as favorite: invalid widget index") + return + } + + var widgetSettings = widgets[widgetIndex] + if (!widgetSettings || widgetSettings.id !== "Tray") { + Logger.w("TrayMenu", "Cannot add as favorite: widget is not a Tray widget") + return + } + + // Get current favorites list + var favorites = widgetSettings.favorites || [] + + // Add to favorites + var newFavorites = favorites.slice() + newFavorites.push(itemName) + + // Update widget settings + var newSettings = Object.assign({}, widgetSettings) + newSettings.favorites = newFavorites + + // Update settings + widgets[widgetIndex] = newSettings + Settings.data.bar.widgets[widgetSection] = widgets + Settings.saveImmediate() + + Logger.i("TrayMenu", "Added", itemName, "as favorite") + } + + function removeFromFavorites() { + if (!trayItem || widgetSection === "" || widgetIndex < 0) { + Logger.w("TrayMenu", "Cannot remove from favorites: missing tray item or widget info") + return + } + + // Get the tray item name + const itemName = trayItem.tooltipTitle || trayItem.name || trayItem.id || "" + if (!itemName) { + Logger.w("TrayMenu", "Cannot remove from favorites: tray item has no name") + return + } + + // Get current widget settings + var widgets = Settings.data.bar.widgets[widgetSection] + if (!widgets || widgetIndex >= widgets.length) { + Logger.w("TrayMenu", "Cannot remove from favorites: invalid widget index") + return + } + + var widgetSettings = widgets[widgetIndex] + if (!widgetSettings || widgetSettings.id !== "Tray") { + Logger.w("TrayMenu", "Cannot remove from favorites: widget is not a Tray widget") + return + } + + // Get current favorites list + var favorites = widgetSettings.favorites || [] + + // Remove from favorites + var newFavorites = [] + for (var i = 0; i < favorites.length; i++) { + if (favorites[i] !== itemName) { + newFavorites.push(favorites[i]) + } + } + + // Update widget settings + var newSettings = Object.assign({}, widgetSettings) + newSettings.favorites = newFavorites + + // Update settings + widgets[widgetIndex] = newSettings + Settings.data.bar.widgets[widgetSection] = widgets + Settings.saveImmediate() + + Logger.i("TrayMenu", "Removed", itemName, "from favorites") + } } diff --git a/Modules/Bar/Widgets/Tray.qml b/Modules/Bar/Widgets/Tray.qml index f8f613750..b1472dca2 100644 --- a/Modules/Bar/Widgets/Tray.qml +++ b/Modules/Bar/Widgets/Tray.qml @@ -37,7 +37,9 @@ Rectangle { readonly property bool density: Settings.data.bar.density property real itemSize: Math.round(Style.capsuleHeight * 0.65) property list blacklist: widgetSettings.blacklist || widgetMetadata.blacklist || [] // Read from settings - property var filteredItems: [] + property list favorites: widgetSettings.favorites || widgetMetadata.favorites || [] + property var filteredItems: [] // Items to show inline (favorites) + property var dropdownItems: [] // Items to show in dropdown (non-favorites) function wildCardMatch(str, rule) { if (!str || !rule) { @@ -77,15 +79,6 @@ Rectangle { } function _performFilteredItemsUpdate() { - if (!root.blacklist || root.blacklist.length === 0) { - if (SystemTray.items && SystemTray.items.values) { - filteredItems = SystemTray.items.values - } else { - filteredItems = [] - } - return - } - let newItems = [] if (SystemTray.items && SystemTray.items.values) { const trayItems = SystemTray.items.values @@ -97,12 +90,15 @@ Rectangle { const title = item.tooltipTitle || item.name || item.id || "" + // Check if blacklisted let isBlacklisted = false - for (var j = 0; j < root.blacklist.length; j++) { - const rule = root.blacklist[j] - if (wildCardMatch(title, rule)) { - isBlacklisted = true - break + 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 + } } } @@ -111,7 +107,41 @@ Rectangle { } } } - filteredItems = newItems + + // Build inline (favorites) and dropdown (non-favorites) lists + // If favorites list is empty, all items are inline + // If favorites list has items, favorites are inline, rest go to dropdown + if (favorites && favorites.length > 0) { + let fav = [] + 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 < favorites.length; m++) { + const rule2 = favorites[m] + if (wildCardMatch(title2, rule2)) { + fav.push(item2) + break + } + } + } + filteredItems = fav + + // Non-favorites go to dropdown + let nonFav = [] + for (var v = 0; v < newItems.length; v++) { + const cand = newItems[v] + let isFavorite = false + for (var f = 0; f < filteredItems.length; f++) { + if (filteredItems[f] === cand) { isFavorite = true; break } + } + if (!isFavorite) nonFav.push(cand) + } + dropdownItems = nonFav + } else { + // No favorites: all items are inline + filteredItems = newItems + dropdownItems = [] + } } function updateFilteredItems() { @@ -143,7 +173,7 @@ Rectangle { root.updateFilteredItems() // Initial update } - visible: filteredItems.length > 0 + visible: filteredItems.length > 0 || dropdownItems.length > 0 implicitWidth: isVertical ? Style.capsuleHeight : Math.round(trayFlow.implicitWidth + Style.marginM * 2) implicitHeight: isVertical ? Math.round(trayFlow.implicitHeight + Style.marginM * 2) : Style.capsuleHeight radius: Style.radiusM @@ -250,6 +280,9 @@ Rectangle { menuY = Style.barHeight } trayMenu.item.menu = modelData.menu + trayMenu.item.trayItem = modelData + trayMenu.item.widgetSection = root.section + trayMenu.item.widgetIndex = root.sectionWidgetIndex trayMenu.item.showAt(parent, menuX, menuY) } else { Logger.i("Tray", "No menu available for", modelData.id, "or trayMenu not set") @@ -265,6 +298,24 @@ Rectangle { } } } + + // Dropdown opener + NIconButton { + id: dropdownButton + visible: dropdownItems.length > 0 + width: itemSize + height: itemSize + icon: isVertical ? (barPosition === "left" ? "chevron-right" : "chevron-left") : "chevron-down" + tooltipText: I18n.tr("open-control-center") // reuse generic tooltip text + onClicked: { + const panel = PanelService.getPanel("trayDropdownPanel", root.screen) + if (panel) { + panel.widgetSection = root.section + panel.widgetIndex = root.sectionWidgetIndex + panel.toggle(this) + } + } + } } PanelWindow { diff --git a/Services/BarWidgetRegistry.qml b/Services/BarWidgetRegistry.qml index 1b0bff2ba..37e203140 100644 --- a/Services/BarWidgetRegistry.qml +++ b/Services/BarWidgetRegistry.qml @@ -154,7 +154,8 @@ Singleton { "Tray": { "allowUserSettings": true, "blacklist": [], - "colorizeIcons": false + "colorizeIcons": false, + "favorites": [] }, "WiFi": { "allowUserSettings": true, diff --git a/shell.qml b/shell.qml index 8e207f679..200ac7707 100644 --- a/shell.qml +++ b/shell.qml @@ -87,6 +87,11 @@ ShellRoot { ControlCenterPanel {} } + Component { + id: trayDropdownComponent + TrayDropdownPanel {} + } + Component { id: calendarComponent CalendarPanel {} @@ -240,6 +245,9 @@ ShellRoot { }, { "id": "controlCenterPanel", "component": controlCenterComponent + }, { + "id": "trayDropdownPanel", + "component": trayDropdownComponent }, { "id": "calendarPanel", "component": calendarComponent