summaryrefslogtreecommitdiff
path: root/.config/noctalia/plugins/timer
diff options
context:
space:
mode:
authorThomas Voss <mail@thomasvoss.com> 2026-03-23 13:06:10 +0100
committerThomas Voss <mail@thomasvoss.com> 2026-03-23 13:06:10 +0100
commit25d3f382b1218b9112b2c4c7219abf2e6ced3c74 (patch)
treedccbfdd2b9f21f691276c909d0ccad4ef2a07c1b /.config/noctalia/plugins/timer
parent7d6bc7e062af943c70332404c925a9e24fdc6127 (diff)
noctalia: Add the Noctalia config
Diffstat (limited to '.config/noctalia/plugins/timer')
-rw-r--r--.config/noctalia/plugins/timer/BarWidget.qml179
-rw-r--r--.config/noctalia/plugins/timer/ControlCenterWidget.qml45
-rw-r--r--.config/noctalia/plugins/timer/Main.qml239
-rw-r--r--.config/noctalia/plugins/timer/Panel.qml593
-rw-r--r--.config/noctalia/plugins/timer/README.md52
-rw-r--r--.config/noctalia/plugins/timer/Settings.qml65
-rw-r--r--.config/noctalia/plugins/timer/i18n/de.json20
-rw-r--r--.config/noctalia/plugins/timer/i18n/en.json20
-rw-r--r--.config/noctalia/plugins/timer/i18n/es.json20
-rw-r--r--.config/noctalia/plugins/timer/i18n/fr.json20
-rw-r--r--.config/noctalia/plugins/timer/i18n/hu.json20
-rw-r--r--.config/noctalia/plugins/timer/i18n/it.json20
-rw-r--r--.config/noctalia/plugins/timer/i18n/ja.json20
-rw-r--r--.config/noctalia/plugins/timer/i18n/ku.json20
-rw-r--r--.config/noctalia/plugins/timer/i18n/nl.json20
-rw-r--r--.config/noctalia/plugins/timer/i18n/pl.json20
-rw-r--r--.config/noctalia/plugins/timer/i18n/pt.json20
-rw-r--r--.config/noctalia/plugins/timer/i18n/ru.json20
-rw-r--r--.config/noctalia/plugins/timer/i18n/tr.json20
-rw-r--r--.config/noctalia/plugins/timer/i18n/uk-UA.json20
-rw-r--r--.config/noctalia/plugins/timer/i18n/zh-CN.json20
-rw-r--r--.config/noctalia/plugins/timer/i18n/zh-TW.json20
-rw-r--r--.config/noctalia/plugins/timer/manifest.json33
-rw-r--r--.config/noctalia/plugins/timer/preview.pngbin0 -> 29832 bytes
-rw-r--r--.config/noctalia/plugins/timer/settings.json5
25 files changed, 1531 insertions, 0 deletions
diff --git a/.config/noctalia/plugins/timer/BarWidget.qml b/.config/noctalia/plugins/timer/BarWidget.qml
new file mode 100644
index 0000000..81d2cb9
--- /dev/null
+++ b/.config/noctalia/plugins/timer/BarWidget.qml
@@ -0,0 +1,179 @@
+import QtQuick
+import QtQuick.Layouts
+import Quickshell
+import qs.Commons
+import qs.Widgets
+import qs.Services.UI
+import qs.Services.System
+
+Item {
+ id: root
+
+ property var pluginApi: null
+ property ShellScreen screen
+ property string widgetId: ""
+ property string section: ""
+ property int sectionWidgetIndex: -1
+ property int sectionWidgetsCount: 0
+
+ readonly property bool pillDirection: BarService.getPillDirection(root)
+
+ readonly property var mainInstance: pluginApi?.mainInstance
+ readonly property bool isActive: mainInstance && (mainInstance.cdRunning || mainInstance.swRunning || mainInstance.swElapsedSeconds > 0 || mainInstance.cdRemainingSeconds > 0 || mainInstance.cdSoundPlaying)
+
+ property var cfg: pluginApi?.pluginSettings || ({})
+ property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({})
+
+ readonly property string iconColorKey: cfg.iconColor ?? defaults.iconColor ?? "none"
+ readonly property color iconColor: Color.resolveColorKey(iconColorKey)
+
+ readonly property string textColorKey: cfg.textColor ?? defaults.textColor ?? "none"
+ readonly property color textColor: Color.resolveColorKey(textColorKey)
+
+ // Bar positioning properties
+ readonly property string screenName: screen ? screen.name : ""
+ readonly property string barPosition: Settings.getBarPositionForScreen(screenName)
+ readonly property bool isVertical: barPosition === "left" || barPosition === "right"
+ readonly property real barHeight: Style.getBarHeightForScreen(screenName)
+ readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName)
+ readonly property real barFontSize: Style.getBarFontSizeForScreen(screenName)
+
+ readonly property real contentWidth: {
+ if (isVertical) return root.capsuleHeight
+ if (isActive) return contentRow.implicitWidth + Style.marginM * 2
+ return root.capsuleHeight
+ }
+ readonly property real contentHeight: root.capsuleHeight
+
+ implicitWidth: contentWidth
+ implicitHeight: contentHeight
+
+ function formatTime(seconds) {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+
+ if (hours > 0) {
+ return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
+ }
+ return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
+ }
+
+ Rectangle {
+ id: visualCapsule
+ x: Style.pixelAlignCenter(parent.width, width)
+ y: Style.pixelAlignCenter(parent.height, height)
+ width: root.contentWidth
+ height: root.contentHeight
+ color: mouseArea.containsMouse ? Color.mHover : Style.capsuleColor
+ radius: Style.radiusL
+ border.color: Style.capsuleBorderColor
+ border.width: Style.capsuleBorderWidth
+
+ RowLayout {
+ id: contentRow
+ anchors.centerIn: parent
+ spacing: Style.marginS
+ layoutDirection: pillDirection ? Qt.LeftToRight : Qt.RightToLeft
+
+ NIcon {
+ icon: {
+ if (mainInstance && mainInstance.timerSoundPlaying) return "bell-ringing"
+ if (mainInstance && mainInstance.timerStopwatchMode) return "stopwatch"
+ return "hourglass"
+ }
+ applyUiScale: false
+ color: mouseArea.containsMouse ? Color.mOnHover : root.iconColor
+ }
+
+ NText {
+ visible: !isVertical && mainInstance && (mainInstance.cdRunning || mainInstance.swRunning || mainInstance.swElapsedSeconds > 0 || mainInstance.cdRemainingSeconds > 0 || mainInstance.cdSoundPlaying)
+ family: Settings.data.ui.fontFixed
+ pointSize: root.barFontSize
+ font.weight: Style.fontWeightBold
+ color: mouseArea.containsMouse ? Color.mOnHover : root.textColor
+ text: {
+ if (!mainInstance) return ""
+ if (mainInstance.timerStopwatchMode) {
+ return formatTime(mainInstance.timerElapsedSeconds)
+ }
+ return formatTime(mainInstance.timerRemainingSeconds)
+ }
+ }
+ }
+ }
+
+ NPopupContextMenu {
+ id: contextMenu
+
+ model: {
+ var items = [];
+
+ if (mainInstance) {
+ // Pause / Resume & Reset
+ const modeActive = mainInstance.timerStopwatchMode
+ ? (mainInstance.swRunning || mainInstance.swElapsedSeconds > 0)
+ : (mainInstance.cdRunning || mainInstance.cdRemainingSeconds > 0 || mainInstance.cdSoundPlaying);
+ if (modeActive) {
+ items.push({
+ "label": mainInstance.timerRunning ? pluginApi.tr("panel.pause") : pluginApi.tr("panel.resume"),
+ "action": "toggle",
+ "icon": mainInstance.timerRunning ? "media-pause" : "media-play"
+ });
+
+ items.push({
+ "label": pluginApi.tr("panel.reset"),
+ "action": "reset",
+ "icon": "refresh"
+ });
+ }
+ }
+
+ // Settings
+ items.push({
+ "label": pluginApi.tr("panel.settings"),
+ "action": "widget-settings",
+ "icon": "settings"
+ });
+
+ return items;
+ }
+
+ onTriggered: action => {
+ contextMenu.close();
+ PanelService.closeContextMenu(screen);
+
+ if (action === "widget-settings") {
+ BarService.openPluginSettings(screen, pluginApi.manifest);
+ } else if (mainInstance) {
+ if (action === "toggle") {
+ if (mainInstance.timerRunning) {
+ mainInstance.timerPause();
+ } else {
+ mainInstance.timerStart();
+ }
+ } else if (action === "reset") {
+ mainInstance.timerReset();
+ }
+ }
+ }
+ }
+
+ MouseArea {
+ id: mouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
+
+ onClicked: (mouse) => {
+ if (mouse.button === Qt.LeftButton) {
+ if (pluginApi) {
+ pluginApi.openPanel(root.screen, root)
+ }
+ } else if (mouse.button === Qt.RightButton) {
+ PanelService.showContextMenu(contextMenu, root, screen);
+ }
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/timer/ControlCenterWidget.qml b/.config/noctalia/plugins/timer/ControlCenterWidget.qml
new file mode 100644
index 0000000..528ee04
--- /dev/null
+++ b/.config/noctalia/plugins/timer/ControlCenterWidget.qml
@@ -0,0 +1,45 @@
+import QtQuick
+import Quickshell
+import qs.Widgets
+import qs.Commons
+
+NIconButton {
+ property ShellScreen screen
+ property var pluginApi: null
+ readonly property var mainInstance: pluginApi?.mainInstance
+
+ icon: {
+ if (mainInstance && mainInstance.timerSoundPlaying) return "bell-ringing"
+ if (mainInstance && mainInstance.timerStopwatchMode) return "stopwatch"
+ return "hourglass"
+ }
+
+ tooltipText: {
+ if (!mainInstance) return "Timer"
+ if (mainInstance.timerSoundPlaying) return "Timer Finished!"
+ if (mainInstance.timerStopwatchMode) {
+ return mainInstance.timerRunning ? "Stopwatch Running" : "Stopwatch"
+ }
+ return mainInstance.timerRunning ? "Timer Running" : "Timer"
+ }
+
+ colorFg: {
+ if (mainInstance && (mainInstance.cdRunning || mainInstance.swRunning || mainInstance.cdSoundPlaying)) {
+ return Color.mOnPrimary
+ }
+ return Color.mPrimary
+ }
+
+ colorBg: {
+ if (mainInstance && (mainInstance.cdRunning || mainInstance.swRunning || mainInstance.cdSoundPlaying)) {
+ return Color.mPrimary
+ }
+ return Style.capsuleColor
+ }
+
+ onClicked: {
+ if (pluginApi) {
+ pluginApi.togglePanel(screen);
+ }
+ }
+}
diff --git a/.config/noctalia/plugins/timer/Main.qml b/.config/noctalia/plugins/timer/Main.qml
new file mode 100644
index 0000000..db2193d
--- /dev/null
+++ b/.config/noctalia/plugins/timer/Main.qml
@@ -0,0 +1,239 @@
+import QtQuick
+import Quickshell
+import qs.Commons
+import qs.Services.System
+import qs.Services.UI
+import Quickshell.Io
+
+Item {
+ id: root
+
+ property var pluginApi: null
+
+ IpcHandler {
+ target: "plugin:timer"
+
+ function toggle() {
+ if (pluginApi) {
+ pluginApi.withCurrentScreen(screen => {
+ pluginApi.togglePanel(screen);
+ });
+ }
+ }
+
+ function start(duration_str: string) {
+ if (duration_str && duration_str === "stopwatch") {
+ root.stopwatchReset();
+ root.timerStopwatchMode = true;
+ root.stopwatchStart();
+ } else if (duration_str && duration_str !== "") {
+ const seconds = root.parseDuration(duration_str);
+ if (seconds > 0) {
+ root.countdownReset();
+ root.cdRemainingSeconds = seconds;
+ root.timerStopwatchMode = false;
+ root.countdownStart();
+ }
+ } else {
+ root.timerStart();
+ }
+ }
+
+ function pause() {
+ root.timerPause();
+ }
+
+ function reset() {
+ root.timerReset();
+ }
+ }
+
+ // View mode (which tab is active in the panel)
+ property bool timerStopwatchMode: false
+
+ // Countdown state
+ property bool cdRunning: false
+ property int cdRemainingSeconds: 0
+ property int cdTotalSeconds: 0
+ property int cdStartTimestamp: 0
+ property int cdPausedAt: 0
+ property bool cdSoundPlaying: false
+
+ // Stopwatch state
+ property bool swRunning: false
+ property int swElapsedSeconds: 0
+ property int swStartTimestamp: 0
+ property int swPausedAt: 0
+
+ // Backward-compatible computed properties (used by bar/CC widgets)
+ readonly property bool timerRunning: timerStopwatchMode ? swRunning : cdRunning
+ readonly property int timerRemainingSeconds: cdRemainingSeconds
+ readonly property int timerTotalSeconds: cdTotalSeconds
+ readonly property int timerElapsedSeconds: swElapsedSeconds
+ readonly property bool timerSoundPlaying: cdSoundPlaying
+
+ // Current timestamp
+ property int timestamp: Math.floor(Date.now() / 1000)
+
+ // Main timer loop
+ Timer {
+ id: updateTimer
+ interval: 1000
+ repeat: true
+ running: true
+ triggeredOnStart: false
+ onTriggered: {
+ var now = new Date();
+ root.timestamp = Math.floor(now.getTime() / 1000);
+
+ // Update countdown if running
+ if (root.cdRunning && root.cdStartTimestamp > 0) {
+ const elapsed = root.timestamp - root.cdStartTimestamp;
+ root.cdRemainingSeconds = root.cdTotalSeconds - elapsed;
+ if (root.cdRemainingSeconds <= 0) {
+ root.countdownOnFinished();
+ }
+ }
+
+ // Update stopwatch if running
+ if (root.swRunning && root.swStartTimestamp > 0) {
+ const elapsed = root.timestamp - root.swStartTimestamp;
+ root.swElapsedSeconds = root.swPausedAt + elapsed;
+ }
+
+ // Sync to next second
+ var msIntoSecond = now.getMilliseconds();
+ if (msIntoSecond > 100) {
+ updateTimer.interval = 1000 - msIntoSecond + 10;
+ updateTimer.restart();
+ } else {
+ updateTimer.interval = 1000;
+ }
+ }
+ }
+
+ Component.onCompleted: {
+ // Sync start
+ var now = new Date();
+ var msUntilNextSecond = 1000 - now.getMilliseconds();
+ updateTimer.interval = msUntilNextSecond + 10;
+ updateTimer.restart();
+ }
+
+ // Countdown logic
+ function countdownStart() {
+ if (root.cdRemainingSeconds <= 0) return;
+ root.cdTotalSeconds = root.cdRemainingSeconds;
+ root.cdStartTimestamp = root.timestamp;
+ root.cdPausedAt = 0;
+ root.cdRunning = true;
+ }
+
+ function countdownPause() {
+ if (root.cdRunning) {
+ const currentTimestamp = Math.floor(Date.now() / 1000);
+ const elapsed = currentTimestamp - root.cdStartTimestamp;
+ const remaining = root.cdTotalSeconds - elapsed;
+ root.cdPausedAt = Math.max(0, remaining);
+ root.cdRemainingSeconds = root.cdPausedAt;
+ }
+ root.cdRunning = false;
+ root.cdStartTimestamp = 0;
+ SoundService.stopSound("alarm-beep.wav");
+ root.cdSoundPlaying = false;
+ }
+
+ function countdownReset() {
+ root.cdRunning = false;
+ root.cdStartTimestamp = 0;
+ root.cdRemainingSeconds = 0;
+ root.cdTotalSeconds = 0;
+ root.cdPausedAt = 0;
+ SoundService.stopSound("alarm-beep.wav");
+ root.cdSoundPlaying = false;
+ }
+
+ // Stopwatch logic
+ function stopwatchStart() {
+ root.swStartTimestamp = root.timestamp;
+ root.swPausedAt = root.swElapsedSeconds;
+ root.swRunning = true;
+ }
+
+ function stopwatchPause() {
+ if (root.swRunning) {
+ root.swPausedAt = root.swElapsedSeconds;
+ }
+ root.swRunning = false;
+ root.swStartTimestamp = 0;
+ }
+
+ function stopwatchReset() {
+ root.swRunning = false;
+ root.swStartTimestamp = 0;
+ root.swElapsedSeconds = 0;
+ root.swPausedAt = 0;
+ }
+
+ // Convenience: operate on current mode
+ function timerStart() {
+ if (root.timerStopwatchMode) stopwatchStart();
+ else countdownStart();
+ }
+
+ function timerPause() {
+ if (root.timerStopwatchMode) stopwatchPause();
+ else countdownPause();
+ }
+
+ function timerReset() {
+ if (root.timerStopwatchMode) stopwatchReset();
+ else countdownReset();
+ }
+
+ function parseDuration(duration_str) {
+ if (!duration_str) return 0;
+
+ // Default to minutes if just a number
+ if (/^\d+$/.test(duration_str)) {
+ return parseInt(duration_str) * 60;
+ }
+
+ var totalSeconds = 0;
+ var regex = /(\d+)([hms])/g;
+ var match;
+
+ while ((match = regex.exec(duration_str)) !== null) {
+ var value = parseInt(match[1]);
+ var unit = match[2];
+
+ if (unit === 'h') totalSeconds += value * 3600;
+ else if (unit === 'm') totalSeconds += value * 60;
+ else if (unit === 's') totalSeconds += value;
+ }
+
+ return totalSeconds;
+ }
+
+ function countdownOnFinished() {
+ root.cdRunning = false;
+ root.cdRemainingSeconds = 0;
+ root.cdSoundPlaying = true;
+ SoundService.playSound("alarm-beep.wav", {
+ repeat: true,
+ volume: 0.3
+ });
+ ToastService.showNotice(
+ pluginApi?.tr("toast.title") || "Timer",
+ pluginApi?.tr("toast.finished") || "Timer finished!",
+ "hourglass",
+ {
+ onDismissed: () => {
+ if (root.cdSoundPlaying) {
+ root.countdownPause();
+ }
+ }
+ }
+ );
+ }
+}
diff --git a/.config/noctalia/plugins/timer/Panel.qml b/.config/noctalia/plugins/timer/Panel.qml
new file mode 100644
index 0000000..b79389e
--- /dev/null
+++ b/.config/noctalia/plugins/timer/Panel.qml
@@ -0,0 +1,593 @@
+import QtQuick
+import QtQuick.Layouts
+import QtQuick.Controls
+import qs.Commons
+import qs.Services.System
+import qs.Widgets
+
+Item {
+ id: root
+
+ property var pluginApi: null
+ readonly property var geometryPlaceholder: panelContainer
+ property real contentPreferredWidth: (compactMode ? 340 : 380) * Style.uiScaleRatio
+ property real contentPreferredHeight: (compactMode ? 230 : 350) * Style.uiScaleRatio
+ readonly property bool allowAttach: true
+
+
+
+ readonly property bool compactMode:
+ pluginApi?.pluginSettings?.compactMode ??
+ pluginApi?.manifest?.metadata?.defaultSettings?.compactMode ??
+ false
+
+
+
+ anchors.fill: parent
+
+ readonly property var mainInstance: pluginApi?.mainInstance
+
+ readonly property bool isRunning: mainInstance ? mainInstance.timerRunning : false
+ property bool isStopwatchMode: mainInstance ? mainInstance.timerStopwatchMode : false
+ readonly property int remainingSeconds: mainInstance ? mainInstance.timerRemainingSeconds : 0
+ readonly property int totalSeconds: mainInstance ? mainInstance.timerTotalSeconds : 0
+ readonly property int elapsedSeconds: mainInstance ? mainInstance.timerElapsedSeconds : 0
+ readonly property bool soundPlaying: mainInstance ? mainInstance.timerSoundPlaying : false
+
+ function startTimer() { if (mainInstance) mainInstance.timerStart(); }
+ function pauseTimer() { if (mainInstance) mainInstance.timerPause(); }
+ function resetTimer() {
+ if (mainInstance) {
+ mainInstance.timerReset();
+ // Do not apply default duration here. User wants 00:00:00 on reset.
+ }
+ }
+
+ function setTimerStopwatchMode(mode) {
+ if (mainInstance) {
+ mainInstance.timerStopwatchMode = mode;
+ }
+ }
+
+ function setTimerRemainingSeconds(seconds) {
+ if (mainInstance) mainInstance.cdRemainingSeconds = seconds;
+ }
+
+ function formatTime(seconds, totalTimeSeconds) {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+
+ if (seconds === 0 && totalTimeSeconds > 0) {
+ return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
+ }
+
+ 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);
+
+ setTimerRemainingSeconds((hours * 3600) + (minutes * 60) + seconds);
+ }
+
+ Component.onCompleted: {
+ // Do not auto-set default duration on load
+ }
+
+ function applyTimeFromBuffer() {
+ if (timerDisplayItem.inputBuffer !== "") {
+ parseDigitsToTime(timerDisplayItem.inputBuffer);
+ timerDisplayItem.inputBuffer = "";
+ }
+ }
+
+ onVisibleChanged: {
+ if (visible) {
+ if (!isRunning && !isStopwatchMode && totalSeconds === 0) {
+ timerInput.forceActiveFocus();
+ }
+ }
+ }
+
+ Rectangle {
+ id: panelContainer
+ anchors.fill: parent
+ color: "transparent"
+
+ ColumnLayout {
+ anchors {
+ fill: parent
+ margins: Style.marginM
+ }
+ spacing: Style.marginL
+
+ NBox {
+ Layout.fillWidth: true
+ Layout.fillHeight: 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: pluginApi?.tr("panel.title") || "Timer"
+ pointSize: Style.fontSizeL
+ font.weight: Style.fontWeightBold
+ color: Color.mOnSurface
+ Layout.fillWidth: true
+ }
+ }
+
+ Item {
+ id: timerDisplayItem
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.alignment: Qt.AlignHCenter
+
+ property string inputBuffer: ""
+ property bool isEditing: false
+
+ WheelHandler {
+ target: timerDisplayItem
+ acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
+ enabled: !isRunning && !isStopwatchMode && totalSeconds === 0
+ onWheel: function (event) {
+ if (!enabled || !mainInstance) {
+ return;
+ }
+ const step = 5;
+ if (event.angleDelta.y > 0) {
+ mainInstance.cdRemainingSeconds = Math.max(0, mainInstance.cdRemainingSeconds + step);
+ event.accepted = true;
+ } else if (event.angleDelta.y < 0) {
+ mainInstance.cdRemainingSeconds = Math.max(0, mainInstance.cdRemainingSeconds - step);
+ event.accepted = true;
+ }
+ }
+ }
+
+ Rectangle {
+ id: textboxBorder
+ anchors.centerIn: parent
+ width: Math.max(timerInput.implicitWidth + Style.marginM * 2, parent.width * 0.8)
+ height: timerInput.implicitHeight + Style.marginM * 2
+ radius: Style.iRadiusM
+ color: Color.mSurfaceVariant
+ 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
+ }
+ }
+ }
+
+ Canvas {
+ id: progressRing
+ anchors.centerIn: parent
+ width: Math.min(parent.width, parent.height) * 1.0
+ height: width
+ visible: !isStopwatchMode && totalSeconds > 0 && !compactMode && (isRunning || elapsedSeconds > 0)
+ z: -1
+
+ property real progressRatio: {
+ if (totalSeconds <= 0)
+ return 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.min(width, height) / 2 - 5;
+
+ if (radius <= 0) {
+ return;
+ }
+
+ ctx.reset();
+
+ ctx.beginPath();
+ ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
+ ctx.lineWidth = 10;
+ ctx.strokeStyle = Qt.alpha(Color.mOnSurface, 0.1);
+ ctx.stroke();
+
+ if (progressRatio > 0) {
+ ctx.beginPath();
+ ctx.arc(centerX, centerY, radius, -Math.PI / 2, -Math.PI / 2 + progressRatio * 2 * Math.PI);
+ ctx.lineWidth = 10;
+ ctx.strokeStyle = Color.mPrimary;
+ ctx.lineCap = "round";
+ ctx.stroke();
+ }
+ }
+ }
+
+ Item {
+ id: timerContainer
+ anchors.centerIn: parent
+ width: timerInput.implicitWidth
+ height: timerInput.implicitHeight + 8
+
+ TextInput {
+ id: timerInput
+ anchors.centerIn: parent
+ width: Math.max(implicitWidth, timerDisplayItem.width * 0.8)
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ selectByMouse: false
+ cursorVisible: false
+ cursorDelegate: Item {}
+ readOnly: isStopwatchMode || isRunning || totalSeconds > 0
+ enabled: !isRunning && !isStopwatchMode && totalSeconds === 0
+ font.family: Settings.data.ui.fontFixed
+
+ readonly property bool showingHours: {
+ if (isStopwatchMode) {
+ return elapsedSeconds >= 3600;
+ }
+ if (timerDisplayItem.isEditing) {
+ return true;
+ }
+ return totalSeconds >= 3600;
+ }
+
+ font.pointSize: {
+ const scale = compactMode ? 0.8 : 1.0;
+ if (totalSeconds === 0) {
+ return Style.fontSizeXXXL * 1.5 * scale;
+ }
+ return (showingHours ? Style.fontSizeXXL * 1.3 : (Style.fontSizeXXL * 1.8)) * scale;
+ }
+
+ font.weight: Style.fontWeightBold
+ color: {
+ if (totalSeconds > 0) {
+ return Color.mPrimary;
+ }
+ if (timerDisplayItem.isEditing) {
+ return Color.mPrimary;
+ }
+ return Color.mOnSurface;
+ }
+
+ property string _cachedText: ""
+ property int _textUpdateCounter: 0
+
+ function updateText() {
+ if (isStopwatchMode) {
+ _cachedText = formatTime(elapsedSeconds, elapsedSeconds);
+ } else if (timerDisplayItem.isEditing && totalSeconds === 0 && timerDisplayItem.inputBuffer !== "") {
+ _cachedText = formatTimeFromDigits(timerDisplayItem.inputBuffer);
+ } else if (timerDisplayItem.isEditing && totalSeconds === 0) {
+ _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 onTotalSecondsChanged() { timerInput.updateText(); }
+ function onIsRunningChanged() { timerInput.updateText(); Qt.callLater(() => { timerInput.updateText(); }); }
+ function onElapsedSecondsChanged() { timerInput.updateText(); }
+ function onIsStopwatchModeChanged() { timerInput.updateText(); }
+ }
+
+ Connections {
+ target: timerDisplayItem
+ function onIsEditingChanged() {
+ timerInput.updateText();
+ }
+ }
+
+ Component.onCompleted: updateText()
+
+ Keys.onPressed: event => {
+ if (isRunning || isStopwatchMode || totalSeconds > 0) {
+ if (event.key === Qt.Key_Space) {
+ if (isRunning) mainInstance.timerPause();
+ else mainInstance.timerStart();
+ event.accepted = true;
+ return;
+ }
+ event.accepted = true;
+ return;
+ }
+
+ const keyText = event.text.toLowerCase();
+
+ 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 {
+ setTimerRemainingSeconds(0);
+ }
+ }
+ event.accepted = true;
+ return;
+ }
+
+ if (event.key === Qt.Key_Delete) {
+ if (timerDisplayItem.isEditing) {
+ timerDisplayItem.inputBuffer = "";
+ setTimerRemainingSeconds(0);
+ }
+ event.accepted = true;
+ return;
+ }
+
+ if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Space) {
+ applyTimeFromBuffer();
+ timerDisplayItem.isEditing = false;
+ timerInput.focus = false;
+ if (remainingSeconds > 0) {
+ mainInstance.timerStart();
+ }
+ event.accepted = true;
+ return;
+ }
+
+ if (event.key === Qt.Key_Escape) {
+ timerDisplayItem.inputBuffer = "";
+ setTimerRemainingSeconds(0);
+ timerDisplayItem.isEditing = false;
+ timerInput.focus = false;
+ event.accepted = true;
+ return;
+ }
+
+ if (keyText === 'h' || keyText === 'm' || keyText === 's') {
+ if (timerDisplayItem.inputBuffer.length > 0) {
+ let val = parseInt(timerDisplayItem.inputBuffer) || 0;
+ let secs = 0;
+ if (keyText === 'h') secs = val * 3600;
+ else if (keyText === 'm') secs = val * 60;
+ else if (keyText === 's') secs = val;
+
+ setTimerRemainingSeconds(Math.min(99 * 3600 + 59 * 60 + 59, secs));
+ timerDisplayItem.inputBuffer = "";
+ timerDisplayItem.isEditing = false;
+ timerInput.focus = false;
+ }
+ event.accepted = true;
+ return;
+ }
+
+ 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;
+ }
+ }
+
+ 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
+ preventStealing: true
+ onPressed: (mouse) => {
+ if (!isRunning && !isStopwatchMode && totalSeconds === 0) {
+ timerInput.forceActiveFocus();
+ mouse.accepted = true;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ RowLayout {
+ id: buttonRow
+ Layout.fillWidth: true
+ spacing: Style.marginS
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.preferredWidth: 0
+ implicitHeight: startButton.implicitHeight
+ color: "transparent"
+
+ NButton {
+ id: startButton
+ anchors.fill: parent
+ text: {
+ if (isRunning) return pluginApi?.tr("panel.pause") || "Pause";
+ if (isStopwatchMode && elapsedSeconds > 0) return pluginApi?.tr("panel.resume") || "Resume";
+ if (!isStopwatchMode && totalSeconds > 0) return pluginApi?.tr("panel.resume") || "Resume";
+ return pluginApi?.tr("panel.start") || "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: "transparent"
+
+ NButton {
+ id: resetButton
+ anchors.fill: parent
+ text: pluginApi?.tr("panel.reset") || "Reset"
+ icon: "refresh"
+ enabled: (isStopwatchMode && (elapsedSeconds > 0 || isRunning)) || (!isStopwatchMode && (remainingSeconds > 0 || isRunning || soundPlaying))
+ onClicked: {
+ resetTimer();
+ }
+ }
+ }
+ }
+
+ NTabBar {
+ id: modeTabBar
+ Layout.fillWidth: true
+ Layout.preferredHeight: Style.baseWidgetSize
+ currentIndex: isStopwatchMode ? 1 : 0
+ margins: 0
+ distributeEvenly: true
+ onCurrentIndexChanged: {
+ const newMode = currentIndex === 1;
+ if (newMode !== isStopwatchMode) {
+ timerInput.focus = false;
+ setTimerStopwatchMode(newMode);
+ }
+ }
+ spacing: Style.marginS
+
+
+ Component.onCompleted: {
+ 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 {
+ text: pluginApi?.tr("panel.countdown") || "Timer"
+ tabIndex: 0
+ checked: !isStopwatchMode
+ radius: Style.iRadiusS
+ }
+
+ NTabButton {
+ text: pluginApi?.tr("panel.stopwatch") || "Stopwatch"
+ tabIndex: 1
+ checked: isStopwatchMode
+ radius: Style.iRadiusS
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Shortcut {
+ sequence: "Space"
+ onActivated: {
+ if (!timerInput.activeFocus) {
+ if (isRunning) mainInstance.timerPause();
+ else if (isStopwatchMode || remainingSeconds > 0) mainInstance.timerStart();
+ }
+ }
+ }
+}
+
diff --git a/.config/noctalia/plugins/timer/README.md b/.config/noctalia/plugins/timer/README.md
new file mode 100644
index 0000000..ae04689
--- /dev/null
+++ b/.config/noctalia/plugins/timer/README.md
@@ -0,0 +1,52 @@
+# Timer Plugin
+
+A simple and elegant timer and stopwatch plugin for Noctalia.
+
+## Features
+
+- **Countdown Timer**: Set a duration and get notified when it finishes.
+- **Stopwatch**: Measure elapsed time.
+- **Bar Widget**: Shows status and remaining/elapsed time.
+- **Control Center Widget**: Quick access from the control center.
+- **Notifications**: Sound and toast notification when timer finishes.
+- **Multi-language**: Support for 14 languages.
+
+## IPC Commands
+
+You can control the timer plugin via the command line using the Noctalia IPC interface.
+
+### General Usage
+```bash
+qs -c noctalia-shell ipc call plugin:timer <command>
+```
+
+### Available Commands
+
+| Command | Arguments | Description | Example |
+|---|---|---|---|
+| `toggle` | | Opens or closes the timer panel on the current screen | `qs -c noctalia-shell ipc call plugin:timer toggle` |
+| `start` | `[duration]` or `stopwatch` (optional) | Starts/resumes timer or switches to stopwatch mode. | `qs -c noctalia-shell ipc call plugin:timer start 10m` |
+| `pause` | | Pauses the running timer/stopwatch | `qs -c noctalia-shell ipc call plugin:timer pause` |
+| `reset` | | Resets the timer/stopwatch to initial state | `qs -c noctalia-shell ipc call plugin:timer reset` |
+
+### Duration Format
+
+The `start` command accepts duration strings in the following formats:
+- `30` (defaults to minutes)
+- `10s` (seconds)
+- `5m` (minutes)
+- `2h` (hours)
+- `1h30m` (combined)
+- `stopwatch` (keyword to start stopwatch)
+
+### Examples
+
+**Start a 25-minute timer (Pomodoro):**
+```bash
+qs -c noctalia-shell ipc call plugin:timer start 25m
+```
+
+**Start the stopwatch:**
+```bash
+qs -c noctalia-shell ipc call plugin:timer start stopwatch
+```
diff --git a/.config/noctalia/plugins/timer/Settings.qml b/.config/noctalia/plugins/timer/Settings.qml
new file mode 100644
index 0000000..fc963d8
--- /dev/null
+++ b/.config/noctalia/plugins/timer/Settings.qml
@@ -0,0 +1,65 @@
+import QtQuick
+import QtQuick.Layouts
+import Quickshell
+import qs.Commons
+import qs.Widgets
+import qs.Services.UI
+
+ColumnLayout {
+ id: root
+ spacing: Style.marginL
+
+ property var pluginApi: null
+
+ property bool editCompactMode:
+ pluginApi?.pluginSettings?.compactMode ??
+ pluginApi?.manifest?.metadata?.defaultSettings?.compactMode ??
+ false
+
+ property string editIconColor:
+ pluginApi?.pluginSettings?.iconColor ??
+ pluginApi?.manifest?.metadata?.defaultSettings?.iconColor ??
+ "none"
+
+ property string editTextColor:
+ pluginApi?.pluginSettings?.textColor ??
+ pluginApi?.manifest?.metadata?.defaultSettings?.textColor ??
+ "none"
+
+ function saveSettings() {
+ if (!pluginApi) {
+ Logger.e("Timer", "Cannot save: pluginApi is null")
+ return
+ }
+
+ pluginApi.pluginSettings.compactMode = root.editCompactMode
+ pluginApi.pluginSettings.iconColor = root.editIconColor
+ pluginApi.pluginSettings.textColor = root.editTextColor
+
+ pluginApi.saveSettings()
+ Logger.i("Timer", "Settings saved successfully")
+ }
+
+ // Icon Color
+ NColorChoice {
+ label: I18n.tr("common.select-icon-color")
+ description: I18n.tr("common.select-color-description")
+ currentKey: root.editIconColor
+ onSelected: key => root.editIconColor = key
+ }
+
+ // Text Color
+ NColorChoice {
+ currentKey: root.editTextColor
+ onSelected: key => root.editTextColor = key
+ }
+
+ // Compact Mode
+ NToggle {
+ label: pluginApi?.tr("settings.compact-mode") || "Compact Mode"
+ description: pluginApi?.tr("settings.compact-mode-desc") || "Hide the circular progress bar for a cleaner look"
+ checked: root.editCompactMode
+ onToggled: checked => root.editCompactMode = checked
+ defaultValue: pluginApi?.manifest?.metadata?.defaultSettings?.compactMode ?? false
+ }
+}
diff --git a/.config/noctalia/plugins/timer/i18n/de.json b/.config/noctalia/plugins/timer/i18n/de.json
new file mode 100644
index 0000000..41f35c8
--- /dev/null
+++ b/.config/noctalia/plugins/timer/i18n/de.json
@@ -0,0 +1,20 @@
+{
+ "panel": {
+ "countdown": "Countdown",
+ "pause": "Pause",
+ "reset": "Zurücksetzen",
+ "resume": "Fortsetzen",
+ "settings": "Widget-Einstellungen",
+ "start": "Start",
+ "stopwatch": "Stoppuhr",
+ "title": "Timer"
+ },
+ "settings": {
+ "compact-mode": "Kompaktmodus",
+ "compact-mode-desc": "Kreisförmigen Fortschrittsbalken für ein saubereres Aussehen ausblenden"
+ },
+ "toast": {
+ "finished": "Timer abgelaufen!",
+ "title": "Timer"
+ }
+}
diff --git a/.config/noctalia/plugins/timer/i18n/en.json b/.config/noctalia/plugins/timer/i18n/en.json
new file mode 100644
index 0000000..bdfa722
--- /dev/null
+++ b/.config/noctalia/plugins/timer/i18n/en.json
@@ -0,0 +1,20 @@
+{
+ "panel": {
+ "countdown": "Countdown",
+ "pause": "Pause",
+ "reset": "Reset",
+ "resume": "Resume",
+ "settings": "Widget Settings",
+ "start": "Start",
+ "stopwatch": "Stopwatch",
+ "title": "Timer"
+ },
+ "settings": {
+ "compact-mode": "Compact Mode",
+ "compact-mode-desc": "Hide the circular progress bar for a cleaner look"
+ },
+ "toast": {
+ "finished": "Timer finished!",
+ "title": "Timer"
+ }
+}
diff --git a/.config/noctalia/plugins/timer/i18n/es.json b/.config/noctalia/plugins/timer/i18n/es.json
new file mode 100644
index 0000000..4a0be12
--- /dev/null
+++ b/.config/noctalia/plugins/timer/i18n/es.json
@@ -0,0 +1,20 @@
+{
+ "panel": {
+ "countdown": "Cuenta atrás",
+ "pause": "Pausa",
+ "reset": "Reiniciar",
+ "resume": "Reanudar",
+ "settings": "Configuración del Widget",
+ "start": "Iniciar",
+ "stopwatch": "Cronómetro",
+ "title": "Temporizador"
+ },
+ "settings": {
+ "compact-mode": "Modo compacto",
+ "compact-mode-desc": "Ocultar la barra de progreso circular para una apariencia más limpia"
+ },
+ "toast": {
+ "finished": "¡Temporizador terminado!",
+ "title": "Temporizador"
+ }
+}
diff --git a/.config/noctalia/plugins/timer/i18n/fr.json b/.config/noctalia/plugins/timer/i18n/fr.json
new file mode 100644
index 0000000..d11c630
--- /dev/null
+++ b/.config/noctalia/plugins/timer/i18n/fr.json
@@ -0,0 +1,20 @@
+{
+ "panel": {
+ "countdown": "Compte à rebours",
+ "pause": "Pause",
+ "reset": "Réinitialiser",
+ "resume": "Reprendre",
+ "settings": "Paramètres du Widget",
+ "start": "Démarrer",
+ "stopwatch": "Chronomètre",
+ "title": "Minuteur"
+ },
+ "settings": {
+ "compact-mode": "Mode compact",
+ "compact-mode-desc": "Masquer la barre de progression circulaire pour un aspect plus épuré"
+ },
+ "toast": {
+ "finished": "Minuteur terminé !",
+ "title": "Minuteur"
+ }
+}
diff --git a/.config/noctalia/plugins/timer/i18n/hu.json b/.config/noctalia/plugins/timer/i18n/hu.json
new file mode 100644
index 0000000..be49bb5
--- /dev/null
+++ b/.config/noctalia/plugins/timer/i18n/hu.json
@@ -0,0 +1,20 @@
+{
+ "panel": {
+ "countdown": "Visszaszámlálás",
+ "pause": "Szünet",
+ "reset": "Visszaállítás",
+ "resume": "Folytatás",
+ "settings": "Widget beállítások",
+ "start": "Indítás",
+ "stopwatch": "Stopper",
+ "title": "Időzítő"
+ },
+ "settings": {
+ "compact-mode": "Kompakt mód",
+ "compact-mode-desc": "A körkörös folyamatjelző elrejtése a tisztább megjelenés érdekében"
+ },
+ "toast": {
+ "finished": "Időzítő lejárt!",
+ "title": "Időzítő"
+ }
+}
diff --git a/.config/noctalia/plugins/timer/i18n/it.json b/.config/noctalia/plugins/timer/i18n/it.json
new file mode 100644
index 0000000..40517c8
--- /dev/null
+++ b/.config/noctalia/plugins/timer/i18n/it.json
@@ -0,0 +1,20 @@
+{
+ "panel": {
+ "countdown": "Countdown",
+ "pause": "Pausa",
+ "reset": "Reimposta",
+ "resume": "Riprendi",
+ "settings": "Impostazioni",
+ "start": "Avvia",
+ "stopwatch": "Cronometro",
+ "title": "Timer"
+ },
+ "settings": {
+ "compact-mode": "Modalità compatta",
+ "compact-mode-desc": "Nascondi la barra di avanzamento circolare per un aspetto più pulito"
+ },
+ "toast": {
+ "finished": "Timer terminato",
+ "title": "Timer"
+ }
+}
diff --git a/.config/noctalia/plugins/timer/i18n/ja.json b/.config/noctalia/plugins/timer/i18n/ja.json
new file mode 100644
index 0000000..948109b
--- /dev/null
+++ b/.config/noctalia/plugins/timer/i18n/ja.json
@@ -0,0 +1,20 @@
+{
+ "panel": {
+ "countdown": "カウントダウン",
+ "pause": "一時停止",
+ "reset": "リセット",
+ "resume": "再開",
+ "settings": "ウィジェット設定",
+ "start": "開始",
+ "stopwatch": "ストップウォッチ",
+ "title": "タイマー"
+ },
+ "settings": {
+ "compact-mode": "コンパクトモード",
+ "compact-mode-desc": "円形の進行状況バーを非表示にして、すっきりとした見た目にする"
+ },
+ "toast": {
+ "finished": "タイマー終了!",
+ "title": "タイマー"
+ }
+}
diff --git a/.config/noctalia/plugins/timer/i18n/ku.json b/.config/noctalia/plugins/timer/i18n/ku.json
new file mode 100644
index 0000000..abff80f
--- /dev/null
+++ b/.config/noctalia/plugins/timer/i18n/ku.json
@@ -0,0 +1,20 @@
+{
+ "panel": {
+ "countdown": "Jimartina Paşve",
+ "pause": "Rawestandin",
+ "reset": "Nûkirin",
+ "resume": "Berdewamkirin",
+ "settings": "Mîhengên Widget",
+ "start": "Destpêkirin",
+ "stopwatch": "Demgir",
+ "title": "Demjimêr"
+ },
+ "settings": {
+ "compact-mode": "Moda Kompakt",
+ "compact-mode-desc": "Barê pêşveçûnê yê dorûber veşêre ji bo dîmenek paqijtir"
+ },
+ "toast": {
+ "finished": "Demjimêr qediya!",
+ "title": "Demjimêr"
+ }
+}
diff --git a/.config/noctalia/plugins/timer/i18n/nl.json b/.config/noctalia/plugins/timer/i18n/nl.json
new file mode 100644
index 0000000..8098f3c
--- /dev/null
+++ b/.config/noctalia/plugins/timer/i18n/nl.json
@@ -0,0 +1,20 @@
+{
+ "panel": {
+ "countdown": "Aftellen",
+ "pause": "Pauze",
+ "reset": "Resetten",
+ "resume": "Hervatten",
+ "settings": "Widget-instellingen",
+ "start": "Start",
+ "stopwatch": "Stopwatch",
+ "title": "Timer"
+ },
+ "settings": {
+ "compact-mode": "Compacte modus",
+ "compact-mode-desc": "Verberg de circulaire voortgangsbalk voor een schoner uiterlijk"
+ },
+ "toast": {
+ "finished": "Timer afgelopen!",
+ "title": "Timer"
+ }
+}
diff --git a/.config/noctalia/plugins/timer/i18n/pl.json b/.config/noctalia/plugins/timer/i18n/pl.json
new file mode 100644
index 0000000..8aa6cf2
--- /dev/null
+++ b/.config/noctalia/plugins/timer/i18n/pl.json
@@ -0,0 +1,20 @@
+{
+ "panel": {
+ "countdown": "Odliczanie",
+ "pause": "Pauza",
+ "reset": "Resetuj",
+ "resume": "Wznów",
+ "settings": "Ustawienia widżetu",
+ "start": "Start",
+ "stopwatch": "Stoper",
+ "title": "Czasomierz"
+ },
+ "settings": {
+ "compact-mode": "Tryb kompaktowy",
+ "compact-mode-desc": "Ukryj okrągły pasek postępu dla czystszego wyglądu"
+ },
+ "toast": {
+ "finished": "Czasomierz zakończony!",
+ "title": "Czasomierz"
+ }
+}
diff --git a/.config/noctalia/plugins/timer/i18n/pt.json b/.config/noctalia/plugins/timer/i18n/pt.json
new file mode 100644
index 0000000..50c490d
--- /dev/null
+++ b/.config/noctalia/plugins/timer/i18n/pt.json
@@ -0,0 +1,20 @@
+{
+ "panel": {
+ "countdown": "Contagem regressiva",
+ "pause": "Pausar",
+ "reset": "Reiniciar",
+ "resume": "Retomar",
+ "settings": "Configurações do Widget",
+ "start": "Iniciar",
+ "stopwatch": "Cronômetro",
+ "title": "Temporizador"
+ },
+ "settings": {
+ "compact-mode": "Modo compacto",
+ "compact-mode-desc": "Ocultar a barra de progresso circular para um visual mais limpo"
+ },
+ "toast": {
+ "finished": "Temporizador finalizado!",
+ "title": "Temporizador"
+ }
+}
diff --git a/.config/noctalia/plugins/timer/i18n/ru.json b/.config/noctalia/plugins/timer/i18n/ru.json
new file mode 100644
index 0000000..0269aa7
--- /dev/null
+++ b/.config/noctalia/plugins/timer/i18n/ru.json
@@ -0,0 +1,20 @@
+{
+ "panel": {
+ "countdown": "Обратный отсчет",
+ "pause": "Пауза",
+ "reset": "Сброс",
+ "resume": "Продолжить",
+ "settings": "Настройки виджета",
+ "start": "Старт",
+ "stopwatch": "Секундомер",
+ "title": "Таймер"
+ },
+ "settings": {
+ "compact-mode": "Компактный режим",
+ "compact-mode-desc": "Скрыть круговой индикатор прогресса для более чистого вида"
+ },
+ "toast": {
+ "finished": "Таймер завершён!",
+ "title": "Таймер"
+ }
+}
diff --git a/.config/noctalia/plugins/timer/i18n/tr.json b/.config/noctalia/plugins/timer/i18n/tr.json
new file mode 100644
index 0000000..c3d8f8f
--- /dev/null
+++ b/.config/noctalia/plugins/timer/i18n/tr.json
@@ -0,0 +1,20 @@
+{
+ "panel": {
+ "countdown": "Geri Sayım",
+ "pause": "Duraklat",
+ "reset": "Sıfırla",
+ "resume": "Devam Et",
+ "settings": "Widget Ayarları",
+ "start": "Başlat",
+ "stopwatch": "Kronometre",
+ "title": "Zamanlayıcı"
+ },
+ "settings": {
+ "compact-mode": "Kompakt Mod",
+ "compact-mode-desc": "Daha temiz bir görünüm için dairesel ilerleme çubuğunu gizle"
+ },
+ "toast": {
+ "finished": "Zamanlayıcı bitti!",
+ "title": "Zamanlayıcı"
+ }
+}
diff --git a/.config/noctalia/plugins/timer/i18n/uk-UA.json b/.config/noctalia/plugins/timer/i18n/uk-UA.json
new file mode 100644
index 0000000..a4326cc
--- /dev/null
+++ b/.config/noctalia/plugins/timer/i18n/uk-UA.json
@@ -0,0 +1,20 @@
+{
+ "panel": {
+ "countdown": "Зворотний відлік",
+ "pause": "Пауза",
+ "reset": "Скинути",
+ "resume": "Продовжити",
+ "settings": "Налаштування віджета",
+ "start": "Старт",
+ "stopwatch": "Секундомір",
+ "title": "Таймер"
+ },
+ "settings": {
+ "compact-mode": "Компактний режим",
+ "compact-mode-desc": "Приховати круговий індикатор прогресу для чистішого вигляду"
+ },
+ "toast": {
+ "finished": "Таймер завершено!",
+ "title": "Таймер"
+ }
+}
diff --git a/.config/noctalia/plugins/timer/i18n/zh-CN.json b/.config/noctalia/plugins/timer/i18n/zh-CN.json
new file mode 100644
index 0000000..72bb0e1
--- /dev/null
+++ b/.config/noctalia/plugins/timer/i18n/zh-CN.json
@@ -0,0 +1,20 @@
+{
+ "panel": {
+ "countdown": "倒计时",
+ "pause": "暂停",
+ "reset": "重置",
+ "resume": "继续",
+ "settings": "小部件设置",
+ "start": "开始",
+ "stopwatch": "秒表",
+ "title": "计时器"
+ },
+ "settings": {
+ "compact-mode": "紧凑模式",
+ "compact-mode-desc": "隐藏圆形进度条以获得更整洁的外观"
+ },
+ "toast": {
+ "finished": "计时器结束!",
+ "title": "计时器"
+ }
+}
diff --git a/.config/noctalia/plugins/timer/i18n/zh-TW.json b/.config/noctalia/plugins/timer/i18n/zh-TW.json
new file mode 100644
index 0000000..e9b89bd
--- /dev/null
+++ b/.config/noctalia/plugins/timer/i18n/zh-TW.json
@@ -0,0 +1,20 @@
+{
+ "panel": {
+ "countdown": "倒數",
+ "pause": "暫停",
+ "reset": "重置",
+ "resume": "繼續",
+ "settings": "小工具設定",
+ "start": "開始",
+ "stopwatch": "碼表",
+ "title": "計時器"
+ },
+ "settings": {
+ "compact-mode": "緊湊模式",
+ "compact-mode-desc": "隱藏進度圓環以得到更精簡的外觀"
+ },
+ "toast": {
+ "finished": "時間到了!",
+ "title": "計時器"
+ }
+}
diff --git a/.config/noctalia/plugins/timer/manifest.json b/.config/noctalia/plugins/timer/manifest.json
new file mode 100644
index 0000000..766392a
--- /dev/null
+++ b/.config/noctalia/plugins/timer/manifest.json
@@ -0,0 +1,33 @@
+{
+ "id": "timer",
+ "name": "Timer",
+ "version": "1.1.2",
+ "minNoctaliaVersion": "4.4.4",
+ "author": "Noctalia Team",
+ "official": true,
+ "license": "MIT",
+ "repository": "https://github.com/noctalia-dev/noctalia-plugins",
+ "description": "A timer and stopwatch plugin for the bar & control center.",
+ "tags": [
+ "Bar",
+ "Utility"
+ ],
+ "entryPoints": {
+ "main": "Main.qml",
+ "barWidget": "BarWidget.qml",
+ "panel": "Panel.qml",
+ "controlCenterWidget": "ControlCenterWidget.qml",
+ "settings": "Settings.qml"
+ },
+ "dependencies": {
+ "plugins": []
+ },
+ "metadata": {
+ "defaultSettings": {
+ "defaultDuration": 0,
+ "compactMode": false,
+ "iconColor": "none",
+ "textColor": "none"
+ }
+ }
+} \ No newline at end of file
diff --git a/.config/noctalia/plugins/timer/preview.png b/.config/noctalia/plugins/timer/preview.png
new file mode 100644
index 0000000..bead8e2
--- /dev/null
+++ b/.config/noctalia/plugins/timer/preview.png
Binary files differ
diff --git a/.config/noctalia/plugins/timer/settings.json b/.config/noctalia/plugins/timer/settings.json
new file mode 100644
index 0000000..3979fb2
--- /dev/null
+++ b/.config/noctalia/plugins/timer/settings.json
@@ -0,0 +1,5 @@
+{
+ "compactMode": true,
+ "iconColor": "none",
+ "textColor": "none"
+}