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