Files
noctalia-shell/Modules/Bar/Widgets/Tray.qml
T
ItsLemmy 101b27fcc7 New windowing system
Large commit that totally refactor of the way we handle the bar and
panels.

Testing should focus on Panels, Bar, Keyboard Focus, IPC calls.

Changes brief:
- One NFullScreenWindow per screen which handle it's bar and dedicated
panels.
- Added shadows
- Reintroduced dimming
- New panels animations
- Proper Z ordering
- Panels on overlay laywer is not reimplemented, if we do it then the
bar will be on the Overlay too
- Panel dragging was not reimplemented, to be discussed before
reimplementing
- Still a WIP, need to work more on shadows and polishing + debugging.
2025-11-03 00:53:02 -05:00

303 lines
9.3 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 var filteredItems: []
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() {
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
for (var i = 0; i < trayItems.length; i++) {
const item = trayItems[i]
if (!item) {
continue
}
const title = item.tooltipTitle || item.name || item.id || ""
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 (!isBlacklisted) {
newItems.push(item)
}
}
}
filteredItems = newItems
}
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
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.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()
}
}
}
}
}
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"
}
}
}