diff options
| -rw-r--r-- | AGENTS.md | 3 | ||||
| -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 |
9 files changed, 328 insertions, 20 deletions
@@ -18,7 +18,8 @@ modules. the assistant's daemon and presets, qtmaildir, git, Hyprland's keyboard shortcuts, options and monitors, fonts, the Sonix boot logo, deck-it (the tablet dashboard's autostart and config), - the island bar (pill order, the calendar's default event length) + the island bar (pill order, date and time format, the calendar's + default event length) island/ top-centre pills on DP-1: clock, volume, Sonix player, tray, layout, privacy, calendar, OBS assistant/ the assistant canvas on SUPER+Home, a view over desktop-assistant 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 {}, |
