diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-13 09:55:00 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-13 09:55:00 +0100 |
| commit | 7829a18d8049087eaa963fb922a8701cc9851979 (patch) | |
| tree | 0ce6c851a96ff4f337a47ac40d6253a9f8f7f079 /desktop | |
| parent | 70d2b0c0e2e83dd2f17bd49dc2f06d39a7f7f4cf (diff) | |
| download | quickshell-7829a18d8049087eaa963fb922a8701cc9851979.tar.gz quickshell-7829a18d8049087eaa963fb922a8701cc9851979.zip | |
The clock pill hard-coded "ddd dd/MM" and "HH:mm", and the calendar its
day headings. A new Date and time page in the Island bar group picks a
24- or 12-hour clock and one of 22 date formats, grouped by order (day,
month or year first) and each shown as today's date. The calendar's
headings and editor dates keep spelling the month out and follow the
chosen order; agenda times and the hour picker go compact on the
12-hour clock ("9:30p", "1p"), where width is short. A preview mocks the
pill and panel with the unsaved choice.
The formats live in Cal.js (DATE_GROUPS), which Prefs imports to
validate island.dateFormat. Event times still store and show as 24-hour
HH:MM, since that is what calctl speaks, but normTime now also accepts
am and pm ("9.30pm", "12am"), rejecting an hour outside 1 to 12 with one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop')
| -rw-r--r-- | desktop/Prefs.qml | 9 | ||||
| -rw-r--r-- | desktop/island/ClockPill.qml | 5 | ||||
| -rw-r--r-- | desktop/island/calendar/Cal.js | 52 | ||||
| -rw-r--r-- | desktop/island/calendar/CalendarPanel.qml | 10 | ||||
| -rw-r--r-- | desktop/island/calendar/EventEditor.qml | 2 | ||||
| -rw-r--r-- | desktop/island/calendar/test_cal.js | 19 | ||||
| -rw-r--r-- | desktop/modules/island/IslandClockModule.qml | 247 | ||||
| -rw-r--r-- | desktop/shell.qml | 1 |
8 files changed, 326 insertions, 19 deletions
diff --git a/desktop/Prefs.qml b/desktop/Prefs.qml index 09960c4..1d24796 100644 --- a/desktop/Prefs.qml +++ b/desktop/Prefs.qml @@ -14,6 +14,7 @@ pragma Singleton import Quickshell import Quickshell.Io import QtQuick +import "island/calendar/Cal.js" as Cal // Preferences that must survive a reboot, in one JSON file under the state // home. Only the shell writes it, which is why it is not in ~/.config. @@ -86,8 +87,10 @@ Singleton { // The island bar's settings (modules/island): the length a new event gets, // and that its end keeps from the start, in minutes; the pills left to - // right, the first two left of the screen's centre, the last two right. - readonly property var islandDefaults: ({ eventMinutes: 60, pills: ["media", "clock", "status", "obs"] }) + // right, the first two left of the screen's centre, the last two right; the + // clock, 12-hour or not, and the date's format (island/calendar/Cal.js). + readonly property var islandDefaults: ({ eventMinutes: 60, pills: ["media", "clock", "status", "obs"], + h12: false, dateFormat: Cal.DATE_FORMATS[0] }) property var island: islandDefaults function setIsland(changes) { root.island = Object.assign({}, root.island, changes); @@ -171,6 +174,8 @@ Singleton { pills: Array.isArray(isl.pills) && isl.pills.length === 4 && root.islandDefaults.pills.every(p => isl.pills.includes(p)) ? isl.pills.slice() : root.islandDefaults.pills, + h12: isl.h12 === true, + dateFormat: Cal.shortDateFmt(isl.dateFormat), }; } diff --git a/desktop/island/ClockPill.qml b/desktop/island/ClockPill.qml index 4ed8e38..1d18503 100644 --- a/desktop/island/ClockPill.qml +++ b/desktop/island/ClockPill.qml @@ -12,6 +12,7 @@ import Quickshell import Quickshell.Widgets import QtQuick +import "calendar/Cal.js" as Cal import ".." import "../modules/vm" import "calendar" @@ -62,13 +63,13 @@ Capsule { Text { anchors.verticalCenter: parent.verticalCenter - text: Qt.formatDateTime(clock.date, "ddd dd/MM") + text: Qt.formatDateTime(clock.date, Cal.shortDateFmt(Prefs.island.dateFormat)) font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } color: Theme.subtext } Text { anchors.verticalCenter: parent.verticalCenter - text: Qt.formatDateTime(clock.date, "HH:mm") + text: Qt.formatDateTime(clock.date, Cal.timeFmt(Prefs.island.h12)) font { family: Theme.fontFamily; pixelSize: Theme.fontSize; weight: Font.DemiBold } color: Theme.text } diff --git a/desktop/island/calendar/Cal.js b/desktop/island/calendar/Cal.js index 6ec7bb3..d12ed29 100644 --- a/desktop/island/calendar/Cal.js +++ b/desktop/island/calendar/Cal.js @@ -83,10 +83,42 @@ function dots(events, day, calendars) { return calendars.filter(c => events.some(e => e.calendar === c.name && onDay(e, day))); } -function timeLabel(ev, day) { +// The island's clock settings (Prefs.island): h12 for a 12-hour clock, and +// dateFormat, the clock pill's date as one of DATE_FORMATS (Qt format +// strings). The calendar's longer dates follow its order, day or month +// first. clockTime formats calctl's "HH:MM", compact ("9:30p") where width +// is short. +var DATE_GROUPS = [ + { title: "Day first", formats: ["ddd dd/MM", "dd/MM", "dd/MM/yy", "dd/MM/yyyy", "ddd dd/MM/yyyy", + "dd.MM.yyyy", "dd-MM-yyyy", "d MMM", "ddd d MMM", "d MMM yyyy"] }, + { title: "Month first", formats: ["ddd MM/dd", "MM/dd", "MM/dd/yy", "MM/dd/yyyy", "ddd MM/dd/yyyy", + "MMM d", "ddd MMM d", "MMM d, yyyy"] }, + { title: "Year first", formats: ["yyyy-MM-dd", "ddd yyyy-MM-dd", "yyyy/MM/dd", "ddd MM-dd"] }]; +var DATE_FORMATS = DATE_GROUPS.reduce((a, g) => a.concat(g.formats), []); +function timeFmt(h12) { return h12 ? "h:mm AP" : "HH:mm"; } +function shortDateFmt(fmt) { return DATE_FORMATS.includes(fmt) ? fmt : DATE_FORMATS[0]; } +// Day first unless the month comes before the day, the weekday name aside. +function dayFirst(fmt) { + const f = shortDateFmt(fmt).replace(/ddd+/, ""); + return f.indexOf("d") < f.indexOf("M"); +} +function dayFmt(fmt, long) { + if (dayFirst(fmt)) return long ? "dddd d MMMM" : "ddd d MMM"; + return long ? "dddd, MMMM d" : "ddd MMM d"; +} +function clockTime(hhmm, h12) { + if (!h12 || !validTime(hhmm)) return hhmm; + const h = +hhmm.slice(0, 2); + return (h % 12 || 12) + hhmm.slice(2) + (h < 12 ? "a" : "p"); +} +function hourLabel(h, h12) { + return h12 ? (h % 12 || 12) + (h < 12 ? "a" : "p") : pad(h); +} + +function timeLabel(ev, day, h12) { if (ev.allDay) return "all day"; - const s = ev.start.slice(0, 10) === day ? ev.start.slice(11, 16) : "…"; - const e = ev.end.slice(0, 10) === day ? ev.end.slice(11, 16) : "…"; + const s = ev.start.slice(0, 10) === day ? clockTime(ev.start.slice(11, 16), h12) : "…"; + const e = ev.end.slice(0, 10) === day ? clockTime(ev.end.slice(11, 16), h12) : "…"; return s + "–" + e; } @@ -94,12 +126,18 @@ function validTime(s) { return /^([01]\d|2[0-3]):[0-5]\d$/.test(s); } -// Loose typing as HH:MM: "9.30", "9:30", "930" and "9" mean what they look -// like. Anything else comes back unchanged, for validTime to reject. +// Loose typing as HH:MM: "9.30", "9:30", "930", "9", and with am or pm +// ("9.30pm", "12am") for the 12-hour clock, mean what they look like. +// Anything else comes back unchanged, for validTime to reject. function normTime(s) { - const m = /^\s*(\d{1,2})(?:[:.,h ]?(\d{2}))?\s*$/.exec(s); + const m = /^\s*(\d{1,2})(?:[:.,h ]?(\d{2}))?\s*(?:([ap])\.?m?\.?)?\s*$/i.exec(s); if (!m) return s; - const t = pad(+m[1]) + ":" + (m[2] || "00"); + let h = +m[1]; + if (m[3]) { + if (h < 1 || h > 12) return s; + h = h % 12 + (m[3].toLowerCase() === "p" ? 12 : 0); + } + const t = pad(h) + ":" + (m[2] || "00"); return validTime(t) ? t : s; } diff --git a/desktop/island/calendar/CalendarPanel.qml b/desktop/island/calendar/CalendarPanel.qml index d28a856..fef672c 100644 --- a/desktop/island/calendar/CalendarPanel.qml +++ b/desktop/island/calendar/CalendarPanel.qml @@ -226,8 +226,8 @@ Item { delegate: TimeCell { required property int index width: (panel.gridWidth - 10) / 6 - label: Cal.pad(index) - on: hours.cur.slice(0, 3) === label + ":" + label: Cal.hourLabel(index, Prefs.island.h12) + on: hours.cur.slice(0, 2) === Cal.pad(index) onClicked: editor.pickTime(index, null) } } @@ -291,7 +291,7 @@ Item { Text { text: panel.selected === "" ? "" : Qt.formatDate(new Date(+panel.selected.slice(0, 4), +panel.selected.slice(5, 7) - 1, - +panel.selected.slice(8, 10)), "dddd d MMMM") + +panel.selected.slice(8, 10)), Cal.dayFmt(Prefs.island.dateFormat, true)) color: Theme.text font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } } @@ -327,9 +327,9 @@ Item { Text { id: when x: 16 - width: 82 + width: Prefs.island.h12 ? 92 : 82 anchors.verticalCenter: parent.verticalCenter - text: Cal.timeLabel(row.modelData, panel.selected) + text: Cal.timeLabel(row.modelData, panel.selected, Prefs.island.h12) color: Theme.subtext font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } } diff --git a/desktop/island/calendar/EventEditor.qml b/desktop/island/calendar/EventEditor.qml index 727e1df..e55396b 100644 --- a/desktop/island/calendar/EventEditor.qml +++ b/desktop/island/calendar/EventEditor.qml @@ -109,7 +109,7 @@ Column { function dayLabel(day) { const d = new Date(+day.slice(0, 4), +day.slice(5, 7) - 1, +day.slice(8, 10)); - return Qt.formatDate(d, "ddd d MMM"); + return Qt.formatDate(d, Cal.dayFmt(Prefs.island.dateFormat, false)); } // An unhandled Escape from any field lands here: leave the editor, not diff --git a/desktop/island/calendar/test_cal.js b/desktop/island/calendar/test_cal.js index 853cbb6..5a9c1c0 100644 --- a/desktop/island/calendar/test_cal.js +++ b/desktop/island/calendar/test_cal.js @@ -17,7 +17,7 @@ const assert = require("assert"); const src = fs.readFileSync(__dirname + "/Cal.js", "utf8").replace(/^\.pragma library$/m, ""); const C = new Function(src + "\nreturn { pad, addDays, today, daysBetween, monthCells, shiftMonth," + - " lastDay, onDay, dayEvents, dots, timeLabel, validTime, normTime, stepTime, newDraft, withStart, fromEvent, pick," + + " lastDay, onDay, dayEvents, dots, timeLabel, validTime, normTime, clockTime, hourLabel, shortDateFmt, dayFmt, stepTime, newDraft, withStart, fromEvent, pick," + " toPayload };")(); // Dates are plain YYYY-MM-DD strings, arithmetic in UTC so DST never moves a day. @@ -79,9 +79,24 @@ assert.strictEqual(C.stepTime("bad", 15), "bad"); assert.strictEqual(C.stepTime("9.30", 15), "09:45"); for (const [i, o] of [["9.30", "09:30"], ["9:30", "09:30"], ["930", "09:30"], ["1230", "12:30"], ["9", "09:00"], [" 14h15 ", "14:15"], ["09:05", "09:05"], - ["24.00", "24.00"], ["9.7", "9.7"], ["ab", "ab"], ["", ""]]) + ["24.00", "24.00"], ["9.30pm", "21:30"], ["12am", "00:00"], ["12 PM", "12:00"], + ["9:30 a.m.", "09:30"], ["13pm", "13pm"], ["9.7", "9.7"], ["ab", "ab"], ["", ""]]) assert.strictEqual(C.normTime(i), o, i); +// The 12-hour clock, compact; 24-hour leaves calctl's time alone. +assert.strictEqual(C.clockTime("00:05", true), "12:05a"); +assert.strictEqual(C.clockTime("12:00", true), "12:00p"); +assert.strictEqual(C.clockTime("21:30", true), "9:30p"); +assert.strictEqual(C.clockTime("21:30", false), "21:30"); +assert.strictEqual(C.hourLabel(0, true), "12a"); +assert.strictEqual(C.hourLabel(13, true), "1p"); +assert.strictEqual(C.hourLabel(7, false), "07"); +assert.strictEqual(C.shortDateFmt("bogus"), "ddd dd/MM"); +assert.strictEqual(C.dayFmt("ddd MM/dd", false), "ddd MMM d"); +assert.strictEqual(C.dayFmt("ddd dd/MM", true), "dddd d MMMM"); +assert.strictEqual(C.dayFmt("yyyy-MM-dd", false), "ddd MMM d"); +assert.strictEqual(C.dayFmt("d MMM yyyy", false), "ddd d MMM"); + // A new event: the next whole hour, one hour long, alarm 1h. const d = C.newDraft("2026-10-20", new Date(2026, 9, 13, 9, 20), "home"); assert.strictEqual(d.startTime, "10:00"); diff --git a/desktop/modules/island/IslandClockModule.qml b/desktop/modules/island/IslandClockModule.qml new file mode 100644 index 0000000..ef8c204 --- /dev/null +++ b/desktop/modules/island/IslandClockModule.qml @@ -0,0 +1,247 @@ +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +import QtQuick +import "../.." +import "../appearance" +import "../../island/calendar/Cal.js" as Cal + +// How the island shows time and dates: the clock pill, the calendar's day +// headings, agenda times and hour picker. +Module { + id: mod + name: "island-clock" + label: "Date and time" + // nf-md-clock_outline, present in Inconsolata Nerd Font. + icon: "\u{f0150}" + settings: true + group: "Island bar" + keywords: ["island", "clock", "time", "date", "format", "12-hour", "24-hour", "am", "pm", "day", "month"] + searchItems: [{ text: "Clock", key: "h12" }, { text: "Date format", key: "dateFormat" }] + + property bool h12: Prefs.island.h12 + property string dateFormat: Prefs.island.dateFormat + dirty: h12 !== Prefs.island.h12 || dateFormat !== Prefs.island.dateFormat + function refresh() { if (!mod.dirty) mod.discard(); } + function discard() { mod.h12 = Prefs.island.h12; mod.dateFormat = Prefs.island.dateFormat; } + + page: Component { + Page { + title: "Date and time" + actions: Component { + SaveBar { + dirty: mod.dirty + onSave: Prefs.setIsland({ h12: mod.h12, dateFormat: mod.dateFormat }) + onDiscard: mod.discard() + } + } + Flow { + id: body + width: parent.width + spacing: 32 + SystemClock { id: now; precision: SystemClock.Minutes } + readonly property string today: Cal.today(now.date) + // Two made-up events today, one each side of noon. + readonly property var events: [ + { title: "Standup", allDay: false, start: today + "T09:30", end: today + "T10:00", color: "blue" }, + { title: "Dinner", allDay: false, start: today + "T20:30", end: today + "T22:00", color: "green" }] + + Column { + id: controls + width: 3 * 120 + 2 * 12 + spacing: 6 + + Heading { text: "Clock" } + Hint { text: "The clock pill, the agenda and the hour picker. Event times can also be typed with am and pm (9.30pm)." } + Row { + objectName: "h12" + spacing: 6 + Tab { text: "24-hour"; selected: !mod.h12; onClicked: mod.h12 = false } + Tab { text: "12-hour"; selected: mod.h12; onClicked: mod.h12 = true } + } + + Item { width: 1; height: 14 } + Heading { text: "Date" } + Hint { text: "The clock pill. The calendar spells the month out, in the same order." } + Row { + objectName: "dateFormat" + spacing: 12 + Repeater { + model: Cal.DATE_GROUPS + delegate: Column { + required property var modelData + width: 120 + spacing: 2 + Text { + text: modelData.title + color: Theme.subtext + bottomPadding: 4 + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + Repeater { + model: modelData.formats + delegate: Rectangle { + required property string modelData + readonly property bool on: mod.dateFormat === modelData + width: 120 + height: 26 + radius: 6 + color: on ? Qt.alpha(Theme.accent, 0.25) + : pick.containsMouse ? Qt.alpha(Theme.surface, 0.8) : "transparent" + border.width: on ? 1 : 0 + border.color: Qt.alpha(Theme.accent, 0.5) + Text { + anchors { left: parent.left; leftMargin: 8; verticalCenter: parent.verticalCenter } + text: Qt.formatDate(now.date, parent.modelData) + color: parent.on ? Theme.text : Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + MouseArea { + id: pick + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: mod.dateFormat = parent.modelData + } + } + } + } + } + } + } + + // Beside the controls when there is room, below them when not. + Column { + width: Math.max(220, Math.min(400, body.width - controls.width - body.spacing)) + spacing: 6 + Heading { text: "Preview" } + Hint { text: "With the unsaved choice." } + // A mock of the island: the clock pill at rest, and the + // calendar panel's heading, agenda and hour picker below it. + Rectangle { + width: parent.width + height: mock.implicitHeight + 32 + radius: 12 + color: Qt.alpha(Theme.surface, 0.35) + + Column { + id: mock + anchors { fill: parent; margins: 16 } + spacing: 14 + + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + width: pillRow.implicitWidth + 28 + height: 34 + radius: 17 + color: Qt.alpha(Theme.base, 0.92) + border.width: 1 + border.color: Qt.alpha(Theme.text, 0.12) + Row { + id: pillRow + anchors.centerIn: parent + spacing: 10 + Text { + anchors.verticalCenter: parent.verticalCenter + text: Qt.formatDateTime(now.date, Cal.shortDateFmt(mod.dateFormat)) + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: Qt.formatDateTime(now.date, Cal.timeFmt(mod.h12)) + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize; weight: Font.DemiBold } + } + } + } + + Rectangle { + width: parent.width + height: panelCol.implicitHeight + 24 + radius: 17 + color: Qt.alpha(Theme.base, 0.92) + border.width: 1 + border.color: Qt.alpha(Theme.text, 0.12) + Column { + id: panelCol + anchors { fill: parent; margins: 12 } + spacing: 6 + Text { + text: Qt.formatDate(now.date, Cal.dayFmt(mod.dateFormat, true)) + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + } + Repeater { + model: body.events + delegate: Row { + required property var modelData + spacing: 8 + Rectangle { width: 3; height: 16; radius: 1; color: Theme[modelData.color] } + Text { + width: mod.h12 ? 92 : 82 + text: Cal.timeLabel(modelData, body.today, mod.h12) + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } + } + Text { + text: modelData.title + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + } + } + Text { + text: "When " + Qt.formatDate(now.date, Cal.dayFmt(mod.dateFormat, false)) + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + Row { + spacing: 2 + Repeater { + model: [8, 9, 10, 11, 12] + delegate: Rectangle { + required property int modelData + width: 34 + height: 26 + radius: 6 + color: modelData === 9 ? Qt.alpha(Theme.accent, 0.35) : "transparent" + Text { + anchors.centerIn: parent + text: Cal.hourLabel(parent.modelData, mod.h12) + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + } + } + } + } + } + } + } + } + } + } + } + + component Heading: Text { + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3; bold: true } + color: Theme.text + } + + component Hint: Text { + width: parent ? parent.width : 0 + wrapMode: Text.WordWrap + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } +} diff --git a/desktop/shell.qml b/desktop/shell.qml index 9103ee0..e064c8a 100644 --- a/desktop/shell.qml +++ b/desktop/shell.qml @@ -114,6 +114,7 @@ ShellRoot { MonitorsModule {}, ObsScenesModule {}, IslandPillsModule {}, + IslandClockModule {}, IslandCalendarModule {}, // deck-it's four pages, one draft. DeckItModule {}, |
