import QtQuick 2.12 import QtQuick.Controls 2.12 import QtGraphicalEffects 1.12 Item { id: delegateRoot property bool showLabel: true // Per-button trigger config (from the model). Default: hold-to-activate. readonly property string triggerModeResolved: (typeof triggerMode !== "undefined" && triggerMode) ? triggerMode : "hold" readonly property int holdMsResolved: (typeof holdMs !== "undefined" && holdMs > 0) ? holdMs : 800 readonly property bool latchOn: typeof latchResetEnabled !== "undefined" && latchResetEnabled readonly property bool isVoice: (typeof kind !== "undefined" && kind === "voice") // Voice: allow interaction while lit (cancel) and while recording (stop in click mode). // Block only while uploading (status 3 and not recording) or error. readonly property bool voiceRecording: isVoice && status === 3 && btnController.isVoiceRecording(btnId) readonly property bool inputBlocked: { if (isVoice) { if (status === 2) return true if (status === 3 && !voiceRecording) return true // uploading return false } return status === 3 || status === 2 || (status !== 0 && !latchOn) } // Resolved feedback colour/width for the current status (ok / error). readonly property color okColor: settingsContainer.feedbackOkColor readonly property color errorColor: settingsContainer.feedbackErrorColor readonly property color holdColor: settingsContainer.feedbackHoldColor readonly property color pendingColor: settingsContainer.feedbackPendingColor readonly property color feedbackColor: status === 1 ? okColor : status === 2 ? errorColor : status === 3 ? pendingColor : "#ffffff" readonly property int feedbackWidth: status === 1 ? settingsContainer.feedbackOkWidth : status === 2 ? settingsContainer.feedbackErrorWidth : status === 3 ? settingsContainer.feedbackPendingWidth : 2 // Glow: white=in-progress, ok/error use configured colours Rectangle { id: glowRect anchors.centerIn: parent width: circle.width * glowScale height: width radius: width / 2 color: delegateRoot.feedbackColor opacity: status === 0 ? 0 : 0.5 visible: status !== 0 && settingsContainer.feedbackGlowRadius > 0 property real glowScale: 1.0 Behavior on opacity { NumberAnimation { duration: 300 } } Behavior on color { ColorAnimation { duration: 200 } } SequentialAnimation on glowScale { running: status === 3 loops: Animation.Infinite NumberAnimation { to: 1.4; duration: 700; easing.type: Easing.InOutSine } NumberAnimation { to: 1.0; duration: 700; easing.type: Easing.InOutSine } } NumberAnimation on glowScale { running: status !== 3 to: 1.0; duration: 300 } layer.enabled: true layer.effect: Glow { radius: settingsContainer.feedbackGlowRadius samples: 24 color: delegateRoot.feedbackColor transparentBorder: true } } // Circle button — fixed color from config; only the ring reacts to status. Rectangle { id: circle anchors.centerIn: parent width: Math.min(parent.width, parent.height) * 0.82 height: width radius: width / 2 color: btnColor || "#7b007b" border.color: status === 1 ? delegateRoot.okColor : status === 2 ? delegateRoot.errorColor : status === 3 ? delegateRoot.pendingColor : Qt.lighter(btnColor || "#7b007b", 1.5) border.width: status === 1 || status === 2 || status === 3 ? delegateRoot.feedbackWidth : 0 Behavior on border.width { NumberAnimation { duration: 200 } } Behavior on border.color { enabled: status === 1 || status === 2 || status === 3 ColorAnimation { duration: 200 } } property real holdProgress: 0.0 property bool holding: false scale: holding || delegateRoot.voiceRecording ? 0.93 : 1.0 Behavior on scale { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } Image { anchors.centerIn: parent width: parent.width * 0.8 height: width fillMode: Image.PreserveAspectFit source: iconPath visible: iconPath !== "" asynchronous: true } // Circular progress arc that fills up while the button is held (press buttons only). Canvas { id: holdRing anchors.fill: parent anchors.margins: -Math.max(3, width * 0.04) visible: !delegateRoot.isVoice && circle.holdProgress > 0.001 antialiasing: true renderStrategy: Canvas.Threaded property color baseColor: Qt.lighter(delegateRoot.holdColor, 1.2) property color headColor: delegateRoot.holdColor function mix(c1, c2, t) { return Qt.rgba(c1.r + (c2.r - c1.r) * t, c1.g + (c2.g - c1.g) * t, c1.b + (c2.b - c1.b) * t, c1.a + (c2.a - c1.a) * t) } onPaint: { var ctx = getContext("2d") ctx.reset() var p = circle.holdProgress if (p <= 0) return var cx = width / 2 var cy = height / 2 var lw = Math.max(5, width * 0.085) var r = width / 2 - lw / 2 - 1 var start = -Math.PI / 2 var sweep = p * 2 * Math.PI var end = start + sweep ctx.beginPath() ctx.lineWidth = lw ctx.lineCap = "round" ctx.strokeStyle = Qt.rgba(0, 0, 0, 0.30) ctx.arc(cx, cy, r, 0, 2 * Math.PI, false) ctx.stroke() var tail = Qt.darker(baseColor, 1.5) var N = 120 var segAng = 2 * Math.PI / N var eps = Math.min(segAng * 0.5, 1.5 / r) ctx.lineCap = "butt" ctx.lineWidth = lw for (var i = 0; i < N; i++) { var a0 = i * segAng if (a0 >= sweep) break var a1 = Math.min((i + 1) * segAng, sweep) var t = sweep > 0 ? (a0 / sweep) : 0 ctx.beginPath() ctx.strokeStyle = mix(tail, headColor, t) ctx.arc(cx, cy, r, start + a0 - eps, start + a1 + eps, false) ctx.stroke() } ctx.beginPath() ctx.fillStyle = tail ctx.arc(cx + r * Math.cos(start), cy + r * Math.sin(start), lw / 2, 0, 2 * Math.PI, false) ctx.fill() ctx.beginPath() ctx.fillStyle = headColor ctx.arc(cx + r * Math.cos(end), cy + r * Math.sin(end), lw * 0.55, 0, 2 * Math.PI, false) ctx.fill() } layer.enabled: true layer.effect: Glow { radius: 14 samples: 25 spread: 0.2 color: Qt.lighter(delegateRoot.holdColor, 1.7) transparentBorder: true } Connections { target: circle function onHoldProgressChanged() { holdRing.requestPaint() } } } NumberAnimation { id: holdAnim target: circle property: "holdProgress" from: 0.0 to: 1.0 duration: delegateRoot.holdMsResolved easing.type: Easing.Linear onFinished: { if (circle.holding) { circle.holding = false btnController.invokeButton(btnId) holdLingerTimer.restart() } } } Timer { id: holdLingerTimer interval: 700 onTriggered: resetAnim.restart() } NumberAnimation { id: resetAnim target: circle property: "holdProgress" to: 0.0 duration: 400 easing.type: Easing.OutCubic } MouseArea { anchors.fill: parent enabled: !delegateRoot.inputBlocked onPressed: { if (delegateRoot.inputBlocked) return if (delegateRoot.isVoice) { circle.holding = true btnController.voicePointerPressed(btnId) return } if (delegateRoot.triggerModeResolved !== "click") { resetAnim.stop() circle.holding = true holdAnim.restart() } } onReleased: { if (delegateRoot.isVoice) { circle.holding = false btnController.voicePointerReleased(btnId) return } if (delegateRoot.triggerModeResolved !== "click" && circle.holding) { circle.holding = false holdAnim.stop() resetAnim.restart() } } onCanceled: { if (delegateRoot.isVoice) { circle.holding = false btnController.voicePointerReleased(btnId) return } if (delegateRoot.triggerModeResolved !== "click") { circle.holding = false holdAnim.stop() resetAnim.restart() } } onClicked: { if (delegateRoot.inputBlocked) return if (delegateRoot.isVoice) { if (status === 1 || delegateRoot.triggerModeResolved === "click") btnController.voiceClicked(btnId) return } if (delegateRoot.triggerModeResolved === "click") btnController.invokeButton(btnId) } } } Text { anchors.top: circle.bottom anchors.topMargin: 4 anchors.horizontalCenter: parent.horizontalCenter width: parent.width visible: showLabel text: statusText !== "" ? statusText : label color: settingsContainer.labelColor font.pixelSize: Math.max(10, parent.width * 0.1) horizontalAlignment: Text.AlignHCenter elide: Text.ElideRight } }