@@ -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