diff options
| author | Calvin Morrison <calvin@pobox.com> | 2026-01-03 11:57:44 -0500 |
|---|---|---|
| committer | Calvin Morrison <calvin@pobox.com> | 2026-01-03 11:57:44 -0500 |
| commit | fb51ae563d69944d8399d8b4705f2714d15a74c1 (patch) | |
| tree | 6f2bee73dce53d3526da64563d0ef00e3a5df26e | |
| parent | a6e05ddd0add4500877ceb2df69ea3e0d5ca9b15 (diff) | |
nu stuff
| -rw-r--r-- | CashFlo.pro | 8 | ||||
| -rw-r--r-- | TODO.md | 19 | ||||
| -rw-r--r-- | cashflow.cpp | 369 | ||||
| -rw-r--r-- | cashflow.h | 9 | ||||
| -rw-r--r-- | cashflow.ui | 89 | ||||
| -rw-r--r-- | multiselectcombobox.cpp | 175 | ||||
| -rw-r--r-- | multiselectcombobox.h | 41 |
7 files changed, 676 insertions, 34 deletions
diff --git a/CashFlo.pro b/CashFlo.pro index 920badf..d739ab1 100644 --- a/CashFlo.pro +++ b/CashFlo.pro @@ -1,4 +1,4 @@ -QT += core gui sql +QT += core gui sql charts greaterThan(QT_MAJOR_VERSION, 4): QT += widgets @@ -12,13 +12,15 @@ SOURCES += \ main.cpp \ cashflow.cpp \ database.cpp \ - settingsdialog.cpp + settingsdialog.cpp \ + multiselectcombobox.cpp HEADERS += \ cashflow.h \ database.h \ transaction.h \ - settingsdialog.h + settingsdialog.h \ + multiselectcombobox.h FORMS += \ cashflow.ui \ @@ -50,6 +50,19 @@ transactions table: - [ ] Account types: Asset vs Liability - [ ] Net worth dashboard (Assets - Liabilities) +### Account Management +- [ ] First-class Account table (id, name, account_type, is_active) +- [ ] Account management UI in Preferences +- [ ] Migration: auto-create accounts from existing transaction strings +- [ ] Update combo boxes to use accounts table +- [ ] Keep simple: no interest rates, no starting balances (use transactions) + +### Attachments / Document Storage +- [ ] Store scanned receipts/documents as BLOBs in SQLite +- [ ] Attachment viewer/manager per transaction +- [ ] File upload dialog +- [ ] Display attached file count in grid + ### AI Integration - [ ] MCP server for AI assistant integration - [ ] HTTP REST API in Qt app (QHttpServer) @@ -85,6 +98,12 @@ transactions table: - ✅ Escape key to deselect - ✅ Sortable recurring rules table with numeric sorting - ✅ Delete preserves selection (moves to next row) +- ✅ Charts tab with balance trend visualization +- ✅ Multi-account line charts +- ✅ Hover tooltips showing exact balance at date +- ✅ Mouse wheel zoom on charts +- ✅ Auto-fit chart axes to data range +- ✅ Independent chart date range controls ### UI diff --git a/cashflow.cpp b/cashflow.cpp index 6c5ea7d..a8b6a5a 100644 --- a/cashflow.cpp +++ b/cashflow.cpp @@ -1,6 +1,7 @@ #include "cashflow.h" #include "ui_cashflow.h" #include "settingsdialog.h" +#include "multiselectcombobox.h" #include <QMessageBox> #include <QDebug> #include <QDir> @@ -23,6 +24,8 @@ CashFlow::CashFlow(QWidget *parent) , startingBalance(0.0) , currentAmountFont("Courier New", 10) , weekStartDay(1) // Default to Monday + , chartView(nullptr) + , chart(nullptr) { ui->setupUi(this); @@ -36,6 +39,26 @@ CashFlow::CashFlow(QWidget *parent) setupConnections(); + // Initialize chart view + chart = new QChart(); + chart->setTitle("Balance Over Time"); + chart->setAnimationOptions(QChart::SeriesAnimations); + + chartView = new QChartView(chart); + chartView->setRenderHint(QPainter::Antialiasing); + chartView->setRubberBand(QChartView::RectangleRubberBand); // Enable zoom with mouse drag + chartView->installEventFilter(this); // Install event filter for mouse wheel zoom + + // Add chart view to the chart tab layout + QVBoxLayout *chartLayout = ui->chartsTab->findChild<QVBoxLayout*>("chartViewLayout"); + if (chartLayout) { + chartLayout->addWidget(chartView); + } + + // Initialize chart date range to match main date range + ui->chartDateFromEdit->setDate(QDate::currentDate().addMonths(-1)); + ui->chartDateToEdit->setDate(QDate::currentDate().addMonths(3)); + if (!openDatabase(defaultPath)) { QMessageBox::critical(this, "Database Error", "Failed to open default database: " + database->lastError()); return; @@ -132,6 +155,27 @@ void CashFlow::keyPressEvent(QKeyEvent *event) QMainWindow::keyPressEvent(event); } +bool CashFlow::eventFilter(QObject *watched, QEvent *event) +{ + if (watched == chartView && event->type() == QEvent::Wheel) { + QWheelEvent *wheelEvent = static_cast<QWheelEvent*>(event); + + if (chart->axes().isEmpty()) { + return QMainWindow::eventFilter(watched, event); + } + + // Zoom factor based on wheel delta + double factor = wheelEvent->angleDelta().y() > 0 ? 0.9 : 1.1; + + // Zoom on both axes + chart->zoom(factor); + + return true; + } + + return QMainWindow::eventFilter(watched, event); +} + void CashFlow::setupConnections() { // File menu connect(ui->actionNew, &QAction::triggered, this, &CashFlow::onNewFile); @@ -147,7 +191,7 @@ void CashFlow::setupConnections() { connect(ui->dateFromEdit, &QDateEdit::dateChanged, this, &CashFlow::onDateRangeChanged); connect(ui->dateToEdit, &QDateEdit::dateChanged, this, &CashFlow::onDateRangeChanged); connect(ui->periodCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &CashFlow::onPeriodChanged); - connect(ui->accountFilterCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &CashFlow::onDateRangeChanged); + connect(ui->accountFilterCombo, &MultiSelectComboBox::selectionChanged, this, &CashFlow::onDateRangeChanged); connect(ui->recurringFilterCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &CashFlow::onDateRangeChanged); connect(ui->showAccountBalancesCheck, &QCheckBox::stateChanged, this, &CashFlow::onDateRangeChanged); connect(ui->searchEdit, &QLineEdit::textChanged, this, &CashFlow::onSearchTextChanged); @@ -197,6 +241,10 @@ void CashFlow::setupConnections() { // Set up Delete key shortcut for recurring table ui->deleteRecurringBtn->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_Delete)); + + // Charts tab + connect(ui->chartDateFromEdit, &QDateEdit::dateChanged, this, &CashFlow::refreshCharts); + connect(ui->chartDateToEdit, &QDateEdit::dateChanged, this, &CashFlow::refreshCharts); } void CashFlow::refreshView() { @@ -204,9 +252,8 @@ void CashFlow::refreshView() { // Block signals to prevent recursive refresh ui->accountFilterCombo->blockSignals(true); - QString currentFilter = ui->accountFilterCombo->currentText(); + QStringList previouslySelected = ui->accountFilterCombo->getSelectedItems(); ui->accountFilterCombo->clear(); - ui->accountFilterCombo->addItem("All Accounts"); // Get unique account names and categories QSet<QString> accounts; @@ -224,7 +271,20 @@ void CashFlow::refreshView() { sortedAccounts.sort(); ui->accountFilterCombo->addItems(sortedAccounts); - // Populate entry form account combo + // Restore previous selection if possible, otherwise select all + if (!previouslySelected.isEmpty()) { + QStringList validSelection; + for (const QString &account : previouslySelected) { + if (sortedAccounts.contains(account)) { + validSelection.append(account); + } + } + if (!validSelection.isEmpty()) { + ui->accountFilterCombo->setSelectedItems(validSelection); + } + } + + ui->accountFilterCombo->blockSignals(false); ui->entryAccountCombo->blockSignals(true); QString currentAccount = ui->entryAccountCombo->currentText(); ui->entryAccountCombo->clear(); @@ -256,16 +316,9 @@ void CashFlow::refreshView() { ui->recurringCategoryCombo->addItems(sortedCategories); ui->recurringCategoryCombo->blockSignals(false); - // Restore previous selection if possible - int index = ui->accountFilterCombo->findText(currentFilter); - if (index >= 0) { - ui->accountFilterCombo->setCurrentIndex(index); - } - - ui->accountFilterCombo->blockSignals(false); - refreshTransactionTable(); refreshRecurringTable(); + refreshCharts(); calculateAndDisplayBalance(); } @@ -274,6 +327,34 @@ void CashFlow::refreshTransactionTable() { ui->transactionTable->setRowCount(0); + // Calculate starting balance from all transactions before the date range + QDate startDate = ui->dateFromEdit->date(); + QStringList selectedAccounts = ui->accountFilterCombo->getSelectedItems(); + bool showAllAccounts = ui->accountFilterCombo->allSelected(); + + // Get transactions before the visible range + QList<Transaction> priorTransactions = database->getTransactions(QDate(1900, 1, 1), startDate.addDays(-1)); + + // Filter by selected accounts if applicable + if (!showAllAccounts && !selectedAccounts.isEmpty()) { + QList<Transaction> filtered; + for (const Transaction &t : priorTransactions) { + if (selectedAccounts.contains(t.account)) { + filtered.append(t); + } + } + priorTransactions = filtered; + } + + // Calculate starting balance and per-account balances from prior transactions + double calculatedStartingBalance = 0.0; + QMap<QString, double> accountBalances; // Track per-account balances + + for (const Transaction &t : priorTransactions) { + calculatedStartingBalance += t.amount; + accountBalances[t.account] += t.amount; + } + // Determine if we're showing per-account columns bool showAccountColumns = ui->showAccountBalancesCheck->isChecked(); @@ -288,6 +369,13 @@ void CashFlow::refreshTransactionTable() { } accountList = uniqueAccounts.values(); std::sort(accountList.begin(), accountList.end()); + + // Ensure all accounts in accountList are initialized in accountBalances map + for (const QString &account : accountList) { + if (!accountBalances.contains(account)) { + accountBalances[account] = 0.0; + } + } } // Set up columns: Date | Amount | Balance | [Per-account columns] | Account | Category | Description | Type @@ -307,8 +395,7 @@ void CashFlow::refreshTransactionTable() { // Enable column reordering by dragging headers ui->transactionTable->horizontalHeader()->setSectionsMovable(true); - double runningBalance = startingBalance; - QMap<QString, double> accountBalances; // Track per-account balances + double runningBalance = calculatedStartingBalance; QDate currentPeriodEnd; QString periodLabel; int periodCount = 1; @@ -769,15 +856,252 @@ void CashFlow::refreshRecurringTable() { ui->recurringTable->resizeColumnsToContents(); } +void CashFlow::refreshCharts() { + if (!chart || !chartView) return; + + // Clear existing series and axes + chart->removeAllSeries(); + + // Remove all existing axes + for (QAbstractAxis *axis : chart->axes()) { + chart->removeAxis(axis); + delete axis; + } + + // Get chart-specific date range + QDate chartStartDate = ui->chartDateFromEdit->date(); + QDate chartEndDate = ui->chartDateToEdit->date(); + + // Get all transactions (not filtered by main date range) + QList<Transaction> allTransactions = database->getTransactions(chartStartDate, chartEndDate); + + // Apply account filter from main view + QStringList selectedAccounts = ui->accountFilterCombo->getSelectedItems(); + bool showAllAccounts = ui->accountFilterCombo->allSelected(); + + // Filter transactions by selected accounts + if (!showAllAccounts && !selectedAccounts.isEmpty()) { + QList<Transaction> filtered; + for (const Transaction &t : allTransactions) { + if (selectedAccounts.contains(t.account)) { + filtered.append(t); + } + } + allTransactions = filtered; + } + + // Sort by date + std::sort(allTransactions.begin(), allTransactions.end(), + [](const Transaction &a, const Transaction &b) { + if (a.date != b.date) return a.date < b.date; + if (a.sortOrder != b.sortOrder) return a.sortOrder < b.sortOrder; + return a.amount > b.amount; + }); + + if (allTransactions.isEmpty()) { + chart->setTitle("Balance Over Time (No Data)"); + return; + } + + // Get unique accounts + QSet<QString> accountSet; + for (const Transaction &t : allTransactions) { + if (!t.account.isEmpty()) { + accountSet.insert(t.account); + } + } + QStringList accounts = accountSet.values(); + std::sort(accounts.begin(), accounts.end()); + + // Filter to only selected accounts + if (!showAllAccounts && !selectedAccounts.isEmpty()) { + accounts = selectedAccounts; + } + + // Create a series for each account + QMap<QString, QLineSeries*> seriesMap; + QMap<QString, double> accountBalances; + + for (const QString &account : accounts) { + QLineSeries *series = new QLineSeries(); + series->setName(account); + seriesMap[account] = series; + accountBalances[account] = 0.0; + + // Add starting point + series->append(QDateTime(chartStartDate, QTime(0, 0)).toMSecsSinceEpoch(), 0.0); + } + + // Also create total balance series if showing multiple accounts + QLineSeries *totalSeries = nullptr; + double totalBalance = startingBalance; + if (showAllAccounts && accounts.size() > 1) { + totalSeries = new QLineSeries(); + totalSeries->setName("Total"); + totalSeries->append(QDateTime(chartStartDate, QTime(0, 0)).toMSecsSinceEpoch(), totalBalance); + } + + // Process transactions and update balances + for (const Transaction &t : allTransactions) { + if (t.type == TransactionType::Reconciliation) continue; + + // Update account balance + if (seriesMap.contains(t.account)) { + accountBalances[t.account] += t.amount; + seriesMap[t.account]->append(QDateTime(t.date, QTime(0, 0)).toMSecsSinceEpoch(), + accountBalances[t.account]); + } + + // Update total balance + if (totalSeries) { + totalBalance += t.amount; + totalSeries->append(QDateTime(t.date, QTime(0, 0)).toMSecsSinceEpoch(), totalBalance); + } + } + + // Add all series to chart + for (QLineSeries *series : seriesMap.values()) { + chart->addSeries(series); + } + if (totalSeries) { + chart->addSeries(totalSeries); + QPen pen = totalSeries->pen(); + pen.setWidth(3); + totalSeries->setPen(pen); + } + + // Create axes + QDateTimeAxis *axisX = new QDateTimeAxis; + axisX->setFormat("MMM dd"); + axisX->setTitleText("Date"); + axisX->setTickCount(10); + axisX->setRange(QDateTime(chartStartDate, QTime(0, 0)), QDateTime(chartEndDate, QTime(23, 59))); + chart->addAxis(axisX, Qt::AlignBottom); + + // Find min/max values across all series + double minBalance = 0.0; + double maxBalance = 0.0; + bool firstValue = true; + + for (QLineSeries *series : seriesMap.values()) { + for (const QPointF &point : series->points()) { + if (firstValue) { + minBalance = maxBalance = point.y(); + firstValue = false; + } else { + minBalance = qMin(minBalance, point.y()); + maxBalance = qMax(maxBalance, point.y()); + } + } + } + if (totalSeries) { + for (const QPointF &point : totalSeries->points()) { + if (firstValue) { + minBalance = maxBalance = point.y(); + firstValue = false; + } else { + minBalance = qMin(minBalance, point.y()); + maxBalance = qMax(maxBalance, point.y()); + } + } + } + + // Add 10% padding to the range + double range = maxBalance - minBalance; + double padding = range * 0.1; + if (range < 0.01) padding = 100; // Minimum padding if range is tiny + + QValueAxis *axisY = new QValueAxis; + axisY->setTitleText("Balance ($)"); + axisY->setLabelFormat("$%.2f"); + axisY->setRange(minBalance - padding, maxBalance + padding); + chart->addAxis(axisY, Qt::AlignLeft); + + // Attach axes to all series + for (QLineSeries *series : seriesMap.values()) { + series->attachAxis(axisX); + series->attachAxis(axisY); + + // Enable hover events for tooltips + series->setPointsVisible(false); + connect(series, &QLineSeries::hovered, this, [this, series](const QPointF &point, bool state) { + if (state) { + QDateTime dateTime = QDateTime::fromMSecsSinceEpoch(point.x()); + QString tooltip = QString("%1\n%2\n$%3") + .arg(series->name()) + .arg(dateTime.date().toString("MMM dd, yyyy")) + .arg(formatCurrency(point.y())); + chart->setToolTip(tooltip); + } + }); + } + if (totalSeries) { + totalSeries->attachAxis(axisX); + totalSeries->attachAxis(axisY); + + // Enable hover events for total series + totalSeries->setPointsVisible(false); + connect(totalSeries, &QLineSeries::hovered, this, [this, totalSeries](const QPointF &point, bool state) { + if (state) { + QDateTime dateTime = QDateTime::fromMSecsSinceEpoch(point.x()); + QString tooltip = QString("%1\n%2\n$%3") + .arg(totalSeries->name()) + .arg(dateTime.date().toString("MMM dd, yyyy")) + .arg(formatCurrency(point.y())); + chart->setToolTip(tooltip); + } + }); + } + + // Set chart title + QString title; + if (showAllAccounts) { + title = "Balance Over Time (All Accounts)"; + } else if (selectedAccounts.size() == 1) { + title = QString("Balance Over Time (%1)").arg(selectedAccounts.first()); + } else { + title = QString("Balance Over Time (%1 Accounts)").arg(selectedAccounts.size()); + } + chart->setTitle(title); + + chart->legend()->setVisible(true); + chart->legend()->setAlignment(Qt::AlignBottom); +} + void CashFlow::calculateAndDisplayBalance() { QList<Transaction> allTransactions = getAllTransactionsInRange(); - double endBalance = startingBalance; + // Calculate starting balance from all transactions before the date range + QDate startDate = ui->dateFromEdit->date(); + QStringList selectedAccounts = ui->accountFilterCombo->getSelectedItems(); + bool showAllAccounts = ui->accountFilterCombo->allSelected(); + + // Get transactions before the visible range + QList<Transaction> priorTransactions = database->getTransactions(QDate(1900, 1, 1), startDate.addDays(-1)); + + // Filter by selected accounts if applicable + if (!showAllAccounts && !selectedAccounts.isEmpty()) { + QList<Transaction> filtered; + for (const Transaction &t : priorTransactions) { + if (selectedAccounts.contains(t.account)) { + filtered.append(t); + } + } + priorTransactions = filtered; + } + + // Calculate starting balance from prior transactions + double calculatedStartingBalance = 0.0; + for (const Transaction &t : priorTransactions) { + calculatedStartingBalance += t.amount; + } + + double endBalance = calculatedStartingBalance; for (const Transaction &t : allTransactions) { endBalance += t.amount; } - ui->startBalanceLabel->setText(QString("Starting Balance: $%1").arg(formatCurrency(startingBalance))); + ui->startBalanceLabel->setText(QString("Starting Balance: $%1").arg(formatCurrency(calculatedStartingBalance))); ui->endBalanceLabel->setText(QString("Ending Balance: $%1").arg(formatCurrency(endBalance))); // Color code the ending balance @@ -793,23 +1117,18 @@ void CashFlow::calculateAndDisplayBalance() { QList<Transaction> CashFlow::getAllTransactionsInRange() { QDate startDate = ui->dateFromEdit->date(); QDate endDate = ui->dateToEdit->date(); - QString accountFilter = ui->accountFilterCombo->currentText(); + QStringList selectedAccounts = ui->accountFilterCombo->getSelectedItems(); int recurringFilter = ui->recurringFilterCombo->currentData().toInt(); QString searchText = ui->searchEdit->text().trimmed().toLower(); - // Safety check - if (accountFilter.isEmpty()) { - accountFilter = "All Accounts"; - } - // Get all transactions from database (includes both actual and projected) QList<Transaction> allTransactions = database->getTransactions(startDate, endDate); - // Filter by account if not "All Accounts" - if (accountFilter != "All Accounts") { + // Filter by selected accounts + if (!selectedAccounts.isEmpty() && !ui->accountFilterCombo->allSelected()) { QList<Transaction> filtered; for (const Transaction &t : allTransactions) { - if (t.account == accountFilter) { + if (selectedAccounts.contains(t.account)) { filtered.append(t); } } @@ -4,6 +4,11 @@ #include <QMainWindow> #include <QTableWidget> #include <QDate> +#include <QtCharts/QChartView> +#include <QtCharts/QLineSeries> +#include <QtCharts/QChart> +#include <QtCharts/QDateTimeAxis> +#include <QtCharts/QValueAxis> #include "database.h" #include "transaction.h" @@ -21,6 +26,7 @@ public: protected: void keyPressEvent(QKeyEvent *event) override; + bool eventFilter(QObject *watched, QEvent *event) override; private slots: void onDateRangeChanged(); @@ -64,11 +70,14 @@ private: int weekStartDay; QString currentFilePath; QMap<PeriodType, QSet<int>> collapsedPeriods; // Track which period end rows are collapsed per period type + QChartView *chartView; + QChart *chart; void setupConnections(); void refreshView(); void refreshTransactionTable(); void refreshRecurringTable(); + void refreshCharts(); void calculateAndDisplayBalance(); QList<Transaction> getAllTransactionsInRange(); void clearTransactionEntry(); diff --git a/cashflow.ui b/cashflow.ui index 7f35845..14d4562 100644 --- a/cashflow.ui +++ b/cashflow.ui @@ -90,12 +90,7 @@ </widget> </item> <item> - <widget class="QComboBox" name="accountFilterCombo"> - <item> - <property name="text"> - <string>All Accounts</string> - </property> - </item> + <widget class="MultiSelectComboBox" name="accountFilterCombo"> </widget> </item> <item> @@ -630,6 +625,81 @@ </item> </layout> </widget> + <widget class="QWidget" name="chartsTab"> + <attribute name="title"> + <string>Charts</string> + </attribute> + <layout class="QVBoxLayout" name="verticalLayout_charts"> + <item> + <layout class="QHBoxLayout" name="chartControlsLayout"> + <item> + <widget class="QLabel" name="chartTypeLabel"> + <property name="text"> + <string>Chart Type:</string> + </property> + </widget> + </item> + <item> + <widget class="QComboBox" name="chartTypeCombo"> + <item> + <property name="text"> + <string>Balance Trend</string> + </property> + </item> + </widget> + </item> + <item> + <widget class="QLabel" name="chartDateFromLabel"> + <property name="text"> + <string>From:</string> + </property> + </widget> + </item> + <item> + <widget class="QDateEdit" name="chartDateFromEdit"> + <property name="calendarPopup"> + <bool>true</bool> + </property> + </widget> + </item> + <item> + <widget class="QLabel" name="chartDateToLabel"> + <property name="text"> + <string>To:</string> + </property> + </widget> + </item> + <item> + <widget class="QDateEdit" name="chartDateToEdit"> + <property name="calendarPopup"> + <bool>true</bool> + </property> + </widget> + </item> + <item> + <spacer name="horizontalSpacer_chart"> + <property name="orientation"> + <enum>Qt::Horizontal</enum> + </property> + <property name="sizeHint" stdset="0"> + <size> + <width>40</width> + <height>20</height> + </size> + </property> + </spacer> + </item> + </layout> + </item> + <item> + <layout class="QVBoxLayout" name="chartViewLayout"> + <property name="objectName"> + <string notr="true">chartViewLayout</string> + </property> + </layout> + </item> + </layout> + </widget> </widget> </item> </layout> @@ -714,6 +784,13 @@ </property> </action> </widget> + <customwidgets> + <customwidget> + <class>MultiSelectComboBox</class> + <extends>QComboBox</extends> + <header>multiselectcombobox.h</header> + </customwidget> + </customwidgets> <resources/> <connections/> </ui> diff --git a/multiselectcombobox.cpp b/multiselectcombobox.cpp new file mode 100644 index 0000000..e1fe324 --- /dev/null +++ b/multiselectcombobox.cpp @@ -0,0 +1,175 @@ +#include "multiselectcombobox.h" +#include <QListView> +#include <QMouseEvent> +#include <QCheckBox> + +MultiSelectComboBox::MultiSelectComboBox(QWidget *parent) + : QComboBox(parent) + , suppressUpdate(false) +{ + model = new QStandardItemModel(this); + setModel(model); + + // Use list view for better checkbox display + QListView *listView = new QListView(this); + setView(listView); + + // Create a line edit to display the text (read-only) + displayLabel = new QLineEdit(this); + displayLabel->setReadOnly(true); + setLineEdit(displayLabel); + + // Install event filter to keep popup open on click + view()->viewport()->installEventFilter(this); + + connect(view(), &QAbstractItemView::pressed, this, &MultiSelectComboBox::onItemPressed); +} + +void MultiSelectComboBox::addItem(const QString &text) +{ + // First item is "Select All" + if (model->rowCount() == 0) { + QStandardItem *selectAllItem = new QStandardItem("Select All"); + selectAllItem->setFlags(Qt::ItemIsUserCheckable | Qt::ItemIsEnabled); + selectAllItem->setData(Qt::Checked, Qt::CheckStateRole); + selectAllItem->setData(true, Qt::UserRole); // Mark as "Select All" item + model->appendRow(selectAllItem); + } + + QStandardItem *item = new QStandardItem(text); + item->setFlags(Qt::ItemIsUserCheckable | Qt::ItemIsEnabled); + item->setData(Qt::Checked, Qt::CheckStateRole); + item->setData(false, Qt::UserRole); // Regular item + model->appendRow(item); +} + +void MultiSelectComboBox::addItems(const QStringList &texts) +{ + for (const QString &text : texts) { + addItem(text); + } + updateText(); +} + +QStringList MultiSelectComboBox::getSelectedItems() const +{ + QStringList selected; + for (int i = 1; i < model->rowCount(); ++i) { // Skip "Select All" at index 0 + QStandardItem *item = model->item(i); + if (item && item->checkState() == Qt::Checked) { + selected.append(item->text()); + } + } + return selected; +} + +void MultiSelectComboBox::setSelectedItems(const QStringList &items) +{ + suppressUpdate = true; + + for (int i = 1; i < model->rowCount(); ++i) { // Skip "Select All" at index 0 + QStandardItem *item = model->item(i); + if (item) { + bool shouldCheck = items.contains(item->text()); + item->setCheckState(shouldCheck ? Qt::Checked : Qt::Unchecked); + } + } + + // Update "Select All" checkbox + if (model->rowCount() > 1) { + QStandardItem *selectAllItem = model->item(0); + if (selectAllItem) { + selectAllItem->setCheckState(allSelected() ? Qt::Checked : Qt::Unchecked); + } + } + + suppressUpdate = false; + updateText(); +} + +bool MultiSelectComboBox::allSelected() const +{ + for (int i = 1; i < model->rowCount(); ++i) { // Skip "Select All" at index 0 + QStandardItem *item = model->item(i); + if (item && item->checkState() == Qt::Unchecked) { + return false; + } + } + return model->rowCount() > 1; +} + +void MultiSelectComboBox::clear() +{ + model->clear(); + QComboBox::clear(); +} + +void MultiSelectComboBox::hidePopup() +{ + // Don't hide on item click - only when clicking outside + QComboBox::hidePopup(); + updateText(); + emit selectionChanged(); +} + +bool MultiSelectComboBox::eventFilter(QObject *watched, QEvent *event) +{ + if (watched == view()->viewport()) { + if (event->type() == QEvent::MouseButtonRelease) { + return true; // Prevent popup from closing + } + } + return QComboBox::eventFilter(watched, event); +} + +void MultiSelectComboBox::onItemPressed(const QModelIndex &index) +{ + QStandardItem *item = model->itemFromIndex(index); + if (item) { + // Check if this is the "Select All" item + bool isSelectAll = item->data(Qt::UserRole).toBool(); + + if (isSelectAll) { + // Toggle all items + Qt::CheckState newState = (item->checkState() == Qt::Checked) ? Qt::Unchecked : Qt::Checked; + suppressUpdate = true; + for (int i = 0; i < model->rowCount(); ++i) { + QStandardItem *childItem = model->item(i); + if (childItem) { + childItem->setCheckState(newState); + } + } + suppressUpdate = false; + } else { + // Toggle individual item + Qt::CheckState newState = (item->checkState() == Qt::Checked) ? Qt::Unchecked : Qt::Checked; + item->setCheckState(newState); + + // Update "Select All" state + if (model->rowCount() > 1) { + QStandardItem *selectAllItem = model->item(0); + if (selectAllItem) { + selectAllItem->setCheckState(allSelected() ? Qt::Checked : Qt::Unchecked); + } + } + } + updateText(); + } +} + +void MultiSelectComboBox::updateText() +{ + if (suppressUpdate) return; + + QStringList selected = getSelectedItems(); + + if (selected.isEmpty()) { + displayLabel->setText("No Accounts"); + } else if (allSelected()) { + displayLabel->setText("All Accounts"); + } else if (selected.size() == 1) { + displayLabel->setText(selected.first()); + } else { + displayLabel->setText(QString("%1 Accounts Selected").arg(selected.size())); + } +} diff --git a/multiselectcombobox.h b/multiselectcombobox.h new file mode 100644 index 0000000..2478fae --- /dev/null +++ b/multiselectcombobox.h @@ -0,0 +1,41 @@ +#ifndef MULTISELECTCOMBOBOX_H +#define MULTISELECTCOMBOBOX_H + +#include <QComboBox> +#include <QStandardItemModel> +#include <QStringList> +#include <QEvent> +#include <QLineEdit> + +class MultiSelectComboBox : public QComboBox +{ + Q_OBJECT + +public: + explicit MultiSelectComboBox(QWidget *parent = nullptr); + + void addItem(const QString &text); + void addItems(const QStringList &texts); + QStringList getSelectedItems() const; + void setSelectedItems(const QStringList &items); + bool allSelected() const; + void clear(); + +signals: + void selectionChanged(); + +protected: + void hidePopup() override; + bool eventFilter(QObject *watched, QEvent *event) override; + +private slots: + void onItemPressed(const QModelIndex &index); + void updateText(); + +private: + QStandardItemModel *model; + bool suppressUpdate; + QLineEdit *displayLabel; +}; + +#endif // MULTISELECTCOMBOBOX_H |
