import QtQuick import QtQuick.Controls import Quickshell import qs.Commons import qs.Services.UI import qs.Widgets Item { id: root required property ShellScreen screen property string icon: "" property string text: "" property string suffix: "" property var tooltipText property bool autoHide: false property bool forceOpen: false property bool forceClose: false property bool oppositeDirection: false property bool hovered: false property bool rotateText: false property color customBackgroundColor: "transparent" property color customTextIconColor: "transparent" property color customIconColor: "transparent" property color customTextColor: "transparent" readonly property bool collapseToIcon: forceClose && !forceOpen signal shown signal hidden signal entered signal exited signal clicked signal rightClicked signal middleClicked signal wheel(int delta) // Internal state property bool showPill: false property bool shouldAnimateHide: false // Sizing logic for vertical bars readonly property int buttonSize: Style.getCapsuleHeightForScreen(screen?.name) readonly property real barFontSize: Style.getBarFontSizeForScreen(screen?.name) readonly property int pillHeight: buttonSize readonly property int pillOverlap: Math.round(buttonSize * 0.5) readonly property int maxPillWidth: rotateText ? Math.max(buttonSize, Math.round(textItem.implicitHeight + Style.margin2M)) : buttonSize readonly property int maxPillHeight: rotateText ? Math.max(1, Math.round(textItem.implicitWidth + Style.margin2M + Math.round(iconCircle.height / 4))) : Math.max(1, Math.round(textItem.implicitHeight + Style.margin2M)) // Determine pill direction based on section position readonly property bool openDownward: oppositeDirection readonly property bool openUpward: !oppositeDirection // Effective shown state (true if animated open or forced, but not if force closed) readonly property bool revealed: !forceClose && (forceOpen || showPill) readonly property bool hasIcon: root.icon !== "" // Always prioritize hover color, then the custom one and finally the fallback color readonly property color bgColor: hovered ? Color.mHover : (customBackgroundColor.a > 0) ? customBackgroundColor : Style.capsuleColor readonly property color fgColor: hovered ? Color.mOnHover : (customTextIconColor.a > 0) ? customTextIconColor : Color.mOnSurface readonly property color iconFgColor: hovered ? Color.mOnHover : (customIconColor.a > 0) ? customIconColor : (customTextIconColor.a > 0) ? customTextIconColor : Color.mOnSurface readonly property color textFgColor: hovered ? Color.mOnHover : (customTextColor.a > 0) ? customTextColor : (customTextIconColor.a > 0) ? customTextIconColor : Color.mOnSurface readonly property real iconSize: Style.toOdd(pillHeight * 0.48) // Content height calculation (for implicit sizing) readonly property real contentHeight: { if (collapseToIcon) { return hasIcon ? buttonSize : 0; } var overlap = hasIcon ? pillOverlap : 0; var baseHeight = hasIcon ? buttonSize : 0; return baseHeight + Math.max(0, pill.height - overlap); } // Fill parent width to extend horizontal click area // Keep content-based height for visual layout anchors.left: parent ? parent.left : undefined anchors.right: parent ? parent.right : undefined anchors.verticalCenter: parent ? parent.verticalCenter : undefined height: contentHeight implicitWidth: buttonSize implicitHeight: contentHeight Connections { target: root function onTooltipTextChanged() { if (hovered) { TooltipService.updateText(root.tooltipText); } } } // Unified background for the entire pill area to avoid overlapping opacity Rectangle { id: pillBackground width: buttonSize height: root.contentHeight radius: Style.radiusM color: root.bgColor border.color: Style.capsuleBorderColor border.width: Style.capsuleBorderWidth anchors.verticalCenter: parent.verticalCenter anchors.horizontalCenter: parent.horizontalCenter Behavior on color { enabled: !Color.isTransitioning ColorAnimation { duration: Style.animationFast easing.type: Easing.InOutQuad } } } Rectangle { id: pill width: revealed ? maxPillWidth : 1 height: revealed ? maxPillHeight : 1 // Position based on direction - center the pill relative to the icon x: 0 y: { if (!hasIcon) return 0; return openUpward ? (iconCircle.y + iconCircle.height / 2 - height) : (iconCircle.y + iconCircle.height / 2); } opacity: revealed ? Style.opacityFull : Style.opacityNone color: "transparent" // Make pill background transparent to avoid double opacity // Radius logic for vertical expansion - rounded on the side that connects to icon topLeftRadius: openUpward ? Style.radiusM : 0 bottomLeftRadius: openDownward ? Style.radiusM : 0 topRightRadius: openUpward ? Style.radiusM : 0 bottomRightRadius: openDownward ? Style.radiusM : 0 anchors.horizontalCenter: parent.horizontalCenter NText { id: textItem anchors.horizontalCenter: parent.horizontalCenter anchors.verticalCenter: parent.verticalCenter anchors.verticalCenterOffset: hasIcon ? (openDownward ? Style.marginXXS : -Style.marginXXS) : 0 rotation: rotateText ? -90 : 0 text: root.text + root.suffix family: Settings.data.ui.fontFixed pointSize: root.barFontSize applyUiScale: false horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter color: root.textFgColor visible: revealed function getVerticalCenterOffset() { // A small, symmetrical offset to push the text slightly away from the icon's edge. return openDownward ? Style.marginXS : -Style.marginXS; } } Behavior on width { enabled: showAnim.running || hideAnim.running NumberAnimation { duration: Style.animationNormal easing.type: Easing.OutCubic } } Behavior on height { enabled: showAnim.running || hideAnim.running NumberAnimation { duration: Style.animationNormal easing.type: Easing.OutCubic } } Behavior on opacity { enabled: showAnim.running || hideAnim.running NumberAnimation { duration: Style.animationFast easing.type: Easing.OutCubic } } } Rectangle { id: iconCircle width: buttonSize height: buttonSize radius: Math.min(Style.radiusL, width / 2) color: "transparent" // Make icon background transparent to avoid double opacity // Icon positioning based on direction x: 0 y: openUpward ? (root.contentHeight - height) : 0 anchors.horizontalCenter: parent.horizontalCenter NIcon { icon: root.icon pointSize: iconSize applyUiScale: false color: root.iconFgColor // Center horizontally x: (iconCircle.width - width) / 2 // Center vertically accounting for font metrics y: (iconCircle.height - height) / 2 + (height - contentHeight) / 2 } } ParallelAnimation { id: showAnim running: false NumberAnimation { target: pill property: "width" from: 1 to: maxPillWidth duration: Style.animationNormal easing.type: Easing.OutCubic } NumberAnimation { target: pill property: "height" from: 1 to: maxPillHeight duration: Style.animationNormal easing.type: Easing.OutCubic } NumberAnimation { target: pill property: "opacity" from: 0 to: 1 duration: Style.animationFast easing.type: Easing.OutCubic } onStarted: { showPill = true; } onStopped: { delayedHideAnim.start(); root.shown(); } } SequentialAnimation { id: delayedHideAnim running: false PauseAnimation { duration: 2500 } ScriptAction { script: if (shouldAnimateHide) { hideAnim.start(); } } } ParallelAnimation { id: hideAnim running: false NumberAnimation { target: pill property: "width" from: maxPillWidth to: 1 duration: Style.animationNormal easing.type: Easing.InCubic } NumberAnimation { target: pill property: "height" from: maxPillHeight to: 1 duration: Style.animationNormal easing.type: Easing.InCubic } NumberAnimation { target: pill property: "opacity" from: 1 to: 0 duration: Style.animationFast easing.type: Easing.InCubic } onStopped: { showPill = false; shouldAnimateHide = false; root.hidden(); } } Timer { id: showTimer interval: Style.pillDelay onTriggered: { if (!showPill) { showAnim.start(); } } } MouseArea { anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton cursorShape: root.clicked ? Qt.PointingHandCursor : Qt.ArrowCursor onEntered: { hovered = true; root.entered(); TooltipService.show(root, root.tooltipText, BarService.getTooltipDirection(root.screen?.name), (forceOpen || forceClose) ? Style.tooltipDelay : Style.tooltipDelayLong); if (forceClose) { return; } if (!forceOpen) { showDelayed(); } } onExited: { hovered = false; root.exited(); if (!forceOpen && !forceClose) { hide(); } TooltipService.hide(); } onClicked: mouse => { TooltipService.hide(); if (mouse.button === Qt.LeftButton) { root.clicked(); } else if (mouse.button === Qt.RightButton) { root.rightClicked(); } else if (mouse.button === Qt.MiddleButton) { root.middleClicked(); } } onWheel: wheel => root.wheel(wheel.angleDelta.y) } function show() { if (collapseToIcon || root.text.trim().length === 0) return; if (!showPill) { shouldAnimateHide = autoHide; showAnim.start(); } else { hideAnim.stop(); delayedHideAnim.restart(); } } function hide() { if (collapseToIcon) return; if (forceOpen) { return; } if (showPill) { hideAnim.start(); } showTimer.stop(); } function showDelayed() { if (collapseToIcon || root.text.trim().length === 0) return; if (!showPill) { shouldAnimateHide = autoHide; showTimer.start(); } else { hideAnim.stop(); delayedHideAnim.restart(); } } onForceOpenChanged: { if (forceOpen) { // Immediately lock open without animations showAnim.stop(); hideAnim.stop(); delayedHideAnim.stop(); showPill = true; } else { hide(); } } }