dataframe-textual 1.2.0__py3-none-any.whl → 1.3.9__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.
@@ -9,6 +9,7 @@ from typing import Any
9
9
 
10
10
  import polars as pl
11
11
  from rich.text import Text
12
+ from textual import work
12
13
  from textual.coordinate import Coordinate
13
14
  from textual.events import Click
14
15
  from textual.widgets import DataTable, TabPane
@@ -30,9 +31,11 @@ from .common import (
30
31
  format_row,
31
32
  get_next_item,
32
33
  rindex,
34
+ sleep_async,
33
35
  tentative_expr,
34
36
  validate_expr,
35
37
  )
38
+ from .sql_screen import AdvancedSqlScreen, SimpleSqlScreen
36
39
  from .table_screen import FrequencyScreen, RowDetailScreen, StatisticsScreen
37
40
  from .yes_no_screen import (
38
41
  AddColumnScreen,
@@ -142,6 +145,10 @@ class DataFrameTable(DataTable):
142
145
  - **"** - 📍 Filter to show only selected rows
143
146
  - **T** - 🧹 Clear all selections and matches
144
147
 
148
+ ## 🔍 SQL Interface
149
+ - **l** - 💬 Open simple SQL interface (select columns & WHERE clause)
150
+ - **L** - 🔎 Open advanced SQL interface (full SQL queries)
151
+
145
152
  ## ✏️ Edit & Modify
146
153
  - **Double-click** - ✍️ Edit cell or rename column header
147
154
  - **e** - ✍️ Edit current cell
@@ -154,11 +161,10 @@ class DataFrameTable(DataTable):
154
161
  - **delete** - ❌ Clear current cell (set to NULL)
155
162
  - **-** - ❌ Delete current column
156
163
  - **_** - ❌ Delete column and those after
157
- - **Ctrl+-** - ❌ Delete column and those before
164
+ - **Ctrl+_** - ❌ Delete column and those before
158
165
  - **d** - 📋 Duplicate current column
159
166
  - **D** - 📋 Duplicate current row
160
167
 
161
-
162
168
  ## 🎯 Reorder
163
169
  - **Shift+↑↓** - ⬆️⬇️ Move row up/down
164
170
  - **Shift+←→** - ⬅️➡️ Move column left/right
@@ -235,7 +241,7 @@ class DataFrameTable(DataTable):
235
241
  ("delete", "clear_cell", "Clear cell"),
236
242
  ("minus", "delete_column", "Delete column"), # `-`
237
243
  ("underscore", "delete_column_and_after", "Delete column and those after"), # `_`
238
- ("ctrl+minus", "delete_column_and_before", "Delete column and those before"), # `Ctrl+-`
244
+ ("ctrl+underscore", "delete_column_and_before", "Delete column and those before"), # `Ctrl+_`
239
245
  ("x", "delete_row", "Delete row"),
240
246
  ("X", "delete_row_and_below", "Delete row and those below"),
241
247
  ("ctrl+x", "delete_row_and_up", "Delete row and those up"),
@@ -254,11 +260,14 @@ class DataFrameTable(DataTable):
254
260
  ("shift+up", "move_row_up", "Move row up"),
255
261
  ("shift+down", "move_row_down", "Move row down"),
256
262
  # Type Conversion
257
- ("number_sign", "cast_column_dtype('int')", "Cast column dtype to int"), # `#`
258
- ("percent_sign", "cast_column_dtype('float')", "Cast column dtype to float"), # `%`
259
- ("exclamation_mark", "cast_column_dtype('bool')", "Cast column dtype to bool"), # `!`
260
- ("dollar_sign", "cast_column_dtype('string')", "Cast column dtype to string"), # `$`
263
+ ("number_sign", "cast_column_dtype('pl.Int64')", "Cast column dtype to integer"), # `#`
264
+ ("percent_sign", "cast_column_dtype('pl.Float64')", "Cast column dtype to float"), # `%`
265
+ ("exclamation_mark", "cast_column_dtype('pl.Boolean')", "Cast column dtype to bool"), # `!`
266
+ ("dollar_sign", "cast_column_dtype('pl.String')", "Cast column dtype to string"), # `$`
261
267
  ("at", "make_cell_clickable", "Make cell clickable"), # `@`
268
+ # Sql
269
+ ("l", "simple_sql", "Simple SQL interface"),
270
+ ("L", "advanced_sql", "Advanced SQL interface"),
262
271
  # Undo/Redo
263
272
  ("u", "undo", "Undo"),
264
273
  ("U", "redo", "Redo"),
@@ -854,6 +863,14 @@ class DataFrameTable(DataTable):
854
863
  """Go to the previous selected row."""
855
864
  self._previous_selected_row()
856
865
 
866
+ def action_simple_sql(self) -> None:
867
+ """Open the SQL interface screen."""
868
+ self._simple_sql()
869
+
870
+ def action_advanced_sql(self) -> None:
871
+ """Open the advanced SQL interface screen."""
872
+ self._advanced_sql()
873
+
857
874
  def on_mouse_scroll_down(self, event) -> None:
858
875
  """Load more rows when scrolling down with mouse."""
859
876
  self._check_and_load_more()
@@ -865,6 +882,9 @@ class DataFrameTable(DataTable):
865
882
  Row keys are 0-based indices, which map directly to dataframe row indices.
866
883
  Column keys are header names from the dataframe.
867
884
  """
885
+ self.loaded_rows = 0
886
+ self.show_row_labels = True
887
+
868
888
  # Reset to original dataframe
869
889
  if reset:
870
890
  self.df = self.lazyframe.collect()
@@ -887,12 +907,15 @@ class DataFrameTable(DataTable):
887
907
  stop = row_idx + 1
888
908
  break
889
909
 
910
+ # Ensure all selected rows or matches are loaded
911
+ stop = max(stop, rindex(self.selected_rows, True) + 1)
912
+ stop = max(stop, max(self.matches.keys(), default=0) + 1)
913
+
890
914
  # Save current cursor position before clearing
891
915
  row_idx, col_idx = self.cursor_coordinate
892
916
 
893
917
  self._setup_columns()
894
918
  self._load_rows(stop)
895
- self._do_highlight()
896
919
 
897
920
  # Restore cursor position
898
921
  if row_idx < len(self.rows) and col_idx < len(self.columns):
@@ -904,9 +927,7 @@ class DataFrameTable(DataTable):
904
927
  Column keys are header names from the dataframe.
905
928
  Column labels contain column names from the dataframe, with sort indicators if applicable.
906
929
  """
907
- self.loaded_rows = 0
908
930
  self.clear(columns=True)
909
- self.show_row_labels = True
910
931
 
911
932
  # Add columns with justified headers
912
933
  for col, dtype in zip(self.df.columns, self.df.dtypes):
@@ -944,25 +965,33 @@ class DataFrameTable(DataTable):
944
965
  start = self.loaded_rows
945
966
  df_slice = self.df.slice(start, stop - start)
946
967
 
947
- for row_idx, row in enumerate(df_slice.rows(), start):
948
- if not self.visible_rows[row_idx]:
968
+ for ridx, row in enumerate(df_slice.rows(), start):
969
+ if not self.visible_rows[ridx]:
949
970
  continue # Skip hidden rows
950
971
 
951
- vals, dtypes = [], []
972
+ is_selected = self.selected_rows[ridx]
973
+ match_cols = self.matches.get(ridx, set())
974
+
975
+ vals, dtypes, styles = [], [], []
952
976
  for val, col, dtype in zip(row, self.df.columns, self.df.dtypes):
953
977
  if col in self.hidden_columns:
954
978
  continue # Skip hidden columns
979
+
955
980
  vals.append(val)
956
981
  dtypes.append(dtype)
957
- formatted_row = format_row(vals, dtypes, thousand_separator=self.thousand_separator)
982
+ # Highlight entire row if selected or has matches
983
+ styles.append("red" if is_selected or col in match_cols else None)
984
+
985
+ formatted_row = format_row(vals, dtypes, styles=styles, thousand_separator=self.thousand_separator)
958
986
 
959
987
  # Always add labels so they can be shown/hidden via CSS
960
- self.add_row(*formatted_row, key=str(row_idx), label=str(row_idx + 1))
988
+ self.add_row(*formatted_row, key=str(ridx), label=str(ridx + 1))
961
989
 
962
990
  # Update loaded rows count
963
991
  self.loaded_rows = stop
964
992
 
965
993
  # self.notify(f"Loaded [$accent]{stop}/{len(self.df)}[/] rows from [$success]{self.name}[/]", title="Load")
994
+ # self.log(f"Loaded {stop}/{len(self.df)} rows from {self.name}")
966
995
 
967
996
  def _check_and_load_more(self) -> None:
968
997
  """Check if we need to load more rows and load them."""
@@ -1026,6 +1055,63 @@ class DataFrameTable(DataTable):
1026
1055
  if need_update:
1027
1056
  self.update_cell(row.key, col.key, cell_text)
1028
1057
 
1058
+ @work(exclusive=True, description="Loading rows asynchronously...")
1059
+ async def _load_rows_async(self, stop: int | None = None) -> None:
1060
+ """Asynchronously load a batch of rows into the table.
1061
+
1062
+ Args:
1063
+ stop: Stop loading rows when this index is reached. If None, load until the end of the dataframe.
1064
+ """
1065
+ if stop >= (total := len(self.df)):
1066
+ stop = total
1067
+
1068
+ if stop > self.loaded_rows:
1069
+ # Load incrementally with smaller chunks to prevent UI freezing
1070
+ chunk_size = min(100, stop - self.loaded_rows) # Load max 100 rows at a time
1071
+ next_stop = min(self.loaded_rows + chunk_size, stop)
1072
+ self._load_rows(next_stop)
1073
+
1074
+ # If there's more to load, schedule the next chunk with longer delay
1075
+ if next_stop < stop:
1076
+ # Use longer delay and call work method instead of set_timer
1077
+ await sleep_async(0.1) # 100ms delay to yield to UI
1078
+ self._load_rows_async(stop) # Recursive call within work context
1079
+
1080
+ # self.log(f"Async loaded {stop}/{len(self.df)} rows from {self.name}")
1081
+
1082
+ @work(exclusive=True, description="Doing highlight...")
1083
+ async def _do_highlight_async(self) -> None:
1084
+ """Perform the highlighting preparation in a worker."""
1085
+ try:
1086
+ # Calculate what needs to be loaded without actually loading
1087
+ stop = rindex(self.selected_rows, True) + 1
1088
+ stop = max(stop, max(self.matches.keys(), default=0) + 1)
1089
+
1090
+ # Call the highlighting method (runs in background worker)
1091
+ self._highlight_async(stop)
1092
+
1093
+ except Exception as e:
1094
+ self.notify(f"Error preparing highlight: {str(e)}", title="Search", severity="error")
1095
+
1096
+ @work(exclusive=True, description="Highlighting matches...")
1097
+ async def _highlight_async(self, stop: int) -> None:
1098
+ """Perform highlighting with async loading to avoid blocking."""
1099
+ # Load rows in smaller chunks to avoid blocking
1100
+ if stop > self.loaded_rows:
1101
+ # Load incrementally to avoid one big block
1102
+ chunk_size = min(100, stop - self.loaded_rows) # Load max 100 rows at a time
1103
+ next_stop = min(self.loaded_rows + chunk_size, stop)
1104
+ self._load_rows(next_stop)
1105
+
1106
+ # If there's more to load, yield to event loop with delay
1107
+ if next_stop < stop:
1108
+ await sleep_async(0.05) # 50ms delay to allow UI updates
1109
+ self._highlight_async(stop)
1110
+ return
1111
+
1112
+ # Now do the actual highlighting
1113
+ self._highlight_table(force=False)
1114
+
1029
1115
  # History & Undo
1030
1116
  def _create_history(self, description: str) -> None:
1031
1117
  """Create the initial history state."""
@@ -1080,16 +1166,16 @@ class DataFrameTable(DataTable):
1080
1166
  self.notify("No actions to undo", title="Undo", severity="warning")
1081
1167
  return
1082
1168
 
1083
- # Save current state for redo
1084
- self.history = self._create_history("Redo state")
1085
-
1086
1169
  # Pop the last history state for undo
1087
1170
  history = self.histories.pop()
1088
1171
 
1172
+ # Save current state for redo
1173
+ self.history = self._create_history(history.description)
1174
+
1089
1175
  # Restore state
1090
1176
  self._apply_history(history)
1091
1177
 
1092
- # self.notify(f"Reverted: {history.description}", title="Undo")
1178
+ self.notify(f"Reverted: {history.description}", title="Undo")
1093
1179
 
1094
1180
  def _redo(self) -> None:
1095
1181
  """Redo the last undone action."""
@@ -1097,8 +1183,10 @@ class DataFrameTable(DataTable):
1097
1183
  self.notify("No actions to redo", title="Redo", severity="warning")
1098
1184
  return
1099
1185
 
1186
+ description = self.history.description
1187
+
1100
1188
  # Save current state for undo
1101
- self._add_history("Undo state")
1189
+ self._add_history(description)
1102
1190
 
1103
1191
  # Restore state
1104
1192
  self._apply_history(self.history)
@@ -1106,7 +1194,7 @@ class DataFrameTable(DataTable):
1106
1194
  # Clear redo state
1107
1195
  self.history = None
1108
1196
 
1109
- # self.notify(f"Reapplied: {history.description}", title="Redo")
1197
+ self.notify(f"Reapplied: {description}", title="Redo")
1110
1198
 
1111
1199
  # View
1112
1200
  def _view_row_detail(self) -> None:
@@ -1161,10 +1249,7 @@ class DataFrameTable(DataTable):
1161
1249
  if fixed_columns >= 0:
1162
1250
  self.fixed_columns = fixed_columns
1163
1251
 
1164
- self.notify(
1165
- f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns",
1166
- title="Pin",
1167
- )
1252
+ # self.notify(f"Pinned [$accent]{fixed_rows}[/] rows and [$success]{fixed_columns}[/] columns", title="Pin")
1168
1253
 
1169
1254
  # Delete & Move
1170
1255
  def _delete_column(self, more: str = None) -> None:
@@ -1184,7 +1269,7 @@ class DataFrameTable(DataTable):
1184
1269
  col_names_to_remove.append(col_key.value)
1185
1270
  col_keys_to_remove.append(col_key)
1186
1271
 
1187
- descr = f"Removed column [$success]{col_name}[/] and all columns before"
1272
+ message = f"Removed column [$success]{col_name}[/] and all columns before"
1188
1273
 
1189
1274
  # Remove all columns after the current column
1190
1275
  elif more == "after":
@@ -1193,16 +1278,16 @@ class DataFrameTable(DataTable):
1193
1278
  col_names_to_remove.append(col_key.value)
1194
1279
  col_keys_to_remove.append(col_key)
1195
1280
 
1196
- descr = f"Removed column [$success]{col_name}[/] and all columns after"
1281
+ message = f"Removed column [$success]{col_name}[/] and all columns after"
1197
1282
 
1198
1283
  # Remove only the current column
1199
1284
  else:
1200
1285
  col_names_to_remove.append(col_name)
1201
1286
  col_keys_to_remove.append(col_key)
1202
- descr = f"Removed column [$success]{col_name}[/]"
1287
+ message = f"Removed column [$success]{col_name}[/]"
1203
1288
 
1204
1289
  # Add to history
1205
- self._add_history(descr)
1290
+ self._add_history(message)
1206
1291
 
1207
1292
  # Remove the columns from the table display using the column names as keys
1208
1293
  for ck in col_keys_to_remove:
@@ -1229,7 +1314,7 @@ class DataFrameTable(DataTable):
1229
1314
  # Remove from dataframe
1230
1315
  self.df = self.df.drop(col_names_to_remove)
1231
1316
 
1232
- # self.notify(descr, title="Delete")
1317
+ self.notify(message, title="Delete")
1233
1318
 
1234
1319
  def _hide_column(self) -> None:
1235
1320
  """Hide the currently selected column from the table display."""
@@ -1275,7 +1360,7 @@ class DataFrameTable(DataTable):
1275
1360
  self._setup_table()
1276
1361
 
1277
1362
  self.notify(
1278
- f"Showed [$accent]{hidden_row_count}[/] hidden row(s) and/or [$accent]{hidden_col_count}[/] hidden column(s)",
1363
+ f"Showed [$accent]{hidden_row_count}[/] hidden row(s) and/or [$accent]{hidden_col_count}[/] column(s)",
1279
1364
  title="Show",
1280
1365
  )
1281
1366
 
@@ -1317,10 +1402,7 @@ class DataFrameTable(DataTable):
1317
1402
  # Move cursor to the new duplicated column
1318
1403
  self.move_cursor(column=col_idx + 1)
1319
1404
 
1320
- self.notify(
1321
- f"Duplicated column [$accent]{col_name}[/] as [$success]{new_col_name}[/]",
1322
- title="Duplicate",
1323
- )
1405
+ # self.notify(f"Duplicated column [$accent]{col_name}[/] as [$success]{new_col_name}[/]", title="Duplicate")
1324
1406
 
1325
1407
  def _delete_row(self, more: str = None) -> None:
1326
1408
  """Delete rows from the table and dataframe.
@@ -1385,7 +1467,7 @@ class DataFrameTable(DataTable):
1385
1467
  self._setup_table()
1386
1468
 
1387
1469
  deleted_count = old_count - len(self.df)
1388
- if deleted_count > 1:
1470
+ if deleted_count > 0:
1389
1471
  self.notify(f"Deleted [$accent]{deleted_count}[/] row(s)", title="Delete")
1390
1472
 
1391
1473
  def _duplicate_row(self) -> None:
@@ -1503,7 +1585,7 @@ class DataFrameTable(DataTable):
1503
1585
  return
1504
1586
  swap_idx = row_idx + 1
1505
1587
  else:
1506
- self.notify(f"Invalid direction: {direction}", title="Move", severity="error")
1588
+ # Invalid direction
1507
1589
  return
1508
1590
 
1509
1591
  row_key = self.coordinate_to_cell_key((row_idx, 0)).row_key
@@ -1653,7 +1735,7 @@ class DataFrameTable(DataTable):
1653
1735
  col_key = col_name
1654
1736
  self.update_cell(row_key, col_key, formatted_value, update_width=True)
1655
1737
 
1656
- self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
1738
+ # self.notify(f"Cell updated to [$success]{cell_value}[/]", title="Edit")
1657
1739
  except Exception as e:
1658
1740
  self.notify(f"Failed to update cell: {str(e)}", title="Edit", severity="error")
1659
1741
 
@@ -1682,7 +1764,7 @@ class DataFrameTable(DataTable):
1682
1764
  # Check if term is a valid expression
1683
1765
  elif tentative_expr(term):
1684
1766
  try:
1685
- expr = validate_expr(term, self.df, cidx)
1767
+ expr = validate_expr(term, self.df.columns, cidx)
1686
1768
  except Exception as e:
1687
1769
  self.notify(f"Error validating expression [$error]{term}[/]: {str(e)}", title="Edit", severity="error")
1688
1770
  return
@@ -1695,7 +1777,7 @@ class DataFrameTable(DataTable):
1695
1777
  expr = pl.lit(value)
1696
1778
  except Exception:
1697
1779
  self.notify(
1698
- f"Unable to convert [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
1780
+ f"Error converting [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
1699
1781
  title="Edit",
1700
1782
  severity="error",
1701
1783
  )
@@ -1708,16 +1790,13 @@ class DataFrameTable(DataTable):
1708
1790
  # Apply the expression to the column
1709
1791
  self.df = self.df.with_columns(expr.alias(col_name))
1710
1792
  except Exception as e:
1711
- self.notify(f"Failed to apply expression: [$error]{str(e)}[/]", title="Edit", severity="error")
1793
+ self.notify(f"Error applying expression: [$error]{str(e)}[/]", title="Edit", severity="error")
1712
1794
  return
1713
1795
 
1714
1796
  # Recreate the table for display
1715
1797
  self._setup_table()
1716
1798
 
1717
- self.notify(
1718
- f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]",
1719
- title="Edit",
1720
- )
1799
+ # self.notify(f"Column [$accent]{col_name}[/] updated with [$success]{expr}[/]", title="Edit")
1721
1800
 
1722
1801
  def _rename_column(self) -> None:
1723
1802
  """Open modal to rename the selected column."""
@@ -1764,10 +1843,7 @@ class DataFrameTable(DataTable):
1764
1843
  # Move cursor to the renamed column
1765
1844
  self.move_cursor(column=col_idx)
1766
1845
 
1767
- self.notify(
1768
- f"Renamed column [$success]{col_name}[/] to [$success]{new_name}[/]",
1769
- title="Column",
1770
- )
1846
+ # self.notify(f"Renamed column [$success]{col_name}[/] to [$success]{new_name}[/]", title="Column")
1771
1847
 
1772
1848
  def _clear_cell(self) -> None:
1773
1849
  """Clear the current cell by setting its value to None."""
@@ -1795,9 +1871,9 @@ class DataFrameTable(DataTable):
1795
1871
 
1796
1872
  self.update_cell(row_key, col_key, formatted_value)
1797
1873
 
1798
- self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear")
1874
+ # self.notify(f"Cell cleared to [$success]{NULL_DISPLAY}[/]", title="Clear")
1799
1875
  except Exception as e:
1800
- self.notify(f"Failed to clear cell: {str(e)}", title="Clear", severity="error")
1876
+ self.notify(f"Error clearing cell: {str(e)}", title="Clear", severity="error")
1801
1877
  raise e
1802
1878
 
1803
1879
  def _add_column(self, col_name: str = None, col_value: pl.Expr = None) -> None:
@@ -1840,9 +1916,9 @@ class DataFrameTable(DataTable):
1840
1916
  # Move cursor to the new column
1841
1917
  self.move_cursor(column=cidx + 1)
1842
1918
 
1843
- self.notify(f"Added column [$success]{new_name}[/]", title="Add Column")
1919
+ # self.notify(f"Added column [$success]{new_name}[/]", title="Add Column")
1844
1920
  except Exception as e:
1845
- self.notify(f"Failed to add column: {str(e)}", title="Add Column", severity="error")
1921
+ self.notify(f"Error adding column: {str(e)}", title="Add Column", severity="error")
1846
1922
  raise e
1847
1923
 
1848
1924
  def _add_column_expr(self) -> None:
@@ -1884,53 +1960,32 @@ class DataFrameTable(DataTable):
1884
1960
 
1885
1961
  # self.notify(f"Added column [$success]{col_name}[/]", title="Add Column")
1886
1962
  except Exception as e:
1887
- self.notify(f"Failed to add column: [$error]{str(e)}[/]", title="Add Column", severity="error")
1963
+ self.notify(f"Error adding column: [$error]{str(e)}[/]", title="Add Column", severity="error")
1888
1964
  raise e
1889
1965
 
1890
- def _string_to_polars_dtype(self, dtype_str: str) -> pl.DataType:
1891
- """Convert string type name to Polars DataType.
1892
-
1893
- Args:
1894
- dtype_str: String representation of the type ("string", "int", "float", "bool")
1895
-
1896
- Returns:
1897
- Corresponding Polars DataType
1898
-
1899
- Raises:
1900
- ValueError: If the type string is not recognized
1901
- """
1902
- dtype_map = {
1903
- "string": pl.String,
1904
- "int": pl.Int64,
1905
- "float": pl.Float64,
1906
- "bool": pl.Boolean,
1907
- }
1908
-
1909
- dtype_lower = dtype_str.lower().strip()
1910
- return dtype_map.get(dtype_lower)
1911
-
1912
- def _cast_column_dtype(self, dtype: str | pl.DataType = pl.String) -> None:
1966
+ def _cast_column_dtype(self, dtype: str) -> None:
1913
1967
  """Cast the current column to a different data type.
1914
1968
 
1915
1969
  Args:
1916
- dtype: Target data type (string like "int", "float", "bool", "string" or Polars DataType)
1970
+ dtype: Target data type (string representation, e.g., "pl.String", "pl.Int64")
1917
1971
  """
1918
1972
  cidx = self.cursor_col_idx
1919
1973
  col_name = self.cursor_col_name
1920
1974
  current_dtype = self.df.dtypes[cidx]
1921
1975
 
1922
- # Convert string dtype to Polars DataType if needed
1923
- if isinstance(dtype, str):
1924
- target_dtype = self._string_to_polars_dtype(dtype)
1925
- if target_dtype is None:
1926
- self.notify(
1927
- f"Use string for unknown data type: {dtype}. Supported types: {', '.join(self._string_to_polars_dtype.keys())}",
1928
- title="Cast",
1929
- severity="warning",
1930
- )
1931
- target_dtype = pl.String
1932
- else:
1933
- target_dtype = dtype
1976
+ try:
1977
+ target_dtype = eval(dtype)
1978
+ except Exception:
1979
+ self.notify(f"Invalid target data type: [$error]{dtype}[/]", title="Cast", severity="error")
1980
+ return
1981
+
1982
+ if current_dtype == target_dtype:
1983
+ self.notify(
1984
+ f"Column [$accent]{col_name}[/] is already of type [$success]{target_dtype}[/]",
1985
+ title="Cast",
1986
+ severity="warning",
1987
+ )
1988
+ return # No change needed
1934
1989
 
1935
1990
  # Add to history
1936
1991
  self._add_history(
@@ -1944,13 +1999,13 @@ class DataFrameTable(DataTable):
1944
1999
  # Recreate the table display
1945
2000
  self._setup_table()
1946
2001
 
2002
+ self.notify(f"Cast column [$accent]{col_name}[/] to [$success]{target_dtype}[/]", title="Cast")
2003
+ except Exception as e:
1947
2004
  self.notify(
1948
- f"Cast column [$accent]{col_name}[/] to [$success]{target_dtype}[/]",
2005
+ f"Error casting column [$accent]{col_name}[/] to [$success]{target_dtype}[/]: {str(e)}",
1949
2006
  title="Cast",
2007
+ severity="error",
1950
2008
  )
1951
- except Exception as e:
1952
- self.notify(f"Failed to cast column: {str(e)}", title="Cast", severity="error")
1953
- raise e
1954
2009
 
1955
2010
  def _search_cursor_value(self) -> None:
1956
2011
  """Search with cursor value in current column."""
@@ -1959,7 +2014,7 @@ class DataFrameTable(DataTable):
1959
2014
  # Get the value of the currently selected cell
1960
2015
  term = NULL if self.cursor_value is None else str(self.cursor_value)
1961
2016
 
1962
- self._do_search((term, cidx, False, False))
2017
+ self._do_search((term, cidx, False, True))
1963
2018
 
1964
2019
  def _search_expr(self) -> None:
1965
2020
  """Search by expression."""
@@ -1978,6 +2033,7 @@ class DataFrameTable(DataTable):
1978
2033
  """Search for a term."""
1979
2034
  if result is None:
1980
2035
  return
2036
+
1981
2037
  term, cidx, match_nocase, match_whole = result
1982
2038
  col_name = self.df.columns[cidx]
1983
2039
 
@@ -1987,12 +2043,10 @@ class DataFrameTable(DataTable):
1987
2043
  # Support for polars expressions
1988
2044
  elif tentative_expr(term):
1989
2045
  try:
1990
- expr = validate_expr(term, self.df, cidx)
2046
+ expr = validate_expr(term, self.df.columns, cidx)
1991
2047
  except Exception as e:
1992
2048
  self.notify(
1993
- f"Failed to validate Polars expression [$error]{term}[/]: {str(e)}",
1994
- title="Search",
1995
- severity="error",
2049
+ f"Error validating expression [$error]{term}[/]: {str(e)}", title="Search", severity="error"
1996
2050
  )
1997
2051
  return
1998
2052
 
@@ -2016,7 +2070,7 @@ class DataFrameTable(DataTable):
2016
2070
  term = f"(?i){term}"
2017
2071
  expr = pl.col(col_name).cast(pl.String).str.contains(term)
2018
2072
  self.notify(
2019
- f"Unable to convert [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
2073
+ f"Error converting [$accent]{term}[/] to [$error]{dtype}[/]. Cast to string.",
2020
2074
  title="Search",
2021
2075
  severity="warning",
2022
2076
  )
@@ -2031,7 +2085,7 @@ class DataFrameTable(DataTable):
2031
2085
  matches = set(lf.filter(expr).select(RIDX).collect().to_series().to_list())
2032
2086
  except Exception as e:
2033
2087
  self.notify(
2034
- f"Error applying search filter: [$error]{str(e)}[/]",
2088
+ f"Error applying search filter [$accent]{term}[/]: [$error]{str(e)}[/]",
2035
2089
  title="Search",
2036
2090
  severity="error",
2037
2091
  )
@@ -2040,7 +2094,7 @@ class DataFrameTable(DataTable):
2040
2094
  match_count = len(matches)
2041
2095
  if match_count == 0:
2042
2096
  self.notify(
2043
- f"No matches found for [$warning]{term}[/]. Try [$accent](?i)abc[/] for case-insensitive search.",
2097
+ f"No matches found for [$accent]{term}[/]. Try [$warning](?i)abc[/] for case-insensitive search.",
2044
2098
  title="Search",
2045
2099
  severity="warning",
2046
2100
  )
@@ -2053,11 +2107,12 @@ class DataFrameTable(DataTable):
2053
2107
  for m in matches:
2054
2108
  self.selected_rows[m] = True
2055
2109
 
2056
- # Highlight matches
2057
- self._do_highlight()
2058
-
2110
+ # Show notification immediately, then start highlighting
2059
2111
  self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Search")
2060
2112
 
2113
+ # Start highlighting in a worker to avoid blocking the UI
2114
+ self._do_highlight_async()
2115
+
2061
2116
  def _find_matches(
2062
2117
  self, term: str, cidx: int | None = None, match_nocase: bool = False, match_whole: bool = False
2063
2118
  ) -> dict[int, set[int]]:
@@ -2095,7 +2150,7 @@ class DataFrameTable(DataTable):
2095
2150
  expr = pl.col(col_name).is_null()
2096
2151
  elif tentative_expr(term):
2097
2152
  try:
2098
- expr = validate_expr(term, self.df, col_idx)
2153
+ expr = validate_expr(term, self.df.columns, col_idx)
2099
2154
  except Exception as e:
2100
2155
  raise Exception(f"Error validating Polars expression: {str(e)}")
2101
2156
  else:
@@ -2127,9 +2182,9 @@ class DataFrameTable(DataTable):
2127
2182
 
2128
2183
  if scope == "column":
2129
2184
  cidx = self.cursor_col_idx
2130
- self._do_find((term, cidx, False, False))
2185
+ self._do_find((term, cidx, False, True))
2131
2186
  else:
2132
- self._do_find_global((term, None, False, False))
2187
+ self._do_find_global((term, None, False, True))
2133
2188
 
2134
2189
  def _find_expr(self, scope="column") -> None:
2135
2190
  """Open screen to find by expression.
@@ -2158,16 +2213,12 @@ class DataFrameTable(DataTable):
2158
2213
  try:
2159
2214
  matches = self._find_matches(term, cidx, match_nocase, match_whole)
2160
2215
  except Exception as e:
2161
- self.notify(
2162
- f"Error finding matches: [$error]{str(e)}[/]",
2163
- title="Find",
2164
- severity="error",
2165
- )
2216
+ self.notify(f"Error finding matches for [$error]{term}[/]: {str(e)}", title="Find", severity="error")
2166
2217
  return
2167
2218
 
2168
2219
  if not matches:
2169
2220
  self.notify(
2170
- f"No matches found for [$warning]{term}[/] in current column. Try [$accent](?i)abc[/] for case-insensitive search.",
2221
+ f"No matches found for [$accent]{term}[/] in current column. Try [$warning](?i)abc[/] for case-insensitive search.",
2171
2222
  title="Find",
2172
2223
  severity="warning",
2173
2224
  )
@@ -2181,11 +2232,11 @@ class DataFrameTable(DataTable):
2181
2232
  for ridx, col_idxs in matches.items():
2182
2233
  self.matches[ridx].update(col_idxs)
2183
2234
 
2184
- # Highlight matches
2185
- self._do_highlight()
2186
-
2187
2235
  self.notify(f"Found [$accent]{match_count}[/] matches for [$success]{term}[/]", title="Find")
2188
2236
 
2237
+ # Start highlighting in a worker to avoid blocking the UI
2238
+ self._do_highlight_async()
2239
+
2189
2240
  def _do_find_global(self, result) -> None:
2190
2241
  """Global find a term across all columns."""
2191
2242
  if result is None:
@@ -2195,16 +2246,12 @@ class DataFrameTable(DataTable):
2195
2246
  try:
2196
2247
  matches = self._find_matches(term, cidx=None, match_nocase=match_nocase, match_whole=match_whole)
2197
2248
  except Exception as e:
2198
- self.notify(
2199
- f"Error finding matches: [$error]{str(e)}[/]",
2200
- title="Find",
2201
- severity="error",
2202
- )
2249
+ self.notify(f"Error finding matches for [$error]{term}[/]: {str(e)}", title="Find", severity="error")
2203
2250
  return
2204
2251
 
2205
2252
  if not matches:
2206
2253
  self.notify(
2207
- f"No matches found for [$warning]{term}[/] in any column. Try [$accent](?i)abc[/] for case-insensitive search.",
2254
+ f"No matches found for [$accent]{term}[/] in any column. Try [$warning](?i)abc[/] for case-insensitive search.",
2208
2255
  title="Global Find",
2209
2256
  severity="warning",
2210
2257
  )
@@ -2218,14 +2265,13 @@ class DataFrameTable(DataTable):
2218
2265
  for ridx, col_idxs in matches.items():
2219
2266
  self.matches[ridx].update(col_idxs)
2220
2267
 
2221
- # Highlight matches
2222
- self._do_highlight()
2223
-
2224
2268
  self.notify(
2225
- f"Found [$accent]{match_count}[/] matches for [$success]{term}[/] across all columns",
2226
- title="Global Find",
2269
+ f"Found [$accent]{match_count}[/] matches for [$success]{term}[/] across all columns", title="Global Find"
2227
2270
  )
2228
2271
 
2272
+ # Start highlighting in a worker to avoid blocking the UI
2273
+ self._do_highlight_async()
2274
+
2229
2275
  def _next_match(self) -> None:
2230
2276
  """Move cursor to the next match."""
2231
2277
  if not self.matches:
@@ -2364,16 +2410,12 @@ class DataFrameTable(DataTable):
2364
2410
  matches = self._find_matches(term_find, cidx, match_nocase, match_whole)
2365
2411
 
2366
2412
  if not matches:
2367
- self.notify(
2368
- f"No matches found for [$warning]{term_find}[/]",
2369
- title="Replace",
2370
- severity="warning",
2371
- )
2413
+ self.notify(f"No matches found for [$warning]{term_find}[/]", title="Replace", severity="warning")
2372
2414
  return
2373
2415
 
2374
2416
  # Add to history
2375
2417
  self._add_history(
2376
- f"Replacing [$accent]{term_find}[/] with [$success]{term_replace}[/] in column [$accent]{col_name}[/]"
2418
+ f"Replaced [$accent]{term_find}[/] with [$success]{term_replace}[/] in column [$accent]{col_name}[/]"
2377
2419
  )
2378
2420
 
2379
2421
  # Update matches
@@ -2596,10 +2638,7 @@ class DataFrameTable(DataTable):
2596
2638
 
2597
2639
  # Check if we're highlighting or un-highlighting
2598
2640
  if new_selected_count := self.selected_rows.count(True):
2599
- self.notify(
2600
- f"Toggled selection for [$accent]{new_selected_count}[/] rows",
2601
- title="Toggle",
2602
- )
2641
+ self.notify(f"Toggled selection for [$accent]{new_selected_count}[/] rows", title="Toggle")
2603
2642
 
2604
2643
  # Refresh the highlighting
2605
2644
  self._do_highlight(force=True)
@@ -2665,15 +2704,12 @@ class DataFrameTable(DataTable):
2665
2704
  # Recreate the table for display
2666
2705
  self._setup_table()
2667
2706
 
2668
- self.notify(
2669
- f"Removed unselected rows. Now showing [$accent]{selected_count}[/] rows",
2670
- title="Filter",
2671
- )
2707
+ self.notify(f"Removed unselected rows. Now showing [$accent]{selected_count}[/] rows", title="Filter")
2672
2708
 
2673
2709
  def _view_rows(self) -> None:
2674
2710
  """View rows.
2675
2711
 
2676
- If there are selected rows, view those rows.
2712
+ If there are selected rows or matches, view those rows.
2677
2713
  Otherwise, view based on the value of the currently selected cell.
2678
2714
  """
2679
2715
 
@@ -2689,7 +2725,7 @@ class DataFrameTable(DataTable):
2689
2725
  ridx = self.cursor_row_idx
2690
2726
  term = str(self.df.item(ridx, cidx))
2691
2727
 
2692
- self._do_view_rows((term, cidx, False, False))
2728
+ self._do_view_rows((term, cidx, False, True))
2693
2729
 
2694
2730
  def _view_rows_expr(self) -> None:
2695
2731
  """Open the filter screen to enter an expression."""
@@ -2718,10 +2754,10 @@ class DataFrameTable(DataTable):
2718
2754
  elif tentative_expr(term):
2719
2755
  # Support for polars expressions
2720
2756
  try:
2721
- expr = validate_expr(term, self.df, cidx)
2757
+ expr = validate_expr(term, self.df.columns, cidx)
2722
2758
  except Exception as e:
2723
2759
  self.notify(
2724
- f"Error validating Polars expression [$error]{term}[/]: {str(e)}", title="Filter", severity="error"
2760
+ f"Error validating expression [$error]{term}[/]: {str(e)}", title="Filter", severity="error"
2725
2761
  )
2726
2762
  return
2727
2763
  else:
@@ -2743,9 +2779,7 @@ class DataFrameTable(DataTable):
2743
2779
  term = f"(?i){term}"
2744
2780
  expr = pl.col(col_name).cast(pl.String).str.contains(term)
2745
2781
  self.notify(
2746
- f"Unknown column type [$warning]{dtype}[/]. Cast to string.",
2747
- title="Filter",
2748
- severity="warning",
2782
+ f"Unknown column type [$warning]{dtype}[/]. Cast to string.", title="Filter", severity="warning"
2749
2783
  )
2750
2784
 
2751
2785
  # Lazyframe with row indices
@@ -2759,17 +2793,13 @@ class DataFrameTable(DataTable):
2759
2793
  try:
2760
2794
  df_filtered = lf.filter(expr).collect()
2761
2795
  except Exception as e:
2762
- self.notify(f"Failed to apply filter [$error]{expr}[/]: {str(e)}", title="Filter", severity="error")
2796
+ self.notify(f"Error applying filter [$error]{expr}[/]: {str(e)}", title="Filter", severity="error")
2763
2797
  self.histories.pop() # Remove last history entry
2764
2798
  return
2765
2799
 
2766
2800
  matched_count = len(df_filtered)
2767
2801
  if not matched_count:
2768
- self.notify(
2769
- f"No rows match the expression: [$success]{expr}[/]",
2770
- title="Filter",
2771
- severity="warning",
2772
- )
2802
+ self.notify(f"No rows match the expression: [$success]{expr}[/]", title="Filter", severity="warning")
2773
2803
  return
2774
2804
 
2775
2805
  # Add to history
@@ -2784,11 +2814,9 @@ class DataFrameTable(DataTable):
2784
2814
 
2785
2815
  # Recreate the table for display
2786
2816
  self._setup_table()
2817
+ self._do_highlight()
2787
2818
 
2788
- self.notify(
2789
- f"Filtered to [$accent]{matched_count}[/] matching rows",
2790
- title="Filter",
2791
- )
2819
+ self.notify(f"Filtered to [$accent]{matched_count}[/] matching rows", title="Filter")
2792
2820
 
2793
2821
  def _cycle_cursor_type(self) -> None:
2794
2822
  """Cycle through cursor types: cell -> row -> column -> cell."""
@@ -2862,6 +2890,9 @@ class DataFrameTable(DataTable):
2862
2890
  filepath = Path(filename)
2863
2891
  ext = filepath.suffix.lower()
2864
2892
 
2893
+ # Add to history
2894
+ self._add_history(f"Saved dataframe to [$success]{filename}[/]")
2895
+
2865
2896
  try:
2866
2897
  if ext in (".xlsx", ".xls"):
2867
2898
  self._do_save_excel(filename)
@@ -2878,12 +2909,9 @@ class DataFrameTable(DataTable):
2878
2909
  self.filename = filename # Update current filename
2879
2910
  if not self._all_tabs:
2880
2911
  extra = "current tab with " if len(self.app.tabs) > 1 else ""
2881
- self.notify(
2882
- f"Saved {extra}[$accent]{len(self.df)}[/] rows to [$success]{filename}[/]",
2883
- title="Save",
2884
- )
2912
+ self.notify(f"Saved {extra}[$accent]{len(self.df)}[/] rows to [$success]{filename}[/]", title="Save")
2885
2913
  except Exception as e:
2886
- self.notify(f"Failed to save: {str(e)}", title="Save", severity="error")
2914
+ self.notify(f"Error saving [$error]{filename}[/]: {str(e)}", title="Save", severity="error")
2887
2915
  raise e
2888
2916
 
2889
2917
  def _do_save_excel(self, filename: str) -> None:
@@ -2903,14 +2931,10 @@ class DataFrameTable(DataTable):
2903
2931
 
2904
2932
  # From ConfirmScreen callback, so notify accordingly
2905
2933
  if self._all_tabs is True:
2906
- self.notify(
2907
- f"Saved all tabs to [$success]{filename}[/]",
2908
- title="Save",
2909
- )
2934
+ self.notify(f"Saved all tabs to [$success]{filename}[/]", title="Save")
2910
2935
  else:
2911
2936
  self.notify(
2912
- f"Saved current tab with [$accent]{len(self.df)}[/] rows to [$success]{filename}[/]",
2913
- title="Save",
2937
+ f"Saved current tab with [$accent]{len(self.df)}[/] rows to [$success]{filename}[/]", title="Save"
2914
2938
  )
2915
2939
 
2916
2940
  def _make_cell_clickable(self) -> None:
@@ -2944,6 +2968,66 @@ class DataFrameTable(DataTable):
2944
2968
 
2945
2969
  if url_count:
2946
2970
  self.notify(
2947
- f"Made [$accent]{url_count}[/] cell(s) clickable in column [$success]{col_key.value}[/]",
2948
- title="Make Clickable",
2971
+ f"Use Ctrl/Cmd click to open the links in column [$success]{col_key.value}[/]", title="Hyperlink"
2949
2972
  )
2973
+
2974
+ def _simple_sql(self) -> None:
2975
+ """Open the SQL interface screen."""
2976
+ self.app.push_screen(
2977
+ SimpleSqlScreen(self),
2978
+ callback=self._do_simple_sql,
2979
+ )
2980
+
2981
+ def _do_simple_sql(self, result) -> None:
2982
+ """Handle SQL result result from SimpleSqlScreen."""
2983
+ if result is None:
2984
+ return
2985
+ columns, where = result
2986
+
2987
+ sql = f"SELECT {columns} FROM self"
2988
+ if where:
2989
+ sql += f" WHERE {where}"
2990
+
2991
+ self._do_sql(sql)
2992
+
2993
+ def _advanced_sql(self) -> None:
2994
+ """Open the advanced SQL interface screen."""
2995
+ self.app.push_screen(
2996
+ AdvancedSqlScreen(self),
2997
+ callback=self._do_advanced_sql,
2998
+ )
2999
+
3000
+ def _do_advanced_sql(self, result) -> None:
3001
+ """Handle SQL result result from AdvancedSqlScreen."""
3002
+ if result is None:
3003
+ return
3004
+
3005
+ self._do_sql(result)
3006
+
3007
+ def _do_sql(self, sql: str) -> None:
3008
+ """Execute a SQL query directly.
3009
+
3010
+ Args:
3011
+ sql: The SQL query string to execute.
3012
+ """
3013
+ # Add to history
3014
+ self._add_history(f"SQL Query:\n[$accent]{sql}[/]")
3015
+
3016
+ # Execute the SQL query
3017
+ try:
3018
+ self.df = self.df.sql(sql)
3019
+ except Exception as e:
3020
+ self.notify(f"Error executing SQL query [$error]{sql}[/]: {str(e)}", title="SQL Query", severity="error")
3021
+ return
3022
+
3023
+ if not len(self.df):
3024
+ self.notify(f"SQL query returned no results for [$warning]{sql}[/]", title="SQL Query", severity="warning")
3025
+ return
3026
+
3027
+ # Recreate the table display
3028
+ self._setup_table()
3029
+
3030
+ self.notify(
3031
+ f"SQL query executed successfully. Now showing [$accent]{len(self.df)}[/] rows and [$accent]{len(self.df.columns)}[/] columns.",
3032
+ title="SQL Query",
3033
+ )