Skip to content

Commit 904abda

Browse files
committed
feat: Preserve scroll position after edits in both aggregate and detail views
Add comprehensive scroll position preservation across all edit operations. Previously only cursor position was preserved, causing the view to jump around after edits. Implementation: - Add _save_table_position() helper - saves cursor_row and scroll_y - Add _restore_table_position() helper - restores both after refresh - Update ALL edit methods to use helpers: * Aggregate single group merchant/category edit * Aggregate multi-select merchant/category edit * Detail single transaction merchant/category edit * Detail multi-select merchant/category edit Behavior: - Both cursor position AND scroll position preserved after every edit - View stays exactly where you were - Smooth, non-jarring UX - Works in all contexts (aggregate, detail, multi-select) This fixes the annoying jump-to-bottom behavior in detail view and maintains scroll state in aggregate views. All 744 tests passing.
1 parent 3df24f3 commit 904abda

File tree

1 file changed

+75
-38
lines changed

1 file changed

+75
-38
lines changed

moneyflow/app.py

Lines changed: 75 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,43 @@ def update_loading_progress(self, current: int, total: int, message: str) -> Non
753753
"""Update loading progress message."""
754754
self.status_message = f"{message} ({current}/{total})"
755755

756+
def _save_table_position(self) -> dict:
757+
"""
758+
Save current table cursor and scroll position.
759+
760+
Returns:
761+
Dict with cursor_row and scroll_y
762+
"""
763+
try:
764+
table = self.query_one("#data-table", DataTable)
765+
return {
766+
"cursor_row": table.cursor_row,
767+
"scroll_y": table.scroll_y,
768+
}
769+
except Exception:
770+
return {"cursor_row": 0, "scroll_y": 0}
771+
772+
def _restore_table_position(self, saved_position: dict) -> None:
773+
"""
774+
Restore table cursor and scroll position after refresh.
775+
776+
Args:
777+
saved_position: Dict from _save_table_position()
778+
"""
779+
try:
780+
table = self.query_one("#data-table", DataTable)
781+
cursor_row = saved_position.get("cursor_row", 0)
782+
scroll_y = saved_position.get("scroll_y", 0)
783+
784+
# Restore cursor (bounded by current row count)
785+
if cursor_row < table.row_count:
786+
table.move_cursor(row=cursor_row)
787+
788+
# Restore scroll position
789+
table.scroll_y = scroll_y
790+
except Exception:
791+
pass # Table might not be ready yet
792+
756793
def refresh_view(self, force_rebuild: bool = True) -> None:
757794
"""
758795
Refresh the current view based on state.
@@ -1032,9 +1069,8 @@ async def _bulk_edit_merchant_from_aggregate(self) -> None:
10321069
)
10331070

10341071
if new_merchant:
1035-
# Save cursor position before refresh
1036-
table = self.query_one("#data-table", DataTable)
1037-
saved_cursor_row = table.cursor_row
1072+
# Save cursor and scroll position before refresh
1073+
saved_position = self._save_table_position()
10381074

10391075
# Get all transactions for this merchant
10401076
filtered_df = self.state.get_filtered_df()
@@ -1048,10 +1084,8 @@ async def _bulk_edit_merchant_from_aggregate(self) -> None:
10481084
self._notify(NotificationHelper.edit_queued(count))
10491085
self.refresh_view()
10501086

1051-
# Try to restore cursor position
1052-
table = self.query_one("#data-table", DataTable)
1053-
if saved_cursor_row < table.row_count:
1054-
table.move_cursor(row=saved_cursor_row)
1087+
# Restore cursor and scroll position
1088+
self._restore_table_position(saved_position)
10551089
else:
10561090
self.notify("Edit merchant only works from Merchant view", timeout=2)
10571091

@@ -1094,9 +1128,8 @@ async def _bulk_edit_merchant_from_selected_groups(self) -> None:
10941128
)
10951129

10961130
if new_merchant:
1097-
# Save cursor position before refresh
1098-
table = self.query_one("#data-table", DataTable)
1099-
saved_cursor_row = table.cursor_row
1131+
# Save cursor and scroll position before refresh
1132+
saved_position = self._save_table_position()
11001133

11011134
# Queue edits for all transactions
11021135
count = self.controller.queue_merchant_edits(all_txns, "multiple", new_merchant)
@@ -1105,10 +1138,8 @@ async def _bulk_edit_merchant_from_selected_groups(self) -> None:
11051138
self._notify(NotificationHelper.edit_queued(count))
11061139
self.refresh_view()
11071140

1108-
# Try to restore cursor position
1109-
table = self.query_one("#data-table", DataTable)
1110-
if saved_cursor_row < table.row_count:
1111-
table.move_cursor(row=saved_cursor_row)
1141+
# Restore cursor and scroll position
1142+
self._restore_table_position(saved_position)
11121143

11131144
async def _edit_merchant_detail(self) -> None:
11141145
"""Edit merchant in detail view."""
@@ -1135,6 +1166,9 @@ async def _edit_merchant_detail(self) -> None:
11351166
)
11361167

11371168
if new_merchant:
1169+
# Save cursor and scroll position before refresh
1170+
saved_position = self._save_table_position()
1171+
11381172
# Use controller helper to queue edits for all selected transactions
11391173
selected_txns = self.state.current_data.filter(
11401174
pl.col("id").is_in(list(self.state.selected_ids))
@@ -1147,6 +1181,9 @@ async def _edit_merchant_detail(self) -> None:
11471181
self.notify(f"Queued {count} edits. Press w to review and commit.", timeout=3)
11481182
# Refresh to update the * markers but stay in current view
11491183
self.refresh_view()
1184+
1185+
# Restore cursor and scroll position
1186+
self._restore_table_position(saved_position)
11501187
else:
11511188
# Edit single transaction - pass details for context
11521189
txn_details = {
@@ -1161,8 +1198,8 @@ async def _edit_merchant_detail(self) -> None:
11611198
)
11621199

11631200
if new_merchant:
1164-
# Save cursor position before refresh
1165-
saved_cursor_row = table.cursor_row
1201+
# Save cursor and scroll position before refresh
1202+
saved_position = self._save_table_position()
11661203

11671204
# Use controller helper for consistency
11681205
txn_id = row_data["id"]
@@ -1172,9 +1209,9 @@ async def _edit_merchant_detail(self) -> None:
11721209
self._notify(NotificationHelper.merchant_changed())
11731210
# Refresh to show * marker, stays in detail view since view_mode unchanged
11741211
self.refresh_view()
1175-
# Restore cursor position
1176-
if saved_cursor_row < table.row_count:
1177-
table.move_cursor(row=saved_cursor_row)
1212+
1213+
# Restore cursor and scroll position
1214+
self._restore_table_position(saved_position)
11781215

11791216
def action_edit_category(self) -> None:
11801217
"""Change category for current selection (works in aggregate and detail views)."""
@@ -1253,9 +1290,8 @@ async def _bulk_edit_category_from_aggregate(self) -> None:
12531290
if not new_category_id or (current_category_id and new_category_id == current_category_id):
12541291
return
12551292

1256-
# Save cursor position before refresh
1257-
table = self.query_one("#data-table", DataTable)
1258-
saved_cursor_row = table.cursor_row
1293+
# Save cursor and scroll position before refresh
1294+
saved_position = self._save_table_position()
12591295

12601296
# Get all transactions for this merchant/category/group
12611297
filtered_df = self.state.get_filtered_df()
@@ -1272,10 +1308,8 @@ async def _bulk_edit_category_from_aggregate(self) -> None:
12721308
)
12731309
self.refresh_view()
12741310

1275-
# Try to restore cursor position
1276-
table = self.query_one("#data-table", DataTable)
1277-
if saved_cursor_row < table.row_count:
1278-
table.move_cursor(row=saved_cursor_row)
1311+
# Restore cursor and scroll position
1312+
self._restore_table_position(saved_position)
12791313

12801314
async def _bulk_edit_category_from_selected_groups(self) -> None:
12811315
"""Edit category for all transactions in all selected groups."""
@@ -1315,9 +1349,8 @@ async def _bulk_edit_category_from_selected_groups(self) -> None:
13151349
if not new_category_id:
13161350
return
13171351

1318-
# Save cursor position before refresh
1319-
table = self.query_one("#data-table", DataTable)
1320-
saved_cursor_row = table.cursor_row
1352+
# Save cursor and scroll position before refresh
1353+
saved_position = self._save_table_position()
13211354

13221355
# Queue edits for all transactions
13231356
count = self.controller.queue_category_edits(all_txns, new_category_id)
@@ -1330,10 +1363,8 @@ async def _bulk_edit_category_from_selected_groups(self) -> None:
13301363
)
13311364
self.refresh_view()
13321365

1333-
# Try to restore cursor position
1334-
table = self.query_one("#data-table", DataTable)
1335-
if saved_cursor_row < table.row_count:
1336-
table.move_cursor(row=saved_cursor_row)
1366+
# Restore cursor and scroll position
1367+
self._restore_table_position(saved_position)
13371368

13381369
async def _edit_category(self) -> None:
13391370
"""Show category selection and apply (for detail view)."""
@@ -1362,6 +1393,9 @@ async def _edit_category(self) -> None:
13621393
)
13631394

13641395
if new_category_id:
1396+
# Save cursor and scroll position before refresh
1397+
saved_position = self._save_table_position()
1398+
13651399
# Use controller helper to queue edits for all selected transactions
13661400
selected_txns = self.state.current_data.filter(
13671401
pl.col("id").is_in(list(self.state.selected_ids))
@@ -1374,6 +1408,9 @@ async def _edit_category(self) -> None:
13741408
timeout=3,
13751409
)
13761410
self.refresh_view()
1411+
1412+
# Restore cursor and scroll position
1413+
self._restore_table_position(saved_position)
13771414
else:
13781415
# Single transaction edit_category
13791416
# Pass transaction details for context
@@ -1392,8 +1429,8 @@ async def _edit_category(self) -> None:
13921429
)
13931430

13941431
if new_category_id:
1395-
# Save cursor position before refresh
1396-
saved_cursor_row = table.cursor_row
1432+
# Save cursor and scroll position before refresh
1433+
saved_position = self._save_table_position()
13971434

13981435
# Use controller helper to queue edit for single transaction
13991436
txn_id = row_data["id"]
@@ -1403,9 +1440,9 @@ async def _edit_category(self) -> None:
14031440
self.notify("Category changed. Press w to review and commit.", timeout=2)
14041441
# Refresh to show * marker, stays in detail view since view_mode unchanged
14051442
self.refresh_view()
1406-
# Restore cursor position
1407-
if saved_cursor_row < table.row_count:
1408-
table.move_cursor(row=saved_cursor_row)
1443+
1444+
# Restore cursor and scroll position
1445+
self._restore_table_position(saved_position)
14091446
else:
14101447
self.notify("Edit Category only works in transaction detail view", timeout=2)
14111448

0 commit comments

Comments
 (0)