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 // Header 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: isRunning ? 160 * Style.uiScaleRatio : timerInput.implicitHeight Layout.alignment: Qt.AlignHCenter property string inputBuffer: "" property bool isEditing: false // Circular progress ring (only for countdown mode when running) Canvas { id: progressRing anchors.fill: parent anchors.margins: 12 visible: !isStopwatchMode && isRunning && 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)); } onProgressRatioChanged: requestPaint() onPaint: { var ctx = getContext("2d"); if (width <= 0 || height <= 0) { return; } var centerX = width / 2; var centerY = height / 2; var radius = Math.max(0, Math.min(width, height) / 2 - 6); ctx.reset(); // Background circle (full track) ctx.beginPath(); ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI); ctx.lineWidth = 4; ctx.strokeStyle = Qt.alpha(Color.mOnSurface, 0.2); ctx.stroke(); // Progress arc (elapsed portion) 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(); } } } TextInput { id: timerInput anchors.centerIn: parent width: Math.max(implicitWidth, parent.width) horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter selectByMouse: false cursorVisible: false cursorDelegate: Item {} // Empty cursor delegate to hide cursor readOnly: isStopwatchMode || isRunning enabled: !isRunning font.family: Settings.data.ui.fontFixed // Calculate if hours are being shown readonly property bool showingHours: { if (isStopwatchMode) { return elapsedSeconds >= 3600; } // In edit mode, always show hours (HH:MM:SS format) if (timerDisplayItem.isEditing) { return true; } // When not editing, only show hours if >= 1 hour return remainingSeconds >= 3600; } font.pointSize: { if (!isRunning) { return Style.fontSizeXXXL; } // When running, use smaller font if hours are shown return showingHours ? Style.fontSizeXXL : (Style.fontSizeXXL * 1.2); } font.weight: Style.fontWeightBold color: { if (isRunning) { return Color.mPrimary; } if (timerDisplayItem.isEditing) { return Color.mPrimary; } return Color.mOnSurface; } // Display formatted time, but show input buffer when editing text: { if (isStopwatchMode) { return formatTime(elapsedSeconds, false); // Stopwatch: only show hours if >= 1 hour } if (!timerDisplayItem.isEditing) { // When not editing and not running, always show hours // When running, only show hours if >= 1 hour return formatTime(remainingSeconds, isRunning); } if (timerDisplayItem.inputBuffer !== "") { return formatTimeFromDigits(timerDisplayItem.inputBuffer); } return formatTime(0, false); } // Only accept digit keys Keys.onPressed: event => { if (isRunning || isStopwatchMode) { event.accepted = true; return; } // Handle backspace 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; } // Handle delete if (event.key === Qt.Key_Delete) { if (timerDisplayItem.isEditing) { timerDisplayItem.inputBuffer = ""; Time.timerRemainingSeconds = 0; } event.accepted = true; return; } // Allow navigation keys (but don't let them modify text) if (event.key === Qt.Key_Left || event.key === Qt.Key_Right || event.key === Qt.Key_Home || event.key === Qt.Key_End || (event.modifiers & Qt.ControlModifier) || (event.modifiers & Qt.ShiftModifier)) { event.accepted = false; // Let default handling work for selection return; } // Handle enter/return if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { applyTimeFromBuffer(); timerDisplayItem.isEditing = false; focus = false; event.accepted = true; return; } // Handle escape if (event.key === Qt.Key_Escape) { timerDisplayItem.inputBuffer = ""; Time.timerRemainingSeconds = 0; timerDisplayItem.isEditing = false; focus = false; event.accepted = true; return; } // Only allow digits 0-9 if (event.key >= Qt.Key_0 && event.key <= Qt.Key_9) { // Limit to 6 digits max if (timerDisplayItem.inputBuffer.length >= 6) { event.accepted = true; // Block if already at max return; } // Add the digit to the buffer timerDisplayItem.inputBuffer += String.fromCharCode(event.key); // Update the display and parse parseDigitsToTime(timerDisplayItem.inputBuffer); event.accepted = true; // We handled it } else { event.accepted = true; // Block all other keys } } 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 cursorShape: enabled ? Qt.IBeamCursor : Qt.ArrowCursor onClicked: { if (!isRunning && !isStopwatchMode) { timerInput.forceActiveFocus(); } } } } } // Control buttons RowLayout { 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") : 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(); } } } } // Mode tabs (Android-style) - below buttons NTabBar { Layout.fillWidth: true Layout.alignment: Qt.AlignHCenter visible: !isRunning currentIndex: isStopwatchMode ? 1 : 0 onCurrentIndexChanged: { const newMode = currentIndex === 1; if (newMode !== isStopwatchMode) { if (isRunning) { pauseTimer(); } // Stop any repeating notification sound when switching modes SoundService.stopSound("alarm-beep.wav"); Time.timerSoundPlaying = false; Time.timerStopwatchMode = newMode; if (newMode) { // Reset to 0 for stopwatch Time.timerElapsedSeconds = 0; } else { Time.timerRemainingSeconds = 0; } } } spacing: Style.marginXS NTabButton { text: I18n.tr("calendar.timer.countdown") tabIndex: 0 checked: !isStopwatchMode } NTabButton { text: I18n.tr("calendar.timer.stopwatch") tabIndex: 1 checked: isStopwatchMode } } } // Bind to Time for persistent timer state 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, hideHoursWhenZero) { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = seconds % 60; // If hideHoursWhenZero is true (when running), only show hours if > 0 // Otherwise (when not running or editing), always show hours if (hideHoursWhenZero && hours === 0) { 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) { // Parse digits right-to-left: last 2 = seconds, next 2 = minutes, rest = hours 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; } // Clamp values seconds = Math.min(59, seconds); minutes = Math.min(59, minutes); hours = Math.min(99, hours); // Always show HH:MM:SS format in edit mode return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; } function parseDigitsToTime(digits) { // Parse digits right-to-left: last 2 = seconds, next 2 = minutes, rest = hours 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; } // Clamp values 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(); } }