diff --git a/README.md b/README.md
index 13b5ec8a2..83bfb5419 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,9 @@
caelestia-shell
+> [!NOTE]
+> This is a fork adding a timer and alarm panel to the dashboard (`feat/timer-v3` branch).
+> The PR to upstream will be rebased after caelestia 2.0 is released.
+

diff --git a/assets/timer-done.wav b/assets/timer-done.wav
new file mode 100644
index 000000000..ed2df4dba
Binary files /dev/null and b/assets/timer-done.wav differ
diff --git a/components/DashboardState.qml b/components/DashboardState.qml
index b4355cd7b..b26e8370a 100644
--- a/components/DashboardState.qml
+++ b/components/DashboardState.qml
@@ -3,4 +3,6 @@ import Quickshell
PersistentProperties {
property int currentTab
property date currentDate: new Date()
+ property bool timerPanelOpen: false
+ property int timerPanelTab: 0
}
diff --git a/components/DrawerVisibilities.qml b/components/DrawerVisibilities.qml
index 3286e319f..47f3fb6df 100644
--- a/components/DrawerVisibilities.qml
+++ b/components/DrawerVisibilities.qml
@@ -8,4 +8,5 @@ PersistentProperties {
property bool dashboard
property bool utilities
property bool sidebar
+ property bool fireOverlay
}
diff --git a/modules/bar/Bar.qml b/modules/bar/Bar.qml
index a2ed060e7..39c556b71 100644
--- a/modules/bar/Bar.qml
+++ b/modules/bar/Bar.qml
@@ -34,6 +34,9 @@ ColumnLayout {
function checkPopout(y: real): void {
const ch = childAt(width / 2, y) as WrappedLoader;
+ if (popouts.locked && ch?.id !== "clock")
+ popouts.locked = false;
+
if (ch?.id !== "tray")
closeTray();
@@ -73,6 +76,10 @@ ColumnLayout {
popouts.currentName = id.toLowerCase();
popouts.currentCenter = (ch.item as Item).mapToItem(root, 0, (ch.item as Item).implicitHeight / 2).y ?? 0;
popouts.hasCurrent = true;
+ } else if (id === "clock" && (Config.bar.clock.timer?.enabled ?? true)) {
+ popouts.currentName = "timer";
+ popouts.currentCenter = ch.y + ch.height / 2;
+ popouts.hasCurrent = true;
}
}
@@ -104,6 +111,20 @@ ColumnLayout {
spacing: Tokens.spacing.normal
+ Connections {
+ target: TimerService
+ function onFinished(): void {
+ root.visibilities.dashboard = true;
+ }
+ }
+
+ Connections {
+ target: AlarmService
+ function onFinished(): void {
+ root.visibilities.dashboard = true;
+ }
+ }
+
Repeater {
id: repeater
diff --git a/modules/bar/components/Clock.qml b/modules/bar/components/Clock.qml
index ffe599afd..3154f3f4d 100644
--- a/modules/bar/components/Clock.qml
+++ b/modules/bar/components/Clock.qml
@@ -67,5 +67,47 @@ StyledRect {
font.family: Tokens.font.family.mono
color: root.colour
}
+
+ Loader {
+ active: TimerService.active && (Config.bar.clock.timer?.enabled ?? true)
+ visible: active
+ anchors.horizontalCenter: parent.horizontalCenter
+
+ sourceComponent: Column {
+ spacing: Tokens.spacing.small
+
+ Rectangle {
+ width: 30
+ height: 1
+ color: root.colour
+ opacity: 0.2
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ MaterialIcon {
+ text: "timer"
+ font.pointSize: Tokens.font.size.small
+ color: TimerService.running ? root.colour : Qt.alpha(root.colour, 0.5)
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+
+ StyledText {
+ horizontalAlignment: StyledText.AlignHCenter
+ text: {
+ const s = TimerService.remainingSeconds;
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const sec = s % 60;
+ if (h > 0)
+ return h + "\n" + String(m).padStart(2, "0") + "\n" + String(sec).padStart(2, "0");
+ return String(m).padStart(2, "0") + "\n" + String(sec).padStart(2, "0");
+ }
+ font.pointSize: Tokens.font.size.smaller
+ font.family: Tokens.font.family.mono
+ color: root.colour
+ anchors.horizontalCenter: parent.horizontalCenter
+ }
+ }
+ }
}
}
diff --git a/modules/bar/popouts/Content.qml b/modules/bar/popouts/Content.qml
index 42c5a7667..50e2c2619 100644
--- a/modules/bar/popouts/Content.qml
+++ b/modules/bar/popouts/Content.qml
@@ -121,6 +121,13 @@ Item {
sourceComponent: KbLayout {}
}
+ Popout {
+ name: "timer"
+ sourceComponent: TimerPopout {
+ popouts: root.popouts
+ }
+ }
+
Popout {
name: "lockstatus"
sourceComponent: LockStatus {}
diff --git a/modules/bar/popouts/PopoutState.qml b/modules/bar/popouts/PopoutState.qml
index 6be8169b4..e631d4483 100644
--- a/modules/bar/popouts/PopoutState.qml
+++ b/modules/bar/popouts/PopoutState.qml
@@ -3,6 +3,7 @@ import QtQuick
QtObject {
property string currentName
property bool hasCurrent
+ property bool locked: false
signal detachRequested(mode: string)
}
diff --git a/modules/bar/popouts/TimerPopout.qml b/modules/bar/popouts/TimerPopout.qml
new file mode 100644
index 000000000..f520348c6
--- /dev/null
+++ b/modules/bar/popouts/TimerPopout.qml
@@ -0,0 +1,495 @@
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import QtQuick.Layouts
+import Caelestia.Config
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.utils
+
+Item {
+ id: root
+
+ required property PopoutState popouts
+
+ property int popoutTab: 0
+
+ property int timerHours: 0
+ property int timerMinutes: 0
+ property int timerSeconds: 0
+
+ property int alarmHours: AlarmService.alarmHour
+ property int alarmMinutes: AlarmService.alarmMinute
+
+ implicitWidth: layout.implicitWidth
+ implicitHeight: layout.implicitHeight
+
+ ColumnLayout {
+ id: layout
+
+ anchors.fill: parent
+ spacing: Tokens.spacing.small
+
+ // Tab row
+ RowLayout {
+ Layout.topMargin: Tokens.padding.small
+ Layout.alignment: Qt.AlignHCenter
+ spacing: Tokens.spacing.smaller
+
+ component TabChip: StyledRect {
+ id: chip
+
+ property bool chipActive: false
+ property string chipText: ""
+ property string chipIcon: ""
+ signal clicked()
+
+ implicitWidth: chipRow.implicitWidth + Tokens.padding.normal * 2
+ implicitHeight: chipRow.implicitHeight + Tokens.padding.small * 2
+
+ color: chipActive ? Colours.palette.m3primaryContainer : Colours.tPalette.m3surfaceContainerHigh
+ radius: Tokens.rounding.full
+
+ StateLayer {
+ radius: parent.radius
+ onClicked: chip.clicked()
+ }
+
+ RowLayout {
+ id: chipRow
+ anchors.centerIn: parent
+ spacing: Tokens.spacing.smaller
+
+ MaterialIcon {
+ text: chip.chipIcon
+ font.pointSize: Tokens.font.size.small
+ color: chip.chipActive ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurfaceVariant
+ }
+
+ StyledText {
+ id: chipLabel
+ text: chip.chipText
+ font.pointSize: Tokens.font.size.small
+ color: chip.chipActive ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurfaceVariant
+ }
+ }
+ }
+
+ TabChip {
+ chipText: qsTr("Timer")
+ chipIcon: "timer"
+ chipActive: root.popoutTab === 0
+ onClicked: root.popoutTab = 0
+ }
+
+ TabChip {
+ chipText: qsTr("Alarm")
+ chipIcon: "alarm"
+ chipActive: root.popoutTab === 1
+ onClicked: root.popoutTab = 1
+ }
+ }
+
+ StackLayout {
+ currentIndex: root.popoutTab
+
+ // --- Timer tab ---
+ ColumnLayout {
+ spacing: Tokens.spacing.small
+
+ Item { Layout.fillHeight: true }
+
+ // Timer done: Bongo Cat
+ ColumnLayout {
+ visible: TimerService.timerDone
+ Layout.fillWidth: true
+ spacing: Tokens.spacing.small
+
+ AnimatedImage {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.preferredWidth: 180
+ Layout.preferredHeight: 140
+
+ source: Paths.absolutePath(GlobalConfig.paths.mediaGif)
+ speed: 2.0
+ playing: TimerService.timerDone
+ fillMode: AnimatedImage.PreserveAspectFit
+ asynchronous: true
+ }
+
+ IconTextButton {
+ Layout.fillWidth: true
+ Layout.topMargin: Tokens.spacing.small
+ Layout.alignment: Qt.AlignHCenter
+ inactiveColour: Colours.palette.m3primaryContainer
+ inactiveOnColour: Colours.palette.m3onPrimaryContainer
+ verticalPadding: Tokens.padding.small
+ text: qsTr("Dismiss")
+ icon: "close"
+ onClicked: {
+ TimerService.timerDone = false;
+ }
+ }
+ }
+
+ // Idle: set timer
+ ColumnLayout {
+ visible: !TimerService.active && !TimerService.timerDone
+ Layout.fillWidth: true
+ spacing: Tokens.spacing.small
+
+ RowLayout {
+ Layout.alignment: Qt.AlignHCenter
+ spacing: Tokens.spacing.small
+
+ SpinGroup {
+ label: qsTr("H")
+ max: 23
+ value: root.timerHours
+ onValueModified: v => {
+ root.timerHours = v;
+ }
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignVCenter
+ text: ":"
+ font.pointSize: Tokens.font.size.larger
+ font.family: Tokens.font.family.mono
+ color: Colours.palette.m3onSurfaceVariant
+ }
+
+ SpinGroup {
+ label: qsTr("M")
+ max: 59
+ value: root.timerMinutes
+ onValueModified: v => {
+ root.timerMinutes = v;
+ }
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignVCenter
+ text: ":"
+ font.pointSize: Tokens.font.size.larger
+ font.family: Tokens.font.family.mono
+ color: Colours.palette.m3onSurfaceVariant
+ }
+
+ SpinGroup {
+ label: qsTr("S")
+ max: 59
+ value: root.timerSeconds
+ onValueModified: v => {
+ root.timerSeconds = v;
+ }
+ }
+ }
+
+ IconTextButton {
+ Layout.fillWidth: true
+ Layout.topMargin: Tokens.spacing.small
+ Layout.alignment: Qt.AlignHCenter
+ inactiveColour: Colours.palette.m3primaryContainer
+ inactiveOnColour: Colours.palette.m3onPrimaryContainer
+ verticalPadding: Tokens.padding.small
+ text: qsTr("Start")
+ icon: "play_arrow"
+ onClicked: TimerService.start(root.timerHours, root.timerMinutes, root.timerSeconds)
+ }
+ }
+
+ // Active: running/paused
+ ColumnLayout {
+ visible: TimerService.active && !TimerService.timerDone
+ Layout.fillWidth: true
+ spacing: Tokens.spacing.small
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: TimerService.remainingFormatted
+ font.pointSize: Tokens.font.size.extraLarge
+ font.family: Tokens.font.family.mono
+ font.weight: 500
+ horizontalAlignment: Text.AlignHCenter
+ }
+
+ Item {
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignHCenter
+ implicitHeight: 4
+
+ StyledRect {
+ width: parent.width
+ height: parent.height
+ radius: 2
+ color: Colours.tPalette.m3surfaceContainerHigh
+ }
+
+ StyledRect {
+ width: Math.max(radius * 2, TimerService.progress * parent.width)
+ height: parent.height
+ radius: 2
+ color: Colours.palette.m3primary
+
+ Behavior on width {
+ Anim {}
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignHCenter
+ Layout.topMargin: Tokens.spacing.small
+ spacing: Tokens.spacing.small
+
+ IconTextButton {
+ Layout.fillWidth: true
+ inactiveColour: Colours.palette.m3secondaryContainer
+ inactiveOnColour: Colours.palette.m3onSecondaryContainer
+ verticalPadding: Tokens.padding.small
+ text: TimerService.running ? qsTr("Pause") : qsTr("Resume")
+ icon: TimerService.running ? "pause" : "play_arrow"
+ onClicked: {
+ if (TimerService.running)
+ TimerService.pause();
+ else
+ TimerService.resume();
+ }
+ }
+
+ IconTextButton {
+ inactiveColour: Colours.palette.m3errorContainer
+ inactiveOnColour: Colours.palette.m3onErrorContainer
+ verticalPadding: Tokens.padding.small
+ text: qsTr("Cancel")
+ icon: "close"
+ onClicked: TimerService.cancel()
+ }
+ }
+ }
+
+ Item { Layout.fillHeight: true }
+ }
+
+ // --- Alarm tab ---
+ ColumnLayout {
+ spacing: Tokens.spacing.small
+
+ Item { Layout.fillHeight: true }
+
+ // Active alarm indicator
+ ColumnLayout {
+ visible: AlarmService.active
+ Layout.fillWidth: true
+ spacing: Tokens.spacing.small
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: AlarmService.alarmTimeFormatted
+ font.pointSize: Tokens.font.size.extraLarge
+ font.family: Tokens.font.family.mono
+ font.weight: 500
+ color: Colours.palette.m3primary
+ }
+
+ IconTextButton {
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignHCenter
+ inactiveColour: Colours.palette.m3errorContainer
+ inactiveOnColour: Colours.palette.m3onErrorContainer
+ verticalPadding: Tokens.padding.small
+ text: qsTr("Cancel alarm")
+ icon: "close"
+ onClicked: AlarmService.cancelAlarm()
+ }
+ }
+
+ // Set alarm
+ ColumnLayout {
+ visible: !AlarmService.active
+ Layout.fillWidth: true
+ spacing: Tokens.spacing.small
+
+ RowLayout {
+ Layout.alignment: Qt.AlignHCenter
+ spacing: Tokens.spacing.small
+
+ SpinGroup {
+ label: qsTr("H")
+ max: 23
+ value: root.alarmHours
+ onValueModified: v => {
+ root.alarmHours = v;
+ }
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignVCenter
+ text: ":"
+ font.pointSize: Tokens.font.size.larger
+ font.family: Tokens.font.family.mono
+ color: Colours.palette.m3onSurfaceVariant
+ }
+
+ SpinGroup {
+ label: qsTr("M")
+ max: 59
+ value: root.alarmMinutes
+ onValueModified: v => {
+ root.alarmMinutes = v;
+ }
+ }
+ }
+
+ IconTextButton {
+ Layout.fillWidth: true
+ Layout.topMargin: Tokens.spacing.small
+ Layout.alignment: Qt.AlignHCenter
+ inactiveColour: Colours.palette.m3primaryContainer
+ inactiveOnColour: Colours.palette.m3onPrimaryContainer
+ verticalPadding: Tokens.padding.small
+ text: qsTr("Set alarm")
+ icon: "alarm"
+ onClicked: AlarmService.setAlarm(root.alarmHours, root.alarmMinutes)
+ }
+ }
+
+ Item { Layout.fillHeight: true }
+ }
+ }
+ }
+
+ component SpinGroup: ColumnLayout {
+ id: spinGroup
+
+ property int value: 0
+ property int min: 0
+ property int max: 99
+ property string label: ""
+
+ signal valueModified(int v)
+
+ onValueChanged: {
+ if (!numInput.activeFocus)
+ numInput.text = String(value).padStart(2, "0");
+ }
+
+ spacing: 2
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: spinGroup.label
+ font.pointSize: Tokens.font.size.small
+ color: Colours.palette.m3onSurfaceVariant
+ }
+
+ StyledRect {
+ Layout.alignment: Qt.AlignHCenter
+ color: "transparent"
+ radius: Tokens.rounding.small
+ implicitWidth: numBox.implicitWidth
+ implicitHeight: arrowIcon.implicitHeight + Tokens.padding.small * 2
+
+ StateLayer {
+ radius: parent.radius
+ color: Colours.palette.m3onSurface
+ onClicked: {
+ const v = Math.min(spinGroup.max, spinGroup.value + 1);
+ spinGroup.value = v;
+ spinGroup.valueModified(v);
+ numInput.text = String(v).padStart(2, "0");
+ }
+ }
+
+ MaterialIcon {
+ id: arrowIcon
+ anchors.centerIn: parent
+ text: "keyboard_arrow_up"
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Tokens.font.size.normal
+ }
+ }
+
+ StyledRect {
+ id: numBox
+ Layout.alignment: Qt.AlignHCenter
+ color: Colours.tPalette.m3surfaceContainerHigh
+ radius: Tokens.rounding.small
+ implicitWidth: numText.implicitWidth + Tokens.padding.normal * 2
+ implicitHeight: numText.implicitHeight + Tokens.padding.small * 2
+
+ StyledText {
+ id: numText
+ visible: false
+ text: "00"
+ font.pointSize: Tokens.font.size.larger
+ font.family: Tokens.font.family.mono
+ }
+
+ TextInput {
+ id: numInput
+ anchors.centerIn: parent
+ width: numText.implicitWidth
+
+ Component.onCompleted: text = String(spinGroup.value).padStart(2, "0")
+ font.pointSize: Tokens.font.size.larger
+ font.family: Tokens.font.family.mono
+ color: Colours.palette.m3onSurface
+ selectionColor: Colours.palette.m3primary
+ selectedTextColor: Colours.palette.m3onPrimary
+ horizontalAlignment: TextInput.AlignHCenter
+ inputMethodHints: Qt.ImhDigitsOnly
+ maximumLength: 2
+ selectByMouse: true
+
+ function commit(): void {
+ const v = Math.min(spinGroup.max, Math.max(spinGroup.min, parseInt(text) || 0));
+ spinGroup.value = v;
+ spinGroup.valueModified(v);
+ text = String(v).padStart(2, "0");
+ }
+
+ onEditingFinished: commit()
+ onActiveFocusChanged: {
+ if (activeFocus)
+ selectAll();
+ else
+ commit();
+ }
+ Keys.onEscapePressed: {
+ text = String(spinGroup.value).padStart(2, "0");
+ focus = false;
+ }
+ }
+ }
+
+ StyledRect {
+ Layout.alignment: Qt.AlignHCenter
+ color: "transparent"
+ radius: Tokens.rounding.small
+ implicitWidth: numBox.implicitWidth
+ implicitHeight: arrowIcon.implicitHeight + Tokens.padding.small * 2
+
+ StateLayer {
+ radius: parent.radius
+ color: Colours.palette.m3onSurface
+ onClicked: {
+ const v = Math.max(spinGroup.min, spinGroup.value - 1);
+ spinGroup.value = v;
+ spinGroup.valueModified(v);
+ numInput.text = String(v).padStart(2, "0");
+ }
+ }
+
+ MaterialIcon {
+ anchors.centerIn: parent
+ text: "keyboard_arrow_down"
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Tokens.font.size.normal
+ }
+ }
+ }
+}
diff --git a/modules/bar/popouts/Wrapper.qml b/modules/bar/popouts/Wrapper.qml
index 7a66d3b97..391a51f3d 100644
--- a/modules/bar/popouts/Wrapper.qml
+++ b/modules/bar/popouts/Wrapper.qml
@@ -27,6 +27,7 @@ Item {
property alias currentName: popoutState.currentName
property alias hasCurrent: popoutState.hasCurrent
+ property alias locked: popoutState.locked
property real currentCenter
property string detachedMode
@@ -78,8 +79,8 @@ Item {
}
Keys.onPressed: event => {
- // Don't intercept keys when password popout is active - let it handle them
- if (currentName === "wirelesspassword") {
+ // Don't intercept keys when these popouts are active - let them handle input
+ if (currentName === "wirelesspassword" || currentName === "timer") {
event.accepted = false;
}
}
@@ -97,7 +98,7 @@ Item {
}
Binding {
- when: root.isDetached || (root.hasCurrent && root.currentName === "wirelesspassword")
+ when: root.isDetached || (root.hasCurrent && (root.currentName === "wirelesspassword" || root.currentName === "timer"))
target: QsWindow.window
property: "WlrLayershell.keyboardFocus"
diff --git a/modules/controlcenter/taskbar/TaskbarPane.qml b/modules/controlcenter/taskbar/TaskbarPane.qml
index 601fc09fe..fa342d93e 100644
--- a/modules/controlcenter/taskbar/TaskbarPane.qml
+++ b/modules/controlcenter/taskbar/TaskbarPane.qml
@@ -35,6 +35,7 @@ Item {
property bool showBluetooth: Config.bar.status.showBluetooth ?? true
property bool showBattery: Config.bar.status.showBattery ?? true
property bool showLockStatus: Config.bar.status.showLockStatus ?? true
+ property bool timerEnabled: Config.bar.clock.timer?.enabled ?? true
property bool trayBackground: Config.bar.tray.background ?? false
property bool trayCompact: Config.bar.tray.compact ?? false
property bool trayRecolour: Config.bar.tray.recolour ?? false
@@ -70,6 +71,7 @@ Item {
GlobalConfig.bar.status.showBluetooth = root.showBluetooth;
GlobalConfig.bar.status.showBattery = root.showBattery;
GlobalConfig.bar.status.showLockStatus = root.showLockStatus;
+ GlobalConfig.bar.clock.timer.enabled = root.timerEnabled;
GlobalConfig.bar.tray.background = root.trayBackground;
GlobalConfig.bar.tray.compact = root.trayCompact;
GlobalConfig.bar.tray.recolour = root.trayRecolour;
@@ -580,6 +582,15 @@ Item {
root.saveConfig();
}
}
+
+ SwitchRow {
+ label: qsTr("Enable timer & alarm")
+ checked: root.timerEnabled
+ onToggled: checked => {
+ root.timerEnabled = checked;
+ root.saveConfig();
+ }
+ }
}
SectionContainer {
diff --git a/modules/dashboard/Dash.qml b/modules/dashboard/Dash.qml
index 6785ede8a..bf5dba1a0 100644
--- a/modules/dashboard/Dash.qml
+++ b/modules/dashboard/Dash.qml
@@ -1,103 +1,145 @@
import "dash"
+import QtQuick
import QtQuick.Layouts
import Caelestia.Config
import qs.components
import qs.components.filedialog
import qs.services
-GridLayout {
+Item {
id: root
required property DrawerVisibilities visibilities
required property DashboardState dashState
required property FileDialog facePicker
- rowSpacing: Tokens.spacing.normal
- columnSpacing: Tokens.spacing.normal
+ implicitWidth: grid.implicitWidth
+ implicitHeight: grid.implicitHeight
- Rect {
- Layout.column: 2
- Layout.columnSpan: 3
- Layout.preferredWidth: user.implicitWidth
- Layout.preferredHeight: user.implicitHeight
+ GridLayout {
+ id: grid
+ anchors.fill: parent
- radius: Tokens.rounding.large
+ rowSpacing: Tokens.spacing.normal
+ columnSpacing: Tokens.spacing.normal
- User {
- id: user
+ Rect {
+ Layout.column: 2
+ Layout.columnSpan: 3
+ Layout.preferredWidth: user.implicitWidth
+ Layout.preferredHeight: user.implicitHeight
- visibilities: root.visibilities
- facePicker: root.facePicker
- }
- }
-
- Rect {
- Layout.row: 0
- Layout.columnSpan: 2
- Layout.preferredWidth: Tokens.sizes.dashboard.weatherWidth
- Layout.fillHeight: true
-
- radius: Tokens.rounding.large * 1.5
-
- SmallWeather {}
- }
-
- Rect {
- Layout.row: 1
- Layout.preferredWidth: dateTime.implicitWidth
- Layout.fillHeight: true
+ radius: Tokens.rounding.large
- radius: Tokens.rounding.normal
+ User {
+ id: user
- DateTime {
- id: dateTime
+ visibilities: root.visibilities
+ facePicker: root.facePicker
+ }
}
- }
- Rect {
- Layout.row: 1
- Layout.column: 1
- Layout.columnSpan: 3
- Layout.fillWidth: true
- Layout.preferredHeight: calendar.implicitHeight
+ Rect {
+ Layout.row: 0
+ Layout.columnSpan: 2
+ Layout.preferredWidth: Tokens.sizes.dashboard.weatherWidth
+ Layout.fillHeight: true
- radius: Tokens.rounding.large
+ radius: Tokens.rounding.large * 1.5
- Calendar {
- id: calendar
+ SmallWeather {}
+ }
- dashState: root.dashState
+ // Clock + calendar merged zone. Clock rect expands rightward - no stacking.
+ Item {
+ id: clockCalendarZone
+ Layout.row: 1
+ Layout.column: 0
+ Layout.columnSpan: 4
+ Layout.fillWidth: true
+ Layout.preferredHeight: calendarLoader.implicitHeight
+
+ // Calendar background - disappears instantly when timer opens
+ StyledRect {
+ id: calBg
+ color: Colours.tPalette.m3surfaceContainer
+ radius: Tokens.rounding.large
+ x: Tokens.sizes.dashboard.dateTimeWidth + Tokens.spacing.normal
+ y: 0
+ width: parent.width - x
+ height: parent.height
+ opacity: root.dashState.timerPanelOpen ? 0 : 1
+
+ Loader {
+ id: calendarLoader
+ anchors.fill: parent
+ sourceComponent: Calendar {
+ dashState: root.dashState
+ }
+ }
+ }
+
+ // Clock rect - single rect, expands its right edge to fill the calendar area
+ StyledRect {
+ id: clockBg
+ color: Colours.tPalette.m3surfaceContainer
+ radius: Tokens.rounding.normal
+ x: 0
+ y: 0
+ height: parent.height
+ clip: true
+
+ width: root.dashState.timerPanelOpen
+ ? parent.width
+ : Tokens.sizes.dashboard.dateTimeWidth
+
+ Behavior on width { Anim { type: Anim.DefaultSpatial } }
+
+ // Panel fills the full clockBg so content centers in the whole menu
+ DashTimerPanel {
+ anchors.fill: parent
+ visible: root.dashState.timerPanelOpen
+ dashState: root.dashState
+ }
+
+ // DateTime on top (z:1) so the back button stays visible over the panel
+ DateTime {
+ id: dateTime
+ z: 1
+ dashState: root.dashState
+ }
+ }
}
- }
- Rect {
- Layout.row: 1
- Layout.column: 4
- Layout.preferredWidth: resources.implicitWidth
- Layout.fillHeight: true
+ Rect {
+ Layout.row: 1
+ Layout.column: 4
+ Layout.preferredWidth: resources.implicitWidth
+ Layout.fillHeight: true
- radius: Tokens.rounding.normal
+ radius: Tokens.rounding.normal
- Resources {
- id: resources
+ Resources {
+ id: resources
+ }
}
- }
- Rect {
- Layout.row: 0
- Layout.column: 5
- Layout.rowSpan: 2
- Layout.preferredWidth: media.implicitWidth
- Layout.fillHeight: true
+ Rect {
+ Layout.row: 0
+ Layout.column: 5
+ Layout.rowSpan: 2
+ Layout.preferredWidth: media.implicitWidth
+ Layout.fillHeight: true
- radius: Tokens.rounding.large * 2
+ radius: Tokens.rounding.large * 2
- Media {
- id: media
+ Media {
+ id: media
+ }
}
- }
- component Rect: StyledRect {
- color: Colours.tPalette.m3surfaceContainer
+ component Rect: StyledRect {
+ color: Colours.tPalette.m3surfaceContainer
+ }
}
}
diff --git a/modules/dashboard/FiringOverlay.qml b/modules/dashboard/FiringOverlay.qml
new file mode 100644
index 000000000..8aa2f5e74
--- /dev/null
+++ b/modules/dashboard/FiringOverlay.qml
@@ -0,0 +1,59 @@
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import QtQuick.Layouts
+import Caelestia.Config
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.utils
+
+Item {
+ id: root
+
+ required property DrawerVisibilities visibilities
+
+ function dismiss(): void {
+ TimerService.timerDone = false;
+ AlarmService.alarmFired = false;
+ root.visibilities.fireOverlay = false;
+ root.visibilities.dashboard = false;
+ }
+
+ ColumnLayout {
+ anchors.centerIn: parent
+ spacing: Tokens.spacing.normal
+
+ AnimatedImage {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.preferredWidth: 300
+ Layout.preferredHeight: 220
+
+ source: Paths.absolutePath(GlobalConfig.paths.mediaGif)
+ speed: 2.0
+ playing: true
+ fillMode: AnimatedImage.PreserveAspectFit
+ asynchronous: true
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: qsTr("Your time is up!")
+ font.pointSize: Tokens.font.size.extraLarge
+ font.weight: 600
+ horizontalAlignment: Text.AlignHCenter
+ color: Colours.palette.m3onSurface
+ }
+
+ IconTextButton {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.minimumWidth: 200
+ inactiveColour: Colours.palette.m3primaryContainer
+ inactiveOnColour: Colours.palette.m3onPrimaryContainer
+ verticalPadding: Tokens.padding.normal
+ text: qsTr("Dismiss")
+ icon: "check"
+ onClicked: root.dismiss()
+ }
+ }
+}
diff --git a/modules/dashboard/Wrapper.qml b/modules/dashboard/Wrapper.qml
index f7f037426..64a621b46 100644
--- a/modules/dashboard/Wrapper.qml
+++ b/modules/dashboard/Wrapper.qml
@@ -6,13 +6,14 @@ import Caelestia
import Caelestia.Config
import qs.components
import qs.components.filedialog
+import qs.services
import qs.utils
Item {
id: root
required property DrawerVisibilities visibilities
- readonly property bool needsKeyboard: (content.item as Content)?.needsKeyboard ?? false
+ readonly property bool needsKeyboard: !fireActive && (dashState.timerPanelOpen || ((content.item as Content)?.needsKeyboard ?? false))
readonly property DashboardState dashState: DashboardState {
reloadableId: "dashboardState"
}
@@ -28,14 +29,33 @@ Item {
}
}
- readonly property real nonAnimHeight: state === "visible" ? ((content.item as Content)?.nonAnimHeight ?? 0) : 0
- readonly property bool shouldBeActive: visibilities.dashboard && Config.dashboard.enabled
+ readonly property bool fireActive: TimerService.timerDone || AlarmService.alarmFired
+ property bool _wasOpenBeforeFire: false
+
+ onFireActiveChanged: {
+ if (fireActive && !_wasOpenBeforeFire && !LockState.locked) {
+ _wasOpenBeforeFire = visibilities.dashboard;
+ visibilities.dashboard = true;
+ }
+ }
+
+ Connections {
+ target: LockState
+ function onLockedChanged(): void {
+ if (!LockState.locked && root.fireActive && !root.visibilities.dashboard) {
+ root.visibilities.dashboard = true;
+ }
+ }
+ }
+
+ readonly property real nonAnimHeight: !fireActive ? ((content.item as Content)?.nonAnimHeight ?? 0) : 0
+ readonly property bool shouldBeActive: (visibilities.dashboard && Config.dashboard.enabled) || fireActive
property real offsetScale: shouldBeActive ? 0 : 1
visible: offsetScale < 1
anchors.topMargin: (-implicitHeight - 5) * offsetScale
- implicitHeight: content.implicitHeight
- implicitWidth: content.implicitWidth || 854 // Hard coded fallback for first open
+ implicitHeight: fireActive ? (fireContent.implicitHeight || 400) : (content.implicitHeight)
+ implicitWidth: fireActive ? (fireContent.implicitWidth || 800) : (content.implicitWidth || 854)
opacity: 1 - offsetScale
Behavior on offsetScale {
@@ -50,7 +70,7 @@ Item {
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
- active: root.shouldBeActive || root.visible
+ active: root.shouldBeActive && !root.fireActive
sourceComponent: Content {
visibilities: root.visibilities
@@ -58,4 +78,16 @@ Item {
facePicker: root.facePicker
}
}
+
+ Loader {
+ id: fireContent
+
+ anchors.fill: parent
+
+ active: root.fireActive
+
+ sourceComponent: FiringOverlay {
+ visibilities: root.visibilities
+ }
+ }
}
diff --git a/modules/dashboard/dash/Calendar.qml b/modules/dashboard/dash/Calendar.qml
index e2af3c015..fdbcc7fa9 100644
--- a/modules/dashboard/dash/Calendar.qml
+++ b/modules/dashboard/dash/Calendar.qml
@@ -26,10 +26,16 @@ CustomMouseArea {
anchors.left: parent.left
anchors.right: parent.right
- implicitHeight: inner.implicitHeight + inner.anchors.margins * 2
+ clip: true
+ implicitHeight: inner.anchors.margins * 2 +
+ monthNavigationRow.implicitHeight + inner.spacing +
+ daysRow.implicitHeight + inner.spacing +
+ calGridItem.implicitHeight
acceptedButtons: Qt.MiddleButton
- onClicked: root.dashState.currentDate = new Date()
+ onClicked: {
+ root.dashState.currentDate = new Date();
+ }
ColumnLayout {
id: inner
@@ -38,6 +44,7 @@ CustomMouseArea {
anchors.margins: Tokens.padding.large
spacing: Tokens.spacing.small
+ // Month navigation row
RowLayout {
id: monthNavigationRow
@@ -49,15 +56,12 @@ CustomMouseArea {
implicitHeight: prevMonthText.implicitHeight + Tokens.padding.small * 2
StateLayer {
- id: prevMonthStateLayer
-
radius: Tokens.rounding.full
onClicked: root.dashState.currentDate = new Date(root.currYear, root.currMonth - 1, 1)
}
MaterialIcon {
id: prevMonthText
-
anchors.centerIn: parent
text: "chevron_left"
color: Colours.palette.m3tertiary
@@ -68,30 +72,21 @@ CustomMouseArea {
Item {
Layout.fillWidth: true
-
implicitWidth: monthYearDisplay.implicitWidth + Tokens.padding.small * 2
implicitHeight: monthYearDisplay.implicitHeight + Tokens.padding.small * 2
StateLayer {
- onClicked: {
- root.dashState.currentDate = new Date();
- }
-
+ onClicked: root.dashState.currentDate = new Date()
anchors.fill: monthYearDisplay
anchors.margins: -Tokens.padding.small
anchors.leftMargin: -Tokens.padding.normal
anchors.rightMargin: -Tokens.padding.normal
-
radius: Tokens.rounding.full
- disabled: {
- const now = new Date();
- return root.currMonth === now.getMonth() && root.currYear === now.getFullYear();
- }
+ disabled: root.currMonth === new Date().getMonth() && root.currYear === new Date().getFullYear()
}
StyledText {
id: monthYearDisplay
-
anchors.centerIn: parent
text: grid.title
color: Colours.palette.m3primary
@@ -106,18 +101,12 @@ CustomMouseArea {
implicitHeight: nextMonthText.implicitHeight + Tokens.padding.small * 2
StateLayer {
- id: nextMonthStateLayer
-
- onClicked: {
- root.dashState.currentDate = new Date(root.currYear, root.currMonth + 1, 1);
- }
-
+ onClicked: root.dashState.currentDate = new Date(root.currYear, root.currMonth + 1, 1)
radius: Tokens.rounding.full
}
MaterialIcon {
id: nextMonthText
-
anchors.centerIn: parent
text: "chevron_right"
color: Colours.palette.m3tertiary
@@ -125,8 +114,10 @@ CustomMouseArea {
font.weight: 700
}
}
+
}
+ // Day-of-week header
DayOfWeekRow {
id: daysRow
@@ -135,7 +126,6 @@ CustomMouseArea {
delegate: StyledText {
required property var model
-
horizontalAlignment: Text.AlignHCenter
text: model.shortName
font.weight: 500
@@ -143,7 +133,9 @@ CustomMouseArea {
}
}
+ // Calendar grid
Item {
+ id: calGridItem
Layout.fillWidth: true
implicitHeight: grid.implicitHeight
@@ -164,10 +156,10 @@ CustomMouseArea {
required property var model
implicitWidth: implicitHeight
- implicitHeight: text.implicitHeight + Tokens.padding.small * 2
+ implicitHeight: dayText.implicitHeight + Tokens.padding.small * 2
StyledText {
- id: text
+ id: dayText
anchors.centerIn: parent
@@ -177,13 +169,13 @@ CustomMouseArea {
const dayOfWeek = dayItem.model.date.getUTCDay();
if (dayOfWeek === 0 || dayOfWeek === 6)
return Colours.palette.m3secondary;
-
return Colours.palette.m3onSurfaceVariant;
}
opacity: dayItem.model.today || dayItem.model.month === grid.month ? 1 : 0.4
font.pointSize: Tokens.font.size.normal
font.weight: 500
}
+
}
}
@@ -223,26 +215,18 @@ CustomMouseArea {
colorizationColor: Colours.palette.m3onPrimary
}
- Behavior on opacity {
- Anim {}
- }
-
- Behavior on scale {
- Anim {}
- }
+ Behavior on opacity { Anim {} }
+ Behavior on scale { Anim {} }
Behavior on x {
- Anim {
- type: Anim.DefaultSpatial
- }
+ Anim { type: Anim.DefaultSpatial }
}
Behavior on y {
- Anim {
- type: Anim.DefaultSpatial
- }
+ Anim { type: Anim.DefaultSpatial }
}
}
}
}
+
}
diff --git a/modules/dashboard/dash/DashTimerPanel.qml b/modules/dashboard/dash/DashTimerPanel.qml
new file mode 100644
index 000000000..195fd0441
--- /dev/null
+++ b/modules/dashboard/dash/DashTimerPanel.qml
@@ -0,0 +1,471 @@
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import QtQuick.Layouts
+import Caelestia.Config
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.utils
+
+Item {
+ id: root
+
+ required property DashboardState dashState
+
+ property int timerHours: 0
+ property int timerMinutes: 0
+ property int timerSeconds: 0
+
+ property int alarmHours: AlarmService.alarmHour
+ property int alarmMinutes: AlarmService.alarmMinute
+
+ // Tab column: square buttons (width = each button's height = total height / 3)
+ ColumnLayout {
+ id: tabCol
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.right: parent.right
+ width: height / 3 * 0.8
+ spacing: 0
+ z: 1
+
+ TabBtn {
+ btnIcon: "timer"
+ btnText: qsTr("Timer")
+ btnActive: root.dashState.timerPanelTab === 0
+ topLeftR: Tokens.rounding.normal
+ topRightR: Tokens.rounding.normal
+ onClicked: root.dashState.timerPanelTab = 0
+ }
+
+ TabBtn {
+ btnIcon: "alarm"
+ btnText: qsTr("Alarm")
+ btnActive: root.dashState.timerPanelTab === 1
+ bottomLeftR: Tokens.rounding.normal
+ bottomRightR: Tokens.rounding.normal
+ onClicked: root.dashState.timerPanelTab = 1
+ }
+ }
+
+ // Content with vertical slide animation between tabs
+ Item {
+ anchors.fill: parent
+ anchors.margins: Tokens.padding.normal
+ clip: true
+
+ // Tab 0: Timer
+ Item {
+ y: (0 - root.dashState.timerPanelTab) * parent.height
+ width: parent.width
+ height: parent.height
+ Behavior on y { Anim { type: Anim.DefaultSpatial } }
+
+ BackBtn { dashState: root.dashState }
+
+ ColumnLayout {
+ anchors.verticalCenter: parent.verticalCenter
+ x: (parent.width - tabCol.width - width) / 2
+ width: parent.width - Tokens.sizes.dashboard.dateTimeWidth - Tokens.spacing.normal
+ spacing: Tokens.spacing.small
+
+ RowLayout {
+ Layout.alignment: Qt.AlignHCenter
+ spacing: Tokens.spacing.small
+
+ SpinGroup {
+ readOnly: TimerService.active
+ max: 23
+ value: TimerService.active
+ ? Math.floor(TimerService.remainingSeconds / 3600)
+ : root.timerHours
+ onValueModified: v => root.timerHours = v
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignVCenter
+ text: ":"
+ font.pointSize: Tokens.font.size.larger
+ font.family: Tokens.font.family.mono
+ color: Colours.palette.m3onSurfaceVariant
+ }
+
+ SpinGroup {
+ readOnly: TimerService.active
+ max: 59
+ value: TimerService.active
+ ? Math.floor((TimerService.remainingSeconds % 3600) / 60)
+ : root.timerMinutes
+ onValueModified: v => root.timerMinutes = v
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignVCenter
+ text: ":"
+ font.pointSize: Tokens.font.size.larger
+ font.family: Tokens.font.family.mono
+ color: Colours.palette.m3onSurfaceVariant
+ }
+
+ SpinGroup {
+ readOnly: TimerService.active
+ max: 59
+ value: TimerService.active
+ ? (TimerService.remainingSeconds % 60)
+ : root.timerSeconds
+ onValueModified: v => root.timerSeconds = v
+ }
+ }
+
+ Item {
+ Layout.fillWidth: true
+ implicitHeight: 4
+ opacity: TimerService.active ? 1 : 0
+ Behavior on opacity { Anim {} }
+
+ StyledRect {
+ width: parent.width
+ height: parent.height
+ radius: 2
+ color: Colours.tPalette.m3surfaceContainerHigh
+ }
+
+ StyledRect {
+ width: Math.max(radius * 2, TimerService.progress * parent.width)
+ height: parent.height
+ radius: 2
+ color: Colours.palette.m3primary
+ Behavior on width { Anim {} }
+ }
+ }
+ }
+
+ ActionBtn {
+ visible: !TimerService.active
+ x: (parent.width - tabCol.width - width) / 2
+ width: parent.width - Tokens.sizes.dashboard.dateTimeWidth - Tokens.spacing.normal
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: Tokens.padding.normal
+ inactiveColour: Colours.palette.m3primaryContainer
+ inactiveOnColour: Colours.palette.m3onPrimaryContainer
+ text: qsTr("Start")
+ icon: "play_arrow"
+ onClicked: TimerService.start(root.timerHours, root.timerMinutes, root.timerSeconds)
+ }
+
+ RowLayout {
+ visible: TimerService.active
+ x: (parent.width - tabCol.width - width) / 2
+ width: parent.width - Tokens.sizes.dashboard.dateTimeWidth - Tokens.spacing.normal
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: Tokens.padding.normal
+ spacing: Tokens.spacing.small
+
+ ActionBtn {
+ Layout.fillWidth: true
+ inactiveColour: Colours.palette.m3secondaryContainer
+ inactiveOnColour: Colours.palette.m3onSecondaryContainer
+ text: TimerService.running ? qsTr("Pause") : qsTr("Resume")
+ icon: TimerService.running ? "pause" : "play_arrow"
+ onClicked: TimerService.running ? TimerService.pause() : TimerService.resume()
+ }
+
+ ActionBtn {
+ inactiveColour: Colours.palette.m3errorContainer
+ inactiveOnColour: Colours.palette.m3onErrorContainer
+ text: qsTr("Cancel")
+ icon: "close"
+ onClicked: TimerService.cancel()
+ }
+ }
+ }
+
+ // Tab 1: Alarm
+ Item {
+ y: (1 - root.dashState.timerPanelTab) * parent.height
+ width: parent.width
+ height: parent.height
+ Behavior on y { Anim { type: Anim.DefaultSpatial } }
+
+ BackBtn { dashState: root.dashState }
+
+ ColumnLayout {
+ anchors.verticalCenter: parent.verticalCenter
+ x: (parent.width - tabCol.width - width) / 2
+ width: parent.width - Tokens.sizes.dashboard.dateTimeWidth - Tokens.spacing.normal
+ spacing: Tokens.spacing.small
+
+ RowLayout {
+ Layout.alignment: Qt.AlignHCenter
+ spacing: Tokens.spacing.small
+
+ SpinGroup {
+ readOnly: AlarmService.active
+ max: 23
+ value: AlarmService.active ? AlarmService.alarmHour : root.alarmHours
+ onValueModified: v => root.alarmHours = v
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignVCenter
+ text: ":"
+ font.pointSize: Tokens.font.size.larger
+ font.family: Tokens.font.family.mono
+ color: Colours.palette.m3onSurfaceVariant
+ }
+
+ SpinGroup {
+ readOnly: AlarmService.active
+ max: 59
+ value: AlarmService.active ? AlarmService.alarmMinute : root.alarmMinutes
+ onValueModified: v => root.alarmMinutes = v
+ }
+ }
+ }
+
+ ActionBtn {
+ visible: !AlarmService.active
+ x: (parent.width - tabCol.width - width) / 2
+ width: parent.width - Tokens.sizes.dashboard.dateTimeWidth - Tokens.spacing.normal
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: Tokens.padding.normal
+ inactiveColour: Colours.palette.m3primaryContainer
+ inactiveOnColour: Colours.palette.m3onPrimaryContainer
+ text: qsTr("Set alarm")
+ icon: "alarm"
+ onClicked: AlarmService.setAlarm(root.alarmHours, root.alarmMinutes)
+ }
+
+ ActionBtn {
+ visible: AlarmService.active
+ x: (parent.width - tabCol.width - width) / 2
+ width: parent.width - Tokens.sizes.dashboard.dateTimeWidth - Tokens.spacing.normal
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: Tokens.padding.normal
+ inactiveColour: Colours.palette.m3errorContainer
+ inactiveOnColour: Colours.palette.m3onErrorContainer
+ text: qsTr("Cancel alarm")
+ icon: "close"
+ onClicked: AlarmService.cancelAlarm()
+ }
+ }
+
+ }
+
+ component TabBtn: StyledRect {
+ id: btn
+
+ property bool btnActive: false
+ property string btnIcon: ""
+ property string btnText: ""
+ property real topLeftR: 0
+ property real topRightR: 0
+ property real bottomLeftR: 0
+ property real bottomRightR: 0
+
+ signal clicked()
+
+ Layout.fillHeight: true
+ Layout.fillWidth: true
+
+ radius: 0
+ topLeftRadius: topLeftR
+ topRightRadius: topRightR
+ bottomLeftRadius: bottomLeftR
+ bottomRightRadius: bottomRightR
+
+ color: btnActive
+ ? Colours.palette.m3primaryContainer
+ : Colours.tPalette.m3surfaceContainerHigh
+
+ Rectangle {
+ anchors.fill: parent
+ radius: 0
+ topLeftRadius: btn.topLeftR
+ topRightRadius: btn.topRightR
+ bottomLeftRadius: btn.bottomLeftR
+ bottomRightRadius: btn.bottomRightR
+ color: Colours.palette.m3onSurface
+ opacity: btnMouse.containsPress ? 0.15 : btnMouse.containsMouse ? 0.08 : 0
+ Behavior on opacity { NumberAnimation { duration: 150 } }
+
+ MouseArea {
+ id: btnMouse
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: btn.clicked()
+ }
+ }
+
+ ColumnLayout {
+ anchors.centerIn: parent
+ spacing: Tokens.spacing.smaller
+
+ MaterialIcon {
+ Layout.alignment: Qt.AlignHCenter
+ text: btn.btnIcon
+ font.pointSize: Tokens.font.size.normal
+ color: btn.btnActive
+ ? Colours.palette.m3onPrimaryContainer
+ : Colours.palette.m3onSurfaceVariant
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: btn.btnText
+ font.pointSize: Tokens.font.size.small
+ color: btn.btnActive
+ ? Colours.palette.m3onPrimaryContainer
+ : Colours.palette.m3onSurfaceVariant
+ }
+ }
+ }
+
+ component BackBtn: StyledRect {
+ required property DashboardState dashState
+
+ implicitWidth: implicitHeight
+ implicitHeight: _backIcon.implicitHeight + Tokens.padding.small * 2
+ radius: Tokens.rounding.full
+ color: Colours.tPalette.m3surfaceContainerHigh
+
+ StateLayer {
+ radius: parent.radius
+ onClicked: {
+ dashState.timerPanelOpen = false;
+ dashState.timerPanelTab = 0;
+ }
+ }
+
+ MaterialIcon {
+ id: _backIcon
+ anchors.centerIn: parent
+ text: "arrow_back"
+ color: Colours.palette.m3onSurfaceVariant
+ font.pointSize: Tokens.font.size.normal
+ }
+ }
+
+ component SpinGroup: StyledRect {
+ id: spinGroup
+
+ property int value: 0
+ property int min: 0
+ property int max: 99
+ property bool readOnly: false
+
+ signal valueModified(int v)
+
+ onValueChanged: {
+ if (!readOnly && !numInput.activeFocus)
+ numInput.text = String(value).padStart(2, "0");
+ }
+
+ color: readOnly ? "transparent" : Colours.tPalette.m3surfaceContainerHigh
+ radius: Tokens.rounding.normal
+ implicitWidth: numSizer.implicitWidth + Tokens.padding.large * 2
+ implicitHeight: numSizer.implicitHeight + Tokens.padding.large * 2
+
+ Behavior on color { ColorAnimation { duration: 150 } }
+
+ StyledText {
+ id: numSizer
+ visible: false
+ text: "00"
+ font.pointSize: Tokens.font.size.extraLarge
+ font.family: Tokens.font.family.mono
+ }
+
+ MouseArea {
+ id: spinMouse
+ anchors.fill: parent
+ enabled: !spinGroup.readOnly
+ hoverEnabled: true
+ cursorShape: Qt.SizeVerCursor
+ onClicked: numInput.forceActiveFocus()
+ onWheel: wheel => {
+ if (wheel.angleDelta.y > 0) {
+ spinGroup.valueModified(Math.min(spinGroup.max, spinGroup.value + 1));
+ } else if (wheel.angleDelta.y < 0) {
+ spinGroup.valueModified(Math.max(spinGroup.min, spinGroup.value - 1));
+ }
+ wheel.accepted = true;
+ }
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ radius: spinGroup.radius
+ color: Colours.palette.m3onSurface
+ opacity: !spinGroup.readOnly && spinMouse.containsMouse ? 0.08 : 0
+ Behavior on opacity { NumberAnimation { duration: 150 } }
+ }
+
+ TextInput {
+ id: numInput
+ anchors.centerIn: parent
+ width: numSizer.implicitWidth
+ visible: !spinGroup.readOnly
+
+ Component.onCompleted: text = String(spinGroup.value).padStart(2, "0")
+ font.pointSize: Tokens.font.size.extraLarge
+ font.family: Tokens.font.family.mono
+ color: Colours.palette.m3onSurface
+ selectionColor: Colours.palette.m3primary
+ selectedTextColor: Colours.palette.m3onPrimary
+ horizontalAlignment: TextInput.AlignHCenter
+ inputMethodHints: Qt.ImhDigitsOnly
+ maximumLength: 2
+ selectByMouse: true
+
+ function commit(): void {
+ const v = Math.min(spinGroup.max, Math.max(spinGroup.min, parseInt(text) || 0));
+ spinGroup.valueModified(v);
+ text = String(v).padStart(2, "0");
+ }
+
+ onTextEdited: {
+ const v = Math.min(spinGroup.max, Math.max(spinGroup.min, parseInt(text) || 0));
+ spinGroup.valueModified(v);
+ }
+ onEditingFinished: commit()
+ onActiveFocusChanged: {
+ if (activeFocus)
+ selectAll();
+ else
+ commit();
+ }
+ Keys.onEscapePressed: {
+ text = String(spinGroup.value).padStart(2, "0");
+ focus = false;
+ }
+ }
+
+ StyledText {
+ anchors.centerIn: parent
+ visible: spinGroup.readOnly
+ text: String(spinGroup.value).padStart(2, "0")
+ font.pointSize: Tokens.font.size.extraLarge
+ font.family: Tokens.font.family.mono
+ color: Colours.palette.m3onSurface
+ horizontalAlignment: Text.AlignHCenter
+ }
+ }
+
+ component ActionBtn: IconTextButton {
+ verticalPadding: Tokens.padding.small
+ radius: stateLayer.pressed
+ ? Tokens.rounding.small / 2
+ : implicitHeight / 2 * Math.min(1, Tokens.rounding.scale)
+ scale: stateLayer.pressed ? 1.06 : 1.0
+
+ Behavior on radius {
+ Anim { type: Anim.FastSpatial }
+ }
+ Behavior on scale {
+ Anim { type: Anim.FastSpatial }
+ }
+ }
+}
diff --git a/modules/dashboard/dash/DateTime.qml b/modules/dashboard/dash/DateTime.qml
index 82bee151c..611e65617 100644
--- a/modules/dashboard/dash/DateTime.qml
+++ b/modules/dashboard/dash/DateTime.qml
@@ -13,11 +13,23 @@ Item {
anchors.bottom: parent.bottom
implicitWidth: Tokens.sizes.dashboard.dateTimeWidth
+ required property DashboardState dashState
+
+ // Only clickable when panel is closed - opens the timer panel
+ StateLayer {
+ anchors.fill: parent
+ radius: Tokens.rounding.normal
+ enabled: !root.dashState.timerPanelOpen
+ onClicked: root.dashState.timerPanelOpen = true
+ }
+
+ // Clock - visible when panel is closed
ColumnLayout {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 0
+ visible: !root.dashState.timerPanelOpen
StyledText {
Layout.bottomMargin: -(font.pointSize * 0.4)
@@ -63,4 +75,6 @@ Item {
}
}
}
+
+
}
diff --git a/modules/drawers/Interactions.qml b/modules/drawers/Interactions.qml
index b89cb0077..e492c9b17 100644
--- a/modules/drawers/Interactions.qml
+++ b/modules/drawers/Interactions.qml
@@ -78,7 +78,7 @@ CustomMouseArea {
if (!utilitiesShortcutActive)
visibilities.utilities = false;
- if (!popouts.currentName.startsWith("traymenu") || ((popouts.current as StackView)?.depth ?? 0) <= 1) {
+ if (!popouts.locked && (!popouts.currentName.startsWith("traymenu") || ((popouts.current as StackView)?.depth ?? 0) <= 1)) {
popouts.hasCurrent = false;
bar.closeTray();
}
@@ -216,7 +216,7 @@ CustomMouseArea {
// Show popouts on hover
if (x < bar.implicitWidth) {
bar.checkPopout(y);
- } else if ((!popouts.currentName.startsWith("traymenu") || ((popouts.current as StackView)?.depth ?? 0) <= 1) && !inLeftPanel(panels.popoutsWrapper, x, y)) {
+ } else if (!popouts.locked && (!popouts.currentName.startsWith("traymenu") || ((popouts.current as StackView)?.depth ?? 0) <= 1) && !inLeftPanel(panels.popoutsWrapper, x, y)) {
popouts.hasCurrent = false;
bar.closeTray();
}
diff --git a/modules/lock/Lock.qml b/modules/lock/Lock.qml
index f852cb7ff..3fe8d9f64 100644
--- a/modules/lock/Lock.qml
+++ b/modules/lock/Lock.qml
@@ -5,6 +5,7 @@ import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import qs.components.misc
+import qs.services
Scope {
property alias lock: lock
@@ -12,6 +13,8 @@ Scope {
WlSessionLock {
id: lock
+ onLockedChanged: LockState.locked = locked
+
signal unlock
LockSurface {
diff --git a/plugin/src/Caelestia/Config/barconfig.hpp b/plugin/src/Caelestia/Config/barconfig.hpp
index 43d43205a..2e08631f7 100644
--- a/plugin/src/Caelestia/Config/barconfig.hpp
+++ b/plugin/src/Caelestia/Config/barconfig.hpp
@@ -110,6 +110,19 @@ class BarStatus : public ConfigObject {
: ConfigObject(parent) {}
};
+class BarTimerConfig : public ConfigObject {
+ Q_OBJECT
+ QML_ANONYMOUS
+
+ CONFIG_PROPERTY(bool, enabled, true)
+ CONFIG_GLOBAL_PROPERTY(QString, soundFile, u"/etc/xdg/quickshell/caelestia/assets/timer-done.wav"_s)
+ CONFIG_GLOBAL_PROPERTY(int, reminderLeadMinutes, 15)
+
+public:
+ explicit BarTimerConfig(QObject* parent = nullptr)
+ : ConfigObject(parent) {}
+};
+
class BarClock : public ConfigObject {
Q_OBJECT
QML_ANONYMOUS
@@ -117,10 +130,12 @@ class BarClock : public ConfigObject {
CONFIG_PROPERTY(bool, background, false)
CONFIG_PROPERTY(bool, showDate, false)
CONFIG_PROPERTY(bool, showIcon, true)
+ CONFIG_SUBOBJECT(BarTimerConfig, timer)
public:
explicit BarClock(QObject* parent = nullptr)
- : ConfigObject(parent) {}
+ : ConfigObject(parent)
+ , m_timer(new BarTimerConfig(this)) {}
};
class BarConfig : public ConfigObject {
diff --git a/services/AlarmService.qml b/services/AlarmService.qml
new file mode 100644
index 000000000..666ea3265
--- /dev/null
+++ b/services/AlarmService.qml
@@ -0,0 +1,87 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Caelestia.Config
+
+Singleton {
+ id: root
+
+ property bool active: false
+ property int alarmHour: 0
+ property int alarmMinute: 0
+ property bool alarmFired: false
+
+ readonly property string alarmTimeFormatted: {
+ if (!active)
+ return "";
+ if (GlobalConfig.services.useTwelveHourClock) {
+ const h = alarmHour % 12 || 12;
+ const suffix = alarmHour < 12 ? "AM" : "PM";
+ return `${String(h).padStart(2, "0")}:${String(alarmMinute).padStart(2, "0")} ${suffix}`;
+ }
+ return `${String(alarmHour).padStart(2, "0")}:${String(alarmMinute).padStart(2, "0")}`;
+ }
+
+ signal finished()
+
+ function setAlarm(h: int, m: int): void {
+ const now = new Date();
+ const target = new Date(now.getFullYear(), now.getMonth(), now.getDate(), h, m, 0, 0);
+ if (target.getTime() <= now.getTime())
+ target.setDate(target.getDate() + 1);
+ alarmHour = h;
+ alarmMinute = m;
+ _store.targetMs = target.getTime();
+ alarmFired = false;
+ active = true;
+ }
+
+ function cancelAlarm(): void {
+ active = false;
+ alarmFired = false;
+ _store.targetMs = 0;
+ }
+
+ onFinished: {
+ const sf = GlobalConfig.bar.clock.timer?.soundFile ?? "";
+ if (sf.length > 0)
+ Quickshell.execDetached(["paplay", sf]);
+ }
+
+ Component.onCompleted: {
+ if (_store.targetMs > 0 && _store.targetMs > Date.now()) {
+ const d = new Date(_store.targetMs);
+ alarmHour = d.getHours();
+ alarmMinute = d.getMinutes();
+ active = true;
+ } else {
+ _store.targetMs = 0;
+ }
+ }
+
+ Timer {
+ interval: 15000
+ repeat: true
+ running: root.active
+
+ onTriggered: {
+ if (!root.active || root.alarmFired)
+ return;
+ const now = Date.now();
+ if (now >= _store.targetMs) {
+ root.alarmFired = true;
+ root.active = false;
+ _store.targetMs = 0;
+ root.finished();
+ }
+ }
+ }
+
+ PersistentProperties {
+ id: _store
+ reloadableId: "alarm"
+ property real targetMs: 0
+ }
+}
diff --git a/services/LockState.qml b/services/LockState.qml
new file mode 100644
index 000000000..c9106aac7
--- /dev/null
+++ b/services/LockState.qml
@@ -0,0 +1,7 @@
+pragma Singleton
+
+import Quickshell
+
+Singleton {
+ property bool locked: false
+}
diff --git a/services/TimerService.qml b/services/TimerService.qml
new file mode 100644
index 000000000..de6a14273
--- /dev/null
+++ b/services/TimerService.qml
@@ -0,0 +1,120 @@
+pragma Singleton
+pragma ComponentBehavior: Bound
+
+import QtQuick
+import Quickshell
+import Caelestia.Config
+
+Singleton {
+ id: root
+
+ property bool active: false
+ property bool running: false
+ property int totalSeconds: 0
+ property int remainingSeconds: 0
+ readonly property real progress: totalSeconds > 0 ? Math.max(0, Math.min(1, 1 - remainingSeconds / totalSeconds)) : 0
+
+ readonly property string remainingFormatted: {
+ const s = remainingSeconds;
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const sec = s % 60;
+ if (h > 0)
+ return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
+ return `${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
+ }
+
+ property bool timerDone: false
+
+ signal finished()
+
+ function start(h: int, m: int, s: int): void {
+ const total = h * 3600 + m * 60 + s;
+ if (total <= 0)
+ return;
+ timerDone = false;
+ totalSeconds = total;
+ remainingSeconds = total;
+ endTimeStore.endTime = Date.now() + total * 1000;
+ endTimeStore.total = total;
+ running = true;
+ active = true;
+ countdownTimer.start();
+ }
+
+ function pause(): void {
+ if (!running)
+ return;
+ running = false;
+ countdownTimer.stop();
+ endTimeStore.endTime = Date.now() + remainingSeconds * 1000;
+ }
+
+ function resume(): void {
+ if (running || !active)
+ return;
+ endTimeStore.endTime = Date.now() + remainingSeconds * 1000;
+ running = true;
+ countdownTimer.start();
+ }
+
+ function cancel(): void {
+ countdownTimer.stop();
+ running = false;
+ active = false;
+ totalSeconds = 0;
+ remainingSeconds = 0;
+ endTimeStore.endTime = 0;
+ endTimeStore.total = 0;
+ }
+
+ onFinished: {
+ const sf = GlobalConfig.bar.clock.timer?.soundFile ?? "";
+ if (sf.length > 0)
+ Quickshell.execDetached(["paplay", sf]);
+ }
+
+ Component.onCompleted: {
+ if (endTimeStore.endTime > 0 && endTimeStore.total > 0) {
+ const now = Date.now();
+ if (endTimeStore.endTime > now) {
+ root.totalSeconds = endTimeStore.total;
+ root.remainingSeconds = Math.round((endTimeStore.endTime - now) / 1000);
+ root.running = true;
+ root.active = true;
+ countdownTimer.start();
+ } else {
+ endTimeStore.endTime = 0;
+ endTimeStore.total = 0;
+ }
+ }
+ }
+
+ Timer {
+ id: countdownTimer
+
+ interval: 500
+ repeat: true
+
+ onTriggered: {
+ const remaining = Math.max(0, Math.round((endTimeStore.endTime - Date.now()) / 1000));
+ root.remainingSeconds = remaining;
+ if (remaining <= 0) {
+ countdownTimer.stop();
+ root.running = false;
+ root.active = false;
+ root.timerDone = true;
+ root.finished();
+ }
+ }
+ }
+
+ PersistentProperties {
+ id: endTimeStore
+
+ property real endTime: 0
+ property int total: 0
+
+ reloadableId: "timer"
+ }
+}