Files
noctalia-shell/Modules/Bar/Widgets/Tray.qml
T
2025-11-04 22:30:19 +01:00

354 lines
11 KiB
QML

import QtQuick
import QtQuick.Effects
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import Quickshell.Services.SystemTray
import Quickshell.Widgets
import qs.Commons
import qs.Modules.Bar.Extras
import qs.Services
import qs.Widgets
Rectangle {
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 var widgetMetadata: BarWidgetRegistry.widgetMetadata[widgetId]
property var widgetSettings: {
if (section && sectionWidgetIndex >= 0) {
var widgets = Settings.data.bar.widgets[section]
if (widgets && sectionWidgetIndex < widgets.length) {
return widgets[sectionWidgetIndex]
}
}
return {}
}
readonly property string barPosition: Settings.data.bar.position
readonly property bool isVertical: barPosition === "left" || barPosition === "right"
readonly property bool density: Settings.data.bar.density
property real itemSize: Math.round(Style.capsuleHeight * 0.65)
property list<string> blacklist: widgetSettings.blacklist || widgetMetadata.blacklist || [] // Read from settings
property list<string> 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) {
return false
}
Logger.i("Tray", "wildCardMatch - Input str:", str, "rule:", rule)
// Escape all special regex characters in the rule
let escapedRule = rule.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
// Convert '*' to '.*' for wildcard matching
let pattern = escapedRule.replace(/\\\*/g, '.*')
// Add ^ and $ to match the entire string
pattern = '^' + pattern + '$'
Logger.i("Tray", "wildCardMatch - Generated pattern:", pattern)
try {
const regex = new RegExp(pattern, 'i')
// 'i' for case-insensitive
Logger.i("Tray", "wildCardMatch - Regex test result:", regex.test(str))
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
}
}
// Debounce timer for updateFilteredItems to prevent excessive calls
// when multiple events (e.g., SystemTray changes, settings saves)
// trigger it in rapid succession, reducing redundant processing.
Timer {
id: updateDebounceTimer
interval: 100 // milliseconds
running: false
repeat: false
onTriggered: _performFilteredItemsUpdate()
}
function _performFilteredItemsUpdate() {
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 || ""
// 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)
}
}
}
// 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() {
updateDebounceTimer.restart()
}
function onLoaded() {
// When the widget is fully initialized with its props set the screen for the trayMenu
if (trayMenu.item) {
trayMenu.item.screen = screen
}
}
Connections {
target: SystemTray.items
function onValuesChanged() {
root.updateFilteredItems()
}
}
Connections {
target: Settings
function onSettingsSaved() {
root.updateFilteredItems()
}
}
Component.onCompleted: {
root.updateFilteredItems() // Initial update
}
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
color: Settings.data.bar.showCapsule ? Color.mSurfaceVariant : Color.transparent
Layout.alignment: Qt.AlignVCenter
Flow {
id: trayFlow
anchors.centerIn: parent
spacing: Style.marginM
flow: isVertical ? Flow.TopToBottom : Flow.LeftToRight
Repeater {
id: repeater
model: filteredItems
delegate: Item {
width: itemSize
height: itemSize
visible: modelData
IconImage {
id: trayIcon
property ShellScreen screen: root.screen
anchors.fill: parent
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")
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onClicked: mouse => {
if (!modelData) {
return
}
if (mouse.button === Qt.LeftButton) {
// Close any open menu first
trayPanel.close()
if (!modelData.onlyMenu) {
modelData.activate()
}
} else if (mouse.button === Qt.MiddleButton) {
// Close any open menu first
trayPanel.close()
modelData.secondaryActivate && modelData.secondaryActivate()
} else if (mouse.button === Qt.RightButton) {
TooltipService.hideImmediately()
// Close the menu if it was visible
if (trayPanel && trayPanel.visible) {
trayPanel.close()
return
}
if (modelData.hasMenu && modelData.menu && trayMenu.item) {
trayPanel.open()
// Position menu based on bar position
let menuX, menuY
if (barPosition === "left") {
// For left bar: position menu to the right of the bar
menuX = width + Style.marginM
menuY = 0
} else if (barPosition === "right") {
// For right bar: position menu to the left of the bar
menuX = -trayMenu.item.width - Style.marginM
menuY = 0
} else {
// For horizontal bars: center horizontally and position below
menuX = (width / 2) - (trayMenu.item.width / 2)
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")
}
}
}
onEntered: {
trayPanel.close()
TooltipService.show(Screen, trayIcon, modelData.tooltipTitle || modelData.name || modelData.id || "Tray Item", BarService.getTooltipDirection())
}
onExited: TooltipService.hide()
}
}
}
}
// 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 {
id: trayPanel
anchors.top: true
anchors.left: true
anchors.right: true
anchors.bottom: true
visible: false
color: Color.transparent
screen: screen
function open() {
visible = true
}
function close() {
visible = false
if (trayMenu.item) {
trayMenu.item.hideMenu()
}
}
// Clicking outside of the rectangle to close
MouseArea {
anchors.fill: parent
onClicked: trayPanel.close()
}
Loader {
id: trayMenu
source: "../Extras/TrayMenu.qml"
}
}
}