File indexing completed on 2024-05-19 05:08:28

0001 /*
0002     SPDX-FileCopyrightText: 2019-2020 Thomas Baumgart <tbaumgart@kde.org>
0003     SPDX-License-Identifier: GPL-2.0-or-later
0004 */
0005 
0006 #include "splitview.h"
0007 
0008 // ----------------------------------------------------------------------------
0009 // QT Includes
0010 
0011 #include <QDate>
0012 #include <QDebug>
0013 #include <QHeaderView>
0014 #include <QMenu>
0015 #include <QPainter>
0016 #include <QResizeEvent>
0017 #include <QScopedPointer>
0018 #include <QScrollBar>
0019 
0020 // ----------------------------------------------------------------------------
0021 // KDE Includes
0022 
0023 #include <KLocalizedString>
0024 
0025 // ----------------------------------------------------------------------------
0026 // Project Includes
0027 
0028 #include "accountsmodel.h"
0029 #include "columnselector.h"
0030 #include "icons.h"
0031 #include "mymoneyaccount.h"
0032 #include "mymoneyenums.h"
0033 #include "mymoneyfile.h"
0034 #include "mymoneymoney.h"
0035 #include "mymoneysecurity.h"
0036 #include "splitdelegate.h"
0037 #include "splitmodel.h"
0038 
0039 class SplitView::Private
0040 {
0041 public:
0042     Private(SplitView* p)
0043         : q(p)
0044         , splitDelegate(nullptr)
0045         , adjustableColumn(SplitModel::Column::Memo)
0046         , adjustingColumn(false)
0047         , showValuesInverted(false)
0048         , balanceCalculationPending(false)
0049         , newTransactionPresent(false)
0050         , firstSelectionAfterCreation(true)
0051         , blockEditorStart(false)
0052         , rightMouseButtonPress(false)
0053         , readOnly(false)
0054         , columnSelector(nullptr)
0055     {
0056     }
0057 
0058     void setSingleLineDetailRole(eMyMoney::Model::Roles role)
0059     {
0060         Q_UNUSED(role)
0061 #if 0
0062         auto delegate = qobject_cast<SplitDelegate*>(q->itemDelegate());
0063         if (delegate) {
0064             delegate->setSingleLineDetailRole(role);
0065         }
0066 #endif
0067     }
0068 
0069     void ensureEditorFullyVisible(const QModelIndex& idx)
0070     {
0071         const auto viewportHeight = q->viewport()->height();
0072         const auto verticalOffset = q->verticalHeader()->offset();
0073         const auto verticalPosition = q->verticalHeader()->sectionPosition(idx.row());
0074         const auto cellHeight = q->verticalHeader()->sectionSize(idx.row());
0075 
0076         // in case the idx is displayed passed the viewport
0077         // adjust the position of the scroll area
0078         if (verticalPosition - verticalOffset + cellHeight > viewportHeight) {
0079             q->verticalScrollBar()->setValue(q->verticalScrollBar()->maximum());
0080         }
0081     }
0082 
0083     void setupUnassignedValue(const QModelIndex& targetIdx)
0084     {
0085         if (!totalTransactionValue.isAutoCalc()) {
0086             auto model = const_cast<SplitModel*>(qobject_cast<const SplitModel*>(targetIdx.model()));
0087             MyMoneyMoney splitsTotal;
0088             for (int row = 0; row < model->rowCount(); ++row) {
0089                 if (row == targetIdx.row()) {
0090                     continue;
0091                 }
0092                 const auto idx = model->index(row, 0);
0093                 if (idx.isValid()) {
0094                     splitsTotal += idx.data(eMyMoney::Model::SplitValueRole).value<MyMoneyMoney>();
0095                 }
0096             }
0097 
0098             model->setData(targetIdx, QVariant::fromValue<MyMoneyMoney>(totalTransactionValue - splitsTotal), eMyMoney::Model::SplitValueRole);
0099             model->setData(targetIdx, QVariant::fromValue<MyMoneyMoney>(totalTransactionValue - splitsTotal), eMyMoney::Model::SplitSharesRole);
0100         }
0101     }
0102 
0103     void executeContextMenu(const QPoint& pos)
0104     {
0105         const auto currentIdx = q->currentIndex();
0106         const auto currentId = currentIdx.data(eMyMoney::Model::IdRole).toString();
0107         const auto enableOption = !(currentId.isEmpty() || currentId.endsWith('-'));
0108 
0109         QScopedPointer<QMenu> menu(new QMenu(q));
0110         menu->addSection(i18nc("@title:menu split context menu", "Split Options"));
0111 
0112         menu->addAction(Icons::get(Icons::Icon::DocumentNew), i18nc("@item:inmenu Create a split", "New..."), q, [&]() {
0113             // search the empty split and start editing it
0114             const auto rows = q->model()->rowCount();
0115             for (int row = 0; row < rows; ++row) {
0116                 const auto idx = q->model()->index(row, 0);
0117                 const auto id = idx.data(eMyMoney::Model::IdRole).toString();
0118                 if (id.isEmpty() || id.endsWith('-')) {
0119                     // force a call to currentChanged() to start editing
0120                     q->setCurrentIndex(QModelIndex());
0121                     q->setCurrentIndex(idx);
0122                     break;
0123                 }
0124             }
0125         });
0126 
0127         const auto editItem = menu->addAction(Icons::get(Icons::Icon::DocumentEdit), i18nc("@item:inmenu Edit a split", "Edit..."), q, [&]() {
0128             q->edit(currentIdx);
0129         });
0130         editItem->setEnabled(enableOption);
0131 
0132         const auto duplicateItem = menu->addAction(Icons::get(Icons::Icon::EditCopy), i18nc("@item:inmenu Duplicate selected split(s)", "Duplicate"), q, [&]() {
0133             const auto list = q->selectionModel()->selectedRows();
0134             QPersistentModelIndex newCurrentIdx;
0135             for (const auto idx : list) {
0136                 if (!idx.data(eMyMoney::Model::SplitIsNewRole).toBool()) {
0137                     // we can use any model object for the next operation, but we have to use one
0138                     const auto baseIdx = MyMoneyFile::instance()->accountsModel()->mapToBaseSource(idx);
0139                     auto model = const_cast<SplitModel*>(qobject_cast<const SplitModel*>(baseIdx.model()));
0140                     if (model) {
0141                         // get the original data
0142                         const auto split = model->itemByIndex(baseIdx);
0143 
0144                         model->appendEmptySplit();
0145                         const auto newIdx = model->emptySplit();
0146                         // prevent update signals
0147                         QSignalBlocker block(model);
0148                         // the id of the split will be automatically assigned by the model
0149                         model->setData(newIdx, split.number(), eMyMoney::Model::SplitNumberRole);
0150                         model->setData(newIdx, split.memo(), eMyMoney::Model::SplitMemoRole);
0151                         model->setData(newIdx, split.accountId(), eMyMoney::Model::SplitAccountIdRole);
0152                         model->setData(newIdx, split.costCenterId(), eMyMoney::Model::SplitCostCenterIdRole);
0153                         model->setData(newIdx, split.payeeId(), eMyMoney::Model::SplitPayeeIdRole);
0154                         model->setData(newIdx, QVariant::fromValue<MyMoneyMoney>(split.shares()), eMyMoney::Model::SplitSharesRole);
0155                         // send out the dataChanged signal with the next (last) setData()
0156                         block.unblock();
0157                         model->setData(newIdx, QVariant::fromValue<MyMoneyMoney>(split.value()), eMyMoney::Model::SplitValueRole);
0158 
0159                         // now add a new empty split at the end
0160                         model->appendEmptySplit();
0161                         // and make the new split the current one
0162                         if (!newCurrentIdx.isValid()) {
0163                             newCurrentIdx = newIdx;
0164                         }
0165                     }
0166                 }
0167             }
0168             if (newCurrentIdx.isValid()) {
0169                 q->setCurrentIndex(newCurrentIdx);
0170             }
0171         });
0172         duplicateItem->setEnabled(enableOption);
0173 
0174         const auto deleteItem = menu->addAction(Icons::get(Icons::Icon::EditRemove), i18nc("@item:inmenu Delete selected split(s)", "Delete"), q, [&]() {
0175             Q_EMIT q->deleteSelectedSplits();
0176         });
0177         deleteItem->setEnabled(enableOption);
0178 
0179         menu->exec(q->viewport()->mapToGlobal(pos));
0180     }
0181 
0182     SplitView* q;
0183     SplitDelegate* splitDelegate;
0184     MyMoneyAccount account;
0185     MyMoneyMoney totalTransactionValue;
0186     int adjustableColumn;
0187     bool adjustingColumn;
0188     bool showValuesInverted;
0189     bool balanceCalculationPending;
0190     bool newTransactionPresent;
0191     bool firstSelectionAfterCreation;
0192     bool blockEditorStart;
0193     bool rightMouseButtonPress;
0194     bool readOnly;
0195     ColumnSelector* columnSelector;
0196 };
0197 
0198 
0199 
0200 SplitView::SplitView(QWidget* parent)
0201     : QTableView(parent)
0202     , d(new Private(this))
0203 {
0204     // keep rows as small as possible
0205     verticalHeader()->setMinimumSectionSize(10);
0206     verticalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents);
0207     verticalHeader()->hide();
0208 
0209     horizontalHeader()->setMinimumSectionSize(20);
0210     horizontalHeader()->setSectionResizeMode(QHeaderView::Interactive);
0211 
0212     // since we don't have a vertical header, it does not make sense
0213     // to use the first column to select all items in the view
0214     setCornerButtonEnabled(false);
0215 
0216     // make sure to get informed about resize operations on the columns
0217     connect(horizontalHeader(), &QHeaderView::sectionResized, this, [&]() {
0218         adjustDetailColumn(viewport()->width());
0219     });
0220 
0221     // we don't need autoscroll as we do not support drag/drop
0222     setAutoScroll(false);
0223 
0224     setAlternatingRowColors(true);
0225 
0226     setSelectionBehavior(SelectRows);
0227 
0228     // setup the context menu
0229     setContextMenuPolicy(Qt::CustomContextMenu);
0230     connect(this, &QWidget::customContextMenuRequested, this, [&](QPoint pos) {
0231         d->executeContextMenu(pos);
0232     });
0233 
0234     setTabKeyNavigation(false);
0235 
0236     d->splitDelegate = new SplitDelegate(this);
0237     setItemDelegate(d->splitDelegate);
0238 
0239     d->columnSelector = new ColumnSelector(this, QStringLiteral("SplitEditor"));
0240     d->columnSelector->setAlwaysVisible(
0241         QVector<int>({SplitModel::Column::Category, SplitModel::Column::Tags, SplitModel::Column::Payment, SplitModel::Column::Deposit}));
0242 }
0243 
0244 SplitView::~SplitView()
0245 {
0246     delete d;
0247 }
0248 
0249 void SplitView::setModel(QAbstractItemModel* model)
0250 {
0251     QTableView::setModel(model);
0252 
0253     d->columnSelector->setModel(model);
0254 
0255     // This will allow the user to move the columns
0256     horizontalHeader()->setSectionsMovable(true);
0257 }
0258 
0259 void SplitView::setCommodity(const MyMoneySecurity& commodity)
0260 {
0261     if (d->splitDelegate) {
0262         d->splitDelegate->setCommodity(commodity);
0263     }
0264 }
0265 
0266 bool SplitView::showValuesInverted() const
0267 {
0268     return d->showValuesInverted;
0269 }
0270 
0271 void SplitView::setColumnsHidden(QVector<int> columns)
0272 {
0273     d->columnSelector->setAlwaysHidden(columns);
0274 }
0275 
0276 void SplitView::setColumnsShown(QVector<int> columns)
0277 {
0278     d->columnSelector->setAlwaysVisible(columns);
0279 }
0280 
0281 bool SplitView::edit(const QModelIndex& index, QAbstractItemView::EditTrigger trigger, QEvent* event)
0282 {
0283     bool rc = QTableView::edit(index, trigger, event);
0284 
0285     if(rc) {
0286         // editing started, but we need the editor to cover all columns
0287         // so we close it, set the span to have a single row and recreate
0288         // the editor in that single cell
0289         closeEditor(indexWidget(index), QAbstractItemDelegate::NoHint);
0290 
0291         bool haveEditorInOtherView = false;
0292         /// @todo Here we need to make sure that only a single editor can be started at a time
0293 
0294         if(!haveEditorInOtherView) {
0295             Q_EMIT aboutToStartEdit();
0296 
0297             if (index.data(eMyMoney::Model::SplitIsNewRole).toBool()) {
0298                 d->setupUnassignedValue(index);
0299             }
0300             setSpan(index.row(), 0, 1, horizontalHeader()->count());
0301             QModelIndex editIndex = model()->index(index.row(), 0);
0302             rc = QTableView::edit(editIndex, trigger, event);
0303 
0304             // make sure that the row gets resized according to the requirements of the editor
0305             // and is completely visible
0306             resizeRowToContents(index.row());
0307             d->ensureEditorFullyVisible(index);
0308             QMetaObject::invokeMethod(this, "ensureCurrentItemIsVisible", Qt::QueuedConnection);
0309         } else {
0310             rc = false;
0311         }
0312     }
0313 
0314     return rc;
0315 }
0316 
0317 void SplitView::closeEditor(QWidget* editor, QAbstractItemDelegate::EndEditHint hint)
0318 {
0319     QTableView::closeEditor(editor, hint);
0320     clearSpans();
0321 
0322     // we need to resize the row that contained the editor.
0323     resizeRowsToContents();
0324 
0325     Q_EMIT aboutToFinishEdit();
0326 
0327     QMetaObject::invokeMethod(this, "ensureCurrentItemIsVisible", Qt::QueuedConnection);
0328 }
0329 
0330 void SplitView::mousePressEvent(QMouseEvent* event)
0331 {
0332     // qDebug() << "mousePressEvent";
0333     if(state() != QAbstractItemView::EditingState) {
0334         d->rightMouseButtonPress = (event->button() == Qt::RightButton);
0335         QTableView::mousePressEvent(event);
0336         d->rightMouseButtonPress = false;
0337     }
0338 }
0339 
0340 void SplitView::mouseMoveEvent(QMouseEvent* event)
0341 {
0342     Q_UNUSED(event);
0343     // qDebug() << "mouseMoveEvent";
0344     // QTableView::mouseMoveEvent(event);
0345 }
0346 
0347 void SplitView::mouseDoubleClickEvent(QMouseEvent* event)
0348 {
0349     // qDebug() << "mouseDoubleClickEvent";
0350     QTableView::mouseDoubleClickEvent(event);
0351 }
0352 
0353 void SplitView::wheelEvent(QWheelEvent* e)
0354 {
0355     // qDebug() << "wheelEvent";
0356     QTableView::wheelEvent(e);
0357 }
0358 
0359 void SplitView::skipStartEditing()
0360 {
0361     d->firstSelectionAfterCreation = true;
0362 }
0363 
0364 void SplitView::blockEditorStart(bool blocked)
0365 {
0366     d->blockEditorStart = blocked;
0367 }
0368 
0369 QModelIndex SplitView::moveCursor(QAbstractItemView::CursorAction cursorAction, Qt::KeyboardModifiers modifiers)
0370 {
0371     QModelIndex newIndex;
0372 
0373     if (!(modifiers & Qt::ControlModifier)) {
0374         // for home and end we need to have the ControlModifier set so
0375         // that the base class implementation works on rows instead of
0376         // columns.
0377         switch (cursorAction) {
0378         case MoveHome:
0379         case MoveEnd:
0380             newIndex = QTableView::moveCursor(cursorAction, modifiers | Qt::ControlModifier);
0381             break;
0382 
0383         default:
0384             newIndex = QTableView::moveCursor(cursorAction, modifiers);
0385             break;
0386         }
0387     }
0388 
0389     // now make sure that moving the cursor does not hit the empty
0390     // transaction at the bottom or a schedule.
0391     for (auto row = newIndex.row(); row >= 0; --row) {
0392         newIndex = model()->index(row, newIndex.column(), newIndex.parent());
0393         QString id = newIndex.data(eMyMoney::Model::IdRole).toString();
0394         // skip the empty transaction at the end of a ledger if
0395         // the movement is not the down arrow
0396         if ((id.isEmpty() || id.endsWith('-')) && (cursorAction != MoveDown)) {
0397             continue;
0398         }
0399         if ((newIndex.flags() & (Qt::ItemIsSelectable | Qt::ItemIsEnabled)) == (Qt::ItemIsSelectable | Qt::ItemIsEnabled)) {
0400             return newIndex;
0401         }
0402     }
0403     return {};
0404 }
0405 
0406 void SplitView::currentChanged(const QModelIndex& current, const QModelIndex& previous)
0407 {
0408     // qDebug() << "currentChanged";
0409     QTableView::currentChanged(current, previous);
0410 
0411     // is it a new selection (a different row)?
0412     if (current.isValid() && (current.row() != previous.row())) {
0413         const auto idx = current.model()->index(current.row(), 0);
0414         const auto id = idx.data(eMyMoney::Model::IdRole).toString();
0415         // For a new transaction the id is completely empty, for a split view the transaction
0416         // part is filled but the split id is empty and the string ends with a dash
0417         if (!d->blockEditorStart && !d->firstSelectionAfterCreation && (id.isEmpty() || id.endsWith('-')) && !d->rightMouseButtonPress) {
0418             selectionModel()->clearSelection();
0419             setCurrentIndex(idx);
0420             selectRow(idx.row());
0421             scrollTo(idx, QAbstractItemView::PositionAtBottom);
0422             edit(idx);
0423         } else {
0424             scrollTo(idx, EnsureVisible);
0425             Q_EMIT transactionSelected(MyMoneyFile::baseModel()->mapToBaseSource(idx));
0426         }
0427         d->firstSelectionAfterCreation = false;
0428         QMetaObject::invokeMethod(this, "doItemsLayout", Qt::QueuedConnection);
0429     }
0430 }
0431 
0432 void SplitView::moveEvent(QMoveEvent* event)
0433 {
0434     // qDebug() << "moveEvent";
0435     QWidget::moveEvent(event);
0436 }
0437 
0438 void SplitView::paintEvent(QPaintEvent* event)
0439 {
0440     QTableView::paintEvent(event);
0441 
0442     // the base class implementation paints the regular grid in case there
0443     // is room below the last line and the bottom of the viewport. We check
0444     // here if that is the case and fill that part with the base color to
0445     // remove the false painted grid.
0446 
0447     const QHeaderView *verticalHeader = this->verticalHeader();
0448     if(verticalHeader->count() == 0)
0449         return;
0450 
0451     int lastVisualRow = verticalHeader->visualIndexAt(verticalHeader->viewport()->height());
0452     if (lastVisualRow == -1)
0453         lastVisualRow = model()->rowCount(QModelIndex()) - 1;
0454 
0455     while(lastVisualRow >= model()->rowCount(QModelIndex()))
0456         --lastVisualRow;
0457 
0458     while ((lastVisualRow > -1) && verticalHeader->isSectionHidden(verticalHeader->logicalIndex(lastVisualRow)))
0459         --lastVisualRow;
0460 
0461     int top = 0;
0462     if(lastVisualRow != -1)
0463         top = verticalHeader->sectionViewportPosition(lastVisualRow) + verticalHeader->sectionSize(lastVisualRow);
0464 
0465     if(top < viewport()->height()) {
0466         QPainter painter(viewport());
0467         QRect rect(0, top, viewport()->width(), viewport()->height()-top);
0468         painter.fillRect(rect, QBrush(palette().base()));
0469     }
0470 }
0471 
0472 void SplitView::setSingleLineDetailRole(eMyMoney::Model::Roles role)
0473 {
0474     d->setSingleLineDetailRole(role);
0475 }
0476 
0477 int SplitView::sizeHintForColumn(int col) const
0478 {
0479 #if 0
0480     if (col == JournalModel::Column::Reconciliation) {
0481         QStyleOptionViewItem opt;
0482         const QModelIndex index = model()->index(0, col);
0483         const auto delegate = itemDelegate();
0484         if (delegate) {
0485             int hint = delegate->sizeHint(opt, index).width();
0486             if(showGrid())
0487                 hint += 1;
0488             return hint;
0489         }
0490     }
0491 #endif
0492     return QTableView::sizeHintForColumn(col);
0493 }
0494 
0495 int SplitView::sizeHintForRow(int row) const
0496 {
0497     // we can optimize the sizeHintForRow() operation by asking the
0498     // delegate about the height. There's no need to use the std
0499     // method which scans over all items in a column and takes a long
0500     // time in large ledgers. In case the editor is open in the row, we
0501     // use the regular method.
0502     // We always ask for the detail column as this varies in height
0503     ensurePolished();
0504 
0505     if (model()) {
0506         const QModelIndex index = model()->index(row, SplitModel::Column::Memo);
0507         const auto delegate = itemDelegate();
0508         const auto splitDelegate = qobject_cast<const SplitDelegate*>(delegate);
0509 
0510         if(splitDelegate&& (splitDelegate->editorRow() != row)) {
0511             QStyleOptionViewItem opt;
0512             opt.state |= (row == currentIndex().row()) ? QStyle::State_Selected : QStyle::State_None;
0513             int hint = delegate->sizeHint(opt, index).height();
0514             if(showGrid())
0515                 hint += 1;
0516             return hint;
0517         }
0518     }
0519 
0520     return QTableView::sizeHintForRow(row);
0521 }
0522 
0523 void SplitView::resizeEvent(QResizeEvent* event)
0524 {
0525     // qDebug() << "resizeEvent, old:" << event->oldSize() << "new:" << event->size() << "viewport:" << viewport()->width();
0526     QTableView::resizeEvent(event);
0527     adjustDetailColumn(event->size().width());
0528 }
0529 
0530 void SplitView::adjustDetailColumn(int newViewportWidth)
0531 {
0532     // make sure we don't get here recursively
0533     if(d->adjustingColumn)
0534         return;
0535 
0536     d->adjustingColumn = true;
0537 
0538     QHeaderView* header = horizontalHeader();
0539 
0540     int totalColumnWidth = 0;
0541     for(int i=0; i < header->count(); ++i) {
0542         if(header->isSectionHidden(i)) {
0543             continue;
0544         }
0545         totalColumnWidth += header->sectionSize(i);
0546     }
0547     const int delta = newViewportWidth - totalColumnWidth;
0548     const int newWidth = header->sectionSize(d->adjustableColumn) + delta;
0549     if(newWidth > 10) {
0550         header->resizeSection(d->adjustableColumn, newWidth);
0551     }
0552 
0553     // remember that we're done this time
0554     d->adjustingColumn = false;
0555 }
0556 
0557 void SplitView::ensureCurrentItemIsVisible()
0558 {
0559     scrollTo(currentIndex(), EnsureVisible);
0560 }
0561 
0562 void SplitView::slotSettingsChanged()
0563 {
0564 #if 0
0565 
0566     // KMyMoneySettings::showGrid()
0567     // KMyMoneySettings::sortNormalView()
0568     // KMyMoneySettings::ledgerLens()
0569     // KMyMoneySettings::showRegisterDetailed()
0570     d->m_proxyModel->setHideClosedAccounts(!KMyMoneySettings::showAllAccounts());
0571     d->m_proxyModel->setHideEquityAccounts(!KMyMoneySettings::expertMode());
0572     d->m_proxyModel->setHideFavoriteAccounts(true);
0573 #endif
0574 }
0575 
0576 void SplitView::selectMostRecentTransaction()
0577 {
0578     if (model()->rowCount() > 0) {
0579 
0580         // we need to check that the last row may contain a scheduled transaction or
0581         // the row that is shown for new transacations or a special entry (e.g.
0582         // online balance or date mark).
0583         // in that case, we need to go back to find the actual last transaction
0584         int row = model()->rowCount()-1;
0585         if(row >= 0) {
0586             const QModelIndex idx = model()->index(row, 0);
0587             setCurrentIndex(idx);
0588             selectRow(idx.row());
0589             scrollTo(idx, QAbstractItemView::PositionAtBottom);
0590         }
0591     }
0592 }
0593 
0594 void SplitView::setTransactionPayeeId(const QString& id)
0595 {
0596     if (d->splitDelegate) {
0597         d->splitDelegate->setTransactionPayeeId(id);
0598     }
0599 }
0600 
0601 void SplitView::setReadOnlyMode(bool readOnly)
0602 {
0603     d->readOnly = readOnly;
0604     d->splitDelegate->setReadOnlyMode(readOnly);
0605 }
0606 
0607 void SplitView::setTotalTransactionValue(const MyMoneyMoney& transactionValue)
0608 {
0609     d->totalTransactionValue = transactionValue;
0610 }