mirror of
https://github.com/zoriya/noctalia-shell.git
synced 2025-12-05 22:26:16 +00:00
- All cards now live in Modules/Cards - CalendarPanel is now called ClockPanel - Added a way to ease settings migration in separate QML files
450 lines
14 KiB
QML
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();
|
|
}
|
|
}
|