import QtQuick import Quickshell import qs.Commons import qs.Ui import "Model.js" as Model // The clock's calendar popup: a month grid with ISO week numbers, built to // sit beside the weather panel — same hero-over-detail composition, same // spacing scale, same small-caps labels. // // The grid itself is the shared CalendarView from qs.Ui: days can be // selected with the pointer or the arrow keys, the month label opens a // month picker, and today is filled with the accent color. // // BarWidget.qml owns the bar label and hands this panel the button to // anchor against. Panel { id: root moduleName: "blob.clock" ipcTarget: "blob.clock" manageIpc: false property var anchorItem: null // The bar tracks the widget mounted in its slot — BarWidget.qml — not this // nested panel. Everything the bar identifies a panel by has to be that // widget: the popout coordinator (and with it the open-panel dot under the // pill) compares against `slot.activeItem`, and switchPanelFrom looks the // slot up the same way. property var hostWidget: null readonly property var barIdentity: hostWidget || root // ---- Today. SystemClock keeps this honest across midnight so the // highlight rolls over without the panel being reopened. property date today: new Date() // Pinned to today, not to the month being browsed — stepping through the // calendar does not change how much of the year is gone. readonly property real yearDone: CalendarModel.yearProgress(today.getFullYear(), today.getMonth(), today.getDate()) readonly property int yearDonePercent: CalendarModel.yearProgressPercent(today.getFullYear(), today.getMonth(), today.getDate()) // Memento mori, for anyone who goes looking: double-tapping the year bar // asks for a birth year and a life expectancy, and a second bar tracks one // against the other. A birth year rather than an age, so it keeps counting // on its own. Without one the bar stays hidden. readonly property int birthYear: Model.parseBirthYear(setting("birthYear", 0), today.getFullYear()) readonly property int age: Model.ageFromBirthYear(birthYear, today.getFullYear()) readonly property int lifeExpectancy: Model.parseLifeExpectancy(setting("lifeExpectancy", 0)) readonly property real lifeDone: Model.lifeProgress(age, lifeExpectancy) readonly property int lifeDonePercent: Model.lifeProgressPercent(age, lifeExpectancy) property bool editingLife: false // Unset falls through to the locale's own first day, so a fresh install // starts out matching the rest of the desktop rather than a hardcoded // convention. Clicking the grid's "W" heading writes the choice back to // shell.json. readonly property int weekStart: CalendarModel.normalizedWeekStart(setting("weekStartDay", null), Qt.locale().firstDayOfWeek) // The interface is English throughout, so day names are not taken from the // system locale. Where the week starts still is: that is a regional // convention rather than a translation, and it stays overridable above. readonly property var labelLocale: Qt.locale("en_US") readonly property string nextWeekStartLabel: labelLocale.dayName(CalendarModel.toggledWeekStart(weekStart), Locale.LongFormat) // Guarded so the widget renders before the bar is injected (the bar-widget // contract instantiates it bare). readonly property color contentForeground: bar ? bar.foreground : Colors.foreground readonly property string contentFontFamily: bar ? bar.fontFamily : Style.font.family function open() { refresh() root.controller.show() // Set after showing, not before: showing hands the popout coordinator // over, which closes whichever panel was open, and that close clears the // shared flag. Deferring means the panel taking over always wins, while // a handoff to a panel that does not manage the flag still leaves it // cleared rather than stuck on. Qt.callLater(function() { if (root.opened) setCenterHoverRevealSuppressed(true) }) } function close() { setCenterHoverRevealSuppressed(false) // Dismissing the panel mid-edit would otherwise leave the inputs up, // waiting behind a closed popup for the next time it opens. if (root.editingLife) root.cancelEditingLife() root.controller.hide() } function toggle() { if (root.opened) root.close() else root.open() } function switchPanel(direction) { if (root.bar && typeof root.bar.switchPanelFrom === "function") return root.bar.switchPanelFrom(root.barIdentity, direction) return false } // Summoning by hotkey moves no pointer, so a hover the bar was still // holding must not keep the center indicators revealed behind the panel. function setCenterHoverRevealSuppressed(value) { if (root.bar && typeof root.bar.setCenterHoverRevealSuppressed === "function") root.bar.setCenterHoverRevealSuppressed(value) else if (root.bar && "centerHoverRevealSuppressed" in root.bar) root.bar.centerHoverRevealSuppressed = value } function refresh() { root.today = new Date() calendar.goToToday() } // Applied locally first so the panel redraws on the click itself; the // shell.json write comes back through the bar as the same value. With no // writable entry (the widget is not in the layout) it stays a session-only // preference rather than doing nothing. The host widget builds its own // entry when the label format is cycled, so it has to be kept in step or // it would write this key straight back out from a stale copy. function persistSettings(values) { var entry = { id: root.moduleName } for (var existing in root.settings) if (existing !== "id") entry[existing] = root.settings[existing] for (var key in values) entry[key] = values[key] root.settings = entry if (root.hostWidget && "settings" in root.hostWidget) root.hostWidget.settings = entry if (root.bar && root.bar.shell && typeof root.bar.shell.updateEntryInline === "function") root.bar.shell.updateEntryInline(root.moduleName, entry) } function setWeekStart(day) { var next = CalendarModel.normalizedWeekStart(day, root.weekStart) if (next === root.weekStart) return persistSettings({ weekStartDay: CalendarModel.weekStartSettingName(next) }) } function startEditingLife() { root.editingLife = true Qt.callLater(function() { bornField.text = root.birthYear > 0 ? String(root.birthYear) : "" expectancyField.text = String(root.lifeExpectancy) bornField.selectAll() bornField.forceActiveFocus() }) } function cancelEditingLife() { root.editingLife = false Qt.callLater(function() { if (keyCatcher) keyCatcher.forceActiveFocus() }) } // Shared by both fields: Tab hops to the other one, Enter commits the pair, // Escape drops the lot. function handleLifeKey(event, other) { if (event.key === Qt.Key_Escape) { root.cancelEditingLife() event.accepted = true } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { root.commitLife() event.accepted = true } else if (event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab) { other.selectAll() other.forceActiveFocus() event.accepted = true } } // Double-tapping the life bar puts it away again. The expectancy stays in // the config so setting a birth year again brings your own number back // rather than the default. function clearLife() { if (root.birthYear <= 0) return persistSettings({ birthYear: 0 }) } function commitLife() { var born = Model.parseBirthYear(bornField.text, today.getFullYear()) var span = Model.parseLifeExpectancy(expectancyField.text) if (born !== root.birthYear || span !== root.lifeExpectancy) persistSettings({ birthYear: born, lifeExpectancy: span }) cancelEditingLife() } function toggleWeekStart() { setWeekStart(CalendarModel.toggledWeekStart(root.weekStart)) } function closeOrDismissPicker() { if (!calendar.dismissPicker()) root.close() } SystemClock { id: clock precision: SystemClock.Minutes onDateChanged: { if (CalendarModel.keyForDate(clock.date) === CalendarModel.keyForDate(root.today)) return var followToday = calendar.selectionIsToday root.today = clock.date if (followToday) calendar.goToToday() } } KeyboardPanel { id: panel anchorItem: root.anchorItem owner: root.barIdentity bar: root.bar open: root.opened centerOnBar: true focusTarget: keyCatcher contentWidth: panel.fittedContentWidth(Style.space(560)) contentHeight: panel.fittedContentHeight(calendarColumn.implicitHeight) PanelKeyCatcher { id: keyCatcher anchors.fill: parent blocked: root.editingLife onMoveRequested: function(dx, dy) { calendar.handleMove(dx, dy) } onActivateRequested: calendar.activate() onCloseRequested: root.closeOrDismissPicker() onTabRequested: function(direction) { root.switchPanel(direction) } onTextKey: function(t) { if (t === "[") calendar.moveMonth(-1) else if (t === "]") calendar.moveMonth(1) else if (t === "{") calendar.moveYear(-1) else if (t === "}") calendar.moveYear(1) else if (t === "t" || t === "T") calendar.goToToday() else if (t === "m" || t === "M") calendar.togglePicker() else if (t === "w" || t === "W") root.toggleWeekStart() } Flickable { id: calendarScroll anchors.fill: parent contentWidth: calendarColumn.width contentHeight: calendarColumn.implicitHeight clip: true boundsBehavior: Flickable.StopAtBounds interactive: contentHeight > height || contentWidth > width Column { id: calendarColumn // Never narrower than the grid. The popup width is capped to what // the screen allows, and a fixed seven-column grid would otherwise // lose its last days off the edge instead of scrolling. width: Math.max(calendarScroll.width, calendar.gridWidth) spacing: Style.space(8) // ---- Hero: today, centered. Once the view has stepped back // it is also the way home — clicking the date you are // looking for beats hunting for a reset button. Item { width: parent.width height: heroRow.height Row { id: heroRow anchors.horizontalCenter: parent.horizontalCenter spacing: Style.space(22) Text { // Baseline-aligned, not center-aligned: "July 26" carries a // descender, so centering the two boxes leaves the icon // sitting visibly low against the digits. anchors.baseline: heroDate.baseline text: "󰃭" color: heroMouse.containsMouse ? Style.hoverStateColor(root.contentForeground, Colors.accent) : root.contentForeground font.family: root.contentFontFamily // Decorative, and deliberately outside the Style.font.* // scale. Sized so the glyph reads at the cap height of the // date beside it rather than towering over it. font.pixelSize: 48 } Text { id: heroDate textFormat: Text.PlainText anchors.verticalCenter: parent.verticalCenter text: Qt.formatDate(root.today, "MMMM d") color: heroMouse.containsMouse ? Style.hoverStateColor(root.contentForeground, Colors.accent) : root.contentForeground font.family: root.contentFontFamily font.pixelSize: 52 font.bold: true } } MouseArea { id: heroMouse x: heroRow.x y: heroRow.y width: heroRow.width height: heroRow.height enabled: !calendar.selectionIsToday || calendar.pickingMonth hoverEnabled: enabled cursorShape: Qt.PointingHandCursor onClicked: calendar.goToToday() PanelToolTip { visible: heroMouse.containsMouse text: "Back to today" fontFamily: root.contentFontFamily } } } // ---- Year progress, doubling as the rule under the hero: // a plain hairline said nothing, and whole days done // over days in the year says the same thing louder. Item { width: parent.width height: yearBlock.y + yearBlock.height Item { id: yearBlock y: Style.space(6) anchors.horizontalCenter: parent.horizontalCenter width: calendar.gridWidth height: Math.max(yearLabel.implicitHeight, Style.space(10)) TapHandler { enabled: !root.editingLife onDoubleTapped: root.startEditingLife() } Row { visible: root.editingLife anchors.horizontalCenter: parent.horizontalCenter anchors.verticalCenter: parent.verticalCenter spacing: Style.space(10) Text { anchors.verticalCenter: parent.verticalCenter text: "BORN" color: Qt.darker(root.contentForeground, 1.5) font.family: root.contentFontFamily font.pixelSize: Style.font.bodySmall font.letterSpacing: 1 } TextField { id: bornField width: Style.space(70) anchors.verticalCenter: parent.verticalCenter placeholderText: "year" foreground: root.contentForeground font.family: root.contentFontFamily inputMethodHints: Qt.ImhDigitsOnly Keys.onPressed: function(event) { root.handleLifeKey(event, expectancyField) } } Text { anchors.verticalCenter: parent.verticalCenter anchors.verticalCenterOffset: 0 leftPadding: Style.space(6) text: "LIVE TO" color: Qt.darker(root.contentForeground, 1.5) font.family: root.contentFontFamily font.pixelSize: Style.font.bodySmall font.letterSpacing: 1 } TextField { id: expectancyField width: Style.space(60) anchors.verticalCenter: parent.verticalCenter placeholderText: "90" foreground: root.contentForeground font.family: root.contentFontFamily inputMethodHints: Qt.ImhDigitsOnly Keys.onPressed: function(event) { root.handleLifeKey(event, bornField) } } } Text { id: yearLabel textFormat: Text.PlainText visible: !root.editingLife anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter text: root.today.getFullYear() color: Qt.darker(root.contentForeground, 1.5) font.family: root.contentFontFamily font.pixelSize: Style.font.bodySmall font.letterSpacing: 1 } Text { id: yearPercent textFormat: Text.PlainText visible: !root.editingLife anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter text: root.yearDonePercent + "%" color: root.contentForeground font.family: root.contentFontFamily font.pixelSize: Style.font.bodySmall } Rectangle { id: yearTrack visible: !root.editingLife anchors.left: yearLabel.right anchors.right: yearPercent.left anchors.leftMargin: Style.space(12) anchors.rightMargin: Style.space(12) anchors.verticalCenter: parent.verticalCenter height: Style.space(6) radius: Style.cornerRadius > 0 ? height / 2 : 0 color: Qt.rgba(root.contentForeground.r, root.contentForeground.g, root.contentForeground.b, 0.12) Rectangle { width: Math.round(parent.width * root.yearDone) height: parent.height radius: parent.radius color: Style.selectedStateColor(root.contentForeground, Colors.accent) Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } } } } } // ---- Memento mori. Only here once someone has gone looking and // given an age; the same rail as the year above it, measured // against a nominal lifetime. Item { visible: root.birthYear > 0 width: parent.width height: visible ? lifeBlock.height : 0 Item { id: lifeBlock anchors.horizontalCenter: parent.horizontalCenter width: calendar.gridWidth height: Math.max(lifeLabel.implicitHeight, Style.space(10)) Text { id: lifeLabel anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter text: "LIFE" color: Qt.darker(root.contentForeground, 1.5) font.family: root.contentFontFamily font.pixelSize: Style.font.bodySmall font.letterSpacing: 1 } Text { id: lifePercent textFormat: Text.PlainText anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter text: root.lifeDonePercent + "%" color: root.contentForeground font.family: root.contentFontFamily font.pixelSize: Style.font.bodySmall } Rectangle { anchors.left: lifeLabel.right anchors.right: lifePercent.left anchors.leftMargin: Style.space(12) anchors.rightMargin: Style.space(12) anchors.verticalCenter: parent.verticalCenter height: Style.space(6) radius: Style.cornerRadius > 0 ? height / 2 : 0 color: Qt.rgba(root.contentForeground.r, root.contentForeground.g, root.contentForeground.b, 0.12) Rectangle { width: Math.round(parent.width * root.lifeDone) height: parent.height radius: parent.radius color: Style.selectedStateColor(root.contentForeground, Colors.accent) Behavior on width { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } } } TapHandler { onDoubleTapped: root.clearLife() } MouseArea { id: lifeMouse anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.NoButton PanelToolTip { visible: lifeMouse.containsMouse text: "Memento Mori" fontFamily: root.contentFontFamily } } } } CalendarView { id: calendar width: parent.width topPadding: Style.space(10) today: root.today weekStart: root.weekStart foreground: root.contentForeground fontFamily: root.contentFontFamily showWeekNumbers: true navigationAtBottom: true weekHeaderTooltip: "Start weeks on " + root.nextWeekStartLabel cellWidth: Style.space(52) cellHeight: Style.space(34) cellSpacing: Style.space(2) weekColumnWidth: Style.space(32) gutterWidth: Style.space(14) onWeekHeaderClicked: root.toggleWeekStart() } } } } } }