Files
noctalia-shell/Modules/Tooltip/Tooltip.qml
T

578 lines
17 KiB
QML

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
property int screenX: 0
property int screenY: 0
visible: false
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 <br> for RichText format
const processedText = tipText.replace(/\n/g, '<br>');
// 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;
screenX = s.x;
screenY = s.y;
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 and convert to screen-relative
var targetGlobalAbs = targetItem.mapToGlobal(0, 0);
var targetGlobal = {
"x": targetGlobalAbs.x - screenX,
"y": targetGlobalAbs.y - screenY
};
const targetWidth = targetItem.width;
const targetHeight = targetItem.height;
var newAnchorX = 0;
var newAnchorY = 0;
if (direction === "auto") {
// Calculate available space in each direction (screen-relative)
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;
} 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;
}
}
// Adjust horizontal position to keep tooltip on screen
// For top/bottom tooltips, always adjust horizontally (they don't overlap horizontally)
// For left/right tooltips, check for overlap before adjusting
const globalX = targetGlobal.x + newAnchorX;
const isHorizontalTooltip = (direction === "top" || direction === "bottom");
if (globalX < 0) {
// Clipping at left - only adjust if tooltip won't overlap target
const adjustedX = -targetGlobal.x + margin;
if (isHorizontalTooltip) {
// Top/bottom tooltips: always allow horizontal adjustment
newAnchorX = adjustedX;
} else {
// Left/right tooltips: check for vertical overlap
const wouldOverlap = adjustedX < targetWidth && adjustedX + tipWidth > 0;
if (!wouldOverlap) {
newAnchorX = adjustedX;
}
}
} else if (globalX + tipWidth > screenWidth) {
// Clipping at right - only adjust if tooltip won't overlap target
const adjustedX = screenWidth - targetGlobal.x - tipWidth - margin;
if (isHorizontalTooltip) {
// Top/bottom tooltips: always allow horizontal adjustment
newAnchorX = adjustedX;
} else {
// Left/right tooltips: check for vertical overlap
const wouldOverlap = adjustedX < targetWidth && adjustedX + tipWidth > 0;
if (!wouldOverlap) {
newAnchorX = adjustedX;
}
}
}
// Adjust vertical position to keep tooltip on screen
// For left/right tooltips, always adjust vertically (they don't overlap vertically)
// For top/bottom tooltips, check for overlap before adjusting
const globalY = targetGlobal.y + newAnchorY;
const isVerticalTooltip = (direction === "left" || direction === "right");
if (globalY < 0) {
// Clipping at top - only adjust if tooltip won't overlap target
const adjustedY = -targetGlobal.y + margin;
if (isVerticalTooltip) {
// Left/right tooltips: always allow vertical adjustment
newAnchorY = adjustedY;
} else {
// Top/bottom tooltips: check for horizontal overlap
const wouldOverlap = adjustedY < targetHeight && adjustedY + tipHeight > 0;
if (!wouldOverlap) {
newAnchorY = adjustedY;
}
}
} else if (globalY + tipHeight > screenHeight) {
// Clipping at bottom - only adjust if tooltip won't overlap target
const adjustedY = screenHeight - targetGlobal.y - tipHeight - margin;
if (isVerticalTooltip) {
// Left/right tooltips: always allow vertical adjustment
newAnchorY = adjustedY;
} else {
// Top/bottom tooltips: check for horizontal overlap
const wouldOverlap = adjustedY < targetHeight && adjustedY + tipHeight > 0;
if (!wouldOverlap) {
newAnchorY = adjustedY;
}
}
}
// 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 <br> for RichText format
const processedText = newText.replace(/\n/g, '<br>');
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 (screen-relative)
var targetGlobalAbs = targetItem.mapToGlobal(0, 0);
var targetGlobal = {
"x": targetGlobalAbs.x - screenX,
"y": targetGlobalAbs.y - screenY
};
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
var isHorizontalTooltip = false;
var isVerticalTooltip = false;
if (anchorY > targetHeight / 2) {
// Tooltip is below target
newAnchorX = (targetWidth - tipWidth) / 2;
isHorizontalTooltip = true;
} else if (anchorY < -tipHeight / 2) {
// Tooltip is above target
newAnchorX = (targetWidth - tipWidth) / 2;
isHorizontalTooltip = true;
} else if (anchorX > targetWidth / 2) {
// Tooltip is to the right
newAnchorY = (targetHeight - tipHeight) / 2;
isVerticalTooltip = true;
} else if (anchorX < -tipWidth / 2) {
// Tooltip is to the left
newAnchorY = (targetHeight - tipHeight) / 2;
isVerticalTooltip = true;
}
// Adjust horizontal position to keep tooltip on screen if needed
// For top/bottom tooltips, always adjust horizontally (they don't overlap horizontally)
// For left/right tooltips, check for overlap before adjusting
const globalX = targetGlobal.x + newAnchorX;
if (globalX < 0) {
const adjustedX = -targetGlobal.x + margin;
if (isHorizontalTooltip) {
newAnchorX = adjustedX;
} else {
const wouldOverlap = adjustedX < targetWidth && adjustedX + tipWidth > 0;
if (!wouldOverlap) {
newAnchorX = adjustedX;
}
}
} else if (globalX + tipWidth > screenWidth) {
const adjustedX = screenWidth - targetGlobal.x - tipWidth - margin;
if (isHorizontalTooltip) {
newAnchorX = adjustedX;
} else {
const wouldOverlap = adjustedX < targetWidth && adjustedX + tipWidth > 0;
if (!wouldOverlap) {
newAnchorX = adjustedX;
}
}
}
// Adjust vertical position to keep tooltip on screen if needed
// For left/right tooltips, always adjust vertically (they don't overlap vertically)
// For top/bottom tooltips, check for overlap before adjusting
const globalY = targetGlobal.y + newAnchorY;
if (globalY < 0) {
const adjustedY = -targetGlobal.y + margin;
if (isVerticalTooltip) {
newAnchorY = adjustedY;
} else {
const wouldOverlap = adjustedY < targetHeight && adjustedY + tipHeight > 0;
if (!wouldOverlap) {
newAnchorY = adjustedY;
}
}
} else if (globalY + tipHeight > screenHeight) {
const adjustedY = screenHeight - targetGlobal.y - tipHeight - margin;
if (isVerticalTooltip) {
newAnchorY = adjustedY;
} else {
const wouldOverlap = adjustedY < targetHeight && adjustedY + tipHeight > 0;
if (!wouldOverlap) {
newAnchorY = adjustedY;
}
}
}
// 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();
}
}