File indexing completed on 2025-02-16 04:56:34
0001 // SPDX-FileCopyrightText: 2021 Claudio Cambra <claudio.cambra@gmail.com> 0002 // SPDX-License-Identifier: LGPL-2.0-or-later 0003 0004 #include "hourlyincidencemodel.h" 0005 #include <QTimeZone> 0006 #include <cmath> 0007 0008 using namespace std::chrono_literals; 0009 0010 HourlyIncidenceModel::HourlyIncidenceModel(QObject *parent) 0011 : QAbstractListModel(parent) 0012 { 0013 mRefreshTimer.setSingleShot(true); 0014 mRefreshTimer.setInterval(200ms); 0015 mRefreshTimer.callOnTimeout(this, [this] { 0016 Q_EMIT dataChanged(index(0, 0), index(rowCount() - 1, 0), {IncidencesRole}); 0017 }); 0018 } 0019 0020 int HourlyIncidenceModel::rowCount(const QModelIndex &parent) const 0021 { 0022 // Number of weeks 0023 if (parent.isValid()) { 0024 return 0; 0025 } 0026 0027 if (mSourceModel) { 0028 return qMax(mSourceModel->length(), 1); 0029 } 0030 return 0; 0031 } 0032 0033 static double getDuration(const QDateTime &start, const QDateTime &end, int periodLength) 0034 { 0035 return ((start.secsTo(end) * 1.0) / 60.0) / periodLength; 0036 } 0037 0038 // We first sort all occurrences so we get all-day first (sorted by duration), 0039 // and then the rest sorted by start-date. 0040 QList<QModelIndex> HourlyIncidenceModel::sortedIncidencesFromSourceModel(const QDateTime &rowStart) const 0041 { 0042 // Don't add days if we are going for a daily period 0043 const auto rowEnd = rowStart.date().endOfDay(); 0044 QList<QModelIndex> sorted; 0045 sorted.reserve(mSourceModel->rowCount()); 0046 // Get incidences from source model 0047 for (int row = 0; row < mSourceModel->rowCount(); row++) { 0048 const auto srcIdx = mSourceModel->index(row, 0, {}); 0049 const auto start = srcIdx.data(IncidenceOccurrenceModel::StartTime).toDateTime().toTimeZone(QTimeZone::systemTimeZone()); 0050 const auto end = srcIdx.data(IncidenceOccurrenceModel::EndTime).toDateTime().toTimeZone(QTimeZone::systemTimeZone()); 0051 0052 // Skip incidences not part of the week 0053 if (end < rowStart || start > rowEnd) { 0054 // qCWarning(MERKURO_CALENDAR_LOG) << "Skipping because not part of this week"; 0055 continue; 0056 } 0057 0058 if (m_filters.testFlag(NoAllDay) && srcIdx.data(IncidenceOccurrenceModel::AllDay).toBool()) { 0059 continue; 0060 } 0061 0062 if (m_filters.testFlag(NoMultiDay) && srcIdx.data(IncidenceOccurrenceModel::Duration).value<KCalendarCore::Duration>().asDays() >= 1) { 0063 continue; 0064 } 0065 0066 const auto incidencePtr = srcIdx.data(IncidenceOccurrenceModel::IncidencePtr).value<KCalendarCore::Incidence::Ptr>(); 0067 const auto incidenceIsTodo = incidencePtr->type() == Incidence::TypeTodo; 0068 if (!m_showTodos && incidenceIsTodo) { 0069 continue; 0070 } 0071 0072 if (m_showTodos && incidenceIsTodo && !m_showSubTodos && !incidencePtr->relatedTo().isEmpty()) { 0073 continue; 0074 } 0075 // qCWarning(MERKURO_CALENDAR_LOG) << "found " << srcIdx.data(IncidenceOccurrenceModel::StartTime).toDateTime() << 0076 // srcIdx.data(IncidenceOccurrenceModel::Summary).toString(); 0077 sorted.append(srcIdx); 0078 } 0079 0080 // Sort incidences by date 0081 std::sort(sorted.begin(), sorted.end(), [&](const QModelIndex &left, const QModelIndex &right) { 0082 // All-day first 0083 const auto leftAllDay = left.data(IncidenceOccurrenceModel::AllDay).toBool(); 0084 const auto rightAllDay = right.data(IncidenceOccurrenceModel::AllDay).toBool(); 0085 0086 const auto leftDt = left.data(IncidenceOccurrenceModel::StartTime).toDateTime(); 0087 const auto rightDt = right.data(IncidenceOccurrenceModel::StartTime).toDateTime(); 0088 0089 if (leftAllDay && !rightAllDay) { 0090 return true; 0091 } 0092 if (!leftAllDay && rightAllDay) { 0093 return false; 0094 } 0095 0096 // The rest sorted by start date 0097 return leftDt < rightDt; 0098 }); 0099 0100 return sorted; 0101 } 0102 0103 /* 0104 * Layout the lines: 0105 * 0106 * The line grouping algorithm then always picks the first incidence, 0107 * and tries to add more to the same line. 0108 * 0109 */ 0110 QVariantList HourlyIncidenceModel::layoutLines(const QDateTime &rowStart) const 0111 { 0112 QList<QModelIndex> sorted = sortedIncidencesFromSourceModel(rowStart); 0113 const auto rowEnd = rowStart.date().endOfDay(); 0114 const int periodsPerDay = (24 * 60) / mPeriodLength; 0115 0116 // for (const auto &srcIdx : sorted) { 0117 // qCWarning(MERKURO_CALENDAR_LOG) << "sorted " << srcIdx.data(IncidenceOccurrenceModel::StartTime).toDateTime() << 0118 // srcIdx.data(IncidenceOccurrenceModel::Summary).toString() 0119 // << srcIdx.data(IncidenceOccurrenceModel::AllDay).toBool(); 0120 // } 0121 QVariantList result; 0122 0123 auto addToResults = [&result](const QModelIndex &idx, double start, double duration) { 0124 auto incidenceMap = QVariantMap{ 0125 {QStringLiteral("text"), idx.data(IncidenceOccurrenceModel::Summary)}, 0126 {QStringLiteral("description"), idx.data(IncidenceOccurrenceModel::Description)}, 0127 {QStringLiteral("location"), idx.data(IncidenceOccurrenceModel::Location)}, 0128 {QStringLiteral("startTime"), idx.data(IncidenceOccurrenceModel::StartTime)}, 0129 {QStringLiteral("endTime"), idx.data(IncidenceOccurrenceModel::EndTime)}, 0130 {QStringLiteral("allDay"), idx.data(IncidenceOccurrenceModel::AllDay)}, 0131 {QStringLiteral("todoCompleted"), idx.data(IncidenceOccurrenceModel::TodoCompleted)}, 0132 {QStringLiteral("priority"), idx.data(IncidenceOccurrenceModel::Priority)}, 0133 {QStringLiteral("starts"), start}, 0134 {QStringLiteral("duration"), duration}, 0135 {QStringLiteral("durationString"), idx.data(IncidenceOccurrenceModel::DurationString)}, 0136 {QStringLiteral("recurs"), idx.data(IncidenceOccurrenceModel::Recurs)}, 0137 {QStringLiteral("hasReminders"), idx.data(IncidenceOccurrenceModel::HasReminders)}, 0138 {QStringLiteral("isOverdue"), idx.data(IncidenceOccurrenceModel::IsOverdue)}, 0139 {QStringLiteral("isReadOnly"), idx.data(IncidenceOccurrenceModel::IsReadOnly)}, 0140 {QStringLiteral("color"), idx.data(IncidenceOccurrenceModel::Color)}, 0141 {QStringLiteral("collectionId"), idx.data(IncidenceOccurrenceModel::CollectionId)}, 0142 {QStringLiteral("incidenceId"), idx.data(IncidenceOccurrenceModel::IncidenceId)}, 0143 {QStringLiteral("incidenceType"), idx.data(IncidenceOccurrenceModel::IncidenceType)}, 0144 {QStringLiteral("incidenceTypeStr"), idx.data(IncidenceOccurrenceModel::IncidenceTypeStr)}, 0145 {QStringLiteral("incidenceTypeIcon"), idx.data(IncidenceOccurrenceModel::IncidenceTypeIcon)}, 0146 {QStringLiteral("incidencePtr"), idx.data(IncidenceOccurrenceModel::IncidencePtr)}, 0147 {QStringLiteral("incidenceOccurrence"), idx.data(IncidenceOccurrenceModel::IncidenceOccurrence)}, 0148 }; 0149 0150 result.append(incidenceMap); 0151 }; 0152 0153 // Since our hourly view displays by the minute, we need to know how many incidences there are in each minute. 0154 // This hash's keys are the minute of the given day, as the view has accuracy down to the minute. Each value 0155 // for each key is the number of incidences that occupy that minute's spot. 0156 QHash<int, int> takenSpaces; 0157 auto setTakenSpaces = [&](int start, int end) { 0158 for (int i = start; i < end; i++) { 0159 if (!takenSpaces.contains(i)) { 0160 takenSpaces[i] = 1; 0161 } else { 0162 takenSpaces[i]++; 0163 } 0164 } 0165 }; 0166 0167 while (!sorted.isEmpty()) { 0168 const auto idx = sorted.takeFirst(); 0169 const auto startDT = idx.data(IncidenceOccurrenceModel::StartTime).toDateTime().toTimeZone(QTimeZone::systemTimeZone()) > rowStart 0170 ? idx.data(IncidenceOccurrenceModel::StartTime).toDateTime().toTimeZone(QTimeZone::systemTimeZone()) 0171 : rowStart; 0172 const auto endDT = idx.data(IncidenceOccurrenceModel::EndTime).toDateTime().toTimeZone(QTimeZone::systemTimeZone()) < rowEnd 0173 ? idx.data(IncidenceOccurrenceModel::EndTime).toDateTime().toTimeZone(QTimeZone::systemTimeZone()) 0174 : rowEnd; 0175 // Need to convert ints into doubles to get more accurate starting positions 0176 // We get a start position relative to the number of period spaces there are in a day 0177 const auto start = ((startDT.time().hour() * 1.0) * (60.0 / mPeriodLength)) + ((startDT.time().minute() * 1.0) / mPeriodLength); 0178 auto duration = // Give a minimum acceptable height or otherwise have unclickable incidence 0179 qMax(getDuration(startDT, idx.data(IncidenceOccurrenceModel::EndTime).toDateTime().toTimeZone(QTimeZone::systemTimeZone()), mPeriodLength), 1.0); 0180 0181 // Make sure incidence doesn't extend past the end of the day 0182 if (start + duration > periodsPerDay) { 0183 duration = periodsPerDay - start; 0184 } 0185 0186 const auto realEndMinutesFromDayStart = qMin((endDT.time().hour() * 60) + endDT.time().minute(), 24 * 60 * 60); 0187 // Todos likely won't have end date 0188 const auto startMinutesFromDayStart = 0189 startDT.isValid() ? (startDT.time().hour() * 60) + startDT.time().minute() : qMax(realEndMinutesFromDayStart - mPeriodLength, 0); 0190 const auto displayedEndMinutesFromDayStart = floor(startMinutesFromDayStart + (mPeriodLength * duration)); 0191 0192 addToResults(idx, start, duration); 0193 setTakenSpaces(startMinutesFromDayStart, displayedEndMinutesFromDayStart); 0194 } 0195 0196 QHash<int, double> takenWidth; // We need this for potential movers 0197 QHash<int, double> startX; 0198 // Potential movers are incidences that are placed at first but might need to be moved later as more incidences get placed to 0199 // the left of them. Rather than loop more than once over our incidences, we create a record of these and then deal with them 0200 // later, storing the needed data in a struct. 0201 struct PotentialMover { 0202 QVariantMap incidenceMap; 0203 int resultIterator; 0204 int startMinutesFromDayStart; 0205 int endMinutesFromDayStart; 0206 }; 0207 QList<PotentialMover> potentialMovers; 0208 0209 // Calculate the width and x position of each incidence rectangle 0210 for (int i = 0; i < result.length(); i++) { 0211 auto incidence = result[i].value<QVariantMap>(); 0212 int concurrentIncidences = 1; 0213 0214 const auto startDT = incidence[QLatin1StringView("startTime")].toDateTime().toTimeZone(QTimeZone::systemTimeZone()) > rowStart 0215 ? incidence[QLatin1StringView("startTime")].toDateTime().toTimeZone(QTimeZone::systemTimeZone()) 0216 : rowStart; 0217 const auto endDT = incidence[QLatin1StringView("endTime")].toDateTime().toTimeZone(QTimeZone::systemTimeZone()) < rowEnd 0218 ? incidence[QLatin1StringView("endTime")].toDateTime().toTimeZone(QTimeZone::systemTimeZone()) 0219 : rowEnd; 0220 const auto duration = incidence[QLatin1StringView("duration")].toDouble(); 0221 0222 // We need a "real" and "displayed" end time for two reasons: 0223 // 1. We need the real end minutes to give a fake start time to todos which do not have a start time 0224 // 2. We need the displayed end minutes to be able to properly position those incidences which are displayed as longer 0225 // than they actually are 0226 const auto realEndMinutesFromDayStart = qMin((endDT.time().hour() * 60) + endDT.time().minute(), 24 * 60 * 60); 0227 // Todos likely won't have end date 0228 const auto startMinutesFromDayStart = 0229 startDT.isValid() ? (startDT.time().hour() * 60) + startDT.time().minute() : qMax(realEndMinutesFromDayStart - mPeriodLength, 0); 0230 const int displayedEndMinutesFromDayStart = floor(startMinutesFromDayStart + (mPeriodLength * duration)); 0231 0232 // Get max number of incidences that happen at the same time as this 0233 // (there can be different numbers of concurrent incidences during the time) 0234 for (int i = startMinutesFromDayStart; i < displayedEndMinutesFromDayStart; i++) { 0235 concurrentIncidences = qMax(concurrentIncidences, takenSpaces[i]); 0236 } 0237 0238 incidence[QLatin1StringView("maxConcurrentIncidences")] = concurrentIncidences; 0239 double widthShare = 1.0 / (concurrentIncidences * 1.0); // Width as a fraction of the whole day column width 0240 incidence[QLatin1StringView("widthShare")] = widthShare; 0241 0242 // This is the value that the QML view will use to position the incidence rectangle on the day column's X axis. 0243 double priorTakenWidthShare = 0.0; 0244 // If we have empty space at the very left of the column we want to take advantage and place an incidence there 0245 // even if there have been other incidences that take up space further to the right. For this we use minStartX, 0246 // which gathers the lowest x starting position in a given minute; if this is higher than 0, it means that there 0247 // is empty space at the left of the day column. 0248 double minStartX = 1.0; 0249 0250 for (int i = startMinutesFromDayStart; i < displayedEndMinutesFromDayStart - 1; i++) { 0251 // If this is the first incidence that has taken up this minute position, set details 0252 if (!startX.contains(i)) { 0253 takenWidth[i] = widthShare; 0254 startX[i] = priorTakenWidthShare; 0255 } else { 0256 priorTakenWidthShare = qMax(priorTakenWidthShare, takenWidth[i]); // Get maximum prior space taken so we do not overlap with anything 0257 minStartX = qMin(minStartX, startX[i]); 0258 0259 if (startX[i] > 0) { 0260 takenWidth[i] = widthShare; // Reset as there is space available at the beginning of the column 0261 } else { 0262 takenWidth[i] += widthShare; // Increase the taken width at this minute position 0263 } 0264 } 0265 } 0266 0267 if (minStartX > 0) { 0268 priorTakenWidthShare = 0; 0269 for (int i = startMinutesFromDayStart; i < displayedEndMinutesFromDayStart; i++) { 0270 startX[i] = 0; 0271 } 0272 } 0273 0274 incidence[QLatin1StringView("priorTakenWidthShare")] = priorTakenWidthShare; 0275 0276 if (takenSpaces[startMinutesFromDayStart] < takenSpaces[displayedEndMinutesFromDayStart - 1] && priorTakenWidthShare > 0) { 0277 potentialMovers.append(PotentialMover{incidence, i, startMinutesFromDayStart, displayedEndMinutesFromDayStart}); 0278 } 0279 0280 result[i] = incidence; 0281 } 0282 0283 for (auto &potentialMover : potentialMovers) { 0284 double maxTakenWidth = 0; 0285 for (int i = potentialMover.startMinutesFromDayStart; i < potentialMover.endMinutesFromDayStart; i++) { 0286 maxTakenWidth = qMax(maxTakenWidth, takenWidth[i]); 0287 } 0288 0289 if (maxTakenWidth < 0.98) { 0290 potentialMover.incidenceMap[QLatin1StringView("priorTakenWidthShare")] = 0291 potentialMover.incidenceMap[QLatin1StringView("widthShare")].toDouble() * (takenSpaces[potentialMover.endMinutesFromDayStart - 1] - 1); 0292 0293 result[potentialMover.resultIterator] = potentialMover.incidenceMap; 0294 } 0295 } 0296 0297 return result; 0298 } 0299 0300 QVariant HourlyIncidenceModel::data(const QModelIndex &idx, int role) const 0301 { 0302 Q_ASSERT(hasIndex(idx.row(), idx.column()) && mSourceModel); 0303 0304 const auto rowStart = mSourceModel->start().addDays(idx.row()).startOfDay(); 0305 switch (role) { 0306 case PeriodStartDateTimeRole: 0307 return rowStart; 0308 case IncidencesRole: 0309 return layoutLines(rowStart); 0310 default: 0311 Q_UNREACHABLE(); 0312 } 0313 } 0314 0315 IncidenceOccurrenceModel *HourlyIncidenceModel::model() const 0316 { 0317 return mSourceModel; 0318 } 0319 0320 void HourlyIncidenceModel::setModel(IncidenceOccurrenceModel *model) 0321 { 0322 beginResetModel(); 0323 mSourceModel = model; 0324 Q_EMIT modelChanged(); 0325 endResetModel(); 0326 0327 connect(model, &QAbstractItemModel::dataChanged, this, &HourlyIncidenceModel::scheduleReset); 0328 connect(model, &QAbstractItemModel::layoutChanged, this, &HourlyIncidenceModel::scheduleReset); 0329 connect(model, &QAbstractItemModel::modelReset, this, &HourlyIncidenceModel::scheduleReset); 0330 connect(model, &QAbstractItemModel::rowsInserted, this, &HourlyIncidenceModel::scheduleReset); 0331 connect(model, &QAbstractItemModel::rowsMoved, this, &HourlyIncidenceModel::scheduleReset); 0332 connect(model, &QAbstractItemModel::rowsRemoved, this, &HourlyIncidenceModel::scheduleReset); 0333 connect(model, &IncidenceOccurrenceModel::lengthChanged, this, [this] { 0334 beginResetModel(); 0335 endResetModel(); 0336 }); 0337 } 0338 0339 void HourlyIncidenceModel::scheduleReset() 0340 { 0341 if (!mRefreshTimer.isActive()) { 0342 mRefreshTimer.start(); 0343 } 0344 } 0345 0346 int HourlyIncidenceModel::periodLength() const 0347 { 0348 return mPeriodLength; 0349 } 0350 0351 void HourlyIncidenceModel::setPeriodLength(int periodLength) 0352 { 0353 if (mPeriodLength == periodLength) { 0354 return; 0355 } 0356 mPeriodLength = periodLength; 0357 Q_EMIT periodLengthChanged(); 0358 0359 scheduleReset(); 0360 } 0361 0362 HourlyIncidenceModel::Filters HourlyIncidenceModel::filters() const 0363 { 0364 return m_filters; 0365 } 0366 0367 void HourlyIncidenceModel::setFilters(HourlyIncidenceModel::Filters filters) 0368 { 0369 if (m_filters == filters) { 0370 return; 0371 } 0372 m_filters = filters; 0373 Q_EMIT filtersChanged(); 0374 0375 scheduleReset(); 0376 } 0377 0378 bool HourlyIncidenceModel::showTodos() const 0379 { 0380 return m_showTodos; 0381 } 0382 0383 void HourlyIncidenceModel::setShowTodos(const bool showTodos) 0384 { 0385 if (showTodos == m_showTodos) { 0386 return; 0387 } 0388 0389 m_showTodos = showTodos; 0390 Q_EMIT showTodosChanged(); 0391 0392 scheduleReset(); 0393 } 0394 0395 bool HourlyIncidenceModel::showSubTodos() const 0396 { 0397 return m_showSubTodos; 0398 } 0399 0400 void HourlyIncidenceModel::setShowSubTodos(const bool showSubTodos) 0401 { 0402 if (showSubTodos == m_showSubTodos) { 0403 return; 0404 } 0405 0406 m_showSubTodos = showSubTodos; 0407 Q_EMIT showSubTodosChanged(); 0408 0409 scheduleReset(); 0410 } 0411 0412 bool HourlyIncidenceModel::active() const 0413 { 0414 return m_active; 0415 } 0416 0417 void HourlyIncidenceModel::setActive(const bool active) 0418 { 0419 if (active == m_active) { 0420 return; 0421 } 0422 0423 m_active = active; 0424 Q_EMIT activeChanged(); 0425 0426 if (active && mRefreshTimer.isActive() && std::chrono::milliseconds(mRefreshTimer.remainingTime()) > 200ms) { 0427 Q_EMIT dataChanged(index(0, 0), index(rowCount() - 1, 0)); 0428 mRefreshTimer.stop(); 0429 } 0430 mRefreshTimer.setInterval(active ? 200ms : 1000ms); 0431 } 0432 0433 QHash<int, QByteArray> HourlyIncidenceModel::roleNames() const 0434 { 0435 return { 0436 {IncidencesRole, QByteArrayLiteral("incidences")}, 0437 {PeriodStartDateTimeRole, QByteArrayLiteral("periodStartDateTime")}, 0438 }; 0439 } 0440 0441 #include "moc_hourlyincidencemodel.cpp"