feat: single-click inline editing for category, tag, and person columns

Make all three columns editable via single-click dropdowns with auto-popup.
Add bulk rule application to existing uncategorized transactions after
creating a new rule. Fix column widths for better readability. Add explicit
font-size to QSS child-widget selectors to fix QFont point size warning.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 16:36:14 -05:00
parent dfe62f442a
commit 9214a83ec2
2 changed files with 169 additions and 16 deletions
+3
View File
@@ -126,6 +126,7 @@ QHeaderView::section {
border: none;
border-bottom: 2px solid #00ff41;
border-right: 1px solid #1a2332;
font-size: 13px;
font-weight: bold;
}
@@ -164,6 +165,7 @@ QComboBox QAbstractItemView {
background-color: #0d1117;
color: #b8c4d4;
border: 1px solid #00ff41;
font-size: 13px;
selection-background-color: rgba(0,255,65,0.15);
selection-color: #e8edf5;
}
@@ -231,6 +233,7 @@ QCalendarWidget QToolButton:hover {
QCalendarWidget QAbstractItemView {
background-color: #0d1117;
color: #b8c4d4;
font-size: 13px;
selection-background-color: rgba(0,255,65,0.15);
selection-color: #e8edf5;
}
+166 -16
View File
@@ -10,6 +10,8 @@ from PySide6.QtCore import (
QModelIndex,
QSortFilterProxyModel,
Qt,
QTimer,
Signal,
)
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
@@ -51,7 +53,9 @@ class TransactionTableModel(QAbstractTableModel):
self._session = session
self._transactions: list[Transaction] = []
self._categories: list[Category] = []
self._members: list[HouseholdMember] = []
self._refresh_categories()
self._refresh_members()
# -- public helpers -----------------------------------------------------
@@ -60,6 +64,7 @@ class TransactionTableModel(QAbstractTableModel):
self.beginResetModel()
self._transactions = self._query(filters or {})
self._refresh_categories()
self._refresh_members()
self.endResetModel()
@property
@@ -98,29 +103,44 @@ class TransactionTableModel(QAbstractTableModel):
amount = float(txn.amount) if txn.amount is not None else 0.0
return QColor(EXPENSE) if amount < 0 else QColor(INCOME)
# Store the category id for the delegate
if role == Qt.ItemDataRole.UserRole and col == 4:
return txn.category_id
# Store editable values for delegates
if role == Qt.ItemDataRole.UserRole:
if col == 4:
return txn.category_id
if col == 5:
return txn.tag
if col == 6:
return txn.attributed_to_id
return None
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
base = super().flags(index)
if index.column() == 4: # Category column is editable
if index.column() in (4, 5, 6): # Category, Tag, Person are editable
return base | Qt.ItemFlag.ItemIsEditable
return base
def setData(self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole) -> bool: # noqa: N802
if role != Qt.ItemDataRole.EditRole or index.column() != 4:
if role != Qt.ItemDataRole.EditRole or index.column() not in (4, 5, 6):
return False
txn = self._transactions[index.row()]
new_category_id: int | None = value
col = index.column()
if new_category_id == txn.category_id:
return False
if col == 4:
if value == txn.category_id:
return False
txn.category_id = value
elif col == 5:
new_tag = value or None
if new_tag == txn.tag:
return False
txn.tag = new_tag
elif col == 6:
if value == txn.attributed_to_id:
return False
txn.attributed_to_id = value
txn.category_id = new_category_id
self._session.commit()
self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole])
return True
@@ -130,6 +150,13 @@ class TransactionTableModel(QAbstractTableModel):
def _refresh_categories(self) -> None:
self._categories = list(self._session.query(Category).order_by(Category.name).all())
def _refresh_members(self) -> None:
self._members = list(self._session.query(HouseholdMember).order_by(HouseholdMember.name).all())
@property
def members(self) -> list[HouseholdMember]:
return list(self._members)
def _display_value(self, txn: Transaction, col: int) -> str:
if col == 0:
return str(txn.date)
@@ -186,6 +213,8 @@ class TransactionTableModel(QAbstractTableModel):
class CategoryDelegate(QStyledItemDelegate):
"""Provides a QComboBox editor for the Category column."""
rules_applied = Signal()
def __init__(self, model: TransactionTableModel, session: Session, parent: QWidget | None = None):
super().__init__(parent)
self._table_model = model
@@ -196,6 +225,7 @@ class CategoryDelegate(QStyledItemDelegate):
combo.addItem("", None)
for cat in self._table_model.categories:
combo.addItem(cat.name, cat.id)
QTimer.singleShot(0, combo.showPopup)
return combo
def setEditorData(self, editor: QComboBox, index: QModelIndex) -> None: # noqa: N802
@@ -227,7 +257,6 @@ class CategoryDelegate(QStyledItemDelegate):
source_model.setData(source_index, new_cat_id)
# Ask whether to create a categorization rule
cat_name = editor.currentText() or "Uncategorized"
pattern = _extract_merchant_pattern(txn.description)
answer = QMessageBox.question(
editor.parent(),
@@ -245,6 +274,109 @@ class CategoryDelegate(QStyledItemDelegate):
self._session.add(rule)
self._session.commit()
# Offer to apply the new rule to existing uncategorized transactions
self._apply_rule_to_existing(rule, pattern, txn.id)
def _apply_rule_to_existing(self, rule: CategorizationRule, pattern: str, exclude_id: int) -> None:
"""Offer to apply a newly created rule to existing uncategorized transactions."""
uncategorized = (
self._session.query(Transaction)
.filter(Transaction.category_id.is_(None))
.filter(Transaction.id != exclude_id)
.all()
)
matching = [
t for t in uncategorized
if re.search(re.escape(pattern.upper()), (t.description or "").upper())
]
if not matching:
return
count = len(matching)
answer = QMessageBox.question(
self.parent(),
"Apply Rule",
f'Found {count} other uncategorized transaction{"s" if count != 1 else ""} '
f'matching "{pattern}".\n\nApply this category to all of them?',
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if answer == QMessageBox.StandardButton.Yes:
tag = rule.tag_override
if tag is None and rule.category:
tag = rule.category.default_tag
for t in matching:
t.category_id = rule.category_id
t.tag = tag
t.attributed_to_id = rule.attributed_to_id
self._session.commit()
self.rules_applied.emit()
class TagDelegate(QStyledItemDelegate):
"""Provides a QComboBox editor for the Tag column."""
TAG_OPTIONS = [("(none)", None), ("needs", "needs"), ("wants", "wants"), ("savings", "savings")]
def createEditor(self, parent: QWidget, option, index: QModelIndex) -> QWidget: # noqa: N802
combo = QComboBox(parent)
for label, value in self.TAG_OPTIONS:
combo.addItem(label, value)
QTimer.singleShot(0, combo.showPopup)
return combo
def setEditorData(self, editor: QComboBox, index: QModelIndex) -> None: # noqa: N802
current_tag = index.data(Qt.ItemDataRole.UserRole)
if current_tag:
idx = editor.findData(current_tag)
if idx >= 0:
editor.setCurrentIndex(idx)
def setModelData(self, editor: QComboBox, model, index: QModelIndex) -> None: # noqa: N802
new_tag = editor.currentData()
source_index = index
if isinstance(model, QSortFilterProxyModel):
source_index = model.mapToSource(index)
source_model = model.sourceModel()
else:
source_model = model
source_model.setData(source_index, new_tag)
class PersonDelegate(QStyledItemDelegate):
"""Provides a QComboBox editor for the Person column."""
def __init__(self, model: TransactionTableModel, parent: QWidget | None = None):
super().__init__(parent)
self._table_model = model
def createEditor(self, parent: QWidget, option, index: QModelIndex) -> QWidget: # noqa: N802
combo = QComboBox(parent)
combo.addItem("(none)", None)
for member in self._table_model.members:
combo.addItem(member.name, member.id)
QTimer.singleShot(0, combo.showPopup)
return combo
def setEditorData(self, editor: QComboBox, index: QModelIndex) -> None: # noqa: N802
person_id = index.data(Qt.ItemDataRole.UserRole)
if person_id is not None:
idx = editor.findData(person_id)
if idx >= 0:
editor.setCurrentIndex(idx)
def setModelData(self, editor: QComboBox, model, index: QModelIndex) -> None: # noqa: N802
new_person_id = editor.currentData()
source_index = index
if isinstance(model, QSortFilterProxyModel):
source_index = model.mapToSource(index)
source_model = model.sourceModel()
else:
source_model = model
source_model.setData(source_index, new_person_id)
# ---------------------------------------------------------------------------
# Transactions view widget
@@ -323,25 +455,32 @@ class TransactionsView(QWidget):
self._table.setAlternatingRowColors(True)
self._table.verticalHeader().setVisible(False)
# Column sizing: Date=Fixed, Description=Stretch, Amount=Fixed,
# Account/Category=ResizeToContents, Tag=Fixed, Person=ResizeToContents
# Column sizing
header = self._table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) # Date
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) # Description
header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) # Amount
header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) # Account
header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) # Category
header.setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed) # Category
header.setSectionResizeMode(5, QHeaderView.ResizeMode.Fixed) # Tag
header.setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents) # Person
header.setSectionResizeMode(6, QHeaderView.ResizeMode.Fixed) # Person
self._table.setColumnWidth(0, 100) # Date
self._table.setColumnWidth(2, 110) # Amount
self._table.setColumnWidth(5, 80) # Tag
self._table.setColumnWidth(4, 150) # Category
self._table.setColumnWidth(5, 100) # Tag
self._table.setColumnWidth(6, 130) # Person
header.setMinimumSectionSize(80)
# Delegate for inline category editing
# Delegates for inline editing of Category, Tag, Person
self._cat_delegate = CategoryDelegate(self._table_model, session, self._table)
self._table.setItemDelegateForColumn(4, self._cat_delegate)
self._tag_delegate = TagDelegate(self._table)
self._table.setItemDelegateForColumn(5, self._tag_delegate)
self._person_delegate = PersonDelegate(self._table_model, self._table)
self._table.setItemDelegateForColumn(6, self._person_delegate)
root_layout.addWidget(self._table)
# -- signals --------------------------------------------------------
@@ -353,6 +492,12 @@ class TransactionsView(QWidget):
self._uncategorized_cb.stateChanged.connect(self._apply_filters)
self._search_edit.textChanged.connect(self._apply_filters)
# Single-click editing for Category, Tag, Person columns
self._table.clicked.connect(self._on_cell_clicked)
# Refresh table when rules are bulk-applied to uncategorized transactions
self._cat_delegate.rules_applied.connect(self._apply_filters)
# Initial load
self._apply_filters()
@@ -365,6 +510,11 @@ class TransactionsView(QWidget):
# -- internals ----------------------------------------------------------
def _on_cell_clicked(self, index: QModelIndex) -> None:
"""Open editor on single click for Category, Tag, Person columns."""
if index.column() in (4, 5, 6):
self._table.edit(index)
def _build_filters(self) -> dict[str, Any]:
filters: dict[str, Any] = {}
filters["date_from"] = self._date_from.date().toPython()