Files
noctalia-shell/Modules/Tooltip/Tooltip.qml
T
2025-09-28 00:15:43 -04:00

415 lines
11 KiB
QML

import QtQuick
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services
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: 340
property real scaling: 1.0
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 pendingShow: false
property bool animatingOut: false
visible: false
color: Color.transparent
anchor.item: targetItem
anchor.rect.x: anchorX
anchor.rect.y: anchorY
implicitWidth: Math.min(tooltipText.implicitWidth + padding * 2 * scaling, maxWidth * scaling)
implicitHeight: tooltipText.implicitHeight + padding * 2 * scaling
// 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) {
if (!target || !tipText || tipText === "")
return
if (showDelay !== undefined) {
delay = showDelay
} else {
delay = Style.tooltipDelay
}
// 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()
}
// Set properties
targetItem = target
text = tipText
pendingShow = true
if (customDirection !== undefined) {
direction = customDirection
} else {
direction = "auto"
}
// Start show timer
showTimer.start()
}
// Function to position and display the tooltip
function positionAndShow() {
if (!targetItem || !pendingShow)
return
// Get screen dimensions - try multiple methods
var screenWidth = Screen.width
var screenHeight = Screen.height
// Try to get screen from target item
if (targetItem) {
if (targetItem.screen) {
screenWidth = targetItem.screen.width
screenHeight = targetItem.screen.height
scaling = ScalingService.getScreenScale(targetItem.screen)
}
}
// Calculate tooltip dimensions
const tipWidth = Math.min(tooltipText.implicitWidth + padding * 2 * scaling, maxWidth * scaling)
const tipHeight = tooltipText.implicitHeight + padding * 2 * scaling
// Get target's global position
const targetGlobal = targetItem.mapToGlobal(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 * scaling,
"fits": spaceBottom >= tipHeight + margin * scaling
}, {
"dir": "top",
"space": spaceTop,
"x": (targetWidth - tipWidth) / 2,
"y": -tipHeight - margin * scaling,
"fits": spaceTop >= tipHeight + margin * scaling
}, {
"dir": "right",
"space": spaceRight,
"x": targetWidth + margin * scaling,
"y": (targetHeight - tipHeight) / 2,
"fits": spaceRight >= tipWidth + margin * scaling
}, {
"dir": "left",
"space": spaceLeft,
"x": -tipWidth - margin * scaling,
"y": (targetHeight - tipHeight) / 2,
"fits": spaceLeft >= tipWidth + margin * scaling
}]
// 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 * scaling
} else if (globalX + tipWidth > screenWidth) {
newAnchorX = screenWidth - targetGlobal.x - tipWidth - margin * scaling
}
}
} else {
// Manual direction positioning
switch (direction) {
case "left":
newAnchorX = -tipWidth - margin * scaling
newAnchorY = (targetHeight - tipHeight) / 2
break
case "right":
newAnchorX = targetWidth + margin * scaling
newAnchorY = (targetHeight - tipHeight) / 2
break
case "top":
newAnchorX = (targetWidth - tipWidth) / 2
newAnchorY = -tipHeight - margin * scaling
break
case "bottom":
newAnchorX = (targetWidth - tipWidth) / 2
newAnchorY = targetHeight + margin * scaling
break
}
}
// Apply position
anchorX = newAnchorX
anchorY = newAnchorY
isPositioned = true
pendingShow = false
// Show tooltip and start animation
visible = true
// Initialize animation state
tooltipContainer.opacity = 0.0
tooltipContainer.scale = animationScale
// Start show animation
showAnimation.start()
// Force anchor update after showing
Qt.callLater(() => {
if (root.anchor && root.visible) {
root.anchor.updateAnchor()
}
})
}
// Function to hide tooltip
function hide() {
// Stop show timer if it's running
showTimer.stop()
pendingShow = false
// 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() {
// Hide the popup window
visible = false
animatingOut = false
pendingShow = false
// Clear the text and reset state
text = ""
isPositioned = false
// Reset container state
tooltipContainer.opacity = 1.0
tooltipContainer.scale = 1.0
}
// Quick hide without delay or animation
function hideImmediately() {
showTimer.stop()
hideTimer.stop()
showAnimation.stop()
hideAnimation.stop()
pendingShow = false
animatingOut = false
completeHide()
}
// Update text function for binding support
function updateText(newText) {
if (visible && targetItem) {
text = newText
positionAndShow()
}
}
// 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
pendingShow = 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: Math.max(1, Style.borderS * scaling)
radius: Style.radiusM * scaling
// Only show content when we have text
visible: root.text !== ""
NText {
id: tooltipText
anchors.centerIn: parent
anchors.margins: root.padding * root.scaling
text: root.text
font.pointSize: Style.fontSizeS * scaling
color: Color.mOnSurfaceVariant
wrapMode: Text.WordWrap
width: Math.min(implicitWidth, root.maxWidth * root.scaling - root.padding * 2 * root.scaling)
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
Component.onCompleted: {
reset()
}
Component.onDestruction: {
reset()
}
}