aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-13 10:25:59 +0200
committerDanilo M. <danix@danix.xyz>2026-10-13 10:25:59 +0200
commit06b7d7c77c15f24a00cfabb1925a7718cd97210f (patch)
tree64984da221ba4f657d1e06299b936b7d92ace527
parent713e761a3202754c75db25464ec24d2c2af70420 (diff)
downloadquickshell-06b7d7c77c15f24a00cfabb1925a7718cd97210f.tar.gz
quickshell-06b7d7c77c15f24a00cfabb1925a7718cd97210f.zip
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 <noreply@anthropic.com>
-rw-r--r--desktop/island/calendar/EventEditor.qml356
1 files changed, 356 insertions, 0 deletions
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. <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 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() }
+ }
+ }
+}