import QtQuick import Quickshell import qs.Commons import qs.Widgets PopupWindow { id: root property string text: "" property string direction: "auto" // "auto", "left", "right", "top", "bottom" property int margin: Style.marginXS // distance from target property int padding: Style.marginM property int delay: 0 property int hideDelay: 0 property int maxWidth: 320 property int animationDuration: Style.animationFast property real animationScale: 0.85 // Internal properties property var targetItem: null property real anchorX: 0 property real anchorY: 0 property bool isPositioned: false property bool animatingOut: true property int screenWidth: 1920 property int screenHeight: 1080 visible: false color: Color.transparent anchor.item: targetItem anchor.rect.x: anchorX anchor.rect.y: anchorY // Timer for showing tooltip after delay Timer { id: showTimer interval: root.delay repeat: false onTriggered: { root.positionAndShow(); } } // Timer for hiding tooltip after delay Timer { id: hideTimer interval: root.hideDelay repeat: false onTriggered: { root.startHideAnimation(); } } // Show animation ParallelAnimation { id: showAnimation PropertyAnimation { target: tooltipContainer property: "opacity" from: 0.0 to: 1.0 duration: root.animationDuration easing.type: Easing.OutCubic } PropertyAnimation { target: tooltipContainer property: "scale" from: root.animationScale to: 1.0 duration: root.animationDuration easing.type: Easing.OutBack easing.overshoot: 1.2 } } // Hide animation ParallelAnimation { id: hideAnimation PropertyAnimation { target: tooltipContainer property: "opacity" from: 1.0 to: 0.0 duration: root.animationDuration * 0.75 // Slightly faster hide easing.type: Easing.InCubic } PropertyAnimation { target: tooltipContainer property: "scale" from: 1.0 to: root.animationScale duration: root.animationDuration * 0.75 easing.type: Easing.InCubic } onFinished: { root.completeHide(); } } // Function to show tooltip function show(target, tipText, customDirection, showDelay, fontFamily) { if (!target || !tipText || tipText === "") return; root.delay = showDelay; // Stop any running timers and animations hideTimer.stop(); showTimer.stop(); hideAnimation.stop(); animatingOut = false; // If we're already showing for a different target, hide immediately if (visible && targetItem !== target) { hideImmediately(); } // Convert \n to
for RichText format const processedText = tipText.replace(/\n/g, '
'); // Set properties text = processedText; targetItem = target; // Find the correct screen dimensions based on target's global position // Respect all screens positionning const targetGlobal = target.mapToGlobal(target.width / 2, target.height / 2); let foundScreen = false; for (let i = 0; i < Quickshell.screens.length; i++) { const s = Quickshell.screens[i]; if (targetGlobal.x >= s.x && targetGlobal.x < s.x + s.width && targetGlobal.y >= s.y && targetGlobal.y < s.y + s.height) { screenWidth = s.width; screenHeight = s.height; foundScreen = true; break; } } if (!foundScreen) { Logger.w("Tooltip", "No screen found for target position!"); } // Initialize animation state (hidden) tooltipContainer.opacity = 0.0; tooltipContainer.scale = root.animationScale; // Start show timer (will position and then make visible) showTimer.start(); if (customDirection !== undefined) { direction = customDirection; } else { direction = "auto"; } tooltipText.family = fontFamily ? fontFamily : Settings.data.ui.fontDefault; } // Function to position and display the tooltip function positionAndShow() { if (!targetItem || !targetItem.parent) { return; } // Calculate tooltip dimensions const tipWidth = Math.min(tooltipText.implicitWidth + (padding * 2), maxWidth); root.implicitWidth = tipWidth; const tipHeight = tooltipText.implicitHeight + (padding * 2); root.implicitHeight = tipHeight; // Get target's global position var targetGlobal = targetItem.mapToItem(null, 0, 0); const targetWidth = targetItem.width; const targetHeight = targetItem.height; var newAnchorX = 0; var newAnchorY = 0; if (direction === "auto") { // Calculate available space in each direction const spaceLeft = targetGlobal.x; const spaceRight = screenWidth - (targetGlobal.x + targetWidth); const spaceTop = targetGlobal.y; const spaceBottom = screenHeight - (targetGlobal.y + targetHeight); // Try positions in order of available space const positions = [ { "dir": "bottom", "space": spaceBottom, "x": (targetWidth - tipWidth) / 2, "y": targetHeight + margin, "fits": spaceBottom >= tipHeight + margin }, { "dir": "top", "space": spaceTop, "x": (targetWidth - tipWidth) / 2, "y": -tipHeight - margin, "fits": spaceTop >= tipHeight + margin }, { "dir": "right", "space": spaceRight, "x": targetWidth + margin, "y": (targetHeight - tipHeight) / 2, "fits": spaceRight >= tipWidth + margin }, { "dir": "left", "space": spaceLeft, "x": -tipWidth - margin, "y": (targetHeight - tipHeight) / 2, "fits": spaceLeft >= tipWidth + margin } ]; // Find first position that fits var selectedPosition = null; for (var i = 0; i < positions.length; i++) { if (positions[i].fits) { selectedPosition = positions[i]; break; } } // If none fit perfectly if (!selectedPosition) { // Sort by available space and use position with most space positions.sort(function (a, b) { return b.space - a.space; }); selectedPosition = positions[0]; } newAnchorX = selectedPosition.x; newAnchorY = selectedPosition.y; // Adjust horizontal position to keep tooltip on screen if (direction === "auto") { const globalX = targetGlobal.x + newAnchorX; if (globalX < 0) { newAnchorX = -targetGlobal.x + margin; } else if (globalX + tipWidth > screenWidth) { newAnchorX = screenWidth - targetGlobal.x - tipWidth - margin; } } } else { // Manual direction positioning switch (direction) { case "left": newAnchorX = -tipWidth - margin; newAnchorY = (targetHeight - tipHeight) / 2; break; case "right": newAnchorX = targetWidth + margin; newAnchorY = (targetHeight - tipHeight) / 2; break; case "top": newAnchorX = (targetWidth - tipWidth) / 2; newAnchorY = -tipHeight - margin; break; case "bottom": newAnchorX = (targetWidth - tipWidth) / 2; newAnchorY = targetHeight + margin; break; } } // Apply position first (before making visible) anchorX = newAnchorX; anchorY = newAnchorY; isPositioned = true; // Now make visible and start animation root.visible = true; showAnimation.start(); } // Function to hide tooltip function hide() { // Stop show timer if it's running showTimer.stop(); // Stop hide timer if it's running hideTimer.stop(); if (hideDelay > 0 && visible && !animatingOut) { hideTimer.start(); } else { startHideAnimation(); } } function startHideAnimation() { if (!visible || animatingOut) return; animatingOut = true; showAnimation.stop(); // Stop show animation if running hideAnimation.start(); } function completeHide() { visible = false; animatingOut = false; text = ""; isPositioned = false; tooltipContainer.opacity = 1.0; tooltipContainer.scale = 1.0; } // Quick hide without delay or animation function hideImmediately() { showTimer.stop(); hideTimer.stop(); showAnimation.stop(); hideAnimation.stop(); animatingOut = false; completeHide(); } // Update text function function updateText(newText) { if (visible && targetItem) { // Convert \n to
for RichText format const processedText = newText.replace(/\n/g, '
'); text = processedText; // Recalculate dimensions const tipWidth = Math.min(tooltipText.implicitWidth + (padding * 2), maxWidth); root.implicitWidth = tipWidth; const tipHeight = tooltipText.implicitHeight + (padding * 2); root.implicitHeight = tipHeight; // Reposition based on current direction var targetGlobal = targetItem.mapToItem(null, 0, 0); const targetWidth = targetItem.width; const targetHeight = targetItem.height; // Recalculate base anchor position (center on target for top/bottom, etc.) var newAnchorX = anchorX; var newAnchorY = anchorY; // Determine which direction the tooltip is currently positioned // and recalculate the centering for that direction if (anchorY > targetHeight / 2) { // Tooltip is below target newAnchorX = (targetWidth - tipWidth) / 2; } else if (anchorY < -tipHeight / 2) { // Tooltip is above target newAnchorX = (targetWidth - tipWidth) / 2; } else if (anchorX > targetWidth / 2) { // Tooltip is to the right newAnchorY = (targetHeight - tipHeight) / 2; } else if (anchorX < -tipWidth / 2) { // Tooltip is to the left newAnchorY = (targetHeight - tipHeight) / 2; } // Adjust horizontal position to keep tooltip on screen if needed const globalX = targetGlobal.x + newAnchorX; if (globalX < 0) { newAnchorX = -targetGlobal.x + margin; } else if (globalX + tipWidth > screenWidth) { newAnchorX = screenWidth - targetGlobal.x - tipWidth - margin; } // Apply the new anchor positions anchorX = newAnchorX; anchorY = newAnchorY; // Force anchor update Qt.callLater(() => { if (root.anchor && root.visible) { root.anchor.updateAnchor(); } }); } } // Reset function to clean up state function reset() { // Stop all timers and animations showTimer.stop(); hideTimer.stop(); showAnimation.stop(); hideAnimation.stop(); // Clear all state visible = false; animatingOut = false; text = ""; isPositioned = false; // Reset to defaults direction = "auto"; delay = 0; hideDelay = 0; // Reset container state tooltipContainer.opacity = 1.0; tooltipContainer.scale = 1.0; } // Tooltip content container for animations Item { id: tooltipContainer anchors.fill: parent // Animation properties opacity: 1.0 scale: 1.0 transformOrigin: Item.Center Rectangle { anchors.fill: parent color: Color.mSurface border.color: Color.mOutline border.width: Style.borderS radius: Style.radiusS // Only show content when we have text visible: root.text !== "" NText { id: tooltipText anchors.centerIn: parent anchors.margins: root.padding text: root.text pointSize: Style.fontSizeS family: Settings.data.ui.fontFixed color: Color.mOnSurfaceVariant horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter wrapMode: Text.WordWrap width: Math.min(implicitWidth, root.maxWidth - (root.padding * 2)) richTextEnabled: true } } } Component.onCompleted: { reset(); } Component.onDestruction: { reset(); } }