diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-24 17:01:26 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-24 17:01:26 +0200 |
| commit | c1fc03f2eccad138361d4205e0b56ba832fe73ae (patch) | |
| tree | f450f2064aba72af5c8375389547686ce2b3f1f9 /src/eventpane.cpp | |
| parent | 06cfa9352e1da8ffb44e35a28523755db8dbf7e2 (diff) | |
| download | qtmaildir-c1fc03f2eccad138361d4205e0b56ba832fe73ae.tar.gz qtmaildir-c1fc03f2eccad138361d4205e0b56ba832fe73ae.zip | |
feat: show and edit a calendar event in the side pane
Diffstat (limited to 'src/eventpane.cpp')
| -rw-r--r-- | src/eventpane.cpp | 495 |
1 files changed, 495 insertions, 0 deletions
diff --git a/src/eventpane.cpp b/src/eventpane.cpp new file mode 100644 index 0000000..1b63fd5 --- /dev/null +++ b/src/eventpane.cpp @@ -0,0 +1,495 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * 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. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +#include "eventpane.h" + +#include <QCheckBox> +#include <QComboBox> +#include <QDateEdit> +#include <QDateTimeEdit> +#include <QFormLayout> +#include <QHBoxLayout> +#include <QIcon> +#include <QLabel> +#include <QLineEdit> +#include <QLocale> +#include <QPainter> +#include <QPixmap> +#include <QPlainTextEdit> +#include <QPushButton> +#include <QSpinBox> +#include <QStringList> +#include <QVBoxLayout> + +#include <algorithm> + +namespace { + +QLabel *plainLabel(QWidget *parent) +{ + auto *label = new QLabel(parent); + label->setTextFormat(Qt::PlainText); // untrusted: never AutoText + label->setWordWrap(true); + label->setTextInteractionFlags(Qt::TextSelectableByMouse); + return label; +} + +QIcon swatch(const QColor &colour) +{ + QPixmap pixmap(12, 12); + pixmap.fill(Qt::transparent); + QPainter(&pixmap).fillRect(pixmap.rect(), colour); + return QIcon(pixmap); +} + +bool sameEdit(const EventEdit &a, const EventEdit &b) +{ + return a.summary == b.summary && a.location == b.location + && a.description == b.description && a.start == b.start && a.end == b.end + && a.allDay == b.allDay && a.repeat == b.repeat && a.collectionDir == b.collectionDir; +} + +constexpr int kCustomIndex = 5; + +} // namespace + +EventPane::EventPane(QWidget *parent) : QStackedWidget(parent) +{ + addWidget(new QWidget(this)); // 0: nothing selected + addWidget(buildDetails()); // 1 + addWidget(buildForm()); // 2 + setMinimumWidth(260); + showNothing(); +} + +QWidget *EventPane::buildDetails() +{ + auto *page = new QWidget(this); + auto *layout = new QVBoxLayout(page); + m_title = plainLabel(page); + QFont big = m_title->font(); + big.setBold(true); + big.setPointSizeF(big.pointSizeF() > 0 ? big.pointSizeF() * 1.3 : big.pointSizeF()); + m_title->setFont(big); + layout->addWidget(m_title); + + auto *form = new QFormLayout; + m_when = plainLabel(page); + m_repeatText = plainLabel(page); + m_calendar = plainLabel(page); + m_location = plainLabel(page); + m_notes = plainLabel(page); + m_reminder = plainLabel(page); + m_people = plainLabel(page); + form->addRow(tr("When"), m_when); + form->addRow(tr("Repeats"), m_repeatText); + form->addRow(tr("Calendar"), m_calendar); + form->addRow(tr("Location"), m_location); + form->addRow(tr("Notes"), m_notes); + form->addRow(tr("Reminder"), m_reminder); + form->addRow(tr("People"), m_people); + layout->addLayout(form); + + m_notice = plainLabel(page); + m_notice->setObjectName(QStringLiteral("eventNotice")); + layout->addWidget(m_notice); + + auto *buttons = new QHBoxLayout; + m_editButton = new QPushButton(tr("&Edit"), page); + m_editButton->setObjectName(QStringLiteral("editEvent")); + m_deleteButton = new QPushButton(tr("&Delete"), page); + m_deleteButton->setObjectName(QStringLiteral("deleteEvent")); + buttons->addWidget(m_editButton); + buttons->addWidget(m_deleteButton); + buttons->addStretch(); + layout->addLayout(buttons); + layout->addStretch(); + + connect(m_editButton, &QPushButton::clicked, this, &EventPane::editRequested); + connect(m_deleteButton, &QPushButton::clicked, this, &EventPane::deleteRequested); + return page; +} + +QWidget *EventPane::buildForm() +{ + auto *page = new QWidget(this); + auto *layout = new QVBoxLayout(page); + auto *form = new QFormLayout; + + m_titleEdit = new QLineEdit(page); + m_titleEdit->setObjectName(QStringLiteral("eventTitle")); + m_calendarBox = new QComboBox(page); + m_calendarBox->setObjectName(QStringLiteral("eventCalendar")); + m_allDay = new QCheckBox(tr("All day"), page); + m_startEdit = new QDateTimeEdit(page); + m_startEdit->setObjectName(QStringLiteral("eventStart")); + m_startEdit->setCalendarPopup(true); + m_endEdit = new QDateTimeEdit(page); + m_endEdit->setObjectName(QStringLiteral("eventEnd")); + m_endEdit->setCalendarPopup(true); + m_locationEdit = new QLineEdit(page); + m_notesEdit = new QPlainTextEdit(page); + m_formReminder = plainLabel(page); + m_formPeople = plainLabel(page); + + form->addRow(tr("Title"), m_titleEdit); + form->addRow(tr("Calendar"), m_calendarBox); + form->addRow(QString(), m_allDay); + form->addRow(tr("Starts"), m_startEdit); + form->addRow(tr("Ends"), m_endEdit); + form->addRow(tr("Repeat"), buildRepeat()); + form->addRow(tr("Location"), m_locationEdit); + form->addRow(tr("Notes"), m_notesEdit); + form->addRow(tr("Reminder"), m_formReminder); + form->addRow(tr("People"), m_formPeople); + layout->addLayout(form); + + m_formNotice = plainLabel(page); + layout->addWidget(m_formNotice); + + auto *buttons = new QHBoxLayout; + auto *save = new QPushButton(tr("&Save"), page); + save->setObjectName(QStringLiteral("saveEvent")); + save->setDefault(true); + auto *cancel = new QPushButton(tr("Cancel"), page); + cancel->setObjectName(QStringLiteral("cancelEvent")); + buttons->addStretch(); + buttons->addWidget(cancel); + buttons->addWidget(save); + layout->addLayout(buttons); + + connect(save, &QPushButton::clicked, this, &EventPane::saveRequested); + connect(cancel, &QPushButton::clicked, this, &EventPane::cancelRequested); + connect(m_allDay, &QCheckBox::toggled, this, &EventPane::refreshAllDay); + // Keep the duration when the start moves, as every calendar does. + connect(m_startEdit, &QDateTimeEdit::dateTimeChanged, this, [this](const QDateTime &start) { + if (m_previousStart.isValid() && m_editing) { + const qint64 length = m_previousStart.secsTo(m_endEdit->dateTime()); + m_endEdit->setDateTime(start.addSecs(qMax<qint64>(0, length))); + } + m_previousStart = start; + proposeFromStart(); + }); + return page; +} + +QWidget *EventPane::buildRepeat() +{ + m_repeatBox = new QWidget(this); + auto *box = new QVBoxLayout(m_repeatBox); + box->setContentsMargins(0, 0, 0, 0); + const QLocale locale; + + auto *row = new QHBoxLayout; + m_freq = new QComboBox(m_repeatBox); + m_freq->setObjectName(QStringLiteral("eventRepeat")); + m_freq->addItems({ tr("Does not repeat"), tr("Daily"), tr("Weekly"), + tr("Monthly"), tr("Yearly") }); + m_interval = new QSpinBox(m_repeatBox); + m_interval->setRange(1, 99); + m_interval->setPrefix(tr("every ")); + row->addWidget(m_freq); + row->addWidget(m_interval); + box->addLayout(row); + + m_weekdayRow = new QWidget(m_repeatBox); + auto *days = new QHBoxLayout(m_weekdayRow); + days->setContentsMargins(0, 0, 0, 0); + for (int d = 1; d <= 7; ++d) { + auto *check = new QCheckBox(locale.dayName(d, QLocale::NarrowFormat), m_weekdayRow); + m_weekdays.append(check); + days->addWidget(check); + } + box->addWidget(m_weekdayRow); + + auto *byRow = new QHBoxLayout; + m_by = new QComboBox(m_repeatBox); + m_by->addItems({ tr("on day"), tr("on the") }); + m_monthDay = new QSpinBox(m_repeatBox); + m_monthDay->setRange(1, 31); + m_ordinal = new QComboBox(m_repeatBox); + m_ordinal->addItem(tr("first"), 1); + m_ordinal->addItem(tr("second"), 2); + m_ordinal->addItem(tr("third"), 3); + m_ordinal->addItem(tr("fourth"), 4); + m_ordinal->addItem(tr("last"), -1); + m_weekday = new QComboBox(m_repeatBox); + for (int d = 1; d <= 7; ++d) + m_weekday->addItem(locale.dayName(d), d); + m_month = new QComboBox(m_repeatBox); + for (int m = 1; m <= 12; ++m) + m_month->addItem(tr("of %1").arg(locale.monthName(m)), m); + byRow->addWidget(m_by); + byRow->addWidget(m_monthDay); + byRow->addWidget(m_ordinal); + byRow->addWidget(m_weekday); + byRow->addWidget(m_month); + box->addLayout(byRow); + + auto *endRow = new QHBoxLayout; + m_end = new QComboBox(m_repeatBox); + m_end->addItems({ tr("forever"), tr("until"), tr("for") }); + m_until = new QDateEdit(m_repeatBox); + m_until->setCalendarPopup(true); + m_count = new QSpinBox(m_repeatBox); + m_count->setRange(1, 999); + m_count->setSuffix(tr(" times")); + endRow->addWidget(m_end); + endRow->addWidget(m_until); + endRow->addWidget(m_count); + box->addLayout(endRow); + + m_customLabel = new QLabel(tr("Custom rule, kept as is"), m_repeatBox); + box->addWidget(m_customLabel); + + for (QComboBox *combo : { m_freq, m_by, m_end }) + connect(combo, &QComboBox::currentIndexChanged, this, &EventPane::refreshRepeatVisibility); + connect(m_freq, &QComboBox::currentIndexChanged, this, &EventPane::proposeFromStart); + return m_repeatBox; +} + +void EventPane::refreshRepeatVisibility() +{ + const int freq = m_freq->currentIndex(); + const bool custom = freq == kCustomIndex; + const bool repeats = freq > 0 && !custom; + const bool monthly = freq == 3, yearly = freq == 4; + const bool byWeekday = m_by->currentIndex() == 1; + m_interval->setVisible(repeats); + m_weekdayRow->setVisible(freq == 2); + m_by->setVisible(monthly || yearly); + // Yearly "on day" means on the start date, which needs no control. + m_monthDay->setVisible(monthly && !byWeekday); + m_ordinal->setVisible((monthly || yearly) && byWeekday); + m_weekday->setVisible((monthly || yearly) && byWeekday); + m_month->setVisible(yearly && byWeekday); + m_end->setVisible(repeats); + m_until->setVisible(repeats && m_end->currentIndex() == 1); + m_count->setVisible(repeats && m_end->currentIndex() == 2); + m_customLabel->setVisible(custom); +} + +/// Re-proposes the day-dependent defaults from the start date, as Thunderbird +/// does: "on day 24", or "the fourth Thursday" (the fifth becomes "last"). +void EventPane::proposeFromStart() +{ + const QDate start = m_startEdit->date(); + m_monthDay->setValue(start.day()); + const int ordinal = (start.day() - 1) / 7 + 1; + m_ordinal->setCurrentIndex(m_ordinal->findData(ordinal > 4 ? -1 : ordinal)); + m_weekday->setCurrentIndex(m_weekday->findData(start.dayOfWeek())); + m_month->setCurrentIndex(start.month() - 1); + if (m_freq->currentIndex() == 2 + && std::none_of(m_weekdays.cbegin(), m_weekdays.cend(), + [](QCheckBox *c) { return c->isChecked(); })) + m_weekdays[start.dayOfWeek() - 1]->setChecked(true); +} + +void EventPane::setRepeat(const RepeatRule &rule) +{ + if (m_freq->count() > kCustomIndex) + m_freq->removeItem(kCustomIndex); + m_customRule = RepeatRule(); + if (rule.custom) { + m_customRule = rule; + m_freq->addItem(tr("Custom rule")); + m_freq->setCurrentIndex(kCustomIndex); + refreshRepeatVisibility(); + return; + } + m_freq->setCurrentIndex(static_cast<int>(rule.freq)); + proposeFromStart(); + m_interval->setValue(rule.interval); + for (int d = 0; d < 7; ++d) + m_weekdays[d]->setChecked(rule.weekdays.contains(d + 1)); + m_by->setCurrentIndex(rule.by == RepeatRule::By::Weekday ? 1 : 0); + if (rule.monthDay) + m_monthDay->setValue(rule.monthDay); + if (rule.ordinal) + m_ordinal->setCurrentIndex(m_ordinal->findData(rule.ordinal)); + if (rule.weekday) + m_weekday->setCurrentIndex(m_weekday->findData(rule.weekday)); + if (rule.month) + m_month->setCurrentIndex(rule.month - 1); + m_end->setCurrentIndex(static_cast<int>(rule.end)); + m_until->setDate(rule.until.isValid() ? rule.until : m_startEdit->date().addMonths(1)); + m_count->setValue(qMax(1, rule.count)); + refreshRepeatVisibility(); +} + +RepeatRule EventPane::repeat() const +{ + if (m_freq->currentIndex() == kCustomIndex) + return m_customRule; + RepeatRule rule; + rule.freq = static_cast<RepeatRule::Freq>(m_freq->currentIndex()); + if (rule.freq == RepeatRule::Freq::None) + return rule; + rule.interval = m_interval->value(); + rule.wkst = m_initial.repeat.wkst; // carried through, spec + if (rule.freq == RepeatRule::Freq::Weekly) + for (int d = 0; d < 7; ++d) + if (m_weekdays[d]->isChecked()) + rule.weekdays.append(d + 1); + if (rule.freq == RepeatRule::Freq::Monthly || rule.freq == RepeatRule::Freq::Yearly) { + rule.by = m_by->currentIndex() == 1 ? RepeatRule::By::Weekday : RepeatRule::By::MonthDay; + if (rule.by == RepeatRule::By::Weekday) { + rule.ordinal = m_ordinal->currentData().toInt(); + rule.weekday = m_weekday->currentData().toInt(); + if (rule.freq == RepeatRule::Freq::Yearly) + rule.month = m_month->currentData().toInt(); + } else if (rule.freq == RepeatRule::Freq::Monthly) { + rule.monthDay = m_monthDay->value(); + } + } + rule.end = static_cast<RepeatRule::End>(m_end->currentIndex()); + if (rule.end == RepeatRule::End::Until) + rule.until = m_until->date(); + else if (rule.end == RepeatRule::End::Count) + rule.count = m_count->value(); + return rule; +} + +void EventPane::refreshAllDay() +{ + // All-day shows DATES, and the end is the LAST day, inclusive: what a + // person means by "24 to 25 September". edit() converts back to the + // exclusive end iCalendar needs. + const QString format = m_allDay->isChecked() + ? QLocale().dateFormat(QLocale::ShortFormat) + : QLocale().dateTimeFormat(QLocale::ShortFormat); + m_startEdit->setDisplayFormat(format); + m_endEdit->setDisplayFormat(format); +} + +void EventPane::showNothing() +{ + m_editing = false; + setCurrentIndex(0); +} + +void EventPane::showDetails(const CalEvent &event, const CalCollection &collection, + const Occurrence &occurrence, bool editable) +{ + m_editing = false; + const QLocale locale; + const CalOverride *ov = occurrence.isOverride ? &event.overrides[occurrence.overrideIndex] : nullptr; + const QString summary = ov ? ov->summary : event.summary; + m_title->setText(summary.isEmpty() ? tr("(no title)") : summary); + + const QDateTime start = occurrence.start.toLocalTime(), end = occurrence.end.toLocalTime(); + if (occurrence.allDay) { + const QDate last = end.date().addDays(-1); + m_when->setText(last > start.date() + ? tr("%1 to %2, all day").arg(locale.toString(start.date(), QLocale::LongFormat), + locale.toString(last, QLocale::LongFormat)) + : tr("%1, all day").arg(locale.toString(start.date(), QLocale::LongFormat))); + } else { + m_when->setText(tr("%1, %2 - %3").arg(locale.toString(start.date(), QLocale::LongFormat), + locale.toString(start.time(), QLocale::ShortFormat), + locale.toString(end.time(), QLocale::ShortFormat))); + } + m_repeatText->setText(event.repeat.freq == RepeatRule::Freq::None && !event.repeat.custom + ? tr("Does not repeat") : event.repeat.describe()); + m_calendar->setText(collection.displayName); + m_location->setText(ov ? ov->location : event.location); + m_notes->setText(ov ? ov->description : event.description); + m_reminder->setText(event.hasAlarm ? tr("Yes, kept as set elsewhere") : tr("None")); + + QStringList people; + if (!event.organizer.address.isEmpty()) + people << tr("Organiser: %1").arg(event.organizer.name.isEmpty() + ? event.organizer.address : event.organizer.name); + for (const CalPerson &a : event.attendees) + people << QStringLiteral("%1 (%2)").arg(a.name.isEmpty() ? a.address : a.name, + a.partstat.isEmpty() ? tr("no reply") : a.partstat.toLower()); + m_people->setText(people.join(QLatin1Char('\n'))); + + QString notice; + if (collection.readOnly) + notice = tr("Read-only calendar."); + else if (!editable) + notice = tr("Organised by %1: read-only.").arg(event.organizer.name.isEmpty() + ? event.organizer.address : event.organizer.name); + m_notice->setText(notice); + m_notice->setVisible(!notice.isEmpty()); + // Absent, not disabled (AGENTS.md precedent). + m_editButton->setVisible(editable); + m_deleteButton->setVisible(editable); + setCurrentIndex(1); +} + +void EventPane::startEdit(const EventEdit &initial, const QList<CalCollection> &collections, + bool repeatEditable, bool collectionEditable, + const QString &reminderText, const QString &attendeesText) +{ + m_initial = initial; + m_editing = false; // no duration-keeping while the fields are filled + m_calendarBox->clear(); + for (const CalCollection &c : collections) + m_calendarBox->addItem(swatch(c.color), c.displayName, c.dir); + m_calendarBox->setCurrentIndex(qMax(0, m_calendarBox->findData(initial.collectionDir))); + m_calendarBox->setEnabled(collectionEditable); + + m_previousStart = QDateTime(); + m_titleEdit->setText(initial.summary); + m_allDay->setChecked(initial.allDay); + refreshAllDay(); + m_startEdit->setDateTime(initial.start.toLocalTime()); + m_endEdit->setDateTime(initial.allDay ? initial.end.toLocalTime().addDays(-1) + : initial.end.toLocalTime()); + m_locationEdit->setText(initial.location); + m_notesEdit->setPlainText(initial.description); + setRepeat(initial.repeat); + m_repeatBox->setEnabled(repeatEditable); + m_formReminder->setText(reminderText); + m_formPeople->setText(attendeesText); + m_formNotice->setText(attendeesText.isEmpty() ? QString() + : tr("Attendees have not been notified of changes.")); + m_formNotice->setVisible(!attendeesText.isEmpty()); + + // The form rounds what it shows (seconds, the display format), so the + // baseline for isDirty() is what the form PRODUCES, not what came in. + m_initial = edit(); + m_editing = true; + setCurrentIndex(2); + m_titleEdit->setFocus(); +} + +void EventPane::stopEdit() { m_editing = false; } +bool EventPane::isEditing() const { return m_editing; } +bool EventPane::isDirty() const { return m_editing && !sameEdit(edit(), m_initial); } + +EventEdit EventPane::edit() const +{ + EventEdit e; + e.summary = m_titleEdit->text().trimmed(); + e.location = m_locationEdit->text().trimmed(); + e.description = m_notesEdit->toPlainText(); + e.allDay = m_allDay->isChecked(); + e.collectionDir = m_calendarBox->currentData().toString(); + if (e.allDay) { + e.start = QDateTime(m_startEdit->date(), QTime(0, 0)); + e.end = QDateTime(qMax(m_endEdit->date(), m_startEdit->date()).addDays(1), QTime(0, 0)); + } else { + e.start = m_startEdit->dateTime(); + e.end = qMax(m_endEdit->dateTime(), e.start); + } + e.repeat = repeat(); + return e; +} |
