import QtQuick import QtQuick.Layouts import Quickshell import qs.Commons import qs.Services.System import qs.Widgets // Timer card for the Calendar panel NBox { id: root implicitHeight: content.implicitHeight + (Style.marginM * 2) Layout.fillWidth: true clip: true ColumnLayout { id: content anchors.fill: parent anchors.margins: Style.marginM spacing: Style.marginM clip: true RowLayout { Layout.fillWidth: true spacing: Style.marginS NIcon { icon: isStopwatchMode ? "clock" : "hourglass" pointSize: Style.fontSizeL color: Color.mPrimary } NText { text: I18n.tr("calendar.timer.title") pointSize: Style.fontSizeL font.weight: Style.fontWeightBold color: Color.mOnSurface Layout.fillWidth: true } } // Timer display (editable when not running) Item { id: timerDisplayItem Layout.fillWidth: true Layout.preferredHeight: (totalSeconds > 0) ? 160 * Style.uiScaleRatio : timerInput.implicitHeight Layout.topMargin: Style.marginM Layout.bottomMargin: Style.marginM Layout.alignment: Qt.AlignHCenter property string inputBuffer: "" property bool isEditing: false // Wheel handler for adjusting time in 5 second steps WheelHandler { id: timerWheelHandler target: timerDisplayItem acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad enabled: !isRunning && !isStopwatchMode && totalSeconds === 0 onWheel: function (event) { if (!enabled) { return; } const step = 5; if (event.angleDelta.y > 0) { Time.timerRemainingSeconds = Math.max(0, Time.timerRemainingSeconds + step); event.accepted = true; } else if (event.angleDelta.y < 0) { Time.timerRemainingSeconds = Math.max(0, Time.timerRemainingSeconds - step); event.accepted = true; } } } Rectangle { id: textboxBorder anchors.centerIn: parent anchors.margins: Style.marginM width: Math.max(timerInput.implicitWidth + Style.marginM * 2, parent.width * 0.8) height: timerInput.implicitHeight + Style.marginM * 2 radius: Style.iRadiusM color: Color.mSurface border.color: (timerInput.activeFocus || timerDisplayItem.isEditing) ? Color.mPrimary : Color.mOutline border.width: Style.borderS visible: !isRunning && !isStopwatchMode && totalSeconds === 0 z: 0 Behavior on border.color { ColorAnimation { duration: Style.animationFast } } } // Circular progress ring (only for countdown mode when running or paused) Canvas { id: progressRing anchors.fill: parent anchors.margins: 12 visible: !isStopwatchMode && totalSeconds > 0 z: -1 property real progressRatio: { if (totalSeconds <= 0) return 0; // Inverted: show remaining time (starts at 1, goes to 0) const ratio = remainingSeconds / totalSeconds; return Math.max(0, Math.min(1, ratio)); } // Check if hours are being shown (for radius calculation) readonly property bool showingHours: { if (isStopwatchMode) { return elapsedSeconds >= 3600; } return totalSeconds >= 3600; } onProgressRatioChanged: requestPaint() onShowingHoursChanged: requestPaint() onPaint: { var ctx = getContext("2d"); if (width <= 0 || height <= 0) { return; } var centerX = width / 2; var centerY = height / 2; var radiusOffset = showingHours ? 6 : 16; var radius = Math.max(0, Math.min(width, height) / 2 - radiusOffset); ctx.reset(); ctx.beginPath(); ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI); ctx.lineWidth = 4; ctx.strokeStyle = Qt.alpha(Color.mOnSurface, 0.2); ctx.stroke(); if (progressRatio > 0) { ctx.beginPath(); ctx.arc(centerX, centerY, radius, -Math.PI / 2, -Math.PI / 2 + progressRatio * 2 * Math.PI); ctx.lineWidth = 4; ctx.strokeStyle = Color.mPrimary; ctx.lineCap = "round"; ctx.stroke(); } } } Item { id: timerContainer anchors.centerIn: parent width: timerInput.implicitWidth height: timerInput.implicitHeight + 8 // Always reserve space for underline TextInput { id: timerInput anchors.verticalCenter: parent.verticalCenter anchors.horizontalCenter: parent.horizontalCenter width: Math.max(implicitWidth, timerDisplayItem.width) horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter selectByMouse: false cursorVisible: false cursorDelegate: Item {} // Empty cursor delegate to hide cursor // Only allow editing when: // 1. Not in stopwatch mode // 2. Timer is not running // 3. Timer has never been started (totalSeconds == 0) - this includes after reset // This prevents editing when paused (when totalSeconds > 0) readOnly: isStopwatchMode || isRunning || totalSeconds > 0 enabled: !isRunning && !isStopwatchMode && totalSeconds === 0 font.family: Settings.data.ui.fontFixed // Calculate if hours are being shown (for font sizing) readonly property bool showingHours: { if (isStopwatchMode) { return elapsedSeconds >= 3600; } // In edit mode, always show hours (HH:MM:SS format) if (timerDisplayItem.isEditing) { return true; } // Show hours if total time >= 1 hour (formatting will show HH:MM:SS) return totalSeconds >= 3600; } font.pointSize: { if (totalSeconds === 0) { return Style.fontSizeXXXL; } // When running or paused, use smaller font if hours are shown return showingHours ? Style.fontSizeXXL : (Style.fontSizeXXL * 1.2); } font.weight: Style.fontWeightBold color: { if (totalSeconds > 0) { return Color.mPrimary; } if (timerDisplayItem.isEditing) { return Color.mPrimary; } return Color.mOnSurface; } // Use a computed property that explicitly tracks dependencies property string _cachedText: "" property int _textUpdateCounter: 0 function updateText() { if (isStopwatchMode) { // For stopwatch, use elapsedSeconds as the reference for formatting _cachedText = formatTime(elapsedSeconds, elapsedSeconds); } else if (timerDisplayItem.isEditing && timerDisplayItem.inputBuffer !== "") { // Only use editing mode if we actually have input buffer content _cachedText = formatTimeFromDigits(timerDisplayItem.inputBuffer); } else if (timerDisplayItem.isEditing) { // When editing but buffer is empty, show placeholder (00:00:00) _cachedText = formatTime(0, 0); } else { _cachedText = formatTime(remainingSeconds, totalSeconds); } _textUpdateCounter = _textUpdateCounter + 1; } text: { const counter = _textUpdateCounter; return _cachedText; } Connections { target: root function onRemainingSecondsChanged() { timerInput.updateText(); } function onIsRunningChanged() { // Update twice to catch updates even if remainingSeconds changes at the same time timerInput.updateText(); Qt.callLater(() => { timerInput.updateText(); }); } function onElapsedSecondsChanged() { timerInput.updateText(); } function onIsStopwatchModeChanged() { timerInput.updateText(); } } Connections { target: Time function onTimerRemainingSecondsChanged() { timerInput.updateText(); } } Connections { target: timerDisplayItem function onIsEditingChanged() { timerInput.updateText(); } } Component.onCompleted: updateText() Keys.onPressed: event => { if (isRunning || isStopwatchMode || totalSeconds > 0) { event.accepted = true; return; } const keyText = event.text; if (event.key === Qt.Key_Backspace) { if (timerDisplayItem.isEditing && timerDisplayItem.inputBuffer.length > 0) { timerDisplayItem.inputBuffer = timerDisplayItem.inputBuffer.slice(0, -1); if (timerDisplayItem.inputBuffer !== "") { parseDigitsToTime(timerDisplayItem.inputBuffer); } else { Time.timerRemainingSeconds = 0; } } event.accepted = true; return; } if (event.key === Qt.Key_Delete) { if (timerDisplayItem.isEditing) { timerDisplayItem.inputBuffer = ""; Time.timerRemainingSeconds = 0; } event.accepted = true; return; } if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { applyTimeFromBuffer(); timerDisplayItem.isEditing = false; focus = false; event.accepted = true; return; } if (event.key === Qt.Key_Escape) { timerDisplayItem.inputBuffer = ""; Time.timerRemainingSeconds = 0; timerDisplayItem.isEditing = false; focus = false; event.accepted = true; return; } // Only allow single digit characters 0-9 (check both key code and text) const isDigitKey = event.key >= Qt.Key_0 && event.key <= Qt.Key_9; const isDigitText = keyText.length === 1 && keyText >= '0' && keyText <= '9'; if (isDigitKey && isDigitText) { if (timerDisplayItem.inputBuffer.length >= 6) { event.accepted = true; return; } timerDisplayItem.inputBuffer += keyText; parseDigitsToTime(timerDisplayItem.inputBuffer); event.accepted = true; } else { event.accepted = true; } } Keys.onReturnPressed: { applyTimeFromBuffer(); timerDisplayItem.isEditing = false; focus = false; } Keys.onEscapePressed: { timerDisplayItem.inputBuffer = ""; Time.timerRemainingSeconds = 0; timerDisplayItem.isEditing = false; focus = false; } onActiveFocusChanged: { if (activeFocus) { timerDisplayItem.isEditing = true; timerDisplayItem.inputBuffer = ""; } else { applyTimeFromBuffer(); timerDisplayItem.isEditing = false; timerDisplayItem.inputBuffer = ""; } } MouseArea { anchors.fill: parent enabled: !isRunning && !isStopwatchMode && totalSeconds === 0 cursorShape: enabled ? Qt.IBeamCursor : Qt.ArrowCursor onClicked: { if (!isRunning && !isStopwatchMode && totalSeconds === 0) { timerInput.forceActiveFocus(); } } } } Rectangle { id: editingUnderline anchors.top: timerInput.bottom anchors.topMargin: 2 height: 3 radius: 1.5 color: Color.mPrimary visible: timerDisplayItem.isEditing && totalSeconds === 0 // Calculate which digit position we're at (0-5 for HHMMSS) // We fill from right to left: empty buffer = position 5, "1" = position 5, "12" = position 4, etc. property int currentDigitPos: { const bufLen = timerDisplayItem.inputBuffer.length; if (bufLen === 0) return 5; return Math.max(0, 6 - bufLen); } // Map digit position to character position in "HH:MM:SS" (skip colons) property real digitWidth: timerInput.implicitWidth / 8 property real xOffset: { const pos = currentDigitPos; let charPos = pos; if (pos >= 2) charPos++; if (pos >= 4) charPos++; return (charPos * digitWidth) - (timerInput.implicitWidth / 2); } x: parent.width / 2 + xOffset width: digitWidth * 0.8 Behavior on x { NumberAnimation { duration: 150 easing.type: Easing.OutQuad } } SequentialAnimation on opacity { running: editingUnderline.visible loops: Animation.Infinite NumberAnimation { from: 1.0 to: 0.3 duration: 600 } NumberAnimation { from: 0.3 to: 1.0 duration: 600 } } } } } RowLayout { id: buttonRow Layout.fillWidth: true spacing: Style.marginS Rectangle { Layout.fillWidth: true Layout.preferredWidth: 0 implicitHeight: startButton.implicitHeight color: Color.transparent NButton { id: startButton anchors.fill: parent text: isRunning ? I18n.tr("calendar.timer.pause") : (totalSeconds > 0 ? I18n.tr("calendar.timer.resume") : I18n.tr("calendar.timer.start")) icon: isRunning ? "player-pause" : "player-play" enabled: isStopwatchMode || remainingSeconds > 0 onClicked: { if (isRunning) { pauseTimer(); } else { startTimer(); } } } } Rectangle { Layout.fillWidth: true Layout.preferredWidth: 0 implicitHeight: resetButton.implicitHeight color: Color.transparent NButton { id: resetButton anchors.fill: parent text: I18n.tr("calendar.timer.reset") icon: "refresh" enabled: (isStopwatchMode && (elapsedSeconds > 0 || isRunning)) || (!isStopwatchMode && (remainingSeconds > 0 || isRunning || soundPlaying)) onClicked: { resetTimer(); } } } } NTabBar { id: modeTabBar Layout.fillWidth: true Layout.preferredHeight: startButton.implicitHeight implicitHeight: startButton.implicitHeight visible: totalSeconds === 0 currentIndex: isStopwatchMode ? 1 : 0 margins: 0 onCurrentIndexChanged: { const newMode = currentIndex === 1; if (newMode !== isStopwatchMode) { if (isRunning) { pauseTimer(); } SoundService.stopSound("alarm-beep.wav"); Time.timerSoundPlaying = false; Time.timerStopwatchMode = newMode; if (newMode) { Time.timerElapsedSeconds = 0; } else { Time.timerRemainingSeconds = 0; } } } spacing: Style.marginS Component.onCompleted: { // Remove margins from internal RowLayout to match button row spacing Qt.callLater(() => { if (modeTabBar.children && modeTabBar.children.length > 0) { for (var i = 0; i < modeTabBar.children.length; i++) { var child = modeTabBar.children[i]; if (child && typeof child.spacing !== 'undefined' && child.anchors) { child.anchors.margins = 0; break; } } } }); } NTabButton { Layout.fillWidth: true Layout.preferredWidth: 0 text: I18n.tr("calendar.timer.countdown") tabIndex: 0 checked: !isStopwatchMode radius: Style.iRadiusS } NTabButton { Layout.fillWidth: true Layout.preferredWidth: 0 text: I18n.tr("calendar.timer.stopwatch") tabIndex: 1 checked: isStopwatchMode radius: Style.iRadiusS } } } readonly property bool isRunning: Time.timerRunning property bool isStopwatchMode: Time.timerStopwatchMode readonly property int remainingSeconds: Time.timerRemainingSeconds readonly property int totalSeconds: Time.timerTotalSeconds readonly property int elapsedSeconds: Time.timerElapsedSeconds readonly property bool soundPlaying: Time.timerSoundPlaying function formatTime(seconds, totalTimeSeconds) { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = seconds % 60; if (!totalTimeSeconds || totalTimeSeconds === 0) { return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } if (totalTimeSeconds < 3600) { return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; } function formatTimeFromDigits(digits) { const len = digits.length; let seconds = 0; let minutes = 0; let hours = 0; if (len > 0) { seconds = parseInt(digits.substring(Math.max(0, len - 2))) || 0; } if (len > 2) { minutes = parseInt(digits.substring(Math.max(0, len - 4), len - 2)) || 0; } if (len > 4) { hours = parseInt(digits.substring(0, len - 4)) || 0; } seconds = Math.min(59, seconds); minutes = Math.min(59, minutes); hours = Math.min(99, hours); return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; } function parseDigitsToTime(digits) { const len = digits.length; let seconds = 0; let minutes = 0; let hours = 0; if (len > 0) { seconds = parseInt(digits.substring(Math.max(0, len - 2))) || 0; } if (len > 2) { minutes = parseInt(digits.substring(Math.max(0, len - 4), len - 2)) || 0; } if (len > 4) { hours = parseInt(digits.substring(0, len - 4)) || 0; } seconds = Math.min(59, seconds); minutes = Math.min(59, minutes); hours = Math.min(99, hours); Time.timerRemainingSeconds = (hours * 3600) + (minutes * 60) + seconds; } function applyTimeFromBuffer() { if (timerDisplayItem.inputBuffer !== "") { parseDigitsToTime(timerDisplayItem.inputBuffer); timerDisplayItem.inputBuffer = ""; } } function startTimer() { Time.timerStart(); } function pauseTimer() { Time.timerPause(); } function resetTimer() { Time.timerReset(); timerDisplayItem.isEditing = false; timerDisplayItem.inputBuffer = ""; timerInput.focus = false; } }