pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts import Caelestia.Config import qs.components import qs.services RowLayout { id: root property real value property real max: Infinity property real min: -Infinity property real step: 1 property alias repeatRate: timer.interval property bool isEditing: false property string displayText: root.value.toString() signal valueModified(value: real) spacing: Tokens.spacing.small onValueChanged: { if (!root.isEditing) { root.displayText = root.value.toString(); } } StyledTextField { id: textField inputMethodHints: Qt.ImhFormattedNumbersOnly text: root.isEditing ? text : root.displayText validator: DoubleValidator { bottom: root.min top: root.max decimals: root.step < 1 ? Math.max(1, Math.ceil(-Math.log10(root.step))) : 0 } onActiveFocusChanged: { if (activeFocus) { root.isEditing = true; } else { root.isEditing = false; root.displayText = root.value.toString(); } } onAccepted: { const numValue = parseFloat(text); if (!isNaN(numValue)) { const clampedValue = Math.max(root.min, Math.min(root.max, numValue)); root.value = clampedValue; root.displayText = clampedValue.toString(); root.valueModified(clampedValue); } else { text = root.displayText; } root.isEditing = false; } onEditingFinished: { if (text !== root.displayText) { const numValue = parseFloat(text); if (!isNaN(numValue)) { const clampedValue = Math.max(root.min, Math.min(root.max, numValue)); root.value = clampedValue; root.displayText = clampedValue.toString(); root.valueModified(clampedValue); } else { text = root.displayText; } } root.isEditing = false; } padding: Tokens.padding.extraSmall leftPadding: Tokens.padding.medium rightPadding: Tokens.padding.medium background: StyledRect { implicitWidth: 100 radius: Tokens.rounding.medium color: Colours.tPalette.m3surfaceContainerHigh } } StyledRect { radius: Tokens.rounding.medium color: Colours.palette.m3primary implicitWidth: implicitHeight implicitHeight: upIcon.implicitHeight + Tokens.padding.extraSmall * 2 StateLayer { id: upState color: Colours.palette.m3onPrimary onPressAndHold: timer.start() onReleased: timer.stop() onClicked: { let newValue = Math.min(root.max, root.value + root.step); // Round to avoid floating point precision errors const decimals = root.step < 1 ? Math.max(1, Math.ceil(-Math.log10(root.step))) : 0; newValue = Math.round(newValue * Math.pow(10, decimals)) / Math.pow(10, decimals); root.value = newValue; root.displayText = newValue.toString(); root.valueModified(newValue); } } MaterialIcon { id: upIcon anchors.centerIn: parent text: "keyboard_arrow_up" color: Colours.palette.m3onPrimary } } StyledRect { radius: Tokens.rounding.medium color: Colours.palette.m3primary implicitWidth: implicitHeight implicitHeight: downIcon.implicitHeight + Tokens.padding.extraSmall * 2 StateLayer { id: downState onClicked: { let newValue = Math.max(root.min, root.value - root.step); // Round to avoid floating point precision errors const decimals = root.step < 1 ? Math.max(1, Math.ceil(-Math.log10(root.step))) : 0; newValue = Math.round(newValue * Math.pow(10, decimals)) / Math.pow(10, decimals); root.value = newValue; root.displayText = newValue.toString(); root.valueModified(newValue); } color: Colours.palette.m3onPrimary onPressAndHold: timer.start() onReleased: timer.stop() } MaterialIcon { id: downIcon anchors.centerIn: parent text: "keyboard_arrow_down" color: Colours.palette.m3onPrimary } } Timer { id: timer interval: 100 repeat: true triggeredOnStart: true onTriggered: { if (upState.pressed) upState.clicked(); else if (downState.pressed) downState.clicked(); } } }