From bfdc3ce1e1be950dc810d19490d0b0a8ccaea3e8 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 13 Nov 2026 09:35:54 +0100 Subject: feat(calendar): end time follows the start, default length setting Moving an event's start now moves its end by the island's default event length, whether the start is typed, wheeled or picked, rolling into the next day past midnight. A multi-day timed event keeps its end, since snapping it to the start's day would drop the other days. Typing follows on every edit that normalises to a valid time, so Save without leaving the field still sees the moved end. A new event gets the same length. The length lives in Prefs as island.eventMinutes (5 to 1440, default 60) and is set on a new Island bar group in the settings window, which the island's other settings will join. Co-Authored-By: Claude Opus 5.5 --- desktop/Prefs.qml | 14 +++++ desktop/island/calendar/Cal.js | 28 ++++++--- desktop/island/calendar/CalendarPanel.qml | 2 +- desktop/island/calendar/EventEditor.qml | 11 +++- desktop/island/calendar/test_cal.js | 13 ++++- desktop/modules/island/IslandCalendarModule.qml | 76 +++++++++++++++++++++++++ desktop/shell.qml | 2 + 7 files changed, 135 insertions(+), 11 deletions(-) create mode 100644 desktop/modules/island/IslandCalendarModule.qml (limited to 'desktop') diff --git a/desktop/Prefs.qml b/desktop/Prefs.qml index ef2ac72..0861258 100644 --- a/desktop/Prefs.qml +++ b/desktop/Prefs.qml @@ -84,6 +84,15 @@ Singleton { root.write(Object.assign({}, root.doc, { bootlogo: root.bootlogo })); } + // The island bar's settings (modules/island): the length a new event gets, + // and that its end keeps from the start, in minutes. + readonly property var islandDefaults: ({ eventMinutes: 60 }) + property var island: islandDefaults + function setIsland(changes) { + root.island = Object.assign({}, root.island, changes); + root.write(Object.assign({}, root.doc, { island: root.island })); + } + // The whole parsed object, so a write keeps keys this version does not // know about. property var doc: ({}) @@ -154,6 +163,11 @@ Singleton { const bl = parsed.bootlogo && typeof parsed.bootlogo === "object" && !Array.isArray(parsed.bootlogo) ? parsed.bootlogo : {}; root.bootlogo = Object.assign({}, root.bootlogoDefaults, bl); + const isl = parsed.island && typeof parsed.island === "object" ? parsed.island : {}; + root.island = { + eventMinutes: Number.isInteger(isl.eventMinutes) && isl.eventMinutes >= 5 && isl.eventMinutes <= 1440 + ? isl.eventMinutes : 60, + }; } FileView { diff --git a/desktop/island/calendar/Cal.js b/desktop/island/calendar/Cal.js index 6cf492d..6ec7bb3 100644 --- a/desktop/island/calendar/Cal.js +++ b/desktop/island/calendar/Cal.js @@ -112,14 +112,28 @@ function stepTime(s, minutes) { return pad(Math.floor(m / 60)) + ":" + pad(m % 60); } -// A new event on `day`: the next whole hour, one hour long, alarm 1h (the -// most common alarm in the live calendars). Capped at 22:00 so the end stays -// on the same day. -function newDraft(day, now, calendar) { +// A new event on `day`: the next whole hour, `minutes` long (the island's +// default event length), alarm 1h (the most common alarm in the live +// calendars). Capped at 22:00 so a short event stays on the same day. +function newDraft(day, now, calendar, minutes) { const h = Math.min(22, now.getHours() + 1); - return { uid: "", source: "", calendar: calendar, title: "", allDay: false, - startDay: day, endDay: day, startTime: pad(h) + ":00", endTime: pad(h + 1) + ":00", - repeat: "none", until: "", alarm: "1h", location: "", notes: "", override: false }; + return withStart({ uid: "", source: "", calendar: calendar, title: "", allDay: false, + startDay: day, endDay: day, startTime: pad(h) + ":00", endTime: pad(h) + ":00", + repeat: "none", until: "", alarm: "1h", location: "", notes: "", override: false }, + pad(h) + ":00", minutes); +} + +// The draft with a new start time, as typed, the end following it `minutes` later, +// into the next day if need be. A multi-day event keeps its end: snapping it +// to the start's day would throw the other days away. +function withStart(draft, time, minutes) { + const d = Object.assign({}, draft, { startTime: time }); + const n = normTime(time); + if (!validTime(n) || d.startDay !== d.endDay) return d; + const t = +n.slice(0, 2) * 60 + +n.slice(3, 5) + (minutes > 0 ? minutes : 60); + d.endDay = addDays(d.startDay, Math.floor(t / 1440)); + d.endTime = pad(Math.floor(t % 1440 / 60)) + ":" + pad(t % 60); + return d; } // calctl's event JSON as an editor draft. `source` is the calendar the event diff --git a/desktop/island/calendar/CalendarPanel.qml b/desktop/island/calendar/CalendarPanel.qml index 4a0a011..d28a856 100644 --- a/desktop/island/calendar/CalendarPanel.qml +++ b/desktop/island/calendar/CalendarPanel.qml @@ -82,7 +82,7 @@ Item { } function newEvent() { - editor.load(Cal.newDraft(panel.selected, new Date(), Calendar.defaultCalendar)); + editor.load(Cal.newDraft(panel.selected, new Date(), Calendar.defaultCalendar, Prefs.island.eventMinutes)); panel.editing = true; } diff --git a/desktop/island/calendar/EventEditor.qml b/desktop/island/calendar/EventEditor.qml index 41815e3..727e1df 100644 --- a/desktop/island/calendar/EventEditor.qml +++ b/desktop/island/calendar/EventEditor.qml @@ -55,7 +55,14 @@ Column { function setTime(key, value) { (key === "startTime" ? startTime : endTime).input.text = value; - ed.set(key, value); + if (key === "startTime") ed.setStart(value); + else ed.set(key, value); + } + + // The end follows the start by the island's default event length. + function setStart(text) { + ed.draft = Cal.withStart(ed.draft, text, Prefs.island.eventMinutes); + endTime.input.text = ed.draft.endTime; } // A click in the time picker: an hour keeps the minutes, a minute keeps @@ -195,7 +202,7 @@ Column { id: startTime width: 62 visible: !ed.draft.allDay - input.onTextEdited: ed.set("startTime", input.text) + input.onTextEdited: ed.setStart(input.text) input.onEditingFinished: if (Cal.normTime(input.text) !== input.text) ed.setTime("startTime", Cal.normTime(input.text)) MouseArea { anchors.fill: parent diff --git a/desktop/island/calendar/test_cal.js b/desktop/island/calendar/test_cal.js index de3d1c7..853cbb6 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, fromEvent, pick," + + " lastDay, onDay, dayEvents, dots, timeLabel, validTime, normTime, stepTime, newDraft, withStart, fromEvent, pick," + " toPayload };")(); // Dates are plain YYYY-MM-DD strings, arithmetic in UTC so DST never moves a day. @@ -91,6 +91,17 @@ assert.strictEqual(d.alarm, "1h"); assert.strictEqual(d.calendar, "home"); assert.strictEqual(d.source, ""); assert.strictEqual(C.newDraft("2026-10-20", new Date(2026, 9, 13, 23, 10), "home").startTime, "22:00"); +assert.strictEqual(C.newDraft("2026-10-20", new Date(2026, 9, 13, 9, 20), "home", 90).endTime, "11:30"); + +// The end follows the start, by the default length, past midnight too. +const w = C.withStart(d, "23:30", 60); +assert.strictEqual(w.startTime, "23:30"); +assert.strictEqual(w.endTime, "00:30"); +assert.strictEqual(w.endDay, "2026-10-21"); +assert.strictEqual(C.withStart(d, "14:00", 45).endTime, "14:45"); +assert.strictEqual(C.withStart(d, "9.3", 60).endTime, "11:00"); +const multi = Object.assign({}, d, { endDay: "2026-10-22", endTime: "18:00" }); +assert.strictEqual(C.withStart(multi, "08:00", 60).endTime, "18:00"); // From calctl's JSON. const fe = C.fromEvent({ uid: "u", calendar: "home", title: "Trip", allDay: true, start: "2026-10-14", diff --git a/desktop/modules/island/IslandCalendarModule.qml b/desktop/modules/island/IslandCalendarModule.qml new file mode 100644 index 0000000..6525d11 --- /dev/null +++ b/desktop/modules/island/IslandCalendarModule.qml @@ -0,0 +1,76 @@ +// Copyright (C) 2026 Danilo M. +// +// 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 QtQuick +import "../.." +import "../appearance" + +// The island bar's settings group, starting with the calendar's. +Module { + id: mod + name: "island-calendar" + label: "Calendar" + // nf-md-calendar, present in Inconsolata Nerd Font. + icon: "\u{f00ed}" + settings: true + group: "Island bar" + keywords: ["island", "calendar", "event", "length", "duration", "end time"] + searchItems: [{ text: "Default event length", key: "eventMinutes" }] + + // The draft, as typed; dirty while it differs from what is saved. + property string draft: String(Prefs.island.eventMinutes) + readonly property bool valid: /^\d+$/.test(draft) && +draft >= 5 && +draft <= 1440 + dirty: draft !== String(Prefs.island.eventMinutes) + function refresh() { if (!mod.dirty) mod.draft = String(Prefs.island.eventMinutes); } + + page: Component { + Page { + title: "Calendar" + actions: Component { + SaveBar { + dirty: mod.dirty; valid: mod.valid + error: mod.valid ? "" : "Length is 5 to 1440 minutes." + onSave: Prefs.setIsland({ eventMinutes: +mod.draft }) + onDiscard: mod.draft = String(Prefs.island.eventMinutes) + } + } + Column { + objectName: "eventMinutes" + width: parent.width + spacing: 8 + Row { + spacing: 8 + Text { + anchors.verticalCenter: parent.verticalCenter + text: "Default event length" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + // Read on every change: a click on Save ends no editing. + Field { width: 60; value: mod.draft; onValueChanged: mod.draft = value } + Text { + anchors.verticalCenter: parent.verticalCenter + text: "minutes" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + } + Text { + width: parent.width + wrapMode: Text.WordWrap + text: "A new event gets this length, and its end keeps it when the start moves." + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + } + } + } +} diff --git a/desktop/shell.qml b/desktop/shell.qml index f5ca7d4..dd440c0 100644 --- a/desktop/shell.qml +++ b/desktop/shell.qml @@ -22,6 +22,7 @@ import "modules/appearance" import "modules/assistant" import "modules/autostart" import "modules/deckit" +import "modules/island" import "modules/qtmaildir" import "modules/sbbuilder" import "modules/shells" @@ -112,6 +113,7 @@ ShellRoot { OptionsModule {}, MonitorsModule {}, ObsScenesModule {}, + IslandCalendarModule {}, // deck-it's four pages, one draft. DeckItModule {}, DeckItDashboardModule {}, -- cgit v1.2.3