aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md3
-rw-r--r--desktop/Prefs.qml9
-rw-r--r--desktop/island/ClockPill.qml5
-rw-r--r--desktop/island/calendar/Cal.js52
-rw-r--r--desktop/island/calendar/CalendarPanel.qml10
-rw-r--r--desktop/island/calendar/EventEditor.qml2
-rw-r--r--desktop/island/calendar/test_cal.js19
-rw-r--r--desktop/modules/island/IslandClockModule.qml247
-rw-r--r--desktop/shell.qml1
9 files changed, 328 insertions, 20 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 41baae9..be58ef3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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 {},