287 lines
11 KiB
QML
287 lines
11 KiB
QML
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
|
|
}
|
|
}
|