import QtQuick import QtQuick.Controls import QtQuick.Layouts import qs.Commons import qs.Widgets RowLayout { id: root // Public properties property int value: 0 property int from: 0 property int to: 100 property int stepSize: 1 property string suffix: "" property string prefix: "" property string label: "" property string description: "" property bool hovering: false property int baseSize: Style.baseWidgetSize property var defaultValue: undefined property string settingsPath: "" // Convenience properties for common naming property alias minimum: root.from property alias maximum: root.to // Properties for repeating property int initialRepeatDelay: 400 // The "pause" after the first click (in ms) property int repeatInterval: 80 // How often to step up after fist pause (ms) property int rampFactor: 4 // How many ticks to wait before increasing the step multiplier property int maxStepMultiplier: 10 // The max step (e.g., 10 * stepSize) property int _holdTicks: 0 // Internal counter for hold duration property int _repeatDirection: 0 // -1 for decrease, 1 for increase signal entered signal exited Layout.fillWidth: true readonly property bool isValueChanged: (defaultValue !== undefined) && (value !== defaultValue) readonly property string indicatorTooltip: defaultValue !== undefined ? I18n.tr("panels.indicator.default-value", { "value": String(defaultValue) }) : "" Timer { id: repeatTimer repeat: true interval: root.initialRepeatDelay onTriggered: { if (repeatTimer.interval === root.initialRepeatDelay) { repeatTimer.interval = root.repeatInterval; root._holdTicks = 0; } root._holdTicks++; var stepMultiplier = Math.min(root.maxStepMultiplier, 1 + Math.floor(root._holdTicks / root.rampFactor)); changeValue(root._repeatDirection, root.stepSize * stepMultiplier); } } function changeValue(direction, step) { var currentStep = step || root.stepSize; if (direction === 1 && root.value < root.to) { root.value = Math.min(root.to, root.value + currentStep); } else if (direction === -1 && root.value > root.from) { root.value = Math.max(root.from, root.value - currentStep); } else { return; } if (root.value === root.to || root.value === root.from) { stopRepeat(); } } function stopRepeat() { root._repeatDirection = 0; repeatTimer.stop(); repeatTimer.interval = root.initialRepeatDelay; } NLabel { label: root.label description: root.description showIndicator: root.isValueChanged indicatorTooltip: root.indicatorTooltip } // Main spinbox container Rectangle { id: spinBoxContainer Layout.margins: Style.borderS implicitWidth: 120 implicitHeight: Math.round((root.baseSize - 4) / 2) * 2 radius: Style.iRadiusS color: Color.mSurfaceVariant border.color: (root.hovering || decreaseArea.containsMouse || increaseArea.containsMouse) ? Color.mHover : Color.mOutline border.width: Style.borderS Behavior on border.color { ColorAnimation { duration: Style.animationFast } } // Mouse area for hover and scroll MouseArea { anchors.fill: parent acceptedButtons: Qt.NoButton hoverEnabled: true onEntered: { if (!root.enabled) return; root.hovering = true; root.entered(); } onExited: { root.hovering = false; root.exited(); } onWheel: wheel => { if (wheel.angleDelta.y > 0 && root.value < root.to) { let newValue = Math.min(root.to, root.value + root.stepSize); root.value = newValue; } else if (wheel.angleDelta.y < 0 && root.value > root.from) { let newValue = Math.max(root.from, root.value - root.stepSize); root.value = newValue; } } } // Decrease button (left) Item { id: decreaseButton height: parent.height width: height anchors.top: parent.top anchors.bottom: parent.bottom anchors.left: parent.left opacity: (root.enabled && root.value > root.from) || decreaseArea.containsMouse ? 1.0 : 0.3 Rectangle { anchors.centerIn: parent width: parent.height height: width radius: spinBoxContainer.radius color: Color.mHover opacity: decreaseArea.containsMouse ? 1.0 : 0.0 Behavior on opacity { NumberAnimation { duration: Style.animationFast } } } NIcon { anchors.centerIn: parent icon: "chevron-left" pointSize: Style.fontSizeS color: decreaseArea.containsMouse ? Color.mOnHover : Color.mPrimary } MouseArea { id: decreaseArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor enabled: root.enabled && root.value > root.from onPressed: { root._repeatDirection = -1; changeValue(root._repeatDirection, root.stepSize); repeatTimer.start(); } onReleased: stopRepeat() onExited: stopRepeat() } } // Increase button (right) Item { id: increaseButton height: parent.height width: height anchors.top: parent.top anchors.bottom: parent.bottom anchors.right: parent.right opacity: (root.enabled && root.value < root.to) || increaseArea.containsMouse ? 1.0 : 0.3 Rectangle { anchors.centerIn: parent width: parent.height height: width radius: spinBoxContainer.radius color: Color.mHover opacity: increaseArea.containsMouse ? 1.0 : 0.0 Behavior on opacity { NumberAnimation { duration: Style.animationFast } } } NIcon { anchors.centerIn: parent icon: "chevron-right" pointSize: Style.fontSizeS color: increaseArea.containsMouse ? Color.mOnHover : Color.mPrimary } MouseArea { id: increaseArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor enabled: root.enabled && root.value < root.to onPressed: { root._repeatDirection = 1; changeValue(root._repeatDirection, root.stepSize); repeatTimer.start(); } onReleased: stopRepeat() onExited: stopRepeat() } } // Center value display with separate prefix, value, and suffix Item { id: valueContainer anchors.left: decreaseButton.right anchors.right: increaseButton.left anchors.verticalCenter: parent.verticalCenter anchors.margins: 4 height: parent.height RowLayout { anchors.centerIn: parent spacing: 0 // Prefix text (non-editable) NText { text: root.prefix family: Settings.data.ui.fontFixed pointSize: Style.fontSizeM font.weight: Style.fontWeightMedium color: Qt.alpha(Color.mOnSurface, root.enabled ? 1.0 : 0.6) verticalAlignment: Text.AlignVCenter Layout.alignment: Qt.AlignVCenter visible: root.prefix !== "" } // Editable number input TextInput { id: valueInput text: valueInput.focus ? valueInput.text : root.value.toString() font.family: Settings.data.ui.fontFixed font.pointSize: Style.fontSizeM font.weight: Style.fontWeightMedium color: Qt.alpha(Color.mOnSurface, root.enabled ? 1.0 : 0.6) verticalAlignment: Text.AlignVCenter Layout.alignment: Qt.AlignVCenter selectByMouse: true enabled: root.enabled // Only allow numeric input within range validator: IntValidator { bottom: root.from top: root.to } onAccepted: { applyValue(); focus = false; } Keys.onEscapePressed: { text = root.value.toString(); focus = false; } onFocusChanged: { if (focus) { selectAll(); } else { applyValue(); } } function applyValue() { let newValue = parseInt(text); if (!isNaN(newValue)) { // Don't manually set text here - let the binding handle it newValue = Math.max(root.from, Math.min(root.to, newValue)); root.value = newValue; } } } // Suffix text (non-editable) NText { text: root.suffix family: Settings.data.ui.fontFixed pointSize: Style.fontSizeM font.weight: Style.fontWeightMedium color: Qt.alpha(Color.mOnSurface, root.enabled ? 1.0 : 0.6) verticalAlignment: Text.AlignVCenter Layout.alignment: Qt.AlignVCenter visible: root.suffix !== "" } } } } }