mirror of
https://github.com/noctalia-dev/noctalia-shell.git
synced 2026-05-11 17:08:27 +08:00
452 lines
12 KiB
QML
452 lines
12 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
|
|
|
|
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();
|
|
}
|
|
|
|
// Set properties
|
|
text = tipText;
|
|
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) {
|
|
text = newText;
|
|
|
|
// 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: root.maxWidth - (root.padding * 2)
|
|
}
|
|
}
|
|
}
|
|
|
|
Component.onCompleted: {
|
|
reset();
|
|
}
|
|
|
|
Component.onDestruction: {
|
|
reset();
|
|
}
|
|
}
|