diff --git a/shell/plugins/panels/clock/Model.js b/shell/plugins/panels/clock/Model.js index 767a9276..8d42f7fc 100644 --- a/shell/plugins/panels/clock/Model.js +++ b/shell/plugins/panels/clock/Model.js @@ -149,6 +149,63 @@ function yearProgressPercent(year, month, day) { return Math.round(yearProgress(year, month, day) * 100) } +// Memento mori. The default span is a round number rather than anything from +// an actuarial table: the point of the bar is the reminder, not the +// arithmetic, and whoever wants a different number can say so. +var DEFAULT_LIFE_EXPECTANCY = 90 + +// A birth year rather than an age, so the bar keeps counting on its own +// instead of going stale the moment it is entered. 0 means "not set", which +// is also what a blank, malformed, future, or implausibly distant year means. +function parseBirthYear(value, currentYear) { + var now = Math.round(Number(currentYear)) + if (!isFinite(now)) return 0 + var text = String(value === undefined || value === null ? "" : value).replace(/^\s+|\s+$/g, "") + if (!/^\d{4}$/.test(text)) return 0 + var year = parseInt(text, 10) + if (!isFinite(year) || year > now || year < now - 120) return 0 + return year +} + +// Whole years, the way people say their age: born in 1979 makes you 47 for +// all of 2026, whichever side of your birthday today falls. +function ageFromBirthYear(birthYear, currentYear) { + var born = parseBirthYear(birthYear, currentYear) + if (born <= 0) return 0 + return Math.round(Number(currentYear)) - born +} + +// 0 means "not set", which is also what a blank, negative, fractional, or +// absurd entry means — the life bar simply stays hidden. +function parseAge(value) { + var text = String(value === undefined || value === null ? "" : value).replace(/^\s+|\s+$/g, "") + if (!/^\d+$/.test(text)) return 0 + var years = parseInt(text, 10) + if (!isFinite(years) || years <= 0 || years > 120) return 0 + return years +} + +// Unset or nonsense falls back to the default rather than to zero, so the +// bar always has something to measure against. +function parseLifeExpectancy(value) { + var text = String(value === undefined || value === null ? "" : value).replace(/^\s+|\s+$/g, "") + if (!/^\d+$/.test(text)) return DEFAULT_LIFE_EXPECTANCY + var years = parseInt(text, 10) + if (!isFinite(years) || years <= 0 || years > 150) return DEFAULT_LIFE_EXPECTANCY + return years +} + +function lifeProgress(age, expectancy) { + var years = parseAge(age) + var span = parseLifeExpectancy(expectancy) + if (years <= 0 || span <= 0) return 0 + return Math.max(0, Math.min(1, years / span)) +} + +function lifeProgressPercent(age, expectancy) { + return Math.round(lifeProgress(age, expectancy) * 100) +} + // Always six rows of seven days. A fixed grid keeps the popup exactly the // same height in every month, so stepping through the year never makes the // panel jump under the pointer. @@ -212,6 +269,12 @@ if (typeof module !== "undefined") { daysInYear: daysInYear, yearProgress: yearProgress, yearProgressPercent: yearProgressPercent, + parseAge: parseAge, + parseBirthYear: parseBirthYear, + ageFromBirthYear: ageFromBirthYear, + parseLifeExpectancy: parseLifeExpectancy, + lifeProgress: lifeProgress, + lifeProgressPercent: lifeProgressPercent, monthGrid: monthGrid, stepMonth: stepMonth, clockFormats: clockFormats, diff --git a/shell/plugins/panels/clock/Panel.qml b/shell/plugins/panels/clock/Panel.qml index aba21af9..fd9c4477 100644 --- a/shell/plugins/panels/clock/Panel.qml +++ b/shell/plugins/panels/clock/Panel.qml @@ -48,6 +48,17 @@ Panel { readonly property real yearDone: Model.yearProgress(today.getFullYear(), today.getMonth(), today.getDate()) readonly property int yearDonePercent: Model.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 @@ -84,6 +95,9 @@ Panel { 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() } @@ -125,27 +139,76 @@ Panel { moveMonth(delta * 12) } - function setWeekStart(day) { - var next = Model.normalizedWeekStart(day, root.weekStart) - if (next === root.weekStart) return - + // 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 key in root.settings) if (key !== "id") entry[key] = root.settings[key] - entry.weekStartDay = Model.weekStartSettingName(next) + for (var existing in root.settings) if (existing !== "id") entry[existing] = root.settings[existing] + for (var key in values) entry[key] = values[key] - // Applied locally first so the grid reflows 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. root.settings = entry - // The host widget builds its own entry when the label format is cycled. - // Until shell.json round-trips it would be working from a copy without - // this key, and would write the week start straight back out. 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 = Model.normalizedWeekStart(day, root.weekStart) + if (next === root.weekStart) return + persistSettings({ weekStartDay: Model.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(Model.toggledWeekStart(root.weekStart)) } @@ -181,6 +244,7 @@ Panel { PanelKeyCatcher { id: keyCatcher anchors.fill: parent + blocked: root.editingLife onMoveRequested: function(dx, dy) { if (dx !== 0) root.moveMonth(dx) if (dy !== 0) root.moveYear(dy) @@ -288,8 +352,65 @@ Panel { width: gridColumn.width 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 + visible: !root.editingLife anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter text: root.today.getFullYear() @@ -301,6 +422,7 @@ Panel { Text { id: yearPercent + visible: !root.editingLife anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter text: root.yearDonePercent + "%" @@ -311,6 +433,7 @@ Panel { Rectangle { id: yearTrack + visible: !root.editingLife anchors.left: yearLabel.right anchors.right: yearPercent.left anchors.leftMargin: Style.space(12) @@ -332,6 +455,80 @@ Panel { } } + // ---- 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: gridColumn.width + 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 + 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, Color.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 + } + } + } + } + // ---- Month grid: week numbers down a gutter on the left, then // the seven day columns. Always six rows, so the popup is // exactly as tall in February as it is in August. diff --git a/test/shell.d/clock-test.sh b/test/shell.d/clock-test.sh index db9e6a85..ac9ea572 100755 --- a/test/shell.d/clock-test.sh +++ b/test/shell.d/clock-test.sh @@ -40,6 +40,45 @@ assertEqual(calendar.yearProgressPercent(2026, 0, 1), 0, 'calendar starts the ye assertEqual(calendar.yearProgressPercent(2026, 6, 26), 56, 'calendar reports the share of the year behind you') assertEqual(calendar.yearProgressPercent(2026, 11, 31), 100, 'calendar finishes the year at a hundred percent') assertEqual(calendar.yearProgressPercent(2024, 11, 31), 100, 'calendar finishes a leap year too') + +// ---- memento mori +// A birth year rather than an age, so the bar keeps counting on its own. +assertEqual(calendar.parseBirthYear('1979', 2026), 1979, 'calendar reads a birth year') +assertEqual(calendar.parseBirthYear(1979, 2026), 1979, 'calendar reads a numeric birth year') +assertEqual(calendar.parseBirthYear(' 1979 ', 2026), 1979, 'calendar trims a birth year') +assertEqual(calendar.parseBirthYear('2026', 2026), 2026, 'calendar accepts the current year') +assertEqual(calendar.parseBirthYear('2027', 2026), 0, 'calendar rejects a birth year in the future') +assertEqual(calendar.parseBirthYear('1905', 2026), 0, 'calendar rejects an implausibly distant birth year') +assertEqual(calendar.parseBirthYear('79', 2026), 0, 'calendar rejects a two-digit year') +assertEqual(calendar.parseBirthYear('', 2026), 0, 'calendar treats a blank birth year as unset') +assertEqual(calendar.parseBirthYear('abc', 2026), 0, 'calendar treats a non-numeric birth year as unset') + +assertEqual(calendar.ageFromBirthYear('1979', 2026), 47, 'calendar derives an age from a birth year') +assertEqual(calendar.ageFromBirthYear('1979', 2027), 48, 'calendar keeps counting as the years pass') +assertEqual(calendar.ageFromBirthYear('2026', 2026), 0, 'calendar makes someone born this year zero') +assertEqual(calendar.ageFromBirthYear('', 2026), 0, 'calendar derives no age without a birth year') + +assertEqual(calendar.parseAge('47'), 47, 'calendar reads an age') +assertEqual(calendar.parseAge(47), 47, 'calendar reads a numeric age') +assertEqual(calendar.parseAge(' 47 '), 47, 'calendar trims an age') +assertEqual(calendar.parseAge(''), 0, 'calendar treats a blank age as unset') +assertEqual(calendar.parseAge('0'), 0, 'calendar treats zero as unset') +assertEqual(calendar.parseAge('-3'), 0, 'calendar treats a negative age as unset') +assertEqual(calendar.parseAge('121'), 0, 'calendar treats an implausible age as unset') +assertEqual(calendar.parseAge('abc'), 0, 'calendar treats a non-numeric age as unset') +assertEqual(calendar.parseAge('4.5'), 0, 'calendar treats a fractional age as unset') +assertEqual(calendar.parseLifeExpectancy('65'), 65, 'calendar reads a life expectancy') +assertEqual(calendar.parseLifeExpectancy(''), 90, 'calendar defaults the life expectancy to ninety') +assertEqual(calendar.parseLifeExpectancy(0), 90, 'calendar defaults an unset life expectancy') +assertEqual(calendar.parseLifeExpectancy('abc'), 90, 'calendar defaults a non-numeric life expectancy') +assertEqual(calendar.parseLifeExpectancy('200'), 90, 'calendar defaults an implausible life expectancy') + +assertEqual(calendar.lifeProgressPercent(45, 90), 50, 'calendar measures a life against the expectancy given') +assertEqual(calendar.lifeProgressPercent(45, 65), 69, 'calendar honours a shorter expectancy') +assertEqual(calendar.lifeProgressPercent(45, ''), 50, 'calendar falls back to ninety when no expectancy is set') +assertEqual(calendar.lifeProgressPercent(90, 90), 100, 'calendar fills the life bar at the expectancy') +assertEqual(calendar.lifeProgressPercent(80, 65), 100, 'calendar never overfills the life bar') +assertEqual(calendar.lifeProgressPercent(0, 90), 0, 'calendar leaves the life bar empty when no age is set') assertEqual(calendar.daysInYear(2024), 366, 'calendar knows leap years are longer') assertEqual(calendar.daysInYear(2026), 365, 'calendar knows common years') @@ -108,7 +147,7 @@ assert(/ipcTarget: "omarchy\.clock"/.test(panelSource), 'calendar panel register assert(/manageIpc: false/.test(panelSource), 'calendar panel leaves the IPC target to the bar widget') assert(/anchorItem: root\.anchorItem/.test(panelSource), 'calendar panel anchors to the host widget button') assert(/function toggleWeekStart\(\)/.test(panelSource), 'calendar panel exposes a week start toggle') -assert(/setting\("weekStartDay", null\)/.test(panelSource) && /entry\.weekStartDay =/.test(panelSource), 'calendar reads and writes the week start as weekStartDay') +assert(/setting\("weekStartDay", null\)/.test(panelSource) && /persistSettings\(\{ weekStartDay:/.test(panelSource), 'calendar reads and writes the week start as weekStartDay') assert(/updateEntryInline/.test(panelSource), 'calendar panel persists the week start to shell.json') assert(/function moveMonth\(delta\)/.test(panelSource), 'calendar panel steps between months') assert(!/property bool onToday/.test(panelSource) && !/root\.onToday/.test(panelSource), 'calendar panel avoids the on-prefixed property name QML reads as a signal handler') @@ -123,6 +162,24 @@ assert(!/clampMonth/.test(panelSource), 'calendar steps freely into future month assert(/Qt\.formatDate\(root\.today, "MMMM d"\)/.test(panelSource), 'calendar hero spells out today') assert(/id: yearLabel/.test(panelSource) && /root\.yearDone/.test(panelSource), 'calendar panel shows the year progress bar') +// The memento mori bar is opt-in: double-tapping the year bar asks for an age, +// and nothing shows until one has been given. +assert(/onDoubleTapped: root\.startEditingLife\(\)/.test(panelSource), 'calendar asks for an age when the year bar is double-tapped') +assert(/persistSettings\(\{ birthYear: born, lifeExpectancy: span \}\)/.test(panelSource), 'calendar saves birth year and expectancy together, so neither lands on a stale copy') +assert(/readonly property int birthYear: Model\.parseBirthYear\(setting\("birthYear", 0\)/.test(panelSource), 'calendar reads the saved birth year back') +assert(/readonly property int age: Model\.ageFromBirthYear\(birthYear/.test(panelSource), 'calendar derives the age from the stored birth year') +assert(/readonly property int lifeExpectancy: Model\.parseLifeExpectancy\(setting\("lifeExpectancy", 0\)\)/.test(panelSource), 'calendar reads the saved expectancy back') +assert(/id: expectancyField/.test(panelSource) && /id: bornField/.test(panelSource), 'calendar offers both inputs') +assert(/visible: root\.editingLife\s*\n\s*anchors\.horizontalCenter: parent\.horizontalCenter/.test(panelSource), 'calendar centers the inputs over the bar they replace') +assert(/visible: root\.birthYear > 0/.test(panelSource), 'calendar hides the life bar until a birth year is known') +assert(/text: "LIFE"/.test(panelSource) && /root\.lifeDone/.test(panelSource), 'calendar shows the life bar') +assert(/text: "Memento Mori"/.test(panelSource), 'calendar names the life bar on hover') +assert(/onDoubleTapped: root\.clearLife\(\)/.test(panelSource), 'calendar puts the life bar away when it is double-tapped') +assert(/persistSettings\(\{ birthYear: 0 \}\)/.test(panelSource), 'calendar clears the birth year to hide the life bar') +assertEqual(calendar.parseBirthYear(0, 2026), 0, 'a cleared birth year reads back as unset') +assert(/blocked: root\.editingLife/.test(panelSource), 'calendar lets the inputs have the keyboard while they are up') +assert(/if \(root\.editingLife\) root\.cancelEditingLife\(\)/.test(panelSource), 'calendar drops a half-finished edit when the panel closes') + assert(/source: Qt\.resolvedUrl\("Panel\.qml"\)/.test(widgetSource), 'clock widget hosts the calendar panel') assert(/readonly property bool opened:/.test(widgetSource), 'clock widget exposes the panel open state to shell routing') assert(/Qt\.RightButton\) root\.cycleFormat\(\)/.test(widgetSource), 'clock right click cycles the label format')