1 /*
2 * daymatrix.cpp - calendar day matrix display
3 * Program: kalarm
4 * This class is adapted from KODayMatrix in KOrganizer.
5 *
6 * SPDX-FileCopyrightText: 2001 Eitzenberger Thomas <thomas.eitzenberger@siemens.at>
7 * Parts of the source code have been copied from kdpdatebutton.cpp
8 *
9 * SPDX-FileCopyrightText: 2003 Cornelius Schumacher <schumacher@kde.org>
10 * SPDX-FileCopyrightText: 2003-2004 Reinhold Kainhofer <reinhold@kainhofer.com>
11 * SPDX-FileCopyrightText: 2021 David Jarvie <djarvie@kde.org>
12 *
13 * SPDX-License-Identifier: GPL-2.0-or-later WITH Qt-Commercial-exception-1.0
14 */
15
16 #include "daymatrix.h"
17
18 #include "newalarmaction.h"
19 #include "preferences.h"
20 #include "resources/resources.h"
21 #include "lib/locale.h"
22 #include "lib/synchtimer.h"
23
24 #include <KHolidays/HolidayRegion>
25 #include <KLocalizedString>
26
27 #include <QMenu>
28 #include <QApplication>
29 #include <QMouseEvent>
30 #include <QPainter>
31 #include <QToolTip>
32
33 #include <cmath>
34
35 namespace
36 {
37 const int NUMROWS = 6; // number of rows displayed in the matrix
38 const int NUMDAYS = NUMROWS * 7; // number of days displayed in the matrix
39 const int NO_SELECTION = -1000000; // invalid selection start/end value
40
41 const QColor HOLIDAY_BACKGROUND_COLOUR(255,100,100); // add a preference for this?
42 const int TODAY_MARGIN_WIDTH(2);
43
44 struct TextColours
45 {
46 QColor disabled;
47 QColor thisMonth;
48 QColor otherMonth;
49 QColor thisMonthHoliday {HOLIDAY_BACKGROUND_COLOUR};
50 QColor otherMonthHoliday;
51
52 explicit TextColours(const QPalette& palette);
53
54 private:
55 QColor getShadedColour(const QColor& colour, bool enabled) const;
56 };
57 }
58
DayMatrix(QWidget * parent)59 DayMatrix::DayMatrix(QWidget* parent)
60 : QFrame(parent)
61 , mDayLabels(NUMDAYS)
62 , mSelStart(NO_SELECTION)
63 , mSelEnd(NO_SELECTION)
64 {
65 mHolidays.reserve(NUMDAYS);
66 for (int i = 0; i < NUMDAYS; ++i)
67 mHolidays.append(QString());
68
69 Resources* resources = Resources::instance();
70 connect(resources, &Resources::resourceAdded, this, &DayMatrix::resourceUpdated);
71 connect(resources, &Resources::resourceRemoved, this, &DayMatrix::resourceRemoved);
72 connect(resources, &Resources::eventsAdded, this, &DayMatrix::resourceUpdated);
73 connect(resources, &Resources::eventUpdated, this, &DayMatrix::resourceUpdated);
74 connect(resources, &Resources::eventsRemoved, this, &DayMatrix::resourceUpdated);
75 Preferences::connect(&Preferences::holidaysChanged, this, &DayMatrix::slotUpdateView);
76 Preferences::connect(&Preferences::workTimeChanged, this, &DayMatrix::slotUpdateView);
77 }
78
~DayMatrix()79 DayMatrix::~DayMatrix()
80 {
81 }
82
83 /******************************************************************************
84 * Return all selected dates from mSelStart to mSelEnd, in date order.
85 */
selectedDates() const86 QVector<QDate> DayMatrix::selectedDates() const
87 {
88 QVector<QDate> selDays;
89 if (mSelStart != NO_SELECTION)
90 {
91 selDays.reserve(mSelEnd - mSelStart + 1);
92 for (int i = mSelStart; i <= mSelEnd; ++i)
93 selDays.append(mStartDate.addDays(i));
94 }
95 return selDays;
96 }
97
98 /******************************************************************************
99 * Clear the current selection of dates.
100 */
clearSelection()101 void DayMatrix::clearSelection()
102 {
103 setMouseSelection(NO_SELECTION, NO_SELECTION, true);
104 }
105
106 /******************************************************************************
107 * Evaluate the index for today, and update the display if it has changed.
108 */
updateToday(const QDate & newDate)109 void DayMatrix::updateToday(const QDate& newDate)
110 {
111 const int index = mStartDate.daysTo(newDate);
112 if (index != mTodayIndex)
113 {
114 mTodayIndex = index;
115 updateEvents();
116
117 if (mSelStart != NO_SELECTION && mSelStart < mTodayIndex)
118 {
119 if (mSelEnd < mTodayIndex)
120 setMouseSelection(NO_SELECTION, NO_SELECTION, true);
121 else
122 setMouseSelection(mTodayIndex, mSelEnd, true);
123 }
124 else
125 update();
126 }
127 }
128
129 /******************************************************************************
130 * Set a new start date for the matrix. If changed, or other changes are
131 * pending, recalculates which days in the matrix alarms occur on, and which are
132 * holidays/non-work days, and repaints.
133 */
setStartDate(const QDate & startDate)134 void DayMatrix::setStartDate(const QDate& startDate)
135 {
136 if (!startDate.isValid())
137 return;
138
139 if (startDate != mStartDate)
140 {
141 if (mSelStart != NO_SELECTION)
142 {
143 // Adjust selection indexes to be relative to the new start date.
144 const int diff = startDate.daysTo(mStartDate);
145 mSelStart += diff;
146 mSelEnd += diff;
147 if (mSelectionMustBeVisible)
148 {
149 // Ensure that the whole selection is still visible: if not, cancel the selection.
150 if (mSelStart < 0 || mSelEnd >= NUMDAYS)
151 setMouseSelection(NO_SELECTION, NO_SELECTION, true);
152 }
153 }
154
155 mStartDate = startDate;
156
157 QLocale locale;
158 mMonthStartIndex = -1;
159 mMonthEndIndex = NUMDAYS-1;
160 for (int i = 0; i < NUMDAYS; ++i)
161 {
162 const int day = mStartDate.addDays(i).day();
163 mDayLabels[i] = locale.toString(day);
164
165 if (day == 1) // start of a month
166 {
167 if (mMonthStartIndex < 0)
168 mMonthStartIndex = i;
169 else
170 mMonthEndIndex = i - 1;
171 }
172 }
173
174 mTodayIndex = mStartDate.daysTo(KADateTime::currentDateTime(Preferences::timeSpec()).date());
175 updateView();
176 }
177 else if (mPendingChanges)
178 updateView();
179 }
180
181 /******************************************************************************
182 * If changes are pending, recalculate which days in the matrix have alarms
183 * occurring, and which are holidays/non-work days. Repaint the matrix.
184 */
updateView()185 void DayMatrix::updateView()
186 {
187 if (!mStartDate.isValid())
188 return;
189
190 // TODO_Recurrence: If we just change the selection, but not the data,
191 // there's no need to update the whole list of alarms... This is just a
192 // waste of computational power
193 updateEvents();
194
195 // Find which holidays occur for the dates in the matrix.
196 const KHolidays::HolidayRegion& region = Preferences::holidays();
197 const KHolidays::Holiday::List list = region.holidays(mStartDate, mStartDate.addDays(NUMDAYS-1));
198 QHash<QDate, QStringList> holidaysByDate;
199 for (const KHolidays::Holiday& holiday : list)
200 if (!holiday.name().isEmpty())
201 holidaysByDate[holiday.observedStartDate()].append(holiday.name());
202 for (int i = 0; i < NUMDAYS; ++i)
203 {
204 const QStringList holidays = holidaysByDate[mStartDate.addDays(i)];
205 if (!holidays.isEmpty())
206 mHolidays[i] = holidays.join(i18nc("delimiter for joining holiday names", ","));
207 else
208 mHolidays[i].clear();
209 }
210
211 update();
212 }
213
214 /******************************************************************************
215 * Find which days currently displayed have alarms scheduled.
216 */
updateEvents()217 void DayMatrix::updateEvents()
218 {
219 const KADateTime::Spec timeSpec = Preferences::timeSpec();
220 const QDate startDate = (mTodayIndex <= 0) ? mStartDate : mStartDate.addDays(mTodayIndex);
221 const KADateTime before = KADateTime(startDate, QTime(0,0,0), timeSpec).addSecs(-60);
222 const KADateTime to(mStartDate.addDays(NUMDAYS-1), QTime(23,59,0), timeSpec);
223
224 mEventDates.clear();
225 const QVector<Resource> resources = Resources::enabledResources(CalEvent::ACTIVE);
226 for (const Resource& resource : resources)
227 {
228 const QList<KAEvent> events = resource.events();
229 const CalEvent::Types types = resource.enabledTypes() & CalEvent::ACTIVE;
230 for (const KAEvent& event : events)
231 {
232 if (event.enabled() && (event.category() & types))
233 {
234 // The event has an enabled alarm type.
235 // Find all its recurrences/repetitions within the time period.
236 DateTime nextDt;
237 for (KADateTime from = before; ; )
238 {
239 event.nextOccurrence(from, nextDt, KAEvent::RETURN_REPETITION);
240 if (!nextDt.isValid())
241 break;
242 from = nextDt.effectiveKDateTime().toTimeSpec(timeSpec);
243 if (from > to)
244 break;
245 if (!event.excludedByWorkTimeOrHoliday(from))
246 {
247 mEventDates += from.date();
248 if (mEventDates.count() >= NUMDAYS)
249 break; // all days have alarms due
250 }
251
252 // If the alarm recurs more than once per day, don't waste
253 // time checking any more occurrences for the same day.
254 from.setTime(QTime(23,59,0));
255 }
256 if (mEventDates.count() >= NUMDAYS)
257 break; // all days have alarms due
258 }
259 }
260 if (mEventDates.count() >= NUMDAYS)
261 break; // all days have alarms due
262 }
263
264 mPendingChanges = false;
265 }
266
267 /******************************************************************************
268 * Return the holiday description (if any) for a date.
269 */
getHolidayLabel(int offset) const270 QString DayMatrix::getHolidayLabel(int offset) const
271 {
272 if (offset < 0 || offset > NUMDAYS - 1)
273 return QString();
274 return mHolidays[offset];
275 }
276
277 /******************************************************************************
278 * Determine the day index at a geometric position.
279 * Return = NO_SELECTION if outside the widget, or if the date is earlier than today.
280 */
getDayIndex(const QPoint & pt) const281 int DayMatrix::getDayIndex(const QPoint& pt) const
282 {
283 const int x = pt.x();
284 const int y = pt.y();
285 if (x < 0 || y < 0 || x > width() || y > height())
286 return NO_SELECTION;
287 const int xd = static_cast<int>(x / mDaySize.width());
288 const int i = 7 * int(y / mDaySize.height())
289 + (QApplication::isRightToLeft() ? 6 - xd : xd);
290 if (i < mTodayIndex || i > NUMDAYS-1)
291 return NO_SELECTION;
292 return i;
293 }
294
setRowHeight(int rowHeight)295 void DayMatrix::setRowHeight(int rowHeight)
296 {
297 mRowHeight = rowHeight;
298 setMinimumSize(minimumWidth(), mRowHeight * NUMROWS + TODAY_MARGIN_WIDTH*2);
299 }
300
301 /******************************************************************************
302 * Called when the events in a resource have been updated.
303 * Re-evaluate all events in the resource.
304 */
resourceUpdated(Resource &)305 void DayMatrix::resourceUpdated(Resource&)
306 {
307 mPendingChanges = true;
308 updateView(); //TODO: only update this resource's events
309 }
310
311 /******************************************************************************
312 * Called when a resource has been removed.
313 * Remove all its events from the view.
314 */
resourceRemoved(ResourceId)315 void DayMatrix::resourceRemoved(ResourceId)
316 {
317 mPendingChanges = true;
318 updateView(); //TODO: only remove this resource's events
319 }
320
321 /******************************************************************************
322 * Called when the holiday or work time settings have changed.
323 * Re-evaluate all events in the view.
324 */
slotUpdateView()325 void DayMatrix::slotUpdateView()
326 {
327 mPendingChanges = true;
328 updateView();
329 }
330
331 // ----------------------------------------------------------------------------
332 // M O U S E E V E N T H A N D L I N G
333 // ----------------------------------------------------------------------------
334
event(QEvent * event)335 bool DayMatrix::event(QEvent* event)
336 {
337 if (event->type() == QEvent::ToolTip)
338 {
339 // Tooltip event: show the holiday name.
340 auto* helpEvent = static_cast<QHelpEvent*>(event);
341 const int i = getDayIndex(helpEvent->pos());
342 const QString tipText = getHolidayLabel(i);
343 if (!tipText.isEmpty())
344 QToolTip::showText(helpEvent->globalPos(), tipText);
345 else
346 QToolTip::hideText();
347 }
348 return QWidget::event(event);
349 }
350
mousePressEvent(QMouseEvent * e)351 void DayMatrix::mousePressEvent(QMouseEvent* e)
352 {
353 int i = getDayIndex(e->pos());
354 if (i < 0)
355 {
356 mSelInit = NO_SELECTION; // invalid: it's not in the matrix or it's before today
357 setMouseSelection(NO_SELECTION, NO_SELECTION, true);
358 return;
359 }
360 if (e->button() == Qt::RightButton)
361 {
362 if (i < mSelStart || i > mSelEnd)
363 setMouseSelection(i, i, true);
364 popupMenu(e->globalPos());
365 }
366 else if (e->button() == Qt::LeftButton)
367 {
368 if (i >= mSelStart && i <= mSelEnd)
369 {
370 mSelInit = NO_SELECTION; // already selected: cancel the current selection
371 setMouseSelection(NO_SELECTION, NO_SELECTION, true);
372 return;
373 }
374 mSelInit = i;
375 setMouseSelection(i, i, false); // don't emit signal until mouse move has completed
376 }
377 }
378
popupMenu(const QPoint & pos)379 void DayMatrix::popupMenu(const QPoint& pos)
380 {
381 NewAlarmAction newAction(false, QString(), nullptr);
382 QMenu* popup = newAction.menu();
383 connect(&newAction, &NewAlarmAction::selected, this, &DayMatrix::newAlarm);
384 connect(&newAction, &NewAlarmAction::selectedTemplate, this, &DayMatrix::newAlarmFromTemplate);
385 popup->exec(pos);
386 }
387
mouseReleaseEvent(QMouseEvent * e)388 void DayMatrix::mouseReleaseEvent(QMouseEvent* e)
389 {
390 if (e->button() != Qt::LeftButton)
391 return;
392
393 if (mSelInit < 0)
394 return;
395 int i = getDayIndex(e->pos());
396 if (i < 0)
397 {
398 // Emit signal after move (without changing the selection).
399 setMouseSelection(mSelStart, mSelEnd, true);
400 return;
401 }
402
403 setMouseSelection(mSelInit, i, true);
404 }
405
mouseMoveEvent(QMouseEvent * e)406 void DayMatrix::mouseMoveEvent(QMouseEvent* e)
407 {
408 if (mSelInit < 0)
409 return;
410 int i = getDayIndex(e->pos());
411 setMouseSelection(mSelInit, i, false); // don't emit signal until mouse move has completed
412 }
413
414 /******************************************************************************
415 * Set the current day selection, and update the display.
416 * Note that the selection may extend past the end of the current matrix.
417 */
setMouseSelection(int start,int end,bool emitSignal)418 void DayMatrix::setMouseSelection(int start, int end, bool emitSignal)
419 {
420 if (!mAllowMultipleSelection)
421 start = end;
422 if (end < start)
423 std::swap(start, end);
424 if (start != mSelStart || end != mSelEnd)
425 {
426 mSelStart = start;
427 mSelEnd = end;
428 if (mSelStart < 0 || mSelEnd < 0)
429 mSelStart = mSelEnd = NO_SELECTION;
430 update();
431 }
432
433 if (emitSignal)
434 {
435 const QVector<QDate> dates = selectedDates();
436 if (dates != mLastSelectedDates)
437 {
438 mLastSelectedDates = dates;
439 Q_EMIT selected(dates);
440 }
441 }
442 }
443
444 /******************************************************************************
445 * Called to paint the widget.
446 */
paintEvent(QPaintEvent *)447 void DayMatrix::paintEvent(QPaintEvent*)
448 {
449 QPainter p;
450 const QRect rect = frameRect();
451 const double dayHeight = mDaySize.height();
452 const double dayWidth = mDaySize.width();
453 const bool isRTL = QApplication::isRightToLeft();
454
455 const QPalette pal = palette();
456
457 p.begin(this);
458
459 // Draw the background
460 p.fillRect(0, 0, rect.width(), rect.height(), QBrush(pal.color(QPalette::Base)));
461
462 // Draw the frame
463 p.setPen(pal.color(QPalette::Mid));
464 p.drawRect(0, 0, rect.width() - 1, rect.height() - 1);
465 p.translate(1, 1); // don't paint over borders
466
467 // Draw the background colour for all days not in the selected month.
468 const QColor GREY_COLOUR(pal.color(QPalette::AlternateBase));
469 if (mMonthStartIndex >= 0)
470 colourBackground(p, GREY_COLOUR, 0, mMonthStartIndex - 1);
471 colourBackground(p, GREY_COLOUR, mMonthEndIndex + 1, NUMDAYS - 1);
472
473 // Draw the background colour for all selected days.
474 if (mSelStart != NO_SELECTION)
475 {
476 const QColor SELECTION_COLOUR(pal.color(QPalette::Highlight));
477 colourBackground(p, SELECTION_COLOUR, mSelStart, mSelEnd);
478 }
479
480 // Find holidays which are non-work days.
481 QSet<QDate> nonWorkHolidays;
482 {
483 const KHolidays::HolidayRegion& region = Preferences::holidays();
484 const KHolidays::Holiday::List list = region.holidays(mStartDate, mStartDate.addDays(NUMDAYS-1));
485 for (const KHolidays::Holiday& holiday : list)
486 if (holiday.dayType() == KHolidays::Holiday::NonWorkday)
487 nonWorkHolidays += holiday.observedStartDate();
488 }
489 const QBitArray workDays = Preferences::workDays();
490
491 // Draw the day label for each day in the matrix.
492 TextColours textColours(pal);
493 const QFont savedFont = font();
494 QColor lastColour;
495 for (int i = 0; i < NUMDAYS; ++i)
496 {
497 const int row = i / 7;
498 const int column = isRTL ? 6 - (i - row * 7) : i - row * 7;
499
500 const bool nonWorkDay = (i >= mTodayIndex) && (!workDays[mStartDate.addDays(i).dayOfWeek()-1] || nonWorkHolidays.contains(mStartDate.addDays(i)));
501
502 const QColor colour = textColour(textColours, pal, i, !nonWorkDay);
503 if (colour != lastColour)
504 {
505 lastColour = colour;
506 p.setPen(colour);
507 }
508
509 if (mTodayIndex == i)
510 {
511 // Draw a rectangle round today.
512 const QPen savedPen = p.pen();
513 QPen todayPen = savedPen;
514 todayPen.setWidth(TODAY_MARGIN_WIDTH);
515 p.setPen(todayPen);
516 p.drawRect(QRectF(column * dayWidth, row * dayHeight, dayWidth, dayHeight));
517 p.setPen(savedPen);
518 }
519
520 // If any events occur on the day, draw it in bold
521 const bool hasEvent = mEventDates.contains(mStartDate.addDays(i));
522 if (hasEvent)
523 {
524 QFont evFont = savedFont;
525 evFont.setWeight(QFont::Black);
526 evFont.setPointSize(evFont.pointSize() + 1);
527 evFont.setStretch(110);
528 p.setFont(evFont);
529 }
530
531 p.drawText(QRectF(column * dayWidth, row * dayHeight, dayWidth, dayHeight),
532 Qt::AlignHCenter | Qt::AlignVCenter, mDayLabels.at(i));
533
534 if (hasEvent)
535 p.setFont(savedFont); // restore normal font
536 }
537 p.end();
538 }
539
540 /******************************************************************************
541 * Paint a background colour for a range of days.
542 */
colourBackground(QPainter & p,const QColor & colour,int start,int end)543 void DayMatrix::colourBackground(QPainter& p, const QColor& colour, int start, int end)
544 {
545 if (end < 0)
546 return;
547 if (start < 0)
548 start = 0;
549 const int row = start / 7;
550 if (row >= NUMROWS)
551 return;
552 const int column = start - row * 7;
553
554 const double dayHeight = mDaySize.height();
555 const double dayWidth = mDaySize.width();
556 const bool isRTL = QApplication::isRightToLeft();
557
558 if (row == end / 7)
559 {
560 // Single row to highlight.
561 p.fillRect(QRectF((isRTL ? (7 - (end - start + 1) - column) : column) * dayWidth,
562 row * dayHeight,
563 (end - start + 1) * dayWidth - 2,
564 dayHeight),
565 colour);
566 }
567 else
568 {
569 // Draw first row, to the right of the start day.
570 p.fillRect(QRectF((isRTL ? 0 : column * dayWidth), row * dayHeight,
571 (7 - column) * dayWidth - 2, dayHeight),
572 colour);
573 // Draw full block till last line
574 int selectionHeight = end / 7 - row;
575 if (selectionHeight + row >= NUMROWS)
576 selectionHeight = NUMROWS - row;
577 if (selectionHeight > 1)
578 p.fillRect(QRectF(0, (row + 1) * dayHeight,
579 7 * dayWidth - 2, (selectionHeight - 1) * dayHeight),
580 colour);
581 // Draw last row, to the left of the end day.
582 if (end / 7 < NUMROWS)
583 {
584 const int selectionWidth = end - 7 * (end / 7) + 1;
585 p.fillRect(QRectF((isRTL ? (7 - selectionWidth) * dayWidth : 0),
586 (row + selectionHeight) * dayHeight,
587 selectionWidth * dayWidth - 2, dayHeight),
588 colour);
589 }
590 }
591 }
592
593 /******************************************************************************
594 * Called when the widget is resized. Set the size of each date in the matrix.
595 */
resizeEvent(QResizeEvent *)596 void DayMatrix::resizeEvent(QResizeEvent*)
597 {
598 const QRect sz = frameRect();
599 const int padding = style()->pixelMetric(QStyle::PM_LayoutVerticalSpacing) / 2;
600 mDaySize.setHeight((sz.height() - padding) * 7.0 / NUMDAYS);
601 mDaySize.setWidth(sz.width() / 7.0);
602 }
603
604 /******************************************************************************
605 * Evaluate the text color to show a given date.
606 */
textColour(const TextColours & textColours,const QPalette & palette,int dayIndex,bool workDay) const607 QColor DayMatrix::textColour(const TextColours& textColours, const QPalette& palette, int dayIndex, bool workDay) const
608 {
609 if (dayIndex >= mSelStart && dayIndex <= mSelEnd)
610 {
611 if (dayIndex == mTodayIndex)
612 return QColor(QStringLiteral("lightgrey"));
613 if (workDay)
614 return palette.color(QPalette::HighlightedText);
615 }
616 if (dayIndex < mTodayIndex)
617 return textColours.disabled;
618 if (dayIndex >= mMonthStartIndex && dayIndex <= mMonthEndIndex)
619 return workDay ? textColours.thisMonth : textColours.thisMonthHoliday;
620 else
621 return workDay ? textColours.otherMonth : textColours.otherMonthHoliday;
622 }
623
624 /*===========================================================================*/
625
TextColours(const QPalette & palette)626 TextColours::TextColours(const QPalette& palette)
627 {
628 thisMonth = palette.color(QPalette::Text);
629 disabled = getShadedColour(thisMonth, false);
630 otherMonth = getShadedColour(thisMonth, true);
631 thisMonthHoliday = thisMonth;
632 thisMonthHoliday.setRed((thisMonthHoliday.red() + 255) / 2);
633 otherMonthHoliday = getShadedColour(thisMonthHoliday, true);
634 }
635
getShadedColour(const QColor & colour,bool enabled) const636 QColor TextColours::getShadedColour(const QColor& colour, bool enabled) const
637 {
638 QColor shaded;
639 int h = 0;
640 int s = 0;
641 int v = 0;
642 colour.getHsv(&h, &s, &v);
643 s = s / (enabled ? 2 : 4);
644 v = enabled ? (4*v + 5*255) / 9 : (v + 5*255) / 6;
645 shaded.setHsv(h, s, v);
646 return shaded;
647 }
648
649 // vim: et sw=4:
650