Files
omarchy-beeper/BeeperIcon.qml
T

125 lines
4.0 KiB
QML

import QtQuick
import QtQuick.Window
import qs.Commons
// Chat bubble: rounded outline with a tail, and three dots that fade in when
// something is unread. Stroke, not a filled blob, so the silhouette keeps
// contrast on a transparent bar over light, dark, or mixed wallpapers.
// Every painted extent stays inside the optical canvas at every dot pose.
Item {
id: root
property real iconSize: Style.bar.iconCanvas
property color color: Color.foreground
property color dotColor: color
property bool hasMail: false
width: iconSize
height: iconSize
implicitWidth: iconSize
implicitHeight: iconSize
readonly property real dpr: {
var win = Window.window
return (win && win.devicePixelRatio > 0) ? win.devicePixelRatio : 1
}
function snap(v) {
return Math.round(v * root.dpr) / root.dpr
}
function snapStroke(v) {
return Math.max(1, Math.round(v * root.dpr)) / root.dpr
}
readonly property real stroke: snapStroke(Math.max(1.5, iconSize * 0.11))
readonly property real pad: snap(Math.max(stroke / 2, 0.5))
readonly property real tailH: snap(Math.max(stroke * 1.2, iconSize * 0.16))
readonly property real bodyX: snap(pad)
readonly property real bodyY: snap(pad)
readonly property real bodyW: snap(iconSize - pad * 2)
readonly property real bodyH: snap(Math.max(stroke * 3, iconSize - pad * 2 - tailH))
readonly property real radius: snap(Math.min(bodyW, bodyH) * 0.3)
// The tail hangs off the bottom edge, left of centre, and never reaches
// the corner arc: keep both feet between the arcs.
readonly property real tailLeft: snap(bodyX + Math.max(radius, bodyW * 0.2))
readonly property real tailRight: snap(tailLeft + Math.max(stroke, bodyW * 0.2))
readonly property real tailTipX: snap(tailLeft + Math.max(stroke / 2, bodyW * 0.04))
readonly property real dotR: snap(Math.max(1, Math.min(iconSize * 0.075, bodyH * 0.16)))
readonly property real dotGap: snap(Math.max(dotR * 2.2, bodyW * 0.24))
readonly property real dotY: snap(bodyY + bodyH / 2)
property real dotAmount: hasMail ? 1 : 0
Behavior on dotAmount {
NumberAnimation { duration: 180; easing.type: Easing.InOutQuad }
}
Canvas {
id: canvas
anchors.fill: parent
antialiasing: true
renderTarget: Canvas.FramebufferObject
renderStrategy: Canvas.Cooperative
onPaint: {
var ctx = getContext("2d")
ctx.reset()
ctx.clearRect(0, 0, width, height)
ctx.lineCap = "round"
ctx.lineJoin = "round"
ctx.strokeStyle = root.color
ctx.lineWidth = root.stroke
var x = root.bodyX
var y = root.bodyY
var w = root.bodyW
var h = root.bodyH
var r = Math.min(root.radius, w / 2, h / 2)
// One closed path: bubble outline with the tail cut into the bottom
// edge, so there is no seam where a second stroke would overlap.
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.lineTo(x + w - r, y)
ctx.arcTo(x + w, y, x + w, y + r, r)
ctx.lineTo(x + w, y + h - r)
ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
ctx.lineTo(root.tailRight, y + h)
ctx.lineTo(root.tailTipX, y + h + root.tailH)
ctx.lineTo(root.tailLeft, y + h)
ctx.lineTo(x + r, y + h)
ctx.arcTo(x, y + h, x, y + h - r, r)
ctx.lineTo(x, y + r)
ctx.arcTo(x, y, x + r, y, r)
ctx.closePath()
ctx.stroke()
if (root.dotAmount <= 0.01)
return
ctx.fillStyle = root.dotColor
ctx.globalAlpha = root.dotAmount
var cx = x + w / 2
var scaled = root.dotR * (0.6 + 0.4 * root.dotAmount)
for (var i = -1; i <= 1; i++) {
ctx.beginPath()
ctx.arc(cx + i * root.dotGap, root.dotY, scaled, 0, Math.PI * 2, false)
ctx.fill()
}
ctx.globalAlpha = 1
}
}
onColorChanged: canvas.requestPaint()
onDotColorChanged: canvas.requestPaint()
onDotAmountChanged: canvas.requestPaint()
onIconSizeChanged: canvas.requestPaint()
onBodyWChanged: canvas.requestPaint()
onDotRChanged: canvas.requestPaint()
onDprChanged: canvas.requestPaint()
Component.onCompleted: canvas.requestPaint()
}