Branch data Line data Source code
1 : : #include "CalendarWidget.h"
2 : :
3 : : #include "ui/ThemeManager.h"
4 : :
5 : : #include <QCalendarWidget>
6 : : #include <QDateTime>
7 : : #include <QFontMetrics>
8 : : #include <QKeyEvent>
9 : : #include <QLocale>
10 : : #include <QLoggingCategory>
11 : : #include <QMenu>
12 : : #include <QMouseEvent>
13 : : #include <QPainter>
14 : : #include <QSet>
15 : : #include <QSettings>
16 : : #include <QTimeZone>
17 : : #include <QWheelEvent>
18 : :
19 : : #include "data/CalendarStore.h"
20 : : #include "ui/EventDetailPopup.h"
21 : :
22 : : #include <algorithm>
23 : :
24 : : // ═══════════════════════════════════════════════════════
25 : : // CalendarWidget implementation (Sprint 32 – T-336)
26 : : // ═══════════════════════════════════════════════════════
27 : :
28 : : static const int kToolbarH = 44;
29 : : static const int kHeaderH = 28;
30 : : static const int kTimeLabelW = 50; // T-424: dedicated time label column width
31 : : static const int kWeekHourStart = 0; // T-427: full 24h range
32 : : static const int kWeekHourEnd = 24;
33 : : static const int kHourH = 48; // T-427: fixed pixel height per hour
34 : : static const int kAllDayRowH = 20; // T-428: height of each all-day event row
35 : :
36 : : // Bug 3: Hash-based fallback color palette per calendar
37 : : // (shared palette, 67.B3: ThemeManager owns all color decisions)
38 : 123 : static QColor eventColor(const QString &color, const QString &calendarPath) {
39 [ + - ]: 123 : if (!color.isEmpty())
40 : 123 : return QColor(color);
41 [ # # ]: 0 : const QStringList palette = ThemeManager::calendarPalette();
42 : 0 : return QColor(palette.at(qHash(calendarPath) % palette.size()));
43 : 0 : }
44 : :
45 [ + - + - : 1 : Q_LOGGING_CATEGORY(lcCalendar, "mailjd.calendar")
+ - - - ]
46 : :
47 : 67 : static QMap<QString, QString> parseRRuleParts(const QString &rrule) {
48 : 67 : QMap<QString, QString> parts;
49 [ + - + - : 203 : for (const auto &part : rrule.split(QLatin1Char(';'), Qt::SkipEmptyParts)) {
+ - + + ]
50 : 136 : const int eq = part.indexOf(QLatin1Char('='));
51 [ - + ]: 136 : if (eq <= 0)
52 : 0 : continue;
53 [ + - + - : 136 : parts.insert(part.left(eq).trimmed().toUpper(),
+ - + - ]
54 [ + - + - ]: 272 : part.mid(eq + 1).trimmed());
55 : 67 : }
56 : 67 : return parts;
57 : 0 : }
58 : :
59 : 84 : static int weekdayFromRRuleToken(const QString &token) {
60 [ + - + - ]: 84 : const QString day = token.right(2).toUpper();
61 [ + + ]: 84 : if (day == QStringLiteral("MO")) return 1;
62 [ + + ]: 64 : if (day == QStringLiteral("TU")) return 2;
63 [ + + ]: 54 : if (day == QStringLiteral("WE")) return 3;
64 [ + + ]: 42 : if (day == QStringLiteral("TH")) return 4;
65 [ + + ]: 32 : if (day == QStringLiteral("FR")) return 5;
66 [ + + ]: 20 : if (day == QStringLiteral("SA")) return 6;
67 [ + - ]: 10 : if (day == QStringLiteral("SU")) return 7;
68 : 0 : return 0;
69 : 84 : }
70 : :
71 : : // T-79.D3/M13: keys/values the expander actually implements. RRULEs with
72 : : // anything else (BYMONTHDAY, BYSETPOS, monthly BYDAY like "2TU", BYMONTH,
73 : : // unknown FREQ, …) would render occurrences on wrong dates — the caller
74 : : // falls back to showing only the DTSTART occurrence instead.
75 : 67 : static bool rruleIsSupported(const QMap<QString, QString> &rule) {
76 : : // WKST is tolerated: it only affects week grouping for WEEKLY rules
77 : : // with INTERVAL > 1, and rejecting it would degrade the many common
78 : : // rules that carry a redundant WKST.
79 : : static const QSet<QString> supportedKeys = {
80 : 3 : QStringLiteral("FREQ"), QStringLiteral("INTERVAL"),
81 : 3 : QStringLiteral("COUNT"), QStringLiteral("UNTIL"),
82 [ + + + - : 91 : QStringLiteral("BYDAY"), QStringLiteral("WKST")};
+ + - - -
- ]
83 [ + - + - : 203 : for (auto it = rule.constBegin(); it != rule.constEnd(); ++it) {
+ + ]
84 [ - + ]: 136 : if (!supportedKeys.contains(it.key()))
85 : 0 : return false;
86 : : }
87 [ + - + - ]: 134 : const QString freq = rule.value(QStringLiteral("FREQ")).toUpper();
88 [ + + + + : 194 : if (freq != QStringLiteral("DAILY") && freq != QStringLiteral("WEEKLY") &&
+ - + - -
+ ]
89 [ + + + + : 196 : freq != QStringLiteral("MONTHLY") && freq != QStringLiteral("YEARLY"))
- + + + +
+ + + + +
+ + ]
90 : 0 : return false;
91 [ + - + + ]: 67 : if (rule.contains(QStringLiteral("BYDAY"))) {
92 [ + + ]: 11 : if (freq != QStringLiteral("WEEKLY"))
93 : 1 : return false; // ordinal semantics (e.g. "2nd Tuesday") not implemented
94 : : const QStringList tokens =
95 [ + - + - ]: 30 : rule.value(QStringLiteral("BYDAY")).split(QLatin1Char(','));
96 [ + + ]: 52 : for (const QString &token : tokens) {
97 [ + - ]: 42 : const QString t = token.trimmed();
98 [ + - + - : 42 : if (t.length() != 2 || weekdayFromRRuleToken(t) == 0)
- + - + ]
99 : 0 : return false; // ordinal prefix like "2TU" / "-1FR"
100 [ + - ]: 42 : }
101 [ + - ]: 10 : }
102 : 66 : return true;
103 [ + - - - : 88 : }
- - ]
104 : :
105 : : // T-79.D3: true if the occurrence start matches one of the event's EXDATEs
106 : : // (date-level match for all-day events, exact instant otherwise).
107 : 319 : static bool isExcludedOccurrence(const CalendarEvent &event,
108 : : const QDateTime &start) {
109 [ + + ]: 323 : for (const QDateTime &exdate : event.exdates) {
110 [ - + ]: 5 : if (event.allDay) {
111 [ # # # # : 0 : if (exdate.date() == start.date())
# # ]
112 : 1 : return true;
113 [ + - + - : 5 : } else if (exdate.toUTC() == start.toUTC()) {
+ - + + ]
114 : 1 : return true;
115 : : }
116 : : }
117 : 318 : return false;
118 : : }
119 : :
120 : 457 : static QDate displayEndDate(const CalendarEvent &event) {
121 [ + - + - ]: 457 : const QDate startDate = event.dtStart.toLocalTime().date();
122 [ + - - + ]: 457 : if (!event.dtEnd.isValid())
123 : 0 : return startDate;
124 [ + + ]: 457 : if (event.allDay) {
125 : : // iCal all-day DTEND is exclusive → last day is the day before. Clamp
126 : : // to the start date so legacy events stored with an INCLUSIVE end
127 : : // (pre-Sprint-65 EventEditDialog) still render instead of vanishing.
128 [ + - + - : 40 : return qMax(startDate, event.dtEnd.addDays(-1).toLocalTime().date());
+ - ]
129 : : }
130 [ + - + - : 417 : return event.dtEnd.addMSecs(-1).toLocalTime().date();
+ - ]
131 : : }
132 : :
133 : 457 : static void appendDisplayOccurrence(QList<CalendarEvent> *expanded,
134 : : const CalendarEvent &event,
135 : : const QDate &rangeStart,
136 : : const QDate &rangeEnd) {
137 [ + - - + ]: 457 : if (!event.dtStart.isValid())
138 : 0 : return;
139 : :
140 [ + - + - ]: 457 : const QDate startDate = event.dtStart.toLocalTime().date();
141 [ + - ]: 457 : const QDate endDate = displayEndDate(event);
142 : 457 : QDate day = qMax(startDate, rangeStart);
143 : 457 : const QDate lastDay = qMin(endDate, rangeEnd);
144 [ + - + - : 886 : while (day.isValid() && day <= lastDay) {
+ + + + ]
145 : 429 : CalendarEvent displayEvent = event;
146 [ + + ]: 429 : if (day != startDate) {
147 : : displayEvent.dtStart =
148 [ + - + - : 2 : QDateTime(day, QTime(0, 0), QTimeZone::systemTimeZone());
+ - ]
149 : : }
150 [ + + + - : 429 : if (day != endDate && event.dtEnd.isValid()) {
+ - + + ]
151 : : displayEvent.dtEnd =
152 [ + - + - : 2 : QDateTime(day.addDays(1), QTime(0, 0), QTimeZone::systemTimeZone());
+ - + - ]
153 : : }
154 [ + + ]: 429 : if (startDate != endDate)
155 : 3 : displayEvent.allDay = true;
156 [ + - ]: 429 : expanded->append(displayEvent);
157 [ + - ]: 429 : day = day.addDays(1);
158 : 429 : }
159 : : }
160 : :
161 : 66 : static QDateTime parseRRuleUntil(const QString &value) {
162 [ + + ]: 66 : if (value.isEmpty())
163 : 56 : return {};
164 [ + + ]: 10 : if (value.length() == 8)
165 : 10 : return QDateTime(QDate::fromString(value, QStringLiteral("yyyyMMdd")),
166 [ + - + - : 15 : QTime(23, 59, 59), QTimeZone::utc());
+ - + - ]
167 : 5 : QString raw = value;
168 [ + - ]: 5 : const bool utc = raw.endsWith(QLatin1Char('Z'));
169 [ + - ]: 5 : if (utc)
170 [ + - ]: 5 : raw.chop(1);
171 : : QDateTime until =
172 [ + - ]: 5 : QDateTime::fromString(raw, QStringLiteral("yyyyMMddTHHmmss"));
173 [ + - + - : 5 : if (until.isValid() && utc)
+ - + - ]
174 [ + - + - ]: 5 : until.setTimeZone(QTimeZone::utc());
175 : 5 : return until;
176 : 5 : }
177 : :
178 : 318 : static CalendarEvent shiftedOccurrence(const CalendarEvent &event,
179 : : const QDateTime &start) {
180 : 318 : CalendarEvent shifted = event;
181 : 318 : shifted.dtStart = start;
182 [ + - + - ]: 318 : if (event.dtEnd.isValid())
183 [ + - + - ]: 318 : shifted.dtEnd = start.addSecs(event.dtStart.secsTo(event.dtEnd));
184 : 318 : return shifted;
185 : 0 : }
186 : :
187 : 7 : static int skipDailyOccurrences(const CalendarEvent &event,
188 : : const QDate &rangeStart,
189 : : int interval) {
190 [ + - + - : 7 : const int days = event.dtStart.toLocalTime().date().daysTo(rangeStart);
+ - ]
191 [ + + ]: 7 : if (days <= interval)
192 : 6 : return 0;
193 : 1 : return qMax(0, days / interval - 1);
194 : : }
195 : :
196 : 44 : static int skipWeeklyOccurrences(const CalendarEvent &event,
197 : : const QDate &rangeStart,
198 : : int interval) {
199 [ + - + - : 44 : const int days = event.dtStart.toLocalTime().date().daysTo(rangeStart);
+ - ]
200 [ + + ]: 44 : if (days <= interval * 7)
201 : 41 : return 0;
202 : 3 : return qMax(0, days / (interval * 7) - 1);
203 : : }
204 : :
205 : 3 : static int skipMonthlyOccurrences(const CalendarEvent &event,
206 : : const QDate &rangeStart,
207 : : int interval) {
208 [ + - + - ]: 3 : const QDate start = event.dtStart.toLocalTime().date();
209 [ + - + - ]: 3 : int months = (rangeStart.year() - start.year()) * 12 +
210 [ + - + - ]: 3 : (rangeStart.month() - start.month());
211 [ + - + - : 3 : if (rangeStart.day() < start.day())
+ + ]
212 : 2 : --months;
213 [ + + ]: 3 : if (months <= interval)
214 : 1 : return 0;
215 : 2 : return qMax(0, months / interval - 1);
216 : : }
217 : :
218 : 2 : static int skipYearlyOccurrences(const CalendarEvent &event,
219 : : const QDate &rangeStart,
220 : : int interval) {
221 [ + - + - ]: 2 : const QDate start = event.dtStart.toLocalTime().date();
222 [ + - + - ]: 2 : int years = rangeStart.year() - start.year();
223 [ + - + - : 2 : const QDate firstOfMonth(rangeStart.year(), start.month(), 1);
+ - ]
224 : : const QDate anniversary(
225 : : rangeStart.year(), start.month(),
226 [ + - + - : 2 : qMin(start.day(), firstOfMonth.daysInMonth()));
+ - + - +
- ]
227 [ + + ]: 2 : if (anniversary > rangeStart)
228 : 1 : --years;
229 [ + + ]: 2 : if (years <= interval)
230 : 1 : return 0;
231 : 1 : return qMax(0, years / interval - 1);
232 : : }
233 : :
234 : 98 : QList<CalendarEvent> CalendarWidget::expandEventsForDisplay(
235 : : const QList<CalendarEvent> &events,
236 : : const QDate &rangeStart,
237 : : const QDate &rangeEnd) {
238 : 98 : QList<CalendarEvent> expanded;
239 [ + - ]: 98 : const QDateTime rangeEndTime(rangeEnd, QTime(23, 59, 59),
240 [ + - + - ]: 196 : QTimeZone::systemTimeZone());
241 : :
242 [ + + ]: 303 : for (const auto &event : events) {
243 [ + - + + ]: 205 : if (event.rrule.trimmed().isEmpty()) {
244 [ + - ]: 138 : appendDisplayOccurrence(&expanded, event, rangeStart, rangeEnd);
245 : 149 : continue;
246 : : }
247 : :
248 [ + - ]: 67 : const auto rule = parseRRuleParts(event.rrule);
249 : : // T-79.D3/M13: wrong dates are worse than fewer dates — unsupported
250 : : // rules render only the DTSTART occurrence.
251 [ + - + + ]: 67 : if (!rruleIsSupported(rule)) {
252 [ + - + - : 2 : qCWarning(lcCalendar) << "Unsupported RRULE" << event.rrule
+ - + - +
+ ]
253 [ + - + - ]: 1 : << "for event" << event.uid
254 [ + - ]: 1 : << "— rendering only the first occurrence";
255 [ + - ]: 1 : appendDisplayOccurrence(&expanded, event, rangeStart, rangeEnd);
256 : 1 : continue;
257 : 1 : }
258 [ + - + - ]: 132 : const QString freq = rule.value(QStringLiteral("FREQ")).toUpper();
259 : : const int interval =
260 [ + - ]: 198 : qMax(1, rule.value(QStringLiteral("INTERVAL"), QStringLiteral("1"))
261 [ + - ]: 66 : .toInt());
262 [ + - + - ]: 132 : const int count = rule.value(QStringLiteral("COUNT")).toInt();
263 [ + - + - ]: 132 : const QDateTime until = parseRRuleUntil(rule.value(QStringLiteral("UNTIL")));
264 : :
265 : 385 : auto shouldStop = [&](const QDateTime &candidate, int generated) {
266 [ + + + + ]: 385 : if (count > 0 && generated >= count)
267 : 9 : return true;
268 [ + + + + : 376 : if (until.isValid() && candidate > until)
+ + ]
269 : 3 : return true;
270 : 373 : return candidate > rangeEndTime;
271 : 66 : };
272 : :
273 : 66 : int generated = 0;
274 : 66 : int safety = 0;
275 [ + + + - : 252 : if (freq == QStringLiteral("WEEKLY") &&
+ + - - -
- ]
276 [ + - + + : 120 : rule.contains(QStringLiteral("BYDAY"))) {
+ + + + +
- - - -
- ]
277 : 10 : QList<int> weekdays;
278 : 10 : for (const auto &token :
279 [ + - + - : 82 : rule.value(QStringLiteral("BYDAY")).split(QLatin1Char(','))) {
+ - + - +
+ ]
280 [ + - + - ]: 42 : const int weekday = weekdayFromRRuleToken(token.trimmed());
281 [ + - ]: 42 : if (weekday > 0)
282 [ + - ]: 42 : weekdays.append(weekday);
283 : 10 : }
284 [ + - + - : 10 : std::sort(weekdays.begin(), weekdays.end());
+ - ]
285 [ - + ]: 10 : if (weekdays.isEmpty())
286 [ # # # # : 0 : weekdays.append(event.dtStart.date().dayOfWeek());
# # ]
287 : :
288 : : const QDate firstWeekStart =
289 [ + - + - : 10 : event.dtStart.date().addDays(-(event.dtStart.date().dayOfWeek() - 1));
+ - + - ]
290 : : // T-79.D1/H2: candidates skipped because they precede DTSTART (week 0
291 : : // with all BYDAY weekdays before the start weekday) must not end the
292 : : // expansion — the old passedRange flag stayed true in that case and
293 : : // the event never rendered anywhere. shouldStop() already terminates
294 : : // once a candidate passes the range end / COUNT / UNTIL.
295 : 10 : bool stopEvent = false;
296 [ + - + + : 44 : for (int week = 0; safety++ < 2000 && !stopEvent; week += interval) {
+ + ]
297 [ + - + - : 97 : for (const int weekday : weekdays) {
+ + ]
298 : 73 : QDateTime candidate = event.dtStart;
299 [ + - + - ]: 73 : candidate.setDate(firstWeekStart.addDays(week * 7 + weekday - 1));
300 [ + - + + ]: 73 : if (candidate < event.dtStart)
301 : 2 : continue;
302 [ + - + + ]: 71 : if (shouldStop(candidate, generated)) {
303 : 10 : stopEvent = true;
304 : 10 : break;
305 : : }
306 : : // T-79.D3: EXDATE removes the occurrence but still consumes
307 : : // COUNT (RFC 5545: COUNT counts before exclusion).
308 [ + - + - ]: 61 : if (!isExcludedOccurrence(event, candidate)) {
309 [ + - ]: 61 : appendDisplayOccurrence(
310 [ + - ]: 122 : &expanded, shiftedOccurrence(event, candidate),
311 : : rangeStart, rangeEnd);
312 : : }
313 : 61 : ++generated;
314 [ + + + ]: 73 : }
315 : : }
316 : 10 : continue;
317 : 10 : }
318 : :
319 : 56 : int skipped = 0;
320 [ + + ]: 56 : if (freq == QStringLiteral("DAILY"))
321 [ + - ]: 7 : skipped = skipDailyOccurrences(event, rangeStart, interval);
322 [ + + ]: 49 : else if (freq == QStringLiteral("WEEKLY"))
323 [ + - ]: 44 : skipped = skipWeeklyOccurrences(event, rangeStart, interval);
324 [ + + ]: 5 : else if (freq == QStringLiteral("MONTHLY"))
325 [ + - ]: 3 : skipped = skipMonthlyOccurrences(event, rangeStart, interval);
326 [ + - ]: 2 : else if (freq == QStringLiteral("YEARLY"))
327 [ + - ]: 2 : skipped = skipYearlyOccurrences(event, rangeStart, interval);
328 : :
329 : : // T-79.D2/H6: compute occurrence n always from DTSTART, never
330 : : // cumulatively from the previous occurrence — Qt clamps short months
331 : : // (Jan 31 → Feb 28) and a cumulative addMonths() never recovers
332 : : // (Mar 28, Apr 28, …). Per-occurrence clamping from DTSTART keeps
333 : : // "31st of every month" on the 31st wherever it exists (and Feb 29
334 : : // yearly starts on Feb 29 again in leap years).
335 : 314 : auto occurrenceFromStart = [&](int n) {
336 [ + + ]: 314 : if (freq == QStringLiteral("DAILY"))
337 : 33 : return event.dtStart.addDays(qint64(n) * interval);
338 [ + + ]: 281 : if (freq == QStringLiteral("WEEKLY"))
339 : 248 : return event.dtStart.addDays(qint64(n) * interval * 7);
340 [ + + ]: 33 : if (freq == QStringLiteral("MONTHLY"))
341 : 23 : return event.dtStart.addMonths(n * interval);
342 [ + - ]: 10 : if (freq == QStringLiteral("YEARLY"))
343 : 10 : return event.dtStart.addYears(n * interval);
344 : 0 : return event.dtStart; // unreachable: rruleIsSupported validated FREQ
345 : 56 : };
346 : 56 : generated = skipped;
347 : :
348 [ + - ]: 314 : for (int n = skipped; safety++ < 2000; ++n) {
349 [ + - ]: 314 : const QDateTime candidate = occurrenceFromStart(n);
350 [ + - + + ]: 314 : if (shouldStop(candidate, generated))
351 : 56 : break;
352 : : // T-79.D3: EXDATE removes the occurrence but still consumes COUNT.
353 [ + - + + ]: 258 : if (!isExcludedOccurrence(event, candidate)) {
354 [ + - + - ]: 257 : appendDisplayOccurrence(&expanded, shiftedOccurrence(event, candidate),
355 : : rangeStart, rangeEnd);
356 : : }
357 : 258 : ++generated;
358 [ + + ]: 314 : }
359 [ + + + + : 87 : }
+ + ]
360 : :
361 [ + - + - : 98 : std::sort(expanded.begin(), expanded.end(),
+ - ]
362 : 1536 : [](const CalendarEvent &a, const CalendarEvent &b) {
363 : 1536 : return a.dtStart < b.dtStart;
364 : : });
365 : 98 : return expanded;
366 : 98 : }
367 : :
368 [ + - + - : 51 : CalendarWidget::CalendarWidget(QWidget *parent) : QWidget(parent) {
+ - ]
369 [ + - ]: 51 : m_currentDate = QDate::currentDate();
370 [ + - + - : 51 : m_displayMonth = QDate(m_currentDate.year(), m_currentDate.month(), 1);
+ - ]
371 [ + - ]: 51 : setFocusPolicy(Qt::StrongFocus);
372 [ + - ]: 51 : setMouseTracking(true);
373 [ + - ]: 51 : setMinimumSize(400, 300);
374 : :
375 : : // Restore persisted view mode + scroll position
376 [ + - ]: 51 : QSettings settings;
377 [ + - + - ]: 102 : int saved = settings.value(QStringLiteral("calendar/viewMode"), 0).toInt();
378 [ + + ]: 100 : m_viewMode = (saved == 1) ? WeekView
379 [ + + ]: 49 : : (saved == 2) ? DayView
380 : : : MonthView;
381 : : // T-541: Restore scroll offset (setViewMode guard skips this when mode matches)
382 [ + + + + ]: 51 : if (m_viewMode == WeekView || m_viewMode == DayView) {
383 [ + - ]: 15 : m_weekScrollOffset = settings.value(
384 [ + - ]: 10 : QStringLiteral("calendar/scrollOffset"), 7 * kHourH).toInt();
385 : : }
386 : :
387 : : // T-71.5a: Restore the per-calendar visibility selection. It is persisted
388 : : // in showCalendarFilterMenu (:1574-1578) but was never read back, so the
389 : : // selection reset to "all visible" on every restart. Empty list ⟹ all
390 : : // visible (the documented default semantics are preserved).
391 [ + - ]: 102 : const QStringList vis = settings.value(
392 [ + - ]: 153 : QStringLiteral("calendar/visibleCalendars")).toStringList();
393 [ + - ]: 51 : m_visibleCalendars = QSet<QString>(vis.begin(), vis.end());
394 : 51 : }
395 : :
396 : : // Sprint 76 (T-76.B4): resolve the locale from the app's manual language
397 : : // setting (i18n/language) instead of QLocale::system(), so day/month names
398 : : // follow the user's chosen UI language. "auto"/empty → system locale.
399 : 215 : QLocale CalendarWidget::appLocale() const {
400 [ + - ]: 215 : QSettings s;
401 : : const QString lang =
402 [ + - ]: 645 : s.value(QStringLiteral("i18n/language"), QStringLiteral("auto"))
403 [ + - ]: 215 : .toString();
404 [ + - + + : 215 : if (lang.isEmpty() || lang == QLatin1String("auto"))
+ + ]
405 [ + - ]: 211 : return QLocale::system();
406 [ + - ]: 4 : const QLocale l(lang);
407 [ + - - + : 4 : return l.name().isEmpty() ? QLocale::system() : l;
- - ]
408 : 215 : }
409 : :
410 : : // Sprint 76 (T-76.B4): repaint on language change — chrome labels (mode/today
411 : : // buttons, weekday headers, date titles) are tr()/locale-resolved at paint.
412 : 121 : void CalendarWidget::changeEvent(QEvent *event) {
413 : 121 : QWidget::changeEvent(event);
414 [ - + ]: 121 : if (event->type() == QEvent::LanguageChange)
415 : 0 : update();
416 : 121 : }
417 : :
418 : 26 : void CalendarWidget::setCalendarStore(CalendarStore *store) {
419 : 26 : m_store = store;
420 : 26 : loadEventsForVisibleRange();
421 : 26 : update();
422 : 26 : }
423 : :
424 : 8 : void CalendarWidget::setVisibleCalendars(const QSet<QString> &paths) {
425 : 8 : m_visibleCalendars = paths;
426 : 8 : loadEventsForVisibleRange();
427 : 8 : update();
428 : 8 : }
429 : :
430 : 57 : void CalendarWidget::setViewMode(ViewMode mode) {
431 [ + + ]: 57 : if (m_viewMode != mode) {
432 : 44 : m_viewMode = mode;
433 [ + - ]: 44 : QSettings settings;
434 : : // T-427: restore scroll position from settings (default: 7:00)
435 [ + + + + ]: 44 : if (mode == WeekView || mode == DayView) {
436 [ + - ]: 84 : m_weekScrollOffset = settings.value(
437 [ + - ]: 56 : QStringLiteral("calendar/scrollOffset"), 7 * kHourH).toInt();
438 : : }
439 [ + - ]: 88 : settings.setValue(QStringLiteral("calendar/viewMode"),
440 : : static_cast<int>(mode));
441 [ + - ]: 44 : loadEventsForVisibleRange();
442 [ + - ]: 44 : update();
443 : 44 : }
444 : 57 : }
445 : :
446 : 32 : void CalendarWidget::navigateToDate(const QDate &date) {
447 : 32 : m_currentDate = date;
448 [ + - + - : 32 : m_displayMonth = QDate(date.year(), date.month(), 1);
+ - ]
449 : 32 : loadEventsForVisibleRange();
450 : 32 : update();
451 : 32 : }
452 : :
453 : 92 : QDate CalendarWidget::firstVisibleDate() const {
454 : : // Monday of the week containing the 1st of the display month
455 : 92 : QDate first = m_displayMonth;
456 [ + - ]: 92 : int dow = first.dayOfWeek(); // 1=Mon
457 [ + - ]: 184 : return first.addDays(-(dow - 1));
458 : : }
459 : :
460 : 7 : QDate CalendarWidget::dateForCell(int row, int col) const {
461 [ + - + - ]: 7 : return firstVisibleDate().addDays(row * 7 + col);
462 : : }
463 : :
464 : 1 : QRect CalendarWidget::cellRectForDate(const QDate &date) const {
465 [ + - ]: 1 : QDate first = firstVisibleDate();
466 [ + - ]: 1 : int dayOffset = first.daysTo(date);
467 [ + - - + ]: 1 : if (dayOffset < 0 || dayOffset >= 42)
468 : 0 : return {};
469 : 1 : int row = dayOffset / 7;
470 : 1 : int col = dayOffset % 7;
471 : :
472 : 1 : int topY = kToolbarH + kHeaderH;
473 : 1 : int availH = height() - topY;
474 : 1 : int cellW = width() / 7;
475 : 1 : int cellH = availH / 6;
476 : :
477 : 1 : return QRect(col * cellW, topY + row * cellH, cellW, cellH);
478 : : }
479 : :
480 : : // ═══════════════════════════════════════════════════════
481 : : // Event loading
482 : : // ═══════════════════════════════════════════════════════
483 : :
484 : 126 : void CalendarWidget::loadEventsForVisibleRange() {
485 [ + - ]: 126 : m_eventsByDate.clear();
486 [ + - ]: 126 : m_visibleEvents.clear();
487 [ + + + - : 126 : if (!m_store || !m_store->isOpen())
+ + + + ]
488 : 41 : return;
489 : :
490 : 85 : QDate rangeStart, rangeEnd;
491 [ + + ]: 85 : if (m_viewMode == MonthView) {
492 : : // Load 3 months for prefetch
493 [ + - ]: 46 : rangeStart = m_displayMonth.addMonths(-1);
494 [ + - + - ]: 46 : rangeEnd = m_displayMonth.addMonths(2).addDays(-1);
495 [ + + ]: 39 : } else if (m_viewMode == YearView) {
496 : : // T-425: Load entire year
497 [ + - + - ]: 8 : rangeStart = QDate(m_displayMonth.year(), 1, 1);
498 [ + - + - ]: 8 : rangeEnd = QDate(m_displayMonth.year(), 12, 31);
499 [ + + ]: 31 : } else if (m_viewMode == DayView) {
500 : : // T-425: Single day
501 : 19 : rangeStart = m_currentDate;
502 : 19 : rangeEnd = m_currentDate;
503 : : } else {
504 : : // Week view: current week ± 1 week
505 [ + - ]: 12 : int dow = m_currentDate.dayOfWeek();
506 [ + - ]: 12 : rangeStart = m_currentDate.addDays(-(dow - 1) - 7);
507 [ + - ]: 12 : rangeEnd = m_currentDate.addDays(7 - dow + 7);
508 : : }
509 : :
510 : 85 : const QList<CalendarEvent> storedEvents = m_store->eventsForDateRange(
511 [ + - + - : 170 : QDateTime(rangeStart, QTime(0, 0), QTimeZone::utc()),
+ - ]
512 [ + - + - : 255 : QDateTime(rangeEnd, QTime(23, 59, 59), QTimeZone::utc()));
+ - + - ]
513 : : m_visibleEvents =
514 [ + - ]: 85 : expandEventsForDisplay(storedEvents, rangeStart, rangeEnd);
515 : :
516 [ + - + - : 448 : for (const auto &ev : m_visibleEvents) {
+ + ]
517 : : // Bug 3: Filter by visible calendars
518 [ + + + + ]: 398 : if (!m_visibleCalendars.isEmpty() &&
519 [ + + ]: 35 : !m_visibleCalendars.contains(ev.calendarPath))
520 : 18 : continue;
521 [ + - + - ]: 345 : QDate d = ev.dtStart.toLocalTime().date();
522 [ + - + - ]: 345 : m_eventsByDate[d].append(ev);
523 : : }
524 : 85 : }
525 : :
526 : : // ═══════════════════════════════════════════════════════
527 : : // Navigation
528 : : // ═══════════════════════════════════════════════════════
529 : :
530 : 19 : void CalendarWidget::moveSelection(int dayDelta) {
531 : 19 : m_currentDate = m_currentDate.addDays(dayDelta);
532 : : // Auto-switch month if needed
533 [ + + + + ]: 36 : if (m_currentDate.month() != m_displayMonth.month() ||
534 [ - + ]: 17 : m_currentDate.year() != m_displayMonth.year()) {
535 : 2 : m_displayMonth =
536 [ + - + - : 2 : QDate(m_currentDate.year(), m_currentDate.month(), 1);
+ - ]
537 : 2 : loadEventsForVisibleRange();
538 : : }
539 : 19 : update();
540 : 19 : }
541 : :
542 : 11 : void CalendarWidget::switchMonth(int delta) {
543 [ + + ]: 11 : if (m_viewMode == MonthView) {
544 : 7 : m_displayMonth = m_displayMonth.addMonths(delta);
545 : 0 : m_currentDate = QDate(m_displayMonth.year(), m_displayMonth.month(),
546 : 7 : qMin(m_currentDate.day(),
547 [ + - + - : 14 : m_displayMonth.daysInMonth()));
+ - + - +
- ]
548 [ + + ]: 4 : } else if (m_viewMode == DayView) {
549 : : // T-425: ±1 day
550 : 2 : m_currentDate = m_currentDate.addDays(delta);
551 : 2 : m_displayMonth =
552 [ + - + - : 2 : QDate(m_currentDate.year(), m_currentDate.month(), 1);
+ - ]
553 [ + - ]: 2 : } else if (m_viewMode == YearView) {
554 : : // T-425: ±1 year
555 [ + - + - ]: 2 : m_displayMonth = QDate(m_displayMonth.year() + delta, 1, 1);
556 : 0 : m_currentDate = QDate(m_displayMonth.year(),
557 : 2 : qMin(m_currentDate.month(), 12),
558 [ + - + - : 4 : qMin(m_currentDate.day(), 28));
+ - + - ]
559 : : } else {
560 : 0 : m_currentDate = m_currentDate.addDays(delta * 7);
561 : 0 : m_displayMonth =
562 [ # # # # : 0 : QDate(m_currentDate.year(), m_currentDate.month(), 1);
# # ]
563 : : }
564 : 11 : loadEventsForVisibleRange();
565 : 11 : update();
566 : 11 : }
567 : :
568 : : // ═══════════════════════════════════════════════════════
569 : : // Painting
570 : : // ═══════════════════════════════════════════════════════
571 : :
572 : 116 : void CalendarWidget::paintEvent(QPaintEvent *) {
573 [ + - ]: 116 : QPainter p(this);
574 [ + - ]: 116 : p.setRenderHint(QPainter::Antialiasing);
575 : :
576 : : // Background
577 [ + - + - : 116 : p.fillRect(rect(), palette().window());
+ - ]
578 : :
579 [ + - ]: 116 : paintToolbar(p);
580 : :
581 [ + + + + : 116 : switch (m_viewMode) {
- ]
582 [ + - ]: 77 : case MonthView: paintMonthView(p); break;
583 [ + - ]: 14 : case WeekView: paintWeekView(p); break;
584 [ + - ]: 17 : case DayView: paintDayView(p); break;
585 [ + - ]: 8 : case YearView: paintYearView(p); break;
586 : : }
587 : 116 : }
588 : :
589 : 116 : void CalendarWidget::paintToolbar(QPainter &p) {
590 : 116 : QRect toolbar(0, 0, width(), kToolbarH);
591 [ + - + - : 116 : p.fillRect(toolbar, palette().window().color().darker(105));
+ - ]
592 : :
593 [ + - + - ]: 116 : QFont titleFont = font();
[ + - ]
594 [ + - ]: 116 : titleFont.setPointSize(14);
595 [ + - ]: 116 : titleFont.setBold(true);
596 [ + - ]: 116 : p.setFont(titleFont);
597 [ + - + - : 116 : p.setPen(palette().text().color());
+ - ]
598 : :
599 : : // Month/Year title
600 [ + - ]: 116 : QLocale loc = appLocale(); // T-76.B4: app-selected locale (i18n/language)
601 : 116 : QString title;
602 [ + + ]: 116 : if (m_viewMode == DayView) {
603 [ + - ]: 17 : title = loc.toString(m_currentDate, QStringLiteral("dddd, d. MMMM yyyy"));
604 [ + + ]: 99 : } else if (m_viewMode == YearView) {
605 [ + - + - ]: 8 : title = QString::number(m_displayMonth.year());
606 : : } else {
607 [ + - + - ]: 182 : title = loc.standaloneMonthName(m_displayMonth.month()) +
608 [ + - + - : 364 : QStringLiteral(" ") + QString::number(m_displayMonth.year());
+ - + - ]
609 : : }
610 [ + - ]: 116 : p.drawText(toolbar, Qt::AlignCenter, title);
611 : :
612 : : // Nav arrows
613 [ + - + - ]: 116 : QFont arrowFont = font();
[ + - ]
614 [ + - ]: 116 : arrowFont.setPointSize(16);
615 [ + - ]: 116 : p.setFont(arrowFont);
616 [ + - ]: 116 : p.drawText(QRect(8, 0, 40, kToolbarH), Qt::AlignCenter,
617 : 232 : QStringLiteral("◀"));
618 [ + - ]: 116 : p.drawText(QRect(width() - 48, 0, 40, kToolbarH), Qt::AlignCenter,
619 : 232 : QStringLiteral("▶"));
620 : :
621 : : // T-425: Mode indicators (4 modes)
622 [ + - + - ]: 116 : QFont modeFont = font();
[ + - ]
623 [ + - ]: 116 : modeFont.setPointSize(9);
624 [ + - ]: 116 : p.setFont(modeFont);
625 [ + - + - ]: 116 : QColor activeColor = palette().highlight().color();
626 [ + - + - ]: 116 : QColor inactiveColor = palette().text().color().darker(150);
627 : :
628 : 116 : int modeX = width() - 290;
629 : : struct ModeBtn { ViewMode mode; QString label; int w; };
630 : : ModeBtn modes[] = {
631 : : {YearView, tr("Year"), 40},
632 : : {MonthView, tr("Month"), 50},
633 : : {WeekView, tr("Week"), 50},
634 : : {DayView, tr("Day"), 35},
635 [ + - + - : 696 : };
+ - + - -
- - - - -
- - - - ]
636 : 116 : int x = modeX;
637 [ + + ]: 580 : for (const auto &mb : modes) {
638 [ + + + - ]: 464 : p.setPen(m_viewMode == mb.mode ? activeColor : inactiveColor);
639 [ + - ]: 464 : p.drawText(QRect(x, 0, mb.w, kToolbarH), Qt::AlignCenter, mb.label);
640 : 464 : x += mb.w + 5;
641 : : }
642 : : // Today button
643 [ + - + - : 116 : p.setPen(palette().link().color());
+ - ]
644 [ + - ]: 116 : p.drawText(QRect(x + 5, 0, 50, kToolbarH), Qt::AlignCenter,
645 [ + - ]: 232 : tr("Today"));
646 : :
647 : : // Filter button (☰) — highlighted when a filter is active
648 [ + - + - ]: 116 : QFont filterFont = font();
[ + - ]
649 [ + - ]: 116 : filterFont.setPointSize(14);
650 [ + - ]: 116 : p.setFont(filterFont);
651 [ + + + - ]: 232 : p.setPen(m_visibleCalendars.isEmpty()
652 [ + - + - ]: 107 : ? palette().text().color()
653 [ + - + - ]: 9 : : palette().highlight().color());
654 [ + - ]: 116 : p.drawText(QRect(50, 0, 30, kToolbarH), Qt::AlignCenter,
655 : 232 : QStringLiteral("☰"));
656 [ + + - - ]: 812 : }
657 : :
658 : 77 : void CalendarWidget::paintMonthView(QPainter &p) {
659 : 77 : int cellW = width() / 7;
660 : 77 : int topY = kToolbarH;
661 : :
662 : : // Day-of-week headers
663 [ + - + - ]: 77 : QFont headerFont = font();
[ + - ]
664 [ + - ]: 77 : headerFont.setPointSize(9);
665 [ + - ]: 77 : headerFont.setBold(true);
666 [ + - ]: 77 : p.setFont(headerFont);
667 [ + - + - : 77 : p.setPen(palette().text().color().darker(130));
+ - ]
668 : :
669 [ + - ]: 77 : QLocale loc = appLocale(); // T-76.B4: app-selected locale (i18n/language)
670 [ + + ]: 616 : for (int col = 0; col < 7; ++col) {
671 [ + - ]: 539 : QString dayName = loc.standaloneDayName(col + 1, QLocale::ShortFormat);
672 [ + - ]: 539 : p.drawText(QRect(col * cellW, topY, cellW, kHeaderH), Qt::AlignCenter,
673 : : dayName);
674 : 539 : }
675 : :
676 : 77 : topY += kHeaderH;
677 : 77 : int availH = height() - topY;
678 : 77 : int cellH = availH / 6;
679 [ + - ]: 77 : QDate today = QDate::currentDate();
680 [ + - ]: 77 : QDate firstVisible = firstVisibleDate();
681 : :
682 [ + - + - ]: 77 : QFont dayFont = font();
[ + - ]
683 [ + - ]: 77 : dayFont.setPointSize(10);
684 [ + - + - ]: 77 : QFont eventFont = font();
[ + - ]
685 [ + - ]: 77 : eventFont.setPointSize(8);
686 : :
687 [ + + ]: 539 : for (int row = 0; row < 6; ++row) {
688 [ + + ]: 3696 : for (int col = 0; col < 7; ++col) {
689 [ + - ]: 3234 : QDate cellDate = firstVisible.addDays(row * 7 + col);
690 : 3234 : QRect cellRect(col * cellW, topY + row * cellH, cellW, cellH);
691 : :
692 : : // Cell background
693 [ + - + - ]: 3234 : bool isCurrentMonth = (cellDate.month() == m_displayMonth.month());
694 : 3234 : bool isSelected = (cellDate == m_currentDate);
695 : 3234 : bool isToday = (cellDate == today);
696 : 3234 : bool isWeekend = (col >= 5);
697 : :
698 [ + - + - ]: 3234 : QColor bg = palette().base().color();
699 [ + + ]: 3234 : if (!isCurrentMonth)
700 : 868 : bg = bg.darker(108);
701 [ + + + + ]: 3234 : if (isWeekend && isCurrentMonth)
702 : 632 : bg = bg.darker(103);
703 : :
704 [ + - ]: 3234 : p.fillRect(cellRect, bg);
705 : :
706 : : // Selection highlight
707 [ + + ]: 3234 : if (isSelected) {
708 [ + - + - : 77 : p.setPen(QPen(palette().highlight().color(), 2));
+ - + - +
- ]
709 [ + - ]: 77 : p.drawRect(cellRect.adjusted(1, 1, -1, -1));
710 : : }
711 : :
712 : : // Grid lines
713 [ + - + - : 3234 : p.setPen(QPen(palette().mid().color(), 0.5));
+ - + - +
- ]
714 [ + - ]: 3234 : p.drawRect(cellRect);
715 : :
716 : : // Day number
717 [ + - ]: 3234 : p.setFont(dayFont);
718 [ + + + - : 3234 : QColor dayColor = isCurrentMonth ? palette().text().color()
+ - ]
719 [ + - + - ]: 868 : : palette().placeholderText().color();
720 [ + - ]: 3234 : p.setPen(dayColor);
721 : :
722 [ + + ]: 3234 : if (isToday) {
723 : : // Circle around today's number
724 : 43 : int circleR = 14;
725 : 86 : QRect numRect(cellRect.x() + 4, cellRect.y() + 2, circleR * 2,
726 : 43 : circleR * 2);
727 [ + - + - : 43 : p.setBrush(palette().highlight().color());
+ - + - ]
728 [ + - ]: 43 : p.setPen(Qt::NoPen);
729 [ + - ]: 43 : p.drawEllipse(numRect);
730 [ + - + - : 43 : p.setPen(palette().highlightedText().color());
+ - ]
731 [ + - ]: 43 : p.drawText(numRect, Qt::AlignCenter,
732 [ + - + - ]: 86 : QString::number(cellDate.day()));
733 [ + - ]: 43 : p.setBrush(Qt::NoBrush);
734 : : } else {
735 [ + - ]: 6382 : p.drawText(QRect(cellRect.x() + 4, cellRect.y() + 2, cellW - 8,
736 : 3191 : 20),
737 : 3191 : Qt::AlignLeft | Qt::AlignTop,
738 [ + - + - ]: 6382 : QString::number(cellDate.day()));
739 : : }
740 : :
741 : : // Events for this day
742 [ + - ]: 3234 : auto it = m_eventsByDate.constFind(cellDate);
743 [ + - + + ]: 3234 : if (it != m_eventsByDate.constEnd()) {
744 [ + - ]: 66 : p.setFont(eventFont);
745 : 66 : int evY = cellRect.y() + 22;
746 : 66 : int maxEvents = (cellH - 24) / 14;
747 : 66 : int shown = 0;
748 [ + + ]: 135 : for (const auto &ev : it.value()) {
749 [ - + ]: 69 : if (shown >= maxEvents)
750 : 0 : break;
751 : : // Color dot
752 [ + - ]: 69 : QColor evColor = eventColor(ev.color, ev.calendarPath);
753 [ + - + - ]: 69 : p.setBrush(evColor);
754 [ + - ]: 69 : p.setPen(Qt::NoPen);
755 [ + - ]: 69 : p.drawEllipse(cellRect.x() + 4, evY + 3, 6, 6);
756 [ + - ]: 69 : p.setBrush(Qt::NoBrush);
757 : :
758 : : // Event text
759 [ + - + - : 69 : p.setPen(palette().text().color());
+ - ]
760 : 69 : QString text = ev.summary;
761 [ + - ]: 69 : QFontMetrics fm(eventFont);
762 [ + - ]: 69 : text = fm.elidedText(text, Qt::ElideRight, cellW - 18);
763 [ + - ]: 69 : p.drawText(QRect(cellRect.x() + 14, evY, cellW - 18, 14),
764 : 69 : Qt::AlignLeft | Qt::AlignVCenter, text);
765 : 69 : evY += 14;
766 : 69 : ++shown;
767 : 69 : }
768 [ - + ]: 66 : if (it.value().size() > maxEvents) {
769 [ # # # # : 0 : p.setPen(palette().placeholderText().color());
# # ]
770 [ # # ]: 0 : p.drawText(QRect(cellRect.x() + 4, evY, cellW - 8, 14),
771 : : Qt::AlignLeft,
772 : 0 : QStringLiteral("+%1 mehr")
773 [ # # ]: 0 : .arg(it.value().size() - maxEvents));
774 : : }
775 : : }
776 : : }
777 : : }
778 : 77 : }
779 : :
780 : 14 : void CalendarWidget::paintWeekView(QPainter &p) {
781 [ + - ]: 14 : int dow = m_currentDate.dayOfWeek(); // 1=Mon
782 [ + - ]: 14 : QDate weekStart = m_currentDate.addDays(-(dow - 1));
783 : :
784 : : // T-424: Offset columns by time label width
785 : 14 : int cellW = (width() - kTimeLabelW) / 7;
786 : 14 : int topY = kToolbarH + kHeaderH;
787 : 14 : int hours = kWeekHourEnd - kWeekHourStart;
788 : :
789 [ + - ]: 14 : QLocale loc = appLocale(); // T-76.B4: app-selected locale (i18n/language)
790 [ + - ]: 14 : QDate today = QDate::currentDate();
791 : :
792 : : // T-428: Count all-day events to size the banner area
793 : 14 : int maxAllDay = 0;
794 [ + + ]: 112 : for (int col = 0; col < 7; ++col) {
795 [ + - ]: 98 : QDate d = weekStart.addDays(col);
796 [ + - ]: 98 : auto it = m_eventsByDate.constFind(d);
797 [ + - + + ]: 98 : if (it == m_eventsByDate.constEnd())
798 : 77 : continue;
799 : 21 : int count = 0;
800 [ + + ]: 43 : for (const auto &ev : it.value())
801 [ + + ]: 22 : if (ev.allDay) ++count;
802 : 21 : maxAllDay = qMax(maxAllDay, count);
803 : : }
804 [ + + ]: 14 : int allDayH = maxAllDay > 0 ? maxAllDay * kAllDayRowH + 4 : 0;
805 : 14 : int gridTopY = topY + allDayH; // where the hour grid starts
806 : :
807 : : // Day headers
808 [ + - + - ]: 14 : QFont headerFont = font();
[ + - ]
809 [ + - ]: 14 : headerFont.setPointSize(9);
810 [ + - ]: 14 : headerFont.setBold(true);
811 [ + - ]: 14 : p.setFont(headerFont);
812 [ + - + - : 14 : p.setPen(palette().text().color());
+ - ]
813 : :
814 [ + + ]: 112 : for (int col = 0; col < 7; ++col) {
815 [ + - ]: 98 : QDate d = weekStart.addDays(col);
816 [ + - ]: 196 : QString label = loc.standaloneDayName(col + 1, QLocale::ShortFormat) +
817 [ + - + - : 392 : QStringLiteral(" ") + QString::number(d.day());
+ - + - ]
818 [ + - + - ]: 98 : QColor bg = palette().base().color();
819 [ + + ]: 98 : if (d == today)
820 [ + - + - ]: 6 : bg = palette().highlight().color().lighter(180);
821 [ + + ]: 98 : if (d == m_currentDate)
822 [ + - + - ]: 14 : bg = palette().highlight().color().lighter(160);
823 [ + - ]: 98 : p.fillRect(QRect(kTimeLabelW + col * cellW, kToolbarH, cellW, kHeaderH), bg);
824 [ + + + - : 190 : p.setPen(d == today ? palette().highlight().color()
+ - + - ]
825 [ + - + - ]: 92 : : palette().text().color());
826 [ + - ]: 98 : p.drawText(QRect(kTimeLabelW + col * cellW, kToolbarH, cellW, kHeaderH),
827 : : Qt::AlignCenter, label);
828 : 98 : }
829 : :
830 : : // T-428: All-day event banners (between headers and hour grid)
831 [ + + ]: 14 : if (allDayH > 0) {
832 [ + - ]: 6 : p.fillRect(QRect(kTimeLabelW, topY, width() - kTimeLabelW, allDayH),
833 [ + - + - ]: 6 : palette().alternateBase().color());
834 [ + - + - ]: 6 : QFont adFont = font();
[ # # ]
835 [ + - ]: 6 : adFont.setPointSize(8);
836 [ + - ]: 6 : p.setFont(adFont);
837 [ + + ]: 48 : for (int col = 0; col < 7; ++col) {
838 [ + - ]: 42 : QDate d = weekStart.addDays(col);
839 [ + - ]: 42 : auto it = m_eventsByDate.constFind(d);
840 [ + - + + ]: 42 : if (it == m_eventsByDate.constEnd())
841 : 24 : continue;
842 : 18 : int row = 0;
843 [ + + ]: 36 : for (const auto &ev : it.value()) {
844 [ + + ]: 18 : if (!ev.allDay) continue;
845 [ + - ]: 6 : QColor evColor = eventColor(ev.color, ev.calendarPath);
846 : 6 : QRect r(kTimeLabelW + col * cellW + 2, topY + row * kAllDayRowH + 2,
847 : 6 : cellW - 4, kAllDayRowH - 3);
848 [ + - ]: 6 : p.fillRect(r, evColor.lighter(170));
849 [ + - ]: 6 : p.setPen(evColor);
850 [ + - ]: 6 : p.drawRect(r);
851 [ + - + - : 6 : p.setPen(palette().text().color());
+ - ]
852 [ + - ]: 6 : QFontMetrics fm(adFont);
853 [ + - ]: 6 : p.drawText(r.adjusted(3, 1, -2, -1), Qt::AlignLeft | Qt::AlignVCenter,
854 [ + - ]: 12 : fm.elidedText(ev.summary, Qt::ElideRight, r.width() - 6));
855 : 6 : ++row;
856 : 6 : }
857 : : }
858 : : // separator line
859 [ + - + - : 6 : p.setPen(QPen(palette().mid().color(), 1));
+ - + - +
- ]
860 [ + - ]: 6 : p.drawLine(kTimeLabelW, gridTopY - 1, width(), gridTopY - 1);
861 : 6 : }
862 : :
863 : : // T-427: Scroll offset — clamp to valid range
864 : 14 : int totalGridH = hours * kHourH;
865 : 14 : int viewH = height() - gridTopY;
866 : 14 : int maxScroll = qMax(0, totalGridH - viewH);
867 [ + - ]: 14 : m_weekScrollOffset = qBound(0, m_weekScrollOffset, maxScroll);
868 : :
869 : : // Clip to the grid area for scrollable content
870 [ + - ]: 14 : p.save();
871 [ + - ]: 14 : p.setClipRect(QRect(0, gridTopY, width(), viewH));
872 : :
873 : : // Hour grid (with scroll offset)
874 [ + - + - ]: 14 : QFont hourFont = font();
[ + - ]
875 [ + - ]: 14 : hourFont.setPointSize(8);
876 [ + - ]: 14 : p.setFont(hourFont);
877 : :
878 [ + + ]: 350 : for (int h = 0; h < hours; ++h) {
879 : 336 : int y = gridTopY + h * kHourH - m_weekScrollOffset;
880 [ + + + + : 336 : if (y > height() || y + kHourH < gridTopY)
+ + ]
881 : 146 : continue; // off-screen
882 : :
883 [ + - + - : 190 : p.setPen(QPen(palette().mid().color(), 0.5));
+ - + - +
- ]
884 [ + - ]: 190 : p.drawLine(kTimeLabelW, y, width(), y);
885 : :
886 : : // Hour label
887 [ + - + - : 190 : p.setPen(palette().placeholderText().color());
+ - ]
888 [ + - ]: 190 : p.drawText(QRect(2, y, kTimeLabelW - 4, kHourH), Qt::AlignTop | Qt::AlignRight,
889 [ + - ]: 570 : QStringLiteral("%1:00").arg(kWeekHourStart + h, 2, 10,
890 : 190 : QLatin1Char('0')));
891 : : }
892 : :
893 : : // Column dividers
894 [ + + ]: 126 : for (int col = 0; col <= 7; ++col) {
895 [ + - + - : 112 : p.setPen(QPen(palette().mid().color(), 0.5));
+ - + - +
- ]
896 : 112 : p.drawLine(kTimeLabelW + col * cellW, gridTopY,
897 [ + - ]: 112 : kTimeLabelW + col * cellW, gridTopY + totalGridH - m_weekScrollOffset);
898 : : }
899 : :
900 : : // Timed events (with scroll offset)
901 [ + - + - ]: 14 : QFont eventFont = font();
[ + - ]
902 [ + - ]: 14 : eventFont.setPointSize(8);
903 [ + - ]: 14 : p.setFont(eventFont);
904 : :
905 [ + + ]: 112 : for (int col = 0; col < 7; ++col) {
906 [ + - ]: 98 : QDate d = weekStart.addDays(col);
907 [ + - ]: 98 : auto it = m_eventsByDate.constFind(d);
908 [ + - + + ]: 98 : if (it == m_eventsByDate.constEnd())
909 : 77 : continue;
910 : :
911 [ + + ]: 43 : for (const auto &ev : it.value()) {
912 [ + + ]: 22 : if (ev.allDay)
913 : 6 : continue; // shown in all-day banner
914 : :
915 [ + - + - ]: 16 : QTime startTime = ev.dtStart.toLocalTime().time();
916 : : QTime endTime =
917 [ + - + - : 16 : ev.dtEnd.isValid() ? ev.dtEnd.toLocalTime().time() : startTime.addSecs(3600);
+ - + - -
- + - -
- ]
918 : :
919 : : int startMinute =
920 [ + - + - ]: 16 : (startTime.hour() - kWeekHourStart) * 60 + startTime.minute();
921 : : int endMinute =
922 [ + - + - ]: 16 : (endTime.hour() - kWeekHourStart) * 60 + endTime.minute();
923 : :
924 [ - + ]: 16 : if (startMinute < 0)
925 : 0 : startMinute = 0;
926 [ - + ]: 16 : if (endMinute <= startMinute)
927 : 0 : endMinute = startMinute + 30;
928 : :
929 : 16 : int y1 = gridTopY + (startMinute * kHourH) / 60 - m_weekScrollOffset;
930 : 16 : int y2 = gridTopY + (endMinute * kHourH) / 60 - m_weekScrollOffset;
931 : 16 : int evH = qMax(y2 - y1, 16);
932 : :
933 : : // Skip events fully off-screen
934 [ + - - + : 16 : if (y1 + evH < gridTopY || y1 > height())
- + ]
935 : 0 : continue;
936 : :
937 [ + - ]: 16 : QColor evColor = eventColor(ev.color, ev.calendarPath);
938 : 16 : QRect evRect(kTimeLabelW + col * cellW + 2, y1, cellW - 4, evH);
939 : :
940 : : // Event block
941 [ + - ]: 16 : p.fillRect(evRect, evColor.lighter(160));
942 [ + - + - : 16 : p.setPen(QPen(evColor, 2));
+ - ]
943 [ + - ]: 16 : p.drawLine(evRect.left(), evRect.top(), evRect.left(),
944 : : evRect.bottom());
945 : :
946 : : // Event text
947 [ + - + - : 16 : p.setPen(palette().text().color());
+ - ]
948 [ + - ]: 16 : QFontMetrics fm(eventFont);
949 : 16 : QString text = fm.elidedText(ev.summary, Qt::ElideRight,
950 [ + - ]: 16 : evRect.width() - 6);
951 [ + - ]: 16 : p.drawText(evRect.adjusted(4, 2, -2, -2),
952 : 16 : Qt::AlignLeft | Qt::AlignTop, text);
953 : 16 : }
954 : : }
955 : :
956 : : // Sprint 39: Drag preview rectangle
957 [ + + + - : 14 : if (m_isDragging && m_dragStartTime.isValid() && m_dragEndTime.isValid()) {
+ - + - +
- + + ]
958 [ + - ]: 1 : QDateTime t0 = qMin(m_dragStartTime, m_dragEndTime);
959 [ + - ]: 1 : QDateTime t1 = qMax(m_dragStartTime, m_dragEndTime);
960 : : // Compute column from drag start date
961 [ + - + - ]: 1 : int dragCol = weekStart.daysTo(t0.date());
962 [ + - + - ]: 1 : if (dragCol >= 0 && dragCol < 7) {
963 [ + - + - : 1 : double startMinutes = t0.time().hour() * 60.0 + t0.time().minute()
+ - + - ]
964 : 1 : - kWeekHourStart * 60.0;
965 [ + - + - : 1 : double endMinutes = t1.time().hour() * 60.0 + t1.time().minute()
+ - + - ]
966 : 1 : - kWeekHourStart * 60.0;
967 : 1 : int y0 = gridTopY + qRound(startMinutes * kHourH / 60.0) - m_weekScrollOffset;
968 : 1 : int y1 = gridTopY + qRound(endMinutes * kHourH / 60.0) - m_weekScrollOffset;
969 : 1 : int x0 = kTimeLabelW + dragCol * cellW + 2;
970 : 1 : QRect dragRect(x0, y0, cellW - 4, y1 - y0);
971 : : QColor dragAccent(
972 [ + - + - ]: 2 : ThemeManager::instance().color(QStringLiteral("@accent")));
973 : 1 : QColor dragFill = dragAccent;
974 [ + - ]: 1 : dragFill.setAlpha(50);
975 [ + - ]: 1 : p.fillRect(dragRect, dragFill);
976 [ + - + - : 1 : p.setPen(QPen(dragAccent, 2));
+ - ]
977 [ + - ]: 1 : p.drawRoundedRect(dragRect, 4, 4);
978 : : // Time label
979 [ + - + - ]: 1 : QFont tf = font();
[ # # ]
980 [ + - ]: 1 : tf.setPointSize(8);
981 [ + - ]: 1 : tf.setBold(true);
982 [ + - ]: 1 : p.setFont(tf);
983 [ + - ]: 1 : p.setPen(dragAccent);
984 [ + - + - ]: 3 : QString timeStr = t0.time().toString(QStringLiteral("HH:mm")) +
985 [ + - ]: 3 : QStringLiteral(" – ") +
986 [ + - + - : 4 : t1.time().toString(QStringLiteral("HH:mm"));
+ - ]
987 [ + - ]: 1 : p.drawText(dragRect.adjusted(4, 2, -2, -2), Qt::AlignLeft | Qt::AlignTop,
988 : : timeStr);
989 : 1 : }
990 : 1 : }
991 : :
992 [ + - ]: 14 : p.restore(); // remove clip
993 : 14 : }
994 : :
995 : : // ═══════════════════════════════════════════════════════
996 : : // T-425: Day View
997 : : // ═══════════════════════════════════════════════════════
998 : :
999 : 17 : void CalendarWidget::paintDayView(QPainter &p) {
1000 : 17 : int topY = kToolbarH;
1001 : 17 : int hours = kWeekHourEnd - kWeekHourStart;
1002 : :
1003 : : // All-day events banner
1004 [ + - ]: 17 : auto it = m_eventsByDate.constFind(m_currentDate);
1005 : 17 : int allDayCount = 0;
1006 [ + - + + ]: 17 : if (it != m_eventsByDate.constEnd()) {
1007 [ + + ]: 23 : for (const auto &ev : it.value())
1008 [ + + ]: 13 : if (ev.allDay) ++allDayCount;
1009 : : }
1010 [ + + ]: 17 : int allDayH = allDayCount > 0 ? allDayCount * kAllDayRowH + 4 : 0;
1011 : 17 : int gridTopY = topY + allDayH;
1012 : :
1013 : : // All-day banners
1014 [ + + + - : 17 : if (allDayH > 0 && it != m_eventsByDate.constEnd()) {
+ - + + ]
1015 [ + - ]: 3 : p.fillRect(QRect(kTimeLabelW, topY, width() - kTimeLabelW, allDayH),
1016 [ + - + - ]: 3 : palette().alternateBase().color());
1017 [ + - + - ]: 3 : QFont adFont = font();
[ # # ]
1018 [ + - ]: 3 : adFont.setPointSize(9);
1019 [ + - ]: 3 : p.setFont(adFont);
1020 : 3 : int row = 0;
1021 [ + + ]: 6 : for (const auto &ev : it.value()) {
1022 [ - + ]: 3 : if (!ev.allDay) continue;
1023 [ + - ]: 3 : QColor evColor = eventColor(ev.color, ev.calendarPath);
1024 : 3 : QRect r(kTimeLabelW + 2, topY + row * kAllDayRowH + 2,
1025 : 3 : width() - kTimeLabelW - 4, kAllDayRowH - 3);
1026 [ + - ]: 3 : p.fillRect(r, evColor.lighter(170));
1027 [ + - ]: 3 : p.setPen(evColor);
1028 [ + - ]: 3 : p.drawRect(r);
1029 [ + - + - : 3 : p.setPen(palette().text().color());
+ - ]
1030 [ + - ]: 3 : QFontMetrics fm(adFont);
1031 [ + - ]: 3 : p.drawText(r.adjusted(6, 1, -2, -1), Qt::AlignLeft | Qt::AlignVCenter,
1032 [ + - ]: 6 : fm.elidedText(ev.summary, Qt::ElideRight, r.width() - 12));
1033 : 3 : ++row;
1034 : 3 : }
1035 [ + - + - : 3 : p.setPen(QPen(palette().mid().color(), 1));
+ - + - +
- ]
1036 [ + - ]: 3 : p.drawLine(kTimeLabelW, gridTopY - 1, width(), gridTopY - 1);
1037 : 3 : }
1038 : :
1039 : : // Scroll bounds
1040 : 17 : int totalGridH = hours * kHourH;
1041 : 17 : int viewH = height() - gridTopY;
1042 : 17 : int maxScroll = qMax(0, totalGridH - viewH);
1043 [ + - ]: 17 : m_weekScrollOffset = qBound(0, m_weekScrollOffset, maxScroll);
1044 : :
1045 [ + - ]: 17 : p.save();
1046 [ + - ]: 17 : p.setClipRect(QRect(0, gridTopY, width(), viewH));
1047 : :
1048 : : // Hour grid
1049 [ + - + - ]: 17 : QFont hourFont = font();
[ + - ]
1050 [ + - ]: 17 : hourFont.setPointSize(9);
1051 [ + - ]: 17 : p.setFont(hourFont);
1052 : :
1053 [ + + ]: 425 : for (int h = 0; h < hours; ++h) {
1054 : 408 : int y = gridTopY + h * kHourH - m_weekScrollOffset;
1055 [ + + + + : 408 : if (y > height() || y + kHourH < gridTopY) continue;
+ + ]
1056 : :
1057 [ + - + - : 241 : p.setPen(QPen(palette().mid().color(), 0.5));
+ - + - +
- ]
1058 [ + - ]: 241 : p.drawLine(kTimeLabelW, y, width(), y);
1059 [ + - + - : 241 : p.setPen(palette().placeholderText().color());
+ - ]
1060 [ + - ]: 241 : p.drawText(QRect(2, y, kTimeLabelW - 4, kHourH),
1061 : 241 : Qt::AlignTop | Qt::AlignRight,
1062 [ + - ]: 723 : QStringLiteral("%1:00").arg(kWeekHourStart + h, 2, 10,
1063 : 241 : QLatin1Char('0')));
1064 : : }
1065 : :
1066 : : // Timed events
1067 [ + - + + ]: 17 : if (it != m_eventsByDate.constEnd()) {
1068 [ + - + - ]: 10 : QFont eventFont = font();
[ + - ]
1069 [ + - ]: 10 : eventFont.setPointSize(9);
1070 [ + - ]: 10 : p.setFont(eventFont);
1071 : :
1072 [ + + ]: 23 : for (const auto &ev : it.value()) {
1073 [ + + ]: 13 : if (ev.allDay) continue;
1074 : :
1075 [ + - + - ]: 10 : QTime startTime = ev.dtStart.toLocalTime().time();
1076 [ + - ]: 10 : QTime endTime = ev.dtEnd.isValid()
1077 [ + - + - : 20 : ? ev.dtEnd.toLocalTime().time()
- - ]
1078 [ + - + - : 20 : : startTime.addSecs(3600);
- - ]
1079 : :
1080 [ + - + - ]: 10 : int startMin = (startTime.hour() - kWeekHourStart) * 60 + startTime.minute();
1081 [ + - + - ]: 10 : int endMin = (endTime.hour() - kWeekHourStart) * 60 + endTime.minute();
1082 [ - + ]: 10 : if (startMin < 0) startMin = 0;
1083 [ - + ]: 10 : if (endMin <= startMin) endMin = startMin + 30;
1084 : :
1085 : 10 : int y1 = gridTopY + (startMin * kHourH) / 60 - m_weekScrollOffset;
1086 : 10 : int y2 = gridTopY + (endMin * kHourH) / 60 - m_weekScrollOffset;
1087 : 10 : int evH = qMax(y2 - y1, 20);
1088 [ + - - + : 10 : if (y1 + evH < gridTopY || y1 > height()) continue;
- + ]
1089 : :
1090 [ + - ]: 10 : QColor evColor = eventColor(ev.color, ev.calendarPath);
1091 : 10 : QRect evRect(kTimeLabelW + 4, y1, width() - kTimeLabelW - 8, evH);
1092 [ + - ]: 10 : p.fillRect(evRect, evColor.lighter(160));
1093 [ + - + - : 10 : p.setPen(QPen(evColor, 2));
+ - ]
1094 [ + - ]: 10 : p.drawLine(evRect.left(), evRect.top(), evRect.left(), evRect.bottom());
1095 : :
1096 [ + - + - : 10 : p.setPen(palette().text().color());
+ - ]
1097 [ + - ]: 10 : QFontMetrics fm(eventFont);
1098 [ + - ]: 10 : QString time = startTime.toString(QStringLiteral("HH:mm"));
1099 [ + - + - ]: 20 : QString text = time + QStringLiteral(" ") + ev.summary;
1100 [ + - ]: 10 : p.drawText(evRect.adjusted(6, 2, -4, -2),
1101 : 10 : Qt::AlignLeft | Qt::AlignTop,
1102 [ + - ]: 20 : fm.elidedText(text, Qt::ElideRight, evRect.width() - 10));
1103 : 10 : }
1104 : 10 : }
1105 : :
1106 [ + - ]: 17 : p.restore();
1107 : 17 : }
1108 : :
1109 : : // ═══════════════════════════════════════════════════════
1110 : : // T-425: Year View — 4×3 mini-calendar grid
1111 : : // ═══════════════════════════════════════════════════════
1112 : :
1113 : 8 : void CalendarWidget::paintYearView(QPainter &p) {
1114 : 8 : int topY = kToolbarH + 8;
1115 : 8 : int cols = 4;
1116 : 8 : int rows = 3;
1117 : 8 : int cellW = (width() - 20) / cols;
1118 : 8 : int cellH = (height() - topY - 10) / rows;
1119 : :
1120 [ + - ]: 8 : QLocale loc = appLocale(); // T-76.B4: app-selected locale (i18n/language)
1121 [ + - ]: 8 : QDate today = QDate::currentDate();
1122 [ + - ]: 8 : int year = m_displayMonth.year();
1123 : :
1124 [ + - + - ]: 8 : QFont monthFont = font();
[ + - ]
1125 [ + - ]: 8 : monthFont.setPointSize(9);
1126 [ + - ]: 8 : monthFont.setBold(true);
1127 : :
1128 [ + - + - ]: 8 : QFont dayFont = font();
[ + - ]
1129 [ + - ]: 8 : dayFont.setPointSize(7);
1130 : :
1131 [ + - + - ]: 8 : QFont kwFont = font();
[ + - ]
1132 [ + - ]: 8 : kwFont.setPointSize(6);
1133 [ + - ]: 8 : kwFont.setItalic(true);
1134 : :
1135 : 8 : int kwColW = 18; // width for KW column
1136 : :
1137 [ + + ]: 104 : for (int mon = 1; mon <= 12; ++mon) {
1138 : 96 : int r = (mon - 1) / cols;
1139 : 96 : int c = (mon - 1) % cols;
1140 : 96 : int mx = 10 + c * cellW;
1141 : 96 : int my = topY + r * cellH;
1142 : :
1143 : : // Month name
1144 [ + - ]: 96 : p.setFont(monthFont);
1145 [ + - + - : 96 : p.setPen(palette().text().color());
+ - ]
1146 [ + - ]: 96 : p.drawText(QRect(mx, my, cellW, 16), Qt::AlignCenter,
1147 [ + - ]: 192 : loc.standaloneMonthName(mon, QLocale::ShortFormat));
1148 : :
1149 : : // Day-of-week header row (Mo Di Mi ...)
1150 [ + - ]: 96 : p.setFont(kwFont);
1151 [ + - + - : 96 : p.setPen(palette().placeholderText().color());
+ - ]
1152 : 96 : int dayW = (cellW - kwColW) / 7;
1153 : 96 : int dayH = 13;
1154 : 96 : int gridY = my + 18;
1155 : 96 : int gridX = mx + kwColW;
1156 : :
1157 [ + + ]: 768 : for (int dow = 0; dow < 7; ++dow) {
1158 [ + - ]: 672 : p.drawText(QRect(gridX + dow * dayW, gridY - dayH, dayW, dayH),
1159 : : Qt::AlignCenter,
1160 [ + - ]: 1344 : loc.standaloneDayName(dow + 1, QLocale::NarrowFormat));
1161 : : }
1162 : :
1163 : : // Mini-calendar grid
1164 [ + - ]: 96 : p.setFont(dayFont);
1165 [ + - ]: 96 : QDate first(year, mon, 1);
1166 [ + - ]: 96 : int startDow = first.dayOfWeek(); // 1=Mon
1167 [ + - ]: 96 : int daysInMonth = first.daysInMonth();
1168 : :
1169 : : // Track which weeks we've drawn KW for
1170 : 96 : int lastKwDrawn = -1;
1171 : :
1172 [ + + ]: 3016 : for (int d = 1; d <= daysInMonth; ++d) {
1173 : 2920 : int dayOfWeek = ((startDow - 1 + d - 1) % 7); // 0=Mon
1174 : 2920 : int week = (startDow - 1 + d - 1) / 7;
1175 : 2920 : int dx = gridX + dayOfWeek * dayW;
1176 : 2920 : int dy = gridY + week * dayH;
1177 : :
1178 [ + - ]: 2920 : QDate thisDate(year, mon, d);
1179 : :
1180 : : // KW column — draw once per week row
1181 [ + + ]: 2920 : if (week != lastKwDrawn) {
1182 : 504 : lastKwDrawn = week;
1183 [ + - ]: 504 : int kw = thisDate.weekNumber();
1184 [ + - ]: 504 : p.setFont(kwFont);
1185 [ + - + - : 504 : p.setPen(palette().placeholderText().color());
+ - ]
1186 [ + - ]: 504 : p.drawText(QRect(mx, dy, kwColW - 2, dayH),
1187 : 504 : Qt::AlignRight | Qt::AlignVCenter,
1188 [ + - ]: 1008 : QString::number(kw));
1189 [ + - ]: 504 : p.setFont(dayFont);
1190 : : }
1191 : :
1192 : : // Highlight today
1193 [ + + ]: 2920 : if (thisDate == today) {
1194 [ + - ]: 8 : p.fillRect(QRect(dx, dy, dayW, dayH),
1195 [ + - + - ]: 16 : palette().highlight().color().lighter(160));
1196 : : }
1197 : : // Selected date
1198 [ + + ]: 2920 : if (thisDate == m_currentDate) {
1199 [ + - + - : 8 : p.setPen(QPen(palette().highlight().color(), 1));
+ - + - +
- ]
1200 [ + - ]: 8 : p.drawRect(QRect(dx, dy, dayW - 1, dayH - 1));
1201 : : }
1202 : :
1203 : : // Day number
1204 [ + - ]: 2920 : auto it = m_eventsByDate.constFind(thisDate);
1205 [ + - + + : 2920 : bool hasEvents = (it != m_eventsByDate.constEnd() && !it.value().isEmpty());
+ - ]
1206 : :
1207 [ + + + - : 5827 : p.setPen(hasEvents ? palette().highlight().color()
+ - + - ]
1208 [ + - + - ]: 2907 : : palette().text().color());
1209 [ + - ]: 2920 : p.drawText(QRect(dx, dy, dayW, dayH - 3), Qt::AlignHCenter | Qt::AlignTop,
1210 [ + - ]: 5840 : QString::number(d));
1211 : :
1212 : : // Colored event dot below number
1213 [ + + ]: 2920 : if (hasEvents) {
1214 [ + - ]: 13 : QColor dotColor = eventColor(it.value().first().color,
1215 : 13 : it.value().first().calendarPath);
1216 [ + - ]: 13 : p.setPen(Qt::NoPen);
1217 [ + - + - ]: 13 : p.setBrush(dotColor);
1218 [ + - ]: 13 : p.drawEllipse(dx + dayW / 2 - 2, dy + dayH - 5, 4, 4);
1219 [ + - ]: 13 : p.setBrush(Qt::NoBrush);
1220 : : }
1221 : : }
1222 : : }
1223 : 8 : }
1224 : :
1225 : : // ═══════════════════════════════════════════════════════
1226 : : // Input handling
1227 : : // ═══════════════════════════════════════════════════════
1228 : :
1229 : 41 : void CalendarWidget::keyPressEvent(QKeyEvent *event) {
1230 [ + + + + : 41 : switch (event->key()) {
+ + + + +
+ + + +
+ ]
1231 : 5 : case Qt::Key_H:
1232 : : case Qt::Key_Left:
1233 : 5 : moveSelection(-1);
1234 : 5 : break;
1235 : 5 : case Qt::Key_L:
1236 : : case Qt::Key_Right:
1237 : 5 : moveSelection(1);
1238 : 5 : break;
1239 : 5 : case Qt::Key_J:
1240 : : case Qt::Key_Down:
1241 : 5 : moveSelection(7);
1242 : 5 : break;
1243 : 4 : case Qt::Key_K:
1244 : : case Qt::Key_Up:
1245 : 4 : moveSelection(-7);
1246 : 4 : break;
1247 : 1 : case Qt::Key_N:
1248 : 1 : switchMonth(1);
1249 : 1 : break;
1250 : 2 : case Qt::Key_P:
1251 : 2 : switchMonth(-1);
1252 : 2 : break;
1253 : 2 : case Qt::Key_T:
1254 [ + - + - ]: 2 : navigateToDate(QDate::currentDate());
1255 : 2 : break;
1256 : 1 : case Qt::Key_1:
1257 : 1 : setViewMode(MonthView);
1258 : 1 : break;
1259 : 1 : case Qt::Key_2:
1260 : 1 : setViewMode(WeekView);
1261 : 1 : break;
1262 : 1 : case Qt::Key_3:
1263 : 1 : setViewMode(DayView);
1264 : 1 : break;
1265 : 1 : case Qt::Key_4:
1266 : 1 : setViewMode(YearView);
1267 : 1 : break;
1268 : 2 : case Qt::Key_Return:
1269 : : case Qt::Key_Enter: {
1270 [ + - ]: 2 : auto it = m_eventsByDate.constFind(m_currentDate);
1271 [ + - + + : 2 : if (it != m_eventsByDate.constEnd() && !it.value().isEmpty()) {
+ - + + ]
1272 : 1 : const auto &ev = it.value().first();
1273 [ + - ]: 1 : emit eventClicked(ev);
1274 [ + - + - : 1 : showEventPopup(ev, mapToGlobal(cellRectForDate(m_currentDate).center()));
+ - ]
1275 : : }
1276 : 2 : break;
1277 : : }
1278 : 2 : case Qt::Key_Escape:
1279 : : // Delegate to MainWindow ESC handler (CommandBar check first)
1280 : 2 : event->ignore();
1281 : 2 : break;
1282 : 9 : default:
1283 : 9 : QWidget::keyPressEvent(event);
1284 : 9 : break;
1285 : : }
1286 : 41 : }
1287 : :
1288 : 20 : void CalendarWidget::mousePressEvent(QMouseEvent *event) {
1289 [ + - + + ]: 20 : if (qRound(event->position().y()) < kToolbarH) {
1290 : : // Toolbar clicks
1291 [ + - + + ]: 10 : if (qRound(event->position().x()) < 48) {
1292 : 4 : switchMonth(-1);
1293 [ + - - + ]: 6 : } else if (qRound(event->position().x()) > width() - 48) {
1294 : 0 : switchMonth(1);
1295 [ + - + + ]: 6 : } else if (qRound(event->position().x()) > width() - 290) {
1296 : : // T-425: 4 mode buttons + today
1297 [ + - ]: 5 : int relX = qRound(event->position().x()) - (width() - 290);
1298 [ + + ]: 5 : if (relX < 40)
1299 : 1 : setViewMode(YearView);
1300 [ + + ]: 4 : else if (relX < 95)
1301 : 1 : setViewMode(MonthView);
1302 [ + + ]: 3 : else if (relX < 150)
1303 : 1 : setViewMode(WeekView);
1304 [ + + ]: 2 : else if (relX < 190)
1305 : 1 : setViewMode(DayView);
1306 : : else
1307 [ + - + - ]: 1 : navigateToDate(QDate::currentDate());
1308 [ + - + - : 1 : } else if (qRound(event->position().x()) >= 50 && qRound(event->position().x()) < 80) {
+ - - + -
+ ]
1309 : : // Filter button (☰) → open calendar filter menu
1310 [ # # # # ]: 0 : showCalendarFilterMenu(mapToGlobal(QPoint(50, kToolbarH)));
1311 [ + - + - : 1 : } else if (qRound(event->position().x()) >= 80 && qRound(event->position().x()) < width() - 300) {
+ - + - +
- ]
1312 : : // T-429: Click on title area → date picker popup
1313 [ + - - + : 1 : auto *picker = new QCalendarWidget(this);
- - ]
1314 [ + - ]: 1 : picker->setWindowFlags(Qt::Popup);
1315 : 1 : picker->setSelectedDate(m_currentDate);
1316 : 1 : picker->setGridVisible(true);
1317 : 1 : connect(picker, &QCalendarWidget::activated, this,
1318 [ + - ]: 1 : [this, picker](const QDate &date) {
1319 : 1 : navigateToDate(date);
1320 : 1 : picker->close();
1321 : 1 : picker->deleteLater();
1322 : 1 : });
1323 [ + - + - : 1 : picker->move(mapToGlobal(QPoint(qRound(event->position().x()) - 100, kToolbarH)));
+ - ]
1324 : 1 : picker->show();
1325 : : }
1326 : 10 : return;
1327 : : }
1328 : :
1329 : : // T-422: Calendar filter on right-click anywhere
1330 [ + + + + : 10 : if (event->button() == Qt::RightButton && m_store) {
+ + ]
1331 [ + - + - ]: 3 : showCalendarFilterMenu(event->globalPosition().toPoint());
1332 : 3 : return;
1333 : : }
1334 : :
1335 [ + + ]: 7 : if (m_viewMode == MonthView) {
1336 : 4 : int cellW = width() / 7;
1337 : 4 : int topY = kToolbarH + kHeaderH;
1338 : 4 : int availH = height() - topY;
1339 : 4 : int cellH = availH / 6;
1340 : :
1341 [ + - ]: 4 : int col = qRound(event->position().x()) / cellW;
1342 [ + - ]: 4 : int row = (qRound(event->position().y()) - topY) / cellH;
1343 [ + - + - : 4 : if (col >= 0 && col < 7 && row >= 0 && row < 6) {
+ - + - ]
1344 [ + - ]: 4 : QDate clicked = dateForCell(row, col);
1345 : 4 : m_currentDate = clicked;
1346 [ + - + - : 4 : if (clicked.month() != m_displayMonth.month()) {
- + ]
1347 [ # # # # : 0 : m_displayMonth = QDate(clicked.year(), clicked.month(), 1);
# # ]
1348 [ # # ]: 0 : loadEventsForVisibleRange();
1349 : : }
1350 : : // T-533: Single-click on event → popup, on empty area → just select
1351 [ + - + - ]: 4 : CalendarEvent ev = eventAtPosition(event->pos());
1352 [ - + ]: 4 : if (!ev.uid.isEmpty()) {
1353 [ # # ]: 0 : emit eventClicked(ev);
1354 [ # # # # ]: 0 : showEventPopup(ev, event->globalPosition().toPoint());
1355 : : }
1356 [ + - ]: 4 : emit dateClicked(clicked);
1357 [ + - ]: 4 : update();
1358 : 4 : }
1359 [ + + + - ]: 3 : } else if (m_viewMode == WeekView || m_viewMode == DayView) {
1360 : : // T-533: Start drag-to-select for time range
1361 [ + - + - ]: 3 : CalendarEvent ev = eventAtPosition(event->pos());
1362 [ + + ]: 3 : if (!ev.uid.isEmpty()) {
1363 : : // Clicked on existing event → show popup
1364 [ + - ]: 1 : emit eventClicked(ev);
1365 [ + - + - ]: 1 : showEventPopup(ev, event->globalPosition().toPoint());
1366 : : } else {
1367 : : // Empty area → start drag
1368 : 2 : m_isDragging = true;
1369 [ + - ]: 2 : m_dragStartPos = event->pos();
1370 [ + - ]: 2 : m_dragCurrentPos = event->pos();
1371 [ + - + - ]: 2 : m_dragStartTime = timeAtPosition(event->pos());
1372 : 2 : m_dragEndTime = m_dragStartTime;
1373 [ + - + - ]: 2 : setCursor(Qt::CrossCursor);
1374 : : }
1375 : 3 : }
1376 : : }
1377 : :
1378 : 4 : void CalendarWidget::wheelEvent(QWheelEvent *event) {
1379 : : // T-427: In week/day view, scroll vertically through the 24h timeline
1380 [ + - - + ]: 4 : if (m_viewMode == WeekView || m_viewMode == DayView) {
1381 : 0 : m_weekScrollOffset -= event->angleDelta().y() / 3;
1382 : : // T-541: Persist scroll position
1383 [ # # # # ]: 0 : QSettings().setValue(QStringLiteral("calendar/scrollOffset"),
1384 : : m_weekScrollOffset);
1385 : 0 : update();
1386 : 0 : event->accept();
1387 : 0 : return;
1388 : : }
1389 : 4 : m_wheelAccumulator += event->angleDelta().y();
1390 [ + + ]: 6 : while (m_wheelAccumulator >= 120) {
1391 : 2 : switchMonth(-1);
1392 : 2 : m_wheelAccumulator -= 120;
1393 : : }
1394 [ + + ]: 6 : while (m_wheelAccumulator <= -120) {
1395 : 2 : switchMonth(1);
1396 : 2 : m_wheelAccumulator += 120;
1397 : : }
1398 : : }
1399 : :
1400 : 33 : void CalendarWidget::resizeEvent(QResizeEvent *event) {
1401 : 33 : QWidget::resizeEvent(event);
1402 : 33 : update();
1403 : 33 : }
1404 : :
1405 : 4 : void CalendarWidget::mouseDoubleClickEvent(QMouseEvent *event) {
1406 [ + - + - ]: 4 : CalendarEvent ev = eventAtPosition(event->pos());
1407 [ - + ]: 4 : if (!ev.uid.isEmpty()) {
1408 : : // T-534: Double-click on event → edit
1409 [ # # ]: 0 : emit editEventRequested(ev);
1410 [ + + ]: 4 : } else if (m_viewMode == MonthView) {
1411 : : // T-533: Double-click on empty day → create
1412 : 3 : int cellW = width() / 7;
1413 : 3 : int topY = kToolbarH + kHeaderH;
1414 : 3 : int availH = height() - topY;
1415 : 3 : int cellH = availH / 6;
1416 [ + - ]: 3 : int col = qRound(event->position().x()) / cellW;
1417 [ + - ]: 3 : int row = (qRound(event->position().y()) - topY) / cellH;
1418 [ + - + - : 3 : if (col >= 0 && col < 7 && row >= 0 && row < 6) {
+ - + - ]
1419 [ + - ]: 3 : QDate clicked = dateForCell(row, col);
1420 : : // Only create if click is in the date-header area (top 22px),
1421 : : // or if day has no events at all
1422 [ + - ]: 3 : auto it = m_eventsByDate.constFind(clicked);
1423 : 3 : int evY = topY + row * cellH + 22;
1424 [ + - ]: 3 : bool inEventArea = qRound(event->position().y()) >= evY;
1425 [ + - - + : 3 : bool hasEvents = (it != m_eventsByDate.constEnd() && !it.value().isEmpty());
- - ]
1426 [ + + + - ]: 3 : if (!inEventArea || !hasEvents)
1427 [ + - ]: 3 : emit createEventRequested(clicked);
1428 : : }
1429 [ - + - - ]: 1 : } else if (m_viewMode == WeekView || m_viewMode == DayView) {
1430 : : // T-533: Double-click on empty time → create at that time
1431 [ + - + - ]: 1 : QDateTime clickTime = timeAtPosition(event->pos());
1432 [ + - + - ]: 1 : if (clickTime.isValid()) {
1433 [ + - + - ]: 1 : emit createEventRequested(clickTime.date(),
1434 [ + - ]: 1 : clickTime.time(),
1435 [ + - + - ]: 2 : clickTime.time().addSecs(3600));
1436 : : }
1437 : 1 : }
1438 : 4 : }
1439 : :
1440 : : // T-533: Drag-to-select in Week/Day view
1441 : 2 : void CalendarWidget::mouseMoveEvent(QMouseEvent *event) {
1442 [ + - ]: 2 : if (m_isDragging) {
1443 : 2 : m_dragCurrentPos = event->pos();
1444 [ + - + - ]: 2 : m_dragEndTime = timeAtPosition(event->pos());
1445 : 2 : update();
1446 : : }
1447 : 2 : QWidget::mouseMoveEvent(event);
1448 : 2 : }
1449 : :
1450 : 6 : void CalendarWidget::mouseReleaseEvent(QMouseEvent *event) {
1451 [ + + ]: 6 : if (m_isDragging) {
1452 : 2 : m_isDragging = false;
1453 [ + - + - ]: 2 : setCursor(Qt::ArrowCursor);
1454 [ + - + - ]: 2 : m_dragEndTime = timeAtPosition(event->pos());
1455 : :
1456 : : // Ensure start < end
1457 [ + - ]: 2 : QDateTime start = qMin(m_dragStartTime, m_dragEndTime);
1458 [ + - ]: 2 : QDateTime end = qMax(m_dragStartTime, m_dragEndTime);
1459 : :
1460 : : // Only emit if dragged at least 15 minutes
1461 [ + - + - : 4 : if (start.isValid() && end.isValid() &&
+ - + - +
- ]
1462 [ + - + - ]: 2 : start.secsTo(end) >= 15 * 60) {
1463 [ + - + - : 2 : emit createEventRequested(start.date(), start.time(), end.time());
+ - + - ]
1464 : : }
1465 [ + - ]: 2 : update();
1466 : 2 : }
1467 : 6 : QWidget::mouseReleaseEvent(event);
1468 : 6 : }
1469 : :
1470 : : // T-533: Map pixel position to datetime in Week/Day view
1471 : 10 : QDateTime CalendarWidget::timeAtPosition(const QPoint &pos) const {
1472 [ + + - + ]: 10 : if (m_viewMode != WeekView && m_viewMode != DayView)
1473 : 0 : return {};
1474 : :
1475 : 10 : int timeColW = 50; // matches paintWeekView/paintDayView
1476 : 10 : int topY = kToolbarH + kHeaderH;
1477 : :
1478 : : // Sprint 39: Account for all-day banner height (same as paint methods)
1479 : 10 : int maxAllDay = 0;
1480 [ + + ]: 10 : if (m_viewMode == WeekView) {
1481 [ + - ]: 6 : int dow = m_currentDate.dayOfWeek();
1482 [ + - ]: 6 : QDate weekStart = m_currentDate.addDays(-(dow - 1));
1483 [ + + ]: 48 : for (int c = 0; c < 7; ++c) {
1484 [ + - ]: 42 : QDate d = weekStart.addDays(c);
1485 [ + - ]: 42 : auto it = m_eventsByDate.constFind(d);
1486 [ + - + + ]: 42 : if (it != m_eventsByDate.constEnd()) {
1487 : 18 : int cnt = 0;
1488 [ + + ]: 58 : for (const auto &e : it.value())
1489 [ + + ]: 40 : if (e.allDay) ++cnt;
1490 : 18 : maxAllDay = qMax(maxAllDay, cnt);
1491 : : }
1492 : : }
1493 : : } else { // DayView
1494 [ + - ]: 4 : auto it = m_eventsByDate.constFind(m_currentDate);
1495 [ + - - + ]: 4 : if (it != m_eventsByDate.constEnd()) {
1496 [ # # ]: 0 : for (const auto &e : it.value())
1497 [ # # ]: 0 : if (e.allDay) ++maxAllDay;
1498 : : }
1499 : : }
1500 [ + + ]: 10 : int allDayH = maxAllDay > 0 ? maxAllDay * kAllDayRowH + 4 : 0;
1501 : 10 : int gridTopY = topY + allDayH;
1502 : :
1503 : 10 : int totalH = (kWeekHourEnd - kWeekHourStart) * kHourH;
1504 : :
1505 : : // Y → hour calculation (accounting for scroll offset and all-day banner)
1506 : 10 : int relY = pos.y() - gridTopY + m_weekScrollOffset;
1507 [ - + ]: 10 : if (relY < 0) relY = 0;
1508 [ - + ]: 10 : if (relY > totalH) relY = totalH;
1509 : 10 : double hours = kWeekHourStart + (double)relY / kHourH;
1510 [ + - ]: 10 : int h = qBound(0, (int)hours, 23);
1511 [ + - ]: 10 : int m = qBound(0, (int)((hours - h) * 60), 59);
1512 : : // Snap to 15-minute intervals
1513 : 10 : m = (m / 15) * 15;
1514 : :
1515 : : // X → date calculation
1516 : 10 : QDate date;
1517 [ + + ]: 10 : if (m_viewMode == DayView) {
1518 : 4 : date = m_currentDate;
1519 : : } else {
1520 : : // WeekView: 7 columns after time column
1521 : 6 : int dayW = (width() - timeColW) / 7;
1522 : 6 : int dayCol = (pos.x() - timeColW) / dayW;
1523 [ + - ]: 6 : dayCol = qBound(0, dayCol, 6);
1524 : : // Week starts on Monday
1525 [ + - ]: 6 : int dow = m_currentDate.dayOfWeek(); // 1=Mon
1526 [ + - ]: 6 : date = m_currentDate.addDays(-dow + 1 + dayCol);
1527 : : }
1528 : :
1529 [ + - + - ]: 10 : return QDateTime(date, QTime(h, m));
1530 : : }
1531 : :
1532 : 2 : void CalendarWidget::showEventPopup(const CalendarEvent &event,
1533 : : const QPoint &pos) {
1534 [ + - ]: 2 : if (!m_popup) {
1535 [ + - - + : 2 : m_popup = new EventDetailPopup(this);
- - ]
1536 : 2 : connect(m_popup, &EventDetailPopup::editRequested, this,
1537 [ + - ]: 2 : &CalendarWidget::editEventRequested);
1538 : 2 : connect(m_popup, &EventDetailPopup::deleteRequested, this,
1539 [ + - ]: 4 : &CalendarWidget::deleteEventRequested);
1540 : : }
1541 : 2 : m_popup->showEvent(event, pos);
1542 : 2 : }
1543 : :
1544 : 11 : CalendarEvent CalendarWidget::eventAtPosition(const QPoint &pos) const {
1545 [ + + ]: 11 : if (m_viewMode == MonthView) {
1546 : 7 : int cellW = width() / 7;
1547 : 7 : int topY = kToolbarH + kHeaderH;
1548 : 7 : int availH = height() - topY;
1549 : 7 : int cellH = availH / 6;
1550 : :
1551 : 7 : int col = pos.x() / cellW;
1552 : 7 : int row = (pos.y() - topY) / cellH;
1553 [ + - + - : 7 : if (col < 0 || col >= 7 || row < 0 || row >= 6)
+ - - + ]
1554 : 7 : return {};
1555 : :
1556 [ + - + - ]: 7 : QDate cellDate = firstVisibleDate().addDays(row * 7 + col);
1557 [ + - ]: 7 : auto it = m_eventsByDate.constFind(cellDate);
1558 [ + - + - ]: 7 : if (it == m_eventsByDate.constEnd())
1559 : 7 : return {};
1560 : :
1561 : : // Event slots start at 22px from top of cell, each 16px tall
1562 : 0 : int evY = topY + row * cellH + 22;
1563 : 0 : int eventSlotH = 16;
1564 : 0 : int maxEvents = (cellH - 24) / eventSlotH;
1565 : 0 : int clickY = pos.y() - evY;
1566 [ # # ]: 0 : if (clickY < 0)
1567 : 0 : return {};
1568 : 0 : int evIdx = clickY / eventSlotH;
1569 [ # # # # : 0 : if (evIdx >= 0 && evIdx < qMin(it.value().size(), maxEvents))
# # ]
1570 : 0 : return it.value().at(evIdx);
1571 : :
1572 [ + + + - ]: 4 : } else if (m_viewMode == WeekView || m_viewMode == DayView) {
1573 : : // T-533: Hit-test events in Week/Day view
1574 : : // First check all-day banner area
1575 [ + - ]: 4 : int dow = m_currentDate.dayOfWeek();
1576 [ + - ]: 4 : QDate weekStart = m_currentDate.addDays(-(dow - 1));
1577 [ + + ]: 6 : int cellW = (m_viewMode == WeekView) ? (width() - kTimeLabelW) / 7
1578 : 2 : : width() - kTimeLabelW;
1579 : 4 : int topY = kToolbarH + kHeaderH;
1580 : :
1581 : : // Count max all-day events for banner height
1582 : 4 : int maxAllDay = 0;
1583 [ + + ]: 4 : int numCols = (m_viewMode == WeekView) ? 7 : 1;
1584 [ + + ]: 20 : for (int c = 0; c < numCols; ++c) {
1585 [ + + + - ]: 16 : QDate d = (m_viewMode == WeekView) ? weekStart.addDays(c) : m_currentDate;
1586 [ + - ]: 16 : auto it2 = m_eventsByDate.constFind(d);
1587 [ + - + + ]: 16 : if (it2 != m_eventsByDate.constEnd()) {
1588 : 9 : int cnt = 0;
1589 [ + + ]: 29 : for (const auto &e : it2.value())
1590 [ + + ]: 20 : if (e.allDay) ++cnt;
1591 : 9 : maxAllDay = qMax(maxAllDay, cnt);
1592 : : }
1593 : : }
1594 [ + + ]: 4 : int allDayH = maxAllDay > 0 ? maxAllDay * kAllDayRowH + 4 : 0;
1595 : :
1596 : : // Check if click is in all-day banner
1597 [ + - + + : 4 : if (pos.y() >= topY && pos.y() < topY + allDayH) {
+ + ]
1598 : 1 : int col = (pos.x() - kTimeLabelW) / cellW;
1599 [ + - + - ]: 1 : if (col >= 0 && col < numCols) {
1600 [ - + - - ]: 1 : QDate clickDate = (m_viewMode == WeekView) ? weekStart.addDays(col)
1601 : 1 : : m_currentDate;
1602 [ + - ]: 1 : auto it2 = m_eventsByDate.constFind(clickDate);
1603 [ + - + - ]: 1 : if (it2 != m_eventsByDate.constEnd()) {
1604 : 1 : int adRow = (pos.y() - topY) / kAllDayRowH;
1605 : 1 : int adIdx = 0;
1606 [ + - ]: 1 : for (const auto &e : it2.value()) {
1607 [ - + ]: 1 : if (!e.allDay) continue;
1608 [ + - ]: 1 : if (adIdx == adRow) return e;
1609 : 0 : ++adIdx;
1610 : : }
1611 : : }
1612 : : }
1613 : : }
1614 : :
1615 : : // Then check timed events
1616 [ + - ]: 3 : QDateTime clickTime = timeAtPosition(pos);
1617 [ + - - + ]: 3 : if (!clickTime.isValid())
1618 : 0 : return {};
1619 [ + - ]: 3 : QDate clickDate = clickTime.date();
1620 [ + - ]: 3 : auto it = m_eventsByDate.constFind(clickDate);
1621 [ + - + + ]: 3 : if (it == m_eventsByDate.constEnd())
1622 : 2 : return {};
1623 : :
1624 [ + + ]: 4 : for (const auto &ev : it.value()) {
1625 [ - + ]: 3 : if (ev.allDay) continue;
1626 [ + - + - : 9 : if (ev.dtStart.isValid() && ev.dtEnd.isValid() &&
+ - + - ]
1627 [ + - + - : 9 : clickTime >= ev.dtStart && clickTime < ev.dtEnd) {
+ - - + -
+ ]
1628 : 0 : return ev;
1629 : : }
1630 : : }
1631 [ + + ]: 3 : }
1632 : 1 : return {};
1633 : 10 : }
1634 : :
1635 : 3 : void CalendarWidget::showCalendarFilterMenu(const QPoint &globalPos) {
1636 [ - + ]: 3 : if (!m_store) return;
1637 [ + - ]: 3 : auto calendars = m_store->allCalendars();
1638 [ - + ]: 3 : if (calendars.isEmpty()) return;
1639 : :
1640 [ + - ]: 3 : QMenu menu(this);
1641 [ + - + - ]: 3 : menu.setTitle(tr("Calendar filter"));
1642 [ + - + - : 9 : for (const auto &cal : calendars) {
+ + ]
1643 [ - + + - ]: 6 : auto *action = menu.addAction(
1644 : 6 : cal.displayName.isEmpty() ? cal.path : cal.displayName);
1645 [ + - ]: 6 : action->setCheckable(true);
1646 [ + + + + : 10 : action->setChecked(m_visibleCalendars.isEmpty() ||
+ - ]
1647 : 4 : m_visibleCalendars.contains(cal.path));
1648 [ + - ]: 6 : action->setData(cal.path);
1649 : : // Color swatch. T-71.5a: dim the swatch (low alpha) when the calendar
1650 : : // is hidden — a second visual cue alongside the check state, so the
1651 : : // status is legible even before QMenu paints the indicator.
1652 [ + - ]: 6 : QColor color = eventColor(cal.color, cal.path);
1653 [ + + + + ]: 10 : const bool visible = m_visibleCalendars.isEmpty() ||
1654 : 4 : m_visibleCalendars.contains(cal.path);
1655 [ + + + - ]: 6 : if (!visible) color.setAlpha(80);
1656 [ + - ]: 6 : QPixmap px(12, 12);
1657 [ + - ]: 6 : px.fill(color);
1658 [ + - + - ]: 6 : action->setIcon(QIcon(px));
1659 : 6 : }
1660 [ + - ]: 3 : menu.addSeparator();
1661 [ + - + - ]: 3 : auto *allAction = menu.addAction(tr("Show all"));
1662 : :
1663 [ + - ]: 3 : auto *result = menu.exec(globalPos);
1664 [ + + ]: 3 : if (result == allAction) {
1665 : 1 : m_visibleCalendars.clear();
1666 [ + - ]: 2 : } else if (result) {
1667 [ + - + - ]: 2 : QString path = result->data().toString();
1668 : : // Build visible set if currently showing all
1669 [ + + ]: 2 : if (m_visibleCalendars.isEmpty()) {
1670 [ + - + - : 3 : for (const auto &c : calendars)
+ + ]
1671 [ + - ]: 2 : m_visibleCalendars.insert(c.path);
1672 : : }
1673 [ + + ]: 2 : if (m_visibleCalendars.contains(path))
1674 [ + - ]: 1 : m_visibleCalendars.remove(path);
1675 : : else
1676 [ + - ]: 1 : m_visibleCalendars.insert(path);
1677 : : // If all are selected, clear to "show all" mode
1678 [ + + ]: 2 : if (m_visibleCalendars.size() == calendars.size())
1679 : 1 : m_visibleCalendars.clear();
1680 : 2 : }
1681 : : // Persist
1682 [ + - ]: 3 : QSettings s;
1683 [ + - ]: 3 : QStringList visList(m_visibleCalendars.begin(),
1684 [ + - + - ]: 6 : m_visibleCalendars.end());
1685 [ + - ]: 6 : s.setValue(QStringLiteral("calendar/visibleCalendars"), visList);
1686 [ + - ]: 3 : loadEventsForVisibleRange();
1687 [ + - ]: 3 : update();
1688 [ + - ]: 3 : }
|