dataframe-textual 2.4.2__py3-none-any.whl → 2.9.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,7 +16,7 @@ from textual.coordinate import Coordinate
16
16
  from textual.events import Click
17
17
  from textual.reactive import reactive
18
18
  from textual.render import measure
19
- from textual.widgets import DataTable, TabPane
19
+ from textual.widgets import DataTable
20
20
  from textual.widgets._data_table import (
21
21
  CellDoesNotExist,
22
22
  CellKey,
@@ -35,7 +35,6 @@ from .common import (
35
35
  NULL_DISPLAY,
36
36
  RID,
37
37
  SUBSCRIPT_DIGITS,
38
- SUPPORTED_FORMATS,
39
38
  DtypeConfig,
40
39
  format_row,
41
40
  get_next_item,
@@ -56,7 +55,6 @@ from .yes_no_screen import (
56
55
  FindReplaceScreen,
57
56
  FreezeScreen,
58
57
  RenameColumnScreen,
59
- SaveFileScreen,
60
58
  SearchScreen,
61
59
  )
62
60
 
@@ -163,10 +161,21 @@ class DataFrameTable(DataTable):
163
161
  - **,** - 🔢 Toggle thousand separator for numeric display
164
162
  - **K** - 🔄 Cycle cursor (cell → row → column → cell)
165
163
 
166
- ## ↕️ Sorting
167
- - **[** - 🔼 Sort column ascending
168
- - **]** - 🔽 Sort column descending
169
- - *(Multi-column sort supported)*
164
+ ## ✏️ Editing
165
+ - **Double-click** - ✍️ Edit cell or rename column header
166
+ - **e** - ✍️ Edit current cell
167
+ - **E** - 📊 Edit entire column with expression
168
+ - **a** - ➕ Add empty column after current
169
+ - **A** - ➕ Add column with name and optional expression
170
+ - **@** - 🔗 Add a new link column from template
171
+ - **x** - ❌ Delete current row
172
+ - **X** - ❌ Delete row and those below
173
+ - **Ctrl+X** - ❌ Delete row and those above
174
+ - **delete** - ❌ Clear current cell (set to NULL)
175
+ - **Shift+Delete** - ❌ Clear current column (set matching cells to NULL)
176
+ - **-** - ❌ Delete current column
177
+ - **d** - 📋 Duplicate current column
178
+ - **D** - 📋 Duplicate current row
170
179
 
171
180
  ## ✅ Row Selection
172
181
  - **\\\\** - ✅ Select rows with cell matches or those matching cursor value in current column
@@ -190,28 +199,15 @@ class DataFrameTable(DataTable):
190
199
  - *(Supports case-insensitive & whole-word matching)*
191
200
 
192
201
  ## 👁️ View & Filter
193
- - **"** - 📍 Filter selected rows (removes others)
194
- - **v** - 👁️ View selected rows (hides others)
195
- - **V** - 🔧 View selected rows matching expression (hides others)
202
+ - **"** - 📍 Filter selected rows (others removed)
203
+ - **.** - 👁️ View rows with non-null values in current column (others hidden)
204
+ - **v** - 👁️ View selected rows (others hidden)
205
+ - **V** - 🔧 View selected rows matching expression (others hidden)
196
206
 
197
- ## 🔍 SQL Interface
198
- - **l** - 💬 Open simple SQL interface (select columns & where clause)
199
- - **L** - 🔎 Open advanced SQL interface (full SQL queries)
200
-
201
- ## ✏️ Editing
202
- - **Double-click** - ✍️ Edit cell or rename column header
203
- - **e** - ✍️ Edit current cell
204
- - **E** - 📊 Edit entire column with expression
205
- - **a** - ➕ Add empty column after current
206
- - **A** - ➕ Add column with name and optional expression
207
- - **@** - 🔗 Add a new link column from template
208
- - **x** - ❌ Delete current row
209
- - **X** - ❌ Delete row and those below
210
- - **Ctrl+X** - ❌ Delete row and those above
211
- - **delete** - ❌ Clear current cell (set to NULL)
212
- - **-** - ❌ Delete current column
213
- - **d** - 📋 Duplicate current column
214
- - **D** - 📋 Duplicate current row
207
+ ## ↕️ Sorting
208
+ - **[** - 🔼 Sort column ascending
209
+ - **]** - 🔽 Sort column descending
210
+ - *(Multi-column sort supported)*
215
211
 
216
212
  ## 🎯 Reorder
217
213
  - **Shift+↑↓** - ⬆️⬇️ Move row up/down
@@ -223,11 +219,14 @@ class DataFrameTable(DataTable):
223
219
  - **!** - ✅ Cast column to boolean
224
220
  - **$** - 📝 Cast column to string
225
221
 
226
- ## 💾 Copy & Save
222
+ ## 💾 Copy
227
223
  - **c** - 📋 Copy cell to clipboard
228
224
  - **Ctrl+c** - 📊 Copy column to clipboard
229
225
  - **Ctrl+r** - 📝 Copy row to clipboard (tab-separated)
230
- - **Ctrl+s** - 💾 Save current tab to file
226
+
227
+ ## 🔍 SQL Interface
228
+ - **l** - 💬 Open simple SQL interface (select columns & where clause)
229
+ - **L** - 🔎 Open advanced SQL interface (full SQL queries)
231
230
  """).strip()
232
231
 
233
232
  # fmt: off
@@ -255,8 +254,6 @@ class DataFrameTable(DataTable):
255
254
  ("c", "copy_cell", "Copy cell to clipboard"),
256
255
  ("ctrl+c", "copy_column", "Copy column to clipboard"),
257
256
  ("ctrl+r", "copy_row", "Copy row to clipboard"),
258
- # Save
259
- ("ctrl+s", "save_to_file", "Save to file"),
260
257
  # Metadata, Detail, Frequency, and Statistics
261
258
  ("m", "metadata_shape", "Show metadata for row count and column count"),
262
259
  ("M", "metadata_column", "Show metadata for column"),
@@ -268,6 +265,7 @@ class DataFrameTable(DataTable):
268
265
  ("left_square_bracket", "sort_ascending", "Sort ascending"), # `[`
269
266
  ("right_square_bracket", "sort_descending", "Sort descending"), # `]`
270
267
  # View & Filter
268
+ ("full_stop", "view_rows_non_null", "View rows with non-null values in current column"),
271
269
  ("v", "view_rows", "View selected rows"),
272
270
  ("V", "view_rows_expr", "View selected rows matching expression"),
273
271
  ("quotation_mark", "filter_rows", "Filter selected rows"), # `"`
@@ -290,6 +288,7 @@ class DataFrameTable(DataTable):
290
288
  ("R", "replace_global", "Replace global"), # `Shift+R`
291
289
  # Delete
292
290
  ("delete", "clear_cell", "Clear cell"),
291
+ ("shift+delete", "clear_column", "Clear cells in current column that match cursor value"), # `Shift+Delete`
293
292
  ("minus", "delete_column", "Delete column"), # `-`
294
293
  ("x", "delete_row", "Delete row"),
295
294
  ("X", "delete_row_and_below", "Delete row and those below"),
@@ -744,10 +743,6 @@ class DataFrameTable(DataTable):
744
743
  """Sort by current column in descending order."""
745
744
  self.do_sort_by_column(descending=True)
746
745
 
747
- def action_save_to_file(self) -> None:
748
- """Save the current dataframe to a file."""
749
- self.do_save_to_file()
750
-
751
746
  def action_show_frequency(self) -> None:
752
747
  """Show frequency distribution for the current column."""
753
748
  self.do_show_frequency()
@@ -768,6 +763,10 @@ class DataFrameTable(DataTable):
768
763
  """Show metadata for the current column."""
769
764
  self.do_metadata_column()
770
765
 
766
+ def action_view_rows_non_null(self) -> None:
767
+ """View rows with non-null values in the current column."""
768
+ self.do_view_rows_non_null()
769
+
771
770
  def action_view_rows(self) -> None:
772
771
  """View rows by current cell value."""
773
772
  self.do_view_rows()
@@ -804,6 +803,10 @@ class DataFrameTable(DataTable):
804
803
  """Clear the current cell (set to None)."""
805
804
  self.do_clear_cell()
806
805
 
806
+ def action_clear_column(self) -> None:
807
+ """Clear cells in the current column that match the cursor value."""
808
+ self.do_clear_column()
809
+
807
810
  def action_select_row(self) -> None:
808
811
  """Select rows with cursor value in the current column."""
809
812
  self.do_select_row()
@@ -1758,13 +1761,14 @@ class DataFrameTable(DataTable):
1758
1761
  max_width = label_width
1759
1762
 
1760
1763
  # Scan through all loaded rows that are visible to find max width
1761
- for row_idx in range(self.loaded_rows):
1762
- cell_value = str(self.df.item(row_idx, col_idx))
1763
- cell_width = measure(self.app.console, cell_value, 1)
1764
+ for row_start, row_end in self.loaded_ranges:
1765
+ for row_idx in range(row_start, row_end):
1766
+ cell_value = str(self.df.item(row_idx, col_idx))
1767
+ cell_width = measure(self.app.console, cell_value, 1)
1764
1768
 
1765
- if cell_width > max_width:
1766
- need_expand = True
1767
- max_width = max(max_width, cell_width)
1769
+ if cell_width > max_width:
1770
+ need_expand = True
1771
+ max_width = cell_width
1768
1772
 
1769
1773
  if not need_expand:
1770
1774
  return
@@ -2052,25 +2056,10 @@ class DataFrameTable(DataTable):
2052
2056
  # Also update the view if applicable
2053
2057
  # Update the value of col_name in df_view using the value of col_name from df based on RID mapping between them
2054
2058
  if self.df_view is not None:
2055
- # Get updated column from df for rows that exist in df_view
2056
- col_updated = f"^_{col_name}_^"
2057
- col_exists = "^_exists_^"
2058
- lf_updated = self.df.lazy().select(
2059
- RID, pl.col(col_name).alias(col_updated), pl.lit(True).alias(col_exists)
2060
- )
2061
- # Join and use when/then/otherwise to handle all updates including NULLs
2062
- self.df_view = (
2063
- self.df_view.lazy()
2064
- .join(lf_updated, on=RID, how="left")
2065
- .with_columns(
2066
- pl.when(pl.col(col_exists))
2067
- .then(pl.col(col_updated))
2068
- .otherwise(pl.col(col_name))
2069
- .alias(col_name)
2070
- )
2071
- .drop(col_updated, col_exists)
2072
- .collect()
2073
- )
2059
+ # Get updated column from df
2060
+ lf_updated = self.df.lazy().select(RID, pl.col(col_name))
2061
+ # Update df_view by joining on RID
2062
+ self.df_view = self.df_view.lazy().update(lf_updated, on=RID, include_nulls=True).collect()
2074
2063
  except Exception as e:
2075
2064
  self.notify(
2076
2065
  f"Error applying expression: [$error]{term}[/] to column [$accent]{col_name}[/]",
@@ -2156,18 +2145,26 @@ class DataFrameTable(DataTable):
2156
2145
 
2157
2146
  # Update the cell to None in the dataframe
2158
2147
  try:
2159
- self.df = self.df.with_columns(
2160
- pl.when(pl.arange(0, len(self.df)) == ridx)
2161
- .then(pl.lit(None))
2162
- .otherwise(pl.col(col_name))
2163
- .alias(col_name)
2148
+ self.df = (
2149
+ self.df.lazy()
2150
+ .with_columns(
2151
+ pl.when(pl.arange(0, len(self.df)) == ridx)
2152
+ .then(pl.lit(None))
2153
+ .otherwise(pl.col(col_name))
2154
+ .alias(col_name)
2155
+ )
2156
+ .collect()
2164
2157
  )
2165
2158
 
2166
2159
  # Also update the view if applicable
2167
2160
  if self.df_view is not None:
2168
2161
  ridx_view = self.df.item(ridx, self.df.columns.index(RID))
2169
- self.df_view = self.df_view.with_columns(
2170
- pl.when(pl.col(RID) == ridx_view).then(pl.lit(None)).otherwise(pl.col(col_name)).alias(col_name)
2162
+ self.df_view = (
2163
+ self.df_view.lazy()
2164
+ .with_columns(
2165
+ pl.when(pl.col(RID) == ridx_view).then(pl.lit(None)).otherwise(pl.col(col_name)).alias(col_name)
2166
+ )
2167
+ .collect()
2171
2168
  )
2172
2169
 
2173
2170
  # Update the display
@@ -2186,7 +2183,43 @@ class DataFrameTable(DataTable):
2186
2183
  timeout=10,
2187
2184
  )
2188
2185
  self.log(f"Error clearing cell ({ridx}, {col_name}): {str(e)}")
2189
- raise e
2186
+
2187
+ def do_clear_column(self) -> None:
2188
+ """Clear the current column by setting all its values to None."""
2189
+ col_idx = self.cursor_column
2190
+ col_name = self.cursor_col_name
2191
+ value = self.cursor_value
2192
+
2193
+ # Add to history
2194
+ self.add_history(f"Cleared column [$success]{col_name}[/]", dirty=True)
2195
+
2196
+ try:
2197
+ # Update the entire column to None in the dataframe
2198
+ self.df = (
2199
+ self.df.lazy()
2200
+ .with_columns(
2201
+ pl.when(pl.col(col_name) == value).then(pl.lit(None)).otherwise(pl.col(col_name)).alias(col_name)
2202
+ )
2203
+ .collect()
2204
+ )
2205
+
2206
+ # Also update the view if applicable
2207
+ if self.df_view is not None:
2208
+ lf_updated = self.df.lazy().select(RID, pl.col(col_name))
2209
+ self.df_view = self.df_view.lazy().update(lf_updated, on=RID, include_nulls=True).collect()
2210
+
2211
+ # Recreate table for display
2212
+ self.setup_table()
2213
+
2214
+ # Move cursor to the cleared column
2215
+ self.move_cursor(column=col_idx)
2216
+
2217
+ # self.notify(f"Cleared column [$success]{col_name}[/]", title="Clear Column")
2218
+ except Exception as e:
2219
+ self.notify(
2220
+ f"Error clearing column [$error]{col_name}[/]", title="Clear Column", severity="error", timeout=10
2221
+ )
2222
+ self.log(f"Error clearing column `{col_name}`: {str(e)}")
2190
2223
 
2191
2224
  def do_add_column(self, col_name: str = None) -> None:
2192
2225
  """Add acolumn after the current column."""
@@ -2849,7 +2882,7 @@ class DataFrameTable(DataTable):
2849
2882
  expr = validate_expr(term, self.df.columns, cidx)
2850
2883
  except Exception as e:
2851
2884
  self.notify(
2852
- f"Error validating expression [$error]{term}[/]", title="Search", severity="error", timeout=10
2885
+ f"Error validating expression [$error]{term}[/]", title="Select Row", severity="error", timeout=10
2853
2886
  )
2854
2887
  self.log(f"Error validating expression `{term}`: {str(e)}")
2855
2888
  return
@@ -2875,7 +2908,7 @@ class DataFrameTable(DataTable):
2875
2908
  expr = pl.col(col_name).cast(pl.String).str.contains(term)
2876
2909
  self.notify(
2877
2910
  f"Error converting [$error]{term}[/] to [$accent]{dtype}[/]. Cast to string.",
2878
- title="Search",
2911
+ title="Select Row",
2879
2912
  severity="warning",
2880
2913
  )
2881
2914
 
@@ -2887,7 +2920,7 @@ class DataFrameTable(DataTable):
2887
2920
  ok_rids = set(lf.filter(expr).collect()[RID])
2888
2921
  except Exception as e:
2889
2922
  self.notify(
2890
- f"Error applying search filter `[$error]{term}[/]`", title="Search", severity="error", timeout=10
2923
+ f"Error applying search filter `[$error]{term}[/]`", title="Select Row", severity="error", timeout=10
2891
2924
  )
2892
2925
  self.log(f"Error applying search filter `{term}`: {str(e)}")
2893
2926
  return
@@ -2896,7 +2929,7 @@ class DataFrameTable(DataTable):
2896
2929
  if match_count == 0:
2897
2930
  self.notify(
2898
2931
  f"No matches found for `[$warning]{term}[/]`. Try [$accent](?i)abc[/] for case-insensitive search.",
2899
- title="Search",
2932
+ title="Select Row",
2900
2933
  severity="warning",
2901
2934
  )
2902
2935
  return
@@ -2906,8 +2939,8 @@ class DataFrameTable(DataTable):
2906
2939
  # Add to history
2907
2940
  self.add_history(message)
2908
2941
 
2909
- # Update selected rows to include new selections
2910
- self.selected_rows.update(ok_rids)
2942
+ # Update selected rows
2943
+ self.selected_rows = ok_rids
2911
2944
 
2912
2945
  # Show notification immediately, then start highlighting
2913
2946
  self.notify(message, title="Select Row")
@@ -2970,10 +3003,10 @@ class DataFrameTable(DataTable):
2970
3003
  # self.notify("No selections to clear", title="Clear Selections and Matches", severity="warning")
2971
3004
  return
2972
3005
 
2973
- row_count = len(self.selected_rows | set(self.matches.keys()))
3006
+ # row_count = len(self.selected_rows | set(self.matches.keys()))
2974
3007
 
2975
3008
  # Add to history
2976
- self.add_history("Cleared all selected rows")
3009
+ self.add_history("Cleared all selections and matches")
2977
3010
 
2978
3011
  # Clear all selections
2979
3012
  self.selected_rows = set()
@@ -2982,7 +3015,7 @@ class DataFrameTable(DataTable):
2982
3015
  # Recreate table for display
2983
3016
  self.setup_table()
2984
3017
 
2985
- self.notify(f"Cleared selections for [$success]{row_count}[/] rows", title="Clear Selections and Matches")
3018
+ # self.notify(f"Cleared selections for [$success]{row_count}[/] rows", title="Clear Selections and Matches")
2986
3019
 
2987
3020
  # Find & Replace
2988
3021
  def find_matches(
@@ -3015,7 +3048,7 @@ class DataFrameTable(DataTable):
3015
3048
  else:
3016
3049
  columns_to_search = list(enumerate(self.df.columns))
3017
3050
 
3018
- # Search each column consistently
3051
+ # Handle each column consistently
3019
3052
  for col_idx, col_name in columns_to_search:
3020
3053
  # Build expression based on term type
3021
3054
  if term == NULL:
@@ -3075,8 +3108,9 @@ class DataFrameTable(DataTable):
3075
3108
  cidx = self.cursor_col_idx if scope == "column" else None
3076
3109
 
3077
3110
  # Push the search modal screen
3111
+
3078
3112
  self.app.push_screen(
3079
- SearchScreen("Find", term, self.df, cidx),
3113
+ SearchScreen("Find" if scope == "column" else "Global Find", term, self.df, cidx),
3080
3114
  callback=self.find if scope == "column" else self.find_global,
3081
3115
  )
3082
3116
 
@@ -3106,10 +3140,9 @@ class DataFrameTable(DataTable):
3106
3140
  # Add to history
3107
3141
  self.add_history(f"Found `[$success]{term}[/]` in column [$accent]{col_name}[/]")
3108
3142
 
3109
- # Add to matches and count total
3143
+ # Update matches and count total
3110
3144
  match_count = sum(len(cols) for cols in matches.values())
3111
- for rid, cols in matches.items():
3112
- self.matches[rid].update(cols)
3145
+ self.matches = matches
3113
3146
 
3114
3147
  self.notify(f"Found [$success]{match_count}[/] matches for `[$accent]{term}[/]`", title="Find")
3115
3148
 
@@ -3140,10 +3173,9 @@ class DataFrameTable(DataTable):
3140
3173
  # Add to history
3141
3174
  self.add_history(f"Found `[$success]{term}[/]` across all columns")
3142
3175
 
3143
- # Add to matches and count total
3176
+ # Update matches and count total
3144
3177
  match_count = sum(len(cols) for cols in matches.values())
3145
- for rid, cols in matches.items():
3146
- self.matches[rid].update(cols)
3178
+ self.matches = matches
3147
3179
 
3148
3180
  self.notify(
3149
3181
  f"Found [$success]{match_count}[/] matches for `[$accent]{term}[/]` across all columns",
@@ -3251,7 +3283,7 @@ class DataFrameTable(DataTable):
3251
3283
  """Open replace screen for current column."""
3252
3284
  # Push the replace modal screen
3253
3285
  self.app.push_screen(
3254
- FindReplaceScreen(self, title="Find and Replace in Current Column"),
3286
+ FindReplaceScreen(self, title="Find and Replace"),
3255
3287
  callback=self.replace,
3256
3288
  )
3257
3289
 
@@ -3394,18 +3426,23 @@ class DataFrameTable(DataTable):
3394
3426
  # Only applicable to string columns for substring matches
3395
3427
  if dtype == pl.String and not state.match_whole:
3396
3428
  term_find = f"(?i){state.term_find}" if state.match_nocase else state.term_find
3429
+ new_value = (
3430
+ pl.lit(None)
3431
+ if state.term_replace == NULL
3432
+ else pl.col(col_name).str.replace_all(term_find, state.term_replace)
3433
+ )
3397
3434
  self.df = self.df.with_columns(
3398
- pl.when(mask)
3399
- .then(pl.col(col_name).str.replace_all(term_find, state.term_replace))
3400
- .otherwise(pl.col(col_name))
3401
- .alias(col_name)
3435
+ pl.when(mask).then(new_value).otherwise(pl.col(col_name)).alias(col_name)
3402
3436
  )
3403
3437
  else:
3404
- # Try to convert replacement value to column dtype
3405
- try:
3406
- value = DtypeConfig(dtype).convert(state.term_replace)
3407
- except Exception:
3408
- value = state.term_replace
3438
+ if state.term_replace == NULL:
3439
+ value = None
3440
+ else:
3441
+ # Try to convert replacement value to column dtype
3442
+ try:
3443
+ value = DtypeConfig(dtype).convert(state.term_replace)
3444
+ except Exception:
3445
+ value = state.term_replace
3409
3446
 
3410
3447
  self.df = self.df.with_columns(
3411
3448
  pl.when(mask).then(pl.lit(value)).otherwise(pl.col(col_name)).alias(col_name)
@@ -3413,15 +3450,8 @@ class DataFrameTable(DataTable):
3413
3450
 
3414
3451
  # Also update the view if applicable
3415
3452
  if self.df_view is not None:
3416
- col_updated = f"^_{col_name}_^"
3417
- lf_updated = self.df.lazy().filter(mask).select(pl.col(col_name).alias(col_updated), pl.col(RID))
3418
- self.df_view = (
3419
- self.df_view.lazy()
3420
- .join(lf_updated, on=RID, how="left")
3421
- .with_columns(pl.coalesce(pl.col(col_updated), pl.col(col_name)).alias(col_name))
3422
- .drop(col_updated)
3423
- .collect()
3424
- )
3453
+ lf_updated = self.df.lazy().filter(mask).select(pl.col(RID), pl.col(col_name))
3454
+ self.df_view = self.df_view.lazy().update(lf_updated, on=RID, include_nulls=True).collect()
3425
3455
 
3426
3456
  state.replaced_occurrence += len(ridxs)
3427
3457
 
@@ -3500,9 +3530,14 @@ class DataFrameTable(DataTable):
3500
3530
  # Only applicable to string columns for substring matches
3501
3531
  if dtype == pl.String and not state.match_whole:
3502
3532
  term_find = f"(?i){state.term_find}" if state.match_nocase else state.term_find
3533
+ new_value = (
3534
+ pl.lit(None)
3535
+ if state.term_replace == NULL
3536
+ else pl.col(col_name).str.replace_all(term_find, state.term_replace)
3537
+ )
3503
3538
  self.df = self.df.with_columns(
3504
3539
  pl.when(pl.arange(0, len(self.df)) == ridx)
3505
- .then(pl.col(col_name).str.replace_all(term_find, state.term_replace))
3540
+ .then(new_value)
3506
3541
  .otherwise(pl.col(col_name))
3507
3542
  .alias(col_name)
3508
3543
  )
@@ -3516,11 +3551,14 @@ class DataFrameTable(DataTable):
3516
3551
  .alias(col_name)
3517
3552
  )
3518
3553
  else:
3519
- # try to convert replacement value to column dtype
3520
- try:
3521
- value = DtypeConfig(dtype).convert(state.term_replace)
3522
- except Exception:
3523
- value = state.term_replace
3554
+ if state.term_replace == NULL:
3555
+ value = None
3556
+ else:
3557
+ # try to convert replacement value to column dtype
3558
+ try:
3559
+ value = DtypeConfig(dtype).convert(state.term_replace)
3560
+ except Exception:
3561
+ value = state.term_replace
3524
3562
 
3525
3563
  self.df = self.df.with_columns(
3526
3564
  pl.when(pl.arange(0, len(self.df)) == ridx)
@@ -3548,6 +3586,8 @@ class DataFrameTable(DataTable):
3548
3586
  if not state.done:
3549
3587
  # Get the new value of the current cell after replacement
3550
3588
  new_cell_value = self.df.item(ridx, cidx)
3589
+ if new_cell_value is None:
3590
+ new_cell_value = NULL_DISPLAY
3551
3591
  row_key = str(ridx)
3552
3592
  col_key = col_name
3553
3593
  self.update_cell(
@@ -3568,6 +3608,15 @@ class DataFrameTable(DataTable):
3568
3608
  self.show_next_replace_confirmation()
3569
3609
 
3570
3610
  # View & Filter
3611
+ def do_view_rows_non_null(self) -> None:
3612
+ """View non-null rows based on the cursor column."""
3613
+ cidx = self.cursor_col_idx
3614
+ col_name = self.cursor_col_name
3615
+
3616
+ term = pl.col(col_name).is_not_null()
3617
+
3618
+ self.view_rows((term, cidx, False, True))
3619
+
3571
3620
  def do_view_rows(self) -> None:
3572
3621
  """View rows.
3573
3622
 
@@ -3662,6 +3711,9 @@ class DataFrameTable(DataTable):
3662
3711
 
3663
3712
  expr_str = "boolean list or series" if isinstance(expr, (list, pl.Series)) else str(expr)
3664
3713
 
3714
+ # Add to history
3715
+ self.add_history(f"Viewed rows by expression [$success]{expr_str}[/]")
3716
+
3665
3717
  # Apply the filter expression
3666
3718
  try:
3667
3719
  df_filtered = lf.filter(expr).collect()
@@ -3673,12 +3725,10 @@ class DataFrameTable(DataTable):
3673
3725
 
3674
3726
  matched_count = len(df_filtered)
3675
3727
  if not matched_count:
3728
+ self.histories_undo.pop() # Remove last history entry
3676
3729
  self.notify(f"No rows match the expression: [$success]{expr}[/]", title="View Rows", severity="warning")
3677
3730
  return
3678
3731
 
3679
- # Add to history
3680
- self.add_history(f"Filtered by expression [$success]{expr_str}[/]")
3681
-
3682
3732
  ok_rids = set(df_filtered[RID])
3683
3733
 
3684
3734
  # Create a view of self.df as a copy
@@ -3699,7 +3749,7 @@ class DataFrameTable(DataTable):
3699
3749
  # Recreate table for display
3700
3750
  self.setup_table()
3701
3751
 
3702
- self.notify(f"Filtered to [$success]{matched_count}[/] matching row(s)", title="View Rows")
3752
+ self.notify(f"Showing [$success]{matched_count}[/] matching row(s)", title="View Rows")
3703
3753
 
3704
3754
  def do_filter_rows(self) -> None:
3705
3755
  """Filter rows.
@@ -3755,7 +3805,7 @@ class DataFrameTable(DataTable):
3755
3805
 
3756
3806
  self.notify(f"{message}. Now showing [$success]{len(self.df)}[/] rows.", title="Filter Rows")
3757
3807
 
3758
- # Copy & Save
3808
+ # Copy
3759
3809
  def do_copy_to_clipboard(self, content: str, message: str) -> None:
3760
3810
  """Copy content to clipboard using pbcopy (macOS) or xclip (Linux).
3761
3811
 
@@ -3779,132 +3829,6 @@ class DataFrameTable(DataTable):
3779
3829
  except FileNotFoundError:
3780
3830
  self.notify("Error copying to clipboard", title="Copy to Clipboard", severity="error", timeout=10)
3781
3831
 
3782
- def do_save_to_file(self, all_tabs: bool | None = None, task_after_save: str | None = None) -> None:
3783
- """Open screen to save file."""
3784
- self._task_after_save = task_after_save
3785
- tab_count = len(self.app.tabs)
3786
- save_all = all_tabs is not False
3787
-
3788
- filepath = Path(self.filename)
3789
- if save_all:
3790
- ext = filepath.suffix.lower()
3791
- if ext in (".xlsx", ".xls"):
3792
- filename = self.filename
3793
- else:
3794
- filename = "all-tabs.xlsx"
3795
- else:
3796
- filename = str(filepath.with_stem(self.tabname))
3797
-
3798
- self.app.push_screen(
3799
- SaveFileScreen(filename, save_all=save_all, tab_count=tab_count),
3800
- callback=self.save_to_file,
3801
- )
3802
-
3803
- def save_to_file(self, result) -> None:
3804
- """Handle result from SaveFileScreen."""
3805
- if result is None:
3806
- return
3807
- filename, save_all, overwrite_prompt = result
3808
- self._save_all = save_all
3809
-
3810
- # Check if file exists
3811
- if overwrite_prompt and Path(filename).exists():
3812
- self._pending_filename = filename
3813
- self.app.push_screen(
3814
- ConfirmScreen("File already exists. Overwrite?"),
3815
- callback=self.confirm_overwrite,
3816
- )
3817
- else:
3818
- self.save_file(filename)
3819
-
3820
- def confirm_overwrite(self, should_overwrite: bool) -> None:
3821
- """Handle result from ConfirmScreen."""
3822
- if should_overwrite:
3823
- self.save_file(self._pending_filename)
3824
- else:
3825
- # Go back to SaveFileScreen to allow user to enter a different name
3826
- self.app.push_screen(
3827
- SaveFileScreen(self._pending_filename, save_all=self._save_all),
3828
- callback=self.save_to_file,
3829
- )
3830
-
3831
- def save_file(self, filename: str) -> None:
3832
- """Actually save the dataframe to a file."""
3833
- filepath = Path(filename)
3834
- ext = filepath.suffix.lower()
3835
- if ext == ".gz":
3836
- ext = Path(filename).with_suffix("").suffix.lower()
3837
-
3838
- fmt = ext.removeprefix(".")
3839
- if fmt not in SUPPORTED_FORMATS:
3840
- self.notify(
3841
- f"Unsupported file format [$success]{fmt}[/]. Use [$accent]CSV[/] as fallback. Supported formats: {', '.join(SUPPORTED_FORMATS)}",
3842
- title="Save to File",
3843
- severity="warning",
3844
- )
3845
- fmt = "csv"
3846
-
3847
- df = (self.df if self.df_view is None else self.df_view).select(pl.exclude(RID))
3848
- try:
3849
- if fmt == "csv":
3850
- df.write_csv(filename)
3851
- elif fmt in ("tsv", "tab"):
3852
- df.write_csv(filename, separator="\t")
3853
- elif fmt in ("xlsx", "xls"):
3854
- self.save_excel(filename)
3855
- elif fmt == "json":
3856
- df.write_json(filename)
3857
- elif fmt == "ndjson":
3858
- df.write_ndjson(filename)
3859
- elif fmt == "parquet":
3860
- df.write_parquet(filename)
3861
- else: # Fallback to CSV
3862
- df.write_csv(filename)
3863
-
3864
- # Update current filename
3865
- self.filename = filename
3866
-
3867
- # Reset dirty flag after save
3868
- if self._save_all:
3869
- tabs: dict[TabPane, DataFrameTable] = self.app.tabs
3870
- for table in tabs.values():
3871
- table.dirty = False
3872
- else:
3873
- self.dirty = False
3874
-
3875
- if hasattr(self, "_task_after_save"):
3876
- if self._task_after_save == "close_tab":
3877
- self.app.do_close_tab()
3878
- elif self._task_after_save == "quit_app":
3879
- self.app.exit()
3880
-
3881
- # From ConfirmScreen callback, so notify accordingly
3882
- if self._save_all:
3883
- self.notify(f"Saved all tabs to [$success]{filename}[/]", title="Save to File")
3884
- else:
3885
- self.notify(f"Saved current tab to [$success]{filename}[/]", title="Save to File")
3886
-
3887
- except Exception as e:
3888
- self.notify(f"Error saving [$error]{filename}[/]", title="Save to File", severity="error", timeout=10)
3889
- self.log(f"Error saving file `{filename}`: {str(e)}")
3890
-
3891
- def save_excel(self, filename: str) -> None:
3892
- """Save to an Excel file."""
3893
- import xlsxwriter
3894
-
3895
- if not self._save_all or len(self.app.tabs) == 1:
3896
- # Single tab - save directly
3897
- df = (self.df if self.df_view is None else self.df_view).select(pl.exclude(RID))
3898
- df.write_excel(filename, worksheet=self.tabname)
3899
- else:
3900
- # Multiple tabs - use xlsxwriter to create multiple sheets
3901
- with xlsxwriter.Workbook(filename) as wb:
3902
- tabs: dict[TabPane, DataFrameTable] = self.app.tabs
3903
- for table in tabs.values():
3904
- worksheet = wb.add_worksheet(table.tabname)
3905
- df = (table.df if table.df_view is None else table.df_view).select(pl.exclude(RID))
3906
- df.write_excel(workbook=wb, worksheet=worksheet)
3907
-
3908
3832
  # SQL Interface
3909
3833
  def do_simple_sql(self) -> None:
3910
3834
  """Open the SQL interface screen."""