135 lines
4.3 KiB
QML
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
|
|
}
|
|
}
|
|
}
|