From 06b7d7c77c15f24a00cfabb1925a7718cd97210f Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 13 Oct 2026 10:25:59 +0200 Subject: feat(calendar): the event form Dates come from the month grid beside it; times are typed or scrolled in 15-minute steps. Repeat and alarm offer the presets and show custom, unselectable, for an event carrying something richer, which the save then leaves alone. A failed save keeps the form open with calctl's line. Co-Authored-By: Claude Sonnet 5.5 --- desktop/island/calendar/EventEditor.qml | 356 ++++++++++++++++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 desktop/island/calendar/EventEditor.qml (limited to 'desktop/island/calendar/EventEditor.qml') diff --git a/desktop/island/calendar/EventEditor.qml b/desktop/island/calendar/EventEditor.qml new file mode 100644 index 0000000..63b29f0 --- /dev/null +++ b/desktop/island/calendar/EventEditor.qml @@ -0,0 +1,356 @@ +// 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 "Cal.js" as Cal + +// The event form. The month grid beside it is its date picker: the panel +// forwards grid clicks to pick(). Text fields are filled imperatively by +// load() and write back on edit, so typing never fights a binding. +Column { + id: ed + + property var draft: Cal.newDraft(Cal.today(new Date()), new Date(), "") + property bool pickUntil: false + property bool confirmDelete: false + property bool busy: false + property string err: "" + + signal done() + + spacing: 8 + + function load(d) { + ed.draft = d; + ed.err = ""; + ed.pickUntil = false; + ed.confirmDelete = false; + ed.busy = false; + title.input.text = d.title; + startTime.input.text = d.startTime; + endTime.input.text = d.endTime; + location.input.text = d.location; + notes.text = d.notes; + title.input.forceActiveFocus(); + } + + function set(key, value) { + const d = Object.assign({}, ed.draft); + d[key] = value; + ed.draft = d; + } + + function pick(day, shift) { + if (ed.pickUntil) { + ed.set("until", day); + ed.pickUntil = false; + } else { + ed.draft = Cal.pick(ed.draft, day, shift); + } + } + + function finish(r) { + ed.busy = false; + if (r.ok) ed.done(); + else ed.err = r.error; + } + + function save() { + const r = Cal.toPayload(ed.draft); + if (r.error) { ed.err = r.error; return; } + ed.busy = true; + if (ed.draft.uid) Calendar.update(ed.draft.uid, r.payload, ed.finish); + else Calendar.create(r.payload, ed.finish); + } + + function remove() { + if (!ed.confirmDelete) { ed.confirmDelete = true; return; } + ed.busy = true; + Calendar.remove(ed.draft.uid, ed.finish); + } + + 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"); + } + + // An unhandled Escape from any field lands here: leave the editor, not + // the panel. + Keys.onEscapePressed: ed.done() + + component Field: Rectangle { + property alias input: ti + property string placeholder: "" + height: 30 + radius: 8 + color: Theme.surface + TextInput { + id: ti + anchors { fill: parent; leftMargin: 8; rightMargin: 8 } + verticalAlignment: TextInput.AlignVCenter + clip: true + selectByMouse: true + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Text { + anchors { fill: parent; leftMargin: 8 } + verticalAlignment: Text.AlignVCenter + visible: ti.text === "" && !ti.activeFocus + text: parent.placeholder + color: Theme.overlay + font: ti.font + } + } + + component Chip: Rectangle { + property string label: "" + property bool on: false + property color tint: Theme.accent + signal clicked() + width: chipText.implicitWidth + 16 + height: 26 + radius: 13 + color: on ? Qt.alpha(tint, 0.35) : Theme.surface + Text { + id: chipText + anchors.centerIn: parent + text: parent.label + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: parent.clicked() } + } + + component Label: Text { + width: 58 + anchors.verticalCenter: parent ? parent.verticalCenter : undefined + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + Field { + id: title + width: ed.width + placeholder: "Title" + input.onTextEdited: ed.set("title", input.text) + input.onAccepted: ed.save() + } + + // When: the dates come from the grid. + Row { + spacing: 6 + Label { text: "When" } + Text { + anchors.verticalCenter: parent.verticalCenter + text: ed.draft.startDay === ed.draft.endDay ? ed.dayLabel(ed.draft.startDay) + : ed.dayLabel(ed.draft.startDay) + " – " + ed.dayLabel(ed.draft.endDay) + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: "click the month, shift for the end" + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 6 } + } + } + + Row { + spacing: 6 + Label { text: "Time" } + Field { + id: startTime + width: 62 + visible: !ed.draft.allDay + input.onTextEdited: ed.set("startTime", input.text) + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + property real acc: 0 + onWheel: wheel => { + acc += wheel.angleDelta.y; + if (Math.abs(acc) < 120) return; + parent.input.text = Cal.stepTime(parent.input.text, acc > 0 ? 15 : -15); + ed.set("startTime", parent.input.text); + acc = 0; + } + } + } + Text { + anchors.verticalCenter: parent.verticalCenter + visible: !ed.draft.allDay + text: "–" + color: Theme.subtext + } + Field { + id: endTime + width: 62 + visible: !ed.draft.allDay + input.onTextEdited: ed.set("endTime", input.text) + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + property real acc: 0 + onWheel: wheel => { + acc += wheel.angleDelta.y; + if (Math.abs(acc) < 120) return; + parent.input.text = Cal.stepTime(parent.input.text, acc > 0 ? 15 : -15); + ed.set("endTime", parent.input.text); + acc = 0; + } + } + } + Chip { + anchors.verticalCenter: parent.verticalCenter + label: "all day" + on: ed.draft.allDay + onClicked: ed.set("allDay", !ed.draft.allDay) + } + } + + Row { + spacing: 6 + Label { text: "Calendar" } + Repeater { + model: Calendar.calendars + delegate: Chip { + required property var modelData + anchors.verticalCenter: parent.verticalCenter + label: modelData.name + on: ed.draft.calendar === modelData.name + tint: modelData.color.startsWith("#") ? modelData.color : Theme[modelData.color] + onClicked: ed.set("calendar", modelData.name) + } + } + } + + Row { + spacing: 4 + Label { text: "Repeat" } + Repeater { + // custom is shown, never offered: it means a rule this form + // cannot express, kept as it is. + model: ["none", "daily", "weekly", "monthly", "yearly"] + .concat(ed.draft.repeat === "custom" ? ["custom"] : []) + delegate: Chip { + required property string modelData + anchors.verticalCenter: parent.verticalCenter + label: ({ none: "none", daily: "day", weekly: "week", monthly: "month", + yearly: "year", custom: "custom" })[modelData] + on: ed.draft.repeat === modelData + onClicked: if (modelData !== "custom") ed.set("repeat", modelData) + } + } + } + + Row { + spacing: 6 + visible: ed.draft.repeat !== "none" && ed.draft.repeat !== "custom" + Label { text: "Until" } + Chip { + anchors.verticalCenter: parent.verticalCenter + label: ed.pickUntil ? "pick a day…" : (ed.draft.until ? ed.dayLabel(ed.draft.until) : "forever") + on: ed.pickUntil + onClicked: ed.pickUntil = !ed.pickUntil + } + Chip { + anchors.verticalCenter: parent.verticalCenter + visible: ed.draft.until !== "" + label: "×" + onClicked: ed.set("until", "") + } + } + + Row { + spacing: 4 + Label { text: "Alarm" } + Repeater { + model: ["none", "10m", "1h", "1d"].concat(ed.draft.alarm === "custom" ? ["custom"] : []) + delegate: Chip { + required property string modelData + anchors.verticalCenter: parent.verticalCenter + label: modelData + on: ed.draft.alarm === modelData + onClicked: if (modelData !== "custom") ed.set("alarm", modelData) + } + } + } + + Text { + visible: ed.draft.override === true + width: ed.width + wrapMode: Text.WordWrap + text: "cal-notif has its own alarm for this event, and it wins." + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } + } + + Field { + id: location + width: ed.width + placeholder: "Location" + input.onTextEdited: ed.set("location", input.text) + } + + Rectangle { + width: ed.width + height: 56 + radius: 8 + color: Theme.surface + TextEdit { + id: notes + anchors { fill: parent; margins: 8 } + wrapMode: TextEdit.Wrap + selectByMouse: true + clip: true + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + onTextChanged: if (text !== ed.draft.notes) ed.set("notes", text) + } + Text { + anchors { left: parent.left; top: parent.top; margins: 8 } + visible: notes.text === "" && !notes.activeFocus + text: "Notes" + color: Theme.overlay + font: notes.font + } + } + + Text { + visible: ed.err !== "" + width: ed.width + wrapMode: Text.WordWrap + text: ed.err + color: Theme.red + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + Item { + width: ed.width + height: 28 + + Chip { + visible: ed.draft.uid !== "" + label: ed.confirmDelete ? "Delete, really" : "Delete" + tint: Theme.red + on: ed.confirmDelete + onClicked: if (!ed.busy) ed.remove() + } + Row { + anchors.right: parent.right + spacing: 6 + Chip { label: "Cancel"; onClicked: ed.done() } + Chip { label: ed.busy ? "Saving…" : "Save"; on: true; onClicked: if (!ed.busy) ed.save() } + } + } +} -- cgit v1.2.3