Files
noctalia-shell/Modules/Cards/TimerCard.qml
ItsLemmy e972e1f7aa Cards & Settings refactoring
- All cards now live in Modules/Cards
- CalendarPanel is now called ClockPanel
- Added a way to ease settings migration in separate QML files
2025-11-30 14:26:09 -05:00

450 lines
14 KiB
QML

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();
}
}