Count the other progress bar too
Double-tapping the year bar asks for a birth year and a life expectancy, and a second bar appears below measuring one against the other. Tab moves between the two, Enter commits the pair, Escape drops them. Hovering the bar names the thing, and double-tapping it puts it away again. A birth year rather than an age, so the bar keeps counting on its own instead of going stale the moment it is entered. Expectancy defaults to ninety and falls back to it when what is entered makes no sense, so the bar always has something to measure against; a birth year that makes no sense leaves the bar hidden instead, which is also where it starts. Putting the bar away keeps the expectancy, so setting a birth year again brings your own number back rather than the default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cc4771fd91
commit
9e3da4e27f
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user