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.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 readonly property string barPosition: Settings.data.bar.position readonly property bool isVerticalBar: barPosition === "left" || barPosition === "right" readonly property string density: Settings.data.bar.density readonly property real itemSize: (density === "compact") ? Style.capsuleHeight * 0.9 : Style.capsuleHeight * 0.8 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 int characterCount: 2 readonly property bool hideUnoccupied: (widgetSettings.hideUnoccupied !== undefined) ? widgetSettings.hideUnoccupied : widgetMetadata.hideUnoccupied readonly property string labelMode: (widgetSettings.labelMode !== undefined) ? widgetSettings.labelMode : widgetMetadata.labelMode readonly property bool showLabelsOnlyWhenOccupied: (widgetSettings.showLabelsOnlyWhenOccupied !== undefined) ? widgetSettings.showLabelsOnlyWhenOccupied : widgetMetadata.showLabelsOnlyWhenOccupied readonly property bool colorizeIcons: (widgetSettings.colorizeIcons !== undefined) ? widgetSettings.colorizeIcons : widgetMetadata.colorizeIcons property ListModel localWorkspaces: ListModel {} property real masterProgress: 0.0 property bool effectsActive: false property color effectColor: Color.mPrimary // Wheel scroll handling property int wheelAccumulatedDelta: 0 property bool wheelCooldown: false // Context menu state property var selectedWindow: null property string selectedAppName: "" property int modelUpdateTrigger: 0 // Dummy property to force model re-evaluation function refreshWorkspaces() { localWorkspaces.clear(); if (!screen) return; const screenName = screen.name.toLowerCase(); for (var i = 0; i < CompositorService.workspaces.count; i++) { const ws = CompositorService.workspaces.get(i); if (ws.output.toLowerCase() !== screenName) continue; if (hideUnoccupied && !ws.isOccupied && !ws.isFocused) continue; // Copy all properties from ws and add windows var workspaceData = Object.assign({}, ws); workspaceData.windows = CompositorService.getWindowsForWorkspace(ws.id); localWorkspaces.append(workspaceData); } updateWorkspaceFocus(); } function triggerUnifiedWave() { effectColor = Color.mPrimary; masterAnimation.restart(); } function updateWorkspaceFocus() { for (var i = 0; i < localWorkspaces.count; i++) { const ws = localWorkspaces.get(i); if (ws.isFocused === true) { root.triggerUnifiedWave(); break; } } } function getFocusedLocalIndex() { for (var i = 0; i < localWorkspaces.count; i++) { if (localWorkspaces.get(i).isFocused === true) return i; } return -1; } function switchByOffset(offset) { if (localWorkspaces.count === 0) return; var current = getFocusedLocalIndex(); if (current < 0) current = 0; var next = (current + offset) % localWorkspaces.count; if (next < 0) next = localWorkspaces.count - 1; const ws = localWorkspaces.get(next); if (ws && ws.idx !== undefined) CompositorService.switchToWorkspace(ws); } Component.onCompleted: { refreshWorkspaces(); } onScreenChanged: refreshWorkspaces() onHideUnoccupiedChanged: refreshWorkspaces() implicitWidth: isVerticalBar ? taskbarGrid.implicitWidth + Style.marginM * 2 : Math.round(taskbarGrid.implicitWidth + Style.marginM * 2) implicitHeight: isVerticalBar ? Math.round(taskbarGrid.implicitHeight + Style.marginM * 2) : Style.barHeight Connections { target: CompositorService function onWorkspacesChanged() { refreshWorkspaces(); } function onWindowListChanged() { refreshWorkspaces(); } } SequentialAnimation { id: masterAnimation PropertyAction { target: root property: "effectsActive" value: true } NumberAnimation { target: root property: "masterProgress" from: 0.0 to: 1.0 duration: Style.animationSlow * 2 easing.type: Easing.OutQuint } PropertyAction { target: root property: "effectsActive" value: false } PropertyAction { target: root property: "masterProgress" value: 0.0 } } NPopupContextMenu { id: contextMenu model: { // Reference modelUpdateTrigger to make binding reactive const _ = root.modelUpdateTrigger; var items = []; if (root.selectedWindow) { items.push({ "label": I18n.tr("context-menu.activate-app", { "app": root.selectedAppName }), "action": "activate", "icon": "focus" }); items.push({ "label": I18n.tr("context-menu.close-app", { "app": root.selectedAppName }), "action": "close", "icon": "x" }); } items.push({ "label": I18n.tr("context-menu.widget-settings"), "action": "widget-settings", "icon": "settings" }); return items; } onTriggered: action => { var popupMenuWindow = PanelService.getPopupMenuWindow(screen); if (popupMenuWindow) { popupMenuWindow.close(); } if (action === "activate" && selectedWindow) { CompositorService.focusWindow(selectedWindow); } else if (action === "close" && selectedWindow) { CompositorService.closeWindow(selectedWindow); } else if (action === "widget-settings") { BarService.openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings); } selectedWindow = null; selectedAppName = ""; } } // Debounce timer for wheel interactions Timer { id: wheelDebounce interval: 150 repeat: false onTriggered: { root.wheelCooldown = false; root.wheelAccumulatedDelta = 0; } } // Scroll to switch workspaces WheelHandler { id: wheelHandler target: root acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad onWheel: function (event) { if (root.wheelCooldown) return; // Prefer vertical delta, fall back to horizontal if needed var dy = event.angleDelta.y; var dx = event.angleDelta.x; var useDy = Math.abs(dy) >= Math.abs(dx); var delta = useDy ? dy : dx; // One notch is typically 120 root.wheelAccumulatedDelta += delta; var step = 120; if (Math.abs(root.wheelAccumulatedDelta) >= step) { var direction = root.wheelAccumulatedDelta > 0 ? -1 : 1; // For vertical layout, natural mapping: wheel up -> previous, down -> next (already handled by sign) // For horizontal layout, same mapping using vertical wheel root.switchByOffset(direction); root.wheelCooldown = true; wheelDebounce.restart(); root.wheelAccumulatedDelta = 0; event.accepted = true; } } } Component { id: workspaceRepeaterDelegate Rectangle { id: container required property var model property var workspaceModel: model property bool hasWindows: workspaceModel.windows.count > 0 radius: Style.radiusS border.color: workspaceModel.isFocused ? Color.mPrimary : Color.mOutline border.width: Style.borderS width: (hasWindows ? iconsFlow.implicitWidth : root.itemSize * 0.8) + (root.isVerticalBar ? Style.marginXS : Style.marginL) height: (hasWindows ? iconsFlow.implicitHeight : root.itemSize * 0.8) + (root.isVerticalBar ? Style.marginL : Style.marginXS) color: Style.capsuleColor MouseArea { anchors.fill: parent hoverEnabled: true enabled: !hasWindows cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor acceptedButtons: Qt.LeftButton | Qt.RightButton preventStealing: true onPressed: mouse => { if (mouse.button === Qt.LeftButton) { CompositorService.switchToWorkspace(workspaceModel); } } onReleased: mouse => { if (mouse.button === Qt.RightButton) { mouse.accepted = true; TooltipService.hide(); root.selectedWindow = ""; root.selectedAppName = ""; // Store position and size for timer callback const globalPos = container.mapToItem(root, 0, 0); contextMenuOpenTimer1.globalX = globalPos.x; contextMenuOpenTimer1.globalY = globalPos.y; contextMenuOpenTimer1.itemWidth = container.width; contextMenuOpenTimer1.itemHeight = container.height; contextMenuOpenTimer1.restart(); } } } Flow { id: iconsFlow anchors.centerIn: parent spacing: 4 flow: root.isVerticalBar ? Flow.TopToBottom : Flow.LeftToRight Repeater { model: workspaceModel.windows delegate: Item { id: taskbarItem property bool itemHovered: false width: root.itemSize * 0.8 height: root.itemSize * 0.8 // Smooth scale animation on hover scale: itemHovered ? 1.1 : 1.0 Behavior on scale { NumberAnimation { duration: Style.animationNormal easing.type: Easing.OutBack } } IconImage { id: appIcon width: parent.width height: parent.height source: ThemeIcons.iconForAppId(model.appId) smooth: true asynchronous: true opacity: model.isFocused ? Style.opacityFull : 0.6 layer.enabled: root.colorizeIcons && !model.isFocused Behavior on opacity { NumberAnimation { duration: Style.animationNormal easing.type: Easing.InOutCubic } } Rectangle { id: focusIndicator anchors.bottomMargin: -2 anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter width: model.isFocused ? 4 : 0 height: model.isFocused ? 4 : 0 color: model.isFocused ? Color.mPrimary : Color.transparent radius: width * 0.5 } layer.effect: ShaderEffect { property color targetColor: Settings.data.colorSchemes.darkMode ? Color.mOnSurface : Color.mSurfaceVariant property real colorizeMode: 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 preventStealing: true onPressed: mouse => { if (!model) { return; } if (mouse.button === Qt.LeftButton) { CompositorService.focusWindow(model); } } onReleased: mouse => { if (!model) { return; } if (mouse.button === Qt.RightButton) { mouse.accepted = true; TooltipService.hide(); root.selectedWindow = model; root.selectedAppName = CompositorService.getCleanAppName(model.appId, model.title); // Store position and size for timer callback const globalPos = taskbarItem.mapToItem(root, 0, 0); contextMenuOpenTimer2.globalX = globalPos.x; contextMenuOpenTimer2.globalY = globalPos.y; contextMenuOpenTimer2.itemWidth = taskbarItem.width; contextMenuOpenTimer2.itemHeight = taskbarItem.height; contextMenuOpenTimer2.restart(); } } onEntered: { taskbarItem.itemHovered = true; TooltipService.show(taskbarItem, model.title || model.appId || "Unknown app.", BarService.getTooltipDirection()); } onExited: { taskbarItem.itemHovered = false; TooltipService.hide(); } } } } } Item { id: workspaceNumberContainer visible: root.labelMode !== "none" && (!root.showLabelsOnlyWhenOccupied || container.hasWindows) anchors { left: parent.left top: parent.top leftMargin: -Style.fontSizeXS * 0.5 topMargin: -Style.fontSizeXS * 0.5 } // Doube width margin necessary here for Name or Name+Index, but double height not needed. width: Math.max(workspaceNumber.implicitWidth + (Style.marginXS * 2), Style.fontSizeXXS * 2) height: Math.max(workspaceNumber.implicitHeight + Style.marginXS, Style.fontSizeXXS * 2) Rectangle { id: workspaceNumberBackground anchors.fill: parent radius: width * 0.5 color: { if (workspaceModel.isFocused) return Color.mPrimary; if (workspaceModel.isUrgent) return Color.mError; if (hasWindows) return Color.mSecondary; if (Settings.data.colorSchemes.darkMode) { return Qt.darker(Color.mSecondary, 1.5); } else { return Qt.lighter(Color.mSecondary, 1.5); } } scale: workspaceModel.isActive ? 1.0 : 0.9 Behavior on scale { NumberAnimation { duration: Style.animationNormal easing.type: Easing.OutBack } } Behavior on color { ColorAnimation { duration: Style.animationFast easing.type: Easing.InOutCubic } } } // Burst effect overlay for focused workspace number (smaller outline) Rectangle { id: workspaceNumberBurst anchors.centerIn: workspaceNumberContainer width: workspaceNumberContainer.width + 12 * root.masterProgress height: workspaceNumberContainer.height + 12 * root.masterProgress radius: width / 2 color: Color.transparent border.color: root.effectColor border.width: Math.max(1, Math.round((2 + 4 * (1.0 - root.masterProgress)))) opacity: root.effectsActive && workspaceModel.isFocused ? (1.0 - root.masterProgress) * 0.7 : 0 visible: root.effectsActive && workspaceModel.isFocused z: 1 } NText { id: workspaceNumber anchors.centerIn: parent text: { if (workspaceModel.name && workspaceModel.name.length > 0) { if (root.labelMode === "name") { return workspaceModel.name.substring(0, root.characterCount); } if (root.labelMode === "index+name") { return (workspaceModel.idx.toString() + workspaceModel.name.substring(0, 1)); } } return workspaceModel.idx.toString(); } family: Settings.data.ui.fontFixed font { pointSize: Style.fontSizeXXS weight: Style.fontWeightBold capitalization: Font.AllUppercase } applyUiScale: false color: { if (workspaceModel.isFocused) return Color.mOnPrimary; if (workspaceModel.isUrgent) return Color.mOnError; // if (hasWindows) // return Color.mOnSecondary; return Color.mOnSecondary; } Behavior on opacity { NumberAnimation { duration: Style.animationFast easing.type: Easing.InOutCubic } } } Behavior on opacity { NumberAnimation { duration: Style.animationFast easing.type: Easing.InOutCubic } } } } } Flow { id: taskbarGrid anchors.verticalCenter: isVerticalBar ? undefined : parent.verticalCenter anchors.left: isVerticalBar ? undefined : parent.left anchors.leftMargin: isVerticalBar ? 0 : Style.marginM anchors.horizontalCenter: isVerticalBar ? parent.horizontalCenter : undefined anchors.top: isVerticalBar ? parent.top : undefined anchors.topMargin: isVerticalBar ? Style.marginM : 0 spacing: Style.marginS flow: isVerticalBar ? Flow.TopToBottom : Flow.LeftToRight Repeater { model: localWorkspaces delegate: workspaceRepeaterDelegate } } Timer { id: contextMenuOpenTimer1 interval: 10 repeat: false property real globalX: 0 property real globalY: 0 property real itemWidth: 0 property real itemHeight: 0 onTriggered: openContextMenu(globalX, globalY, itemWidth, itemHeight) } Timer { id: contextMenuOpenTimer2 interval: 10 repeat: false property real globalX: 0 property real globalY: 0 property real itemWidth: 0 property real itemHeight: 0 onTriggered: openContextMenu(globalX, globalY, itemWidth, itemHeight) } // -------------------------------------------------- function openContextMenu(globalX, globalY, itemWidth, itemHeight) { // Directly build and set model as a new array (bypass binding issues) var items = []; if (root.selectedWindow) { items.push({ "label": I18n.tr("context-menu.activate-app", { "app": root.selectedAppName }), "action": "activate", "icon": "focus" }); items.push({ "label": I18n.tr("context-menu.close-app", { "app": root.selectedAppName }), "action": "close", "icon": "x" }); } items.push({ "label": I18n.tr("context-menu.widget-settings"), "action": "widget-settings", "icon": "settings" }); // Set the model directly contextMenu.model = items; var popupMenuWindow = PanelService.getPopupMenuWindow(screen); if (popupMenuWindow) { popupMenuWindow.open(); // Calculate menu position let menuX, menuY; if (root.barPosition === "top") { menuX = globalX + (itemWidth / 2) - (contextMenu.implicitWidth / 2); menuY = Style.barHeight + Style.marginS; } else if (root.barPosition === "bottom") { const menuHeight = 12 + contextMenu.model.length * contextMenu.itemHeight; menuX = globalX + (itemWidth / 2) - (contextMenu.implicitWidth / 2); menuY = -menuHeight - Style.marginS; } else if (root.barPosition === "left") { menuX = Style.barHeight + Style.marginS; menuY = globalY + (itemHeight / 2) - (contextMenu.implicitHeight / 2); } else { // right menuX = -contextMenu.implicitWidth - Style.marginS; menuY = globalY + (itemHeight / 2) - (contextMenu.implicitHeight / 2); } contextMenu.openAtItem(root, menuX, menuY); popupMenuWindow.contentItem = contextMenu; } } }