NexusAI-DMS/src/components/BubbleTip.qml
2026-09-03 20:19:04 +02:00

135 lines
4.3 KiB
QML

import QtQuick
import QtQuick.Controls
import qs.Common
// Comic speech-bubble tooltip: rounded body with a tail that merges
// into the body outline (one continuous path, no seam line across the
// junction). Expects to be hosted as a Popup parented to the chip it
// describes, centered above it. Shows after hovering the anchor for
// `delay` ms and closes as soon as the pointer leaves — drive it via
// `hovering` from the anchor's HoverHandler.onHoveredChanged.
Popup {
id: tip
property string tipText: ""
// Set from outside: true while the pointer is over the anchor.
property bool hovering: false
// Hover dwell time before the bubble pops.
property int delay: 500
readonly property real bodyHeight: bodyText.implicitHeight + 14
readonly property real tailLength: 9
onHoveringChanged: {
if (hovering) {
armTimer.restart();
} else {
armTimer.stop();
if (opened) close();
}
}
Timer {
id: armTimer
interval: tip.delay
onTriggered: tip.open()
}
width: Math.min(bodyText.implicitWidth + 20, 240)
height: bodyHeight + tailLength
padding: 0
background: null
closePolicy: Popup.NoAutoClose
// Centered above the anchor, parent-relative — the same proven
// coordinate pattern as the composer's popups (no popup-layer
// mapping, which evaluates unreliably while closed).
x: (parent.width - width) / 2
y: -height - 1
enter: Transition {
NumberAnimation {
property: "opacity"
from: 0
to: 1
duration: 150
easing.type: Easing.OutCubic
}
}
exit: Transition {
NumberAnimation {
property: "opacity"
to: 0
duration: 120
easing.type: Easing.InCubic
}
}
// QColor → CSS rgba(): Canvas's fillStyle parses #rrggbb but not
// Qt's #aarrggbb toString form, so build the string manually.
function css(c) {
return "rgba(" + Math.round(c.r * 255) + "," + Math.round(c.g * 255)
+ "," + Math.round(c.b * 255) + "," + c.a + ")";
}
contentItem: Item {
implicitWidth: tip.width
implicitHeight: tip.height
Canvas {
id: bubble
anchors.fill: parent
antialiasing: true
onPaint: {
var ctx = getContext("2d");
ctx.clearRect(0, 0, width, height);
var w = width;
var bh = tip.bodyHeight;
var th = tip.tailLength;
var cx = w / 2;
var tw = 7; // tail half-width
var r = 10; // body corner radius
// One continuous outline: rounded rect whose bottom
// edge flows out into the tail and back — the tail
// reads as part of the bubble, not a glued-on arrow.
ctx.beginPath();
ctx.moveTo(r, 0.5);
ctx.lineTo(w - r, 0.5);
ctx.arcTo(w, 0.5, w, r + 0.5, r);
ctx.lineTo(w, bh - r);
ctx.arcTo(w, bh + 0.5, w - r, bh + 0.5, r);
ctx.lineTo(cx + tw, bh + 0.5);
ctx.lineTo(cx, bh + th);
ctx.lineTo(cx - tw, bh + 0.5);
ctx.lineTo(r, bh + 0.5);
ctx.arcTo(0, bh + 0.5, 0, bh - r, r);
ctx.lineTo(0, r + 0.5);
ctx.arcTo(0, 0.5, r, 0.5, r);
ctx.closePath();
ctx.fillStyle = tip.css(Theme.surfaceContainerHigh);
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = tip.css(Theme.outlineVariant);
ctx.stroke();
}
// Canvas never auto-repaints on resize/show.
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
onVisibleChanged: if (visible) requestPaint()
Component.onCompleted: requestPaint()
}
Text {
id: bodyText
x: (parent.width - width) / 2
y: 7
width: Math.min(implicitWidth, tip.width - 20)
text: tip.tipText
color: Theme.surfaceText
font.pixelSize: 12
wrapMode: Text.Wrap
horizontalAlignment: Text.AlignHCenter
}
}
}