import QtQuick 2.12 import QtQuick.Controls 2.12 import QtGraphicalEffects 1.12 Item { id: delegateRoot property bool showLabel: true property bool darkMode: settingsContainer.darkMode // 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 // 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 feedbackColor: status === 1 ? okColor : status === 2 ? errorColor : "#ffffff" readonly property int feedbackWidth: status === 1 ? settingsContainer.feedbackOkWidth : status === 2 ? settingsContainer.feedbackErrorWidth : 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 : Qt.lighter(btnColor || "#7b007b", 1.5) border.width: status === 1 || status === 2 ? delegateRoot.feedbackWidth : 1 Behavior on border.width { NumberAnimation { duration: 200 } } Behavior on border.color { ColorAnimation { duration: 200 } } // Hold-to-activate state (0..1). Only used in "hold" trigger mode. property real holdProgress: 0.0 property bool holding: false // Squash slightly while held for tactile feedback. scale: holding ? 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. Canvas { id: holdRing anchors.fill: parent anchors.margins: -Math.max(3, width * 0.04) visible: circle.holdProgress > 0.001 antialiasing: true renderStrategy: Canvas.Threaded // Ring colour derived from the button colour for a cohesive look. property color baseColor: Qt.lighter(btnColor || "#7b007b", 1.9) property color headColor: "#ffffff" 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 // Recessed track underneath for a sense of depth. 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() // Progress arc drawn as overlapping segments with a manual // colour fade (dim tail -> bright head). Segment boundaries are // fixed in ANGLE space (not derived from progress) so they never // shift between frames -> no jitter. Opaque colours + overlap // hide the joints. Avoids the seam a conical gradient produces. var tail = Qt.darker(baseColor, 1.5) // opaque dim tail var N = 120 var segAng = 2 * Math.PI / N var eps = Math.min(segAng * 0.5, 1.5 / r) // ~1px opaque overlap 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 // 0..1 along progress ctx.beginPath() ctx.strokeStyle = mix(tail, headColor, t) ctx.arc(cx, cy, r, start + a0 - eps, start + a1 + eps, false) ctx.stroke() } // Rounded tail cap. 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() // Bright rounded head for a glossy, raised feel. 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() } // Soft bloom around the ring for volume. layer.enabled: true layer.effect: Glow { radius: 14 samples: 25 spread: 0.2 color: Qt.lighter(btnColor || "#7b007b", 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 onPressed: { if (delegateRoot.triggerModeResolved !== "click") { resetAnim.stop() circle.holding = true holdAnim.restart() } } onReleased: { if (delegateRoot.triggerModeResolved !== "click" && circle.holding) { // Released too early — cancel and rewind. circle.holding = false holdAnim.stop() resetAnim.restart() } } onCanceled: { if (delegateRoot.triggerModeResolved !== "click") { circle.holding = false holdAnim.stop() resetAnim.restart() } } onClicked: { 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: darkMode ? "white" : "black" font.pixelSize: Math.max(10, parent.width * 0.1) horizontalAlignment: Text.AlignHCenter elide: Text.ElideRight } }