-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat(qt): add shift-click range selection to coin control dialog #7260
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
10a9774
099bf3b
7086905
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,12 +5,21 @@ | |||||||||||||||||||||||||||
| #include <qt/coincontroltreewidget.h> | ||||||||||||||||||||||||||||
| #include <qt/coincontroldialog.h> | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| #include <QStyle> | ||||||||||||||||||||||||||||
| #include <QStyleOptionViewItem> | ||||||||||||||||||||||||||||
| #include <QTreeWidgetItemIterator> | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| CoinControlTreeWidget::CoinControlTreeWidget(QWidget *parent) : | ||||||||||||||||||||||||||||
| QTreeWidget(parent) | ||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| void CoinControlTreeWidget::resetAnchor() | ||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||
| m_lastClickedItem = nullptr; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| void CoinControlTreeWidget::keyPressEvent(QKeyEvent *event) | ||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||
| if (event->key() == Qt::Key_Space) // press spacebar -> select checkbox | ||||||||||||||||||||||||||||
|
|
@@ -32,3 +41,109 @@ void CoinControlTreeWidget::keyPressEvent(QKeyEvent *event) | |||||||||||||||||||||||||||
| this->QTreeWidget::keyPressEvent(event); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // Helper: check if an item is a leaf node (UTXO) by its 64-char tx hash | ||||||||||||||||||||||||||||
| static bool isLeafItem(QTreeWidgetItem* item) | ||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||
| int COLUMN_ADDRESS = 3; | ||||||||||||||||||||||||||||
| return item && item->data(COLUMN_ADDRESS, Qt::UserRole).toString().length() == 64; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| bool CoinControlTreeWidget::isCheckboxClick(QTreeWidgetItem* item, const QPoint& pos) const | ||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||
| int COLUMN_CHECKBOX = 0; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if (!item || columnAt(pos.x()) != COLUMN_CHECKBOX) { | ||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const QModelIndex index = indexFromItem(item, COLUMN_CHECKBOX); | ||||||||||||||||||||||||||||
| if (!index.isValid()) { | ||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| QStyleOptionViewItem option; | ||||||||||||||||||||||||||||
| option.initFrom(this); | ||||||||||||||||||||||||||||
| option.rect = visualRect(index); | ||||||||||||||||||||||||||||
| option.features = QStyleOptionViewItem::HasCheckIndicator; | ||||||||||||||||||||||||||||
| option.checkState = item->checkState(COLUMN_CHECKBOX); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| return style()->subElementRect(QStyle::SE_ItemViewItemCheckIndicator, &option, this).contains(pos); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| void CoinControlTreeWidget::mouseReleaseEvent(QMouseEvent *event) | ||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||
| int COLUMN_CHECKBOX = 0; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| QTreeWidgetItem* clickedItem = itemAt(event->pos()); | ||||||||||||||||||||||||||||
| const bool isCheckboxInteraction = isCheckboxClick(clickedItem, event->pos()); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| bool isShiftClick = (event->button() == Qt::LeftButton) | ||||||||||||||||||||||||||||
| && (event->modifiers() & Qt::ShiftModifier) | ||||||||||||||||||||||||||||
| && m_lastClickedItem | ||||||||||||||||||||||||||||
| && clickedItem | ||||||||||||||||||||||||||||
| && clickedItem != m_lastClickedItem | ||||||||||||||||||||||||||||
| && isCheckboxInteraction | ||||||||||||||||||||||||||||
| && isLeafItem(clickedItem) | ||||||||||||||||||||||||||||
| && !clickedItem->isDisabled() | ||||||||||||||||||||||||||||
| && !m_lastClickedItem->isDisabled(); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if (!isShiftClick) { | ||||||||||||||||||||||||||||
| // Normal click — let Qt handle the checkbox toggle on release | ||||||||||||||||||||||||||||
| const Qt::CheckState previousState = clickedItem ? clickedItem->checkState(COLUMN_CHECKBOX) : Qt::Unchecked; | ||||||||||||||||||||||||||||
| QTreeWidget::mouseReleaseEvent(event); | ||||||||||||||||||||||||||||
| // Record anchor after toggle so we capture the post-toggle state | ||||||||||||||||||||||||||||
| if (event->button() == Qt::LeftButton && clickedItem | ||||||||||||||||||||||||||||
| && isCheckboxInteraction | ||||||||||||||||||||||||||||
| && isLeafItem(clickedItem) | ||||||||||||||||||||||||||||
| && !clickedItem->isDisabled() | ||||||||||||||||||||||||||||
| && clickedItem->checkState(COLUMN_CHECKBOX) != previousState) { | ||||||||||||||||||||||||||||
| m_lastClickedItem = clickedItem; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
|
Comment on lines
+78
to
+105
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: Range selection and anchor tracking fire from any column click, not just checkbox interactions Both the shift-range path (line 63) and the anchor-update path (line 71-74) trigger on any left-click on a leaf row, regardless of which column was clicked. In the existing dialog, clicking a leaf's text columns (amount, address, date, etc.) only changes focus/selection — the checkbox state is only toggled when clicking column 0's checkbox indicator. After this PR:
Neither click was on a checkbox, but coins are now selected/deselected. This is a behavioral regression from Qt's normal checkbox handling where state changes are tied to actual checkbox activation. The fix should gate both anchor updates and range application on clicks in the checkbox column. At minimum, check source: ['codex'] 🤖 Fix this with AI agents |
||||||||||||||||||||||||||||
| // Shift+click: select/deselect the range between anchor and target | ||||||||||||||||||||||||||||
| // Read the anchor's current check state live (not cached) so it stays | ||||||||||||||||||||||||||||
| // correct after bulk operations like Select All or parent tristate changes | ||||||||||||||||||||||||||||
| Qt::CheckState stateToApply = m_lastClickedItem->checkState(COLUMN_CHECKBOX); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // Collect visible leaf items in display order, skipping children of | ||||||||||||||||||||||||||||
| // collapsed parent nodes so we don't toggle coins the user can't see | ||||||||||||||||||||||||||||
| std::vector<QTreeWidgetItem*> leafItems; | ||||||||||||||||||||||||||||
| int anchorIdx = -1; | ||||||||||||||||||||||||||||
| int targetIdx = -1; | ||||||||||||||||||||||||||||
| QTreeWidgetItemIterator it(this); | ||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The range-building logic starts from Useful? React with 👍 / 👎. |
||||||||||||||||||||||||||||
| while (*it) { | ||||||||||||||||||||||||||||
| if (isLeafItem(*it)) { | ||||||||||||||||||||||||||||
| QTreeWidgetItem* parent = (*it)->parent(); | ||||||||||||||||||||||||||||
| if (!parent || parent->isExpanded()) { | ||||||||||||||||||||||||||||
| if (*it == m_lastClickedItem) anchorIdx = leafItems.size(); | ||||||||||||||||||||||||||||
| if (*it == clickedItem) targetIdx = leafItems.size(); | ||||||||||||||||||||||||||||
| leafItems.push_back(*it); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| ++it; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if (anchorIdx < 0 || targetIdx < 0) { | ||||||||||||||||||||||||||||
| QTreeWidget::mouseReleaseEvent(event); | ||||||||||||||||||||||||||||
|
Comment on lines
+129
to
+130
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If a user checks a leaf, collapses its parent, and then Shift-clicks another visible checkbox, the saved anchor is omitted from Useful? React with 👍 / 👎. |
||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||
|
Comment on lines
+129
to
+131
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Blocking: Replace an anchor that is no longer visible When the anchor's parent is collapsed, the anchor is deliberately omitted from
Suggested change
source: ['codex'] |
||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if (anchorIdx > targetIdx) std::swap(anchorIdx, targetIdx); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // Batch update: disable widget to suppress per-item updateLabels calls | ||||||||||||||||||||||||||||
| setEnabled(false); | ||||||||||||||||||||||||||||
| for (int i = anchorIdx; i <= targetIdx; ++i) { | ||||||||||||||||||||||||||||
| QTreeWidgetItem* item = leafItems[i]; | ||||||||||||||||||||||||||||
| if (!item->isDisabled() && item->checkState(COLUMN_CHECKBOX) != stateToApply) { | ||||||||||||||||||||||||||||
| item->setCheckState(COLUMN_CHECKBOX, stateToApply); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| setEnabled(true); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // Single label update for the whole batch | ||||||||||||||||||||||||||||
| CoinControlDialog* coinControlDialog = qobject_cast<CoinControlDialog*>(this->parentWidget()); | ||||||||||||||||||||||||||||
| if (coinControlDialog) coinControlDialog->refreshLabels(); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
Comment on lines
+74
to
+149
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: No automated coverage for new shift-click selection logic No Qt test covers the new mouse-release handler. Deterministic cases include recording an anchor only after a real checkbox toggle, applying ranges in list and tree order, skipping disabled coins, excluding collapsed groups, recovering from a hidden anchor, and resetting the anchor when source: ['codex'] |
||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The non-shift path records
m_lastClickedItemfor any left-clicked leaf row, even when that click did not change the checkbox state. In the coin control tree, users can left-click rows for navigation/context without toggling selection, which silently moves the shift-range anchor; a later shift-click then applies an unexpected state across a range of UTXOs. Restrict anchor updates to actual checkbox toggles (or checkbox-indicator clicks) so range selection follows intentional selection actions.Useful? React with 👍 / 👎.