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