dataframe-textual 1.5.0__py3-none-any.whl → 1.10.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.
@@ -1,5 +1,6 @@
1
1
  """Modal screens for Polars sql manipulation"""
2
2
 
3
+ from functools import partial
3
4
  from typing import TYPE_CHECKING
4
5
 
5
6
  if TYPE_CHECKING:
@@ -46,18 +47,20 @@ class SqlScreen(ModalScreen):
46
47
 
47
48
  """
48
49
 
49
- def __init__(self, dftable: "DataFrameTable", on_yes_callback=None) -> None:
50
+ def __init__(self, dftable: "DataFrameTable", on_yes_callback=None, on_maybe_callback=None) -> None:
50
51
  """Initialize the SQL screen."""
51
52
  super().__init__()
52
53
  self.dftable = dftable # DataFrameTable
53
54
  self.df: pl.DataFrame = dftable.df # Polars DataFrame
54
55
  self.on_yes_callback = on_yes_callback
56
+ self.on_maybe_callback = on_maybe_callback
55
57
 
56
58
  def compose(self) -> ComposeResult:
57
59
  """Compose the SQL screen widget structure."""
58
60
  # Shared by subclasses
59
61
  with Horizontal(id="button-container"):
60
- yield Button("Apply", id="yes", variant="success")
62
+ yield Button("View", id="yes", variant="success")
63
+ yield Button("Filter", id="maybe", variant="warning")
61
64
  yield Button("Cancel", id="no", variant="error")
62
65
 
63
66
  def on_key(self, event) -> None:
@@ -66,7 +69,16 @@ class SqlScreen(ModalScreen):
66
69
  self.app.pop_screen()
67
70
  event.stop()
68
71
  elif event.key == "enter":
69
- self._handle_yes()
72
+ for button in self.query(Button):
73
+ if button.has_focus:
74
+ if button.id == "yes":
75
+ self._handle_yes()
76
+ elif button.id == "maybe":
77
+ self._handle_maybe()
78
+ break
79
+ else:
80
+ self._handle_yes()
81
+
70
82
  event.stop()
71
83
  elif event.key == "escape":
72
84
  self.dismiss(None)
@@ -76,6 +88,8 @@ class SqlScreen(ModalScreen):
76
88
  """Handle button press events in the SQL screen."""
77
89
  if event.button.id == "yes":
78
90
  self._handle_yes()
91
+ elif event.button.id == "maybe":
92
+ self._handle_maybe()
79
93
  elif event.button.id == "no":
80
94
  self.dismiss(None)
81
95
 
@@ -87,6 +101,14 @@ class SqlScreen(ModalScreen):
87
101
  else:
88
102
  self.dismiss(True)
89
103
 
104
+ def _handle_maybe(self) -> None:
105
+ """Handle Maybe button press."""
106
+ if self.on_maybe_callback:
107
+ result = self.on_maybe_callback()
108
+ self.dismiss(result)
109
+ else:
110
+ self.dismiss(True)
111
+
90
112
 
91
113
  class SimpleSqlScreen(SqlScreen):
92
114
  """Simple SQL query screen."""
@@ -133,25 +155,35 @@ class SimpleSqlScreen(SqlScreen):
133
155
  Returns:
134
156
  None
135
157
  """
136
- super().__init__(dftable, on_yes_callback=self._handle_simple)
158
+ super().__init__(
159
+ dftable,
160
+ on_yes_callback=self.handle_simple,
161
+ on_maybe_callback=partial(self.handle_simple, view=False),
162
+ )
137
163
 
138
164
  def compose(self) -> ComposeResult:
139
165
  """Compose the simple SQL screen widget structure."""
140
166
  with Container(id="sql-container") as container:
141
167
  container.border_title = "SQL Query"
142
168
  yield Label("Select columns (default to all):", id="select-label")
143
- yield SelectionList(*[Selection(col, col) for col in self.df.columns], id="column-selection")
169
+ yield SelectionList(
170
+ *[Selection(col, col) for col in self.df.columns if col not in self.dftable.hidden_columns],
171
+ id="column-selection",
172
+ )
144
173
  yield Label("Where condition (optional)", id="where-label")
145
174
  yield Input(placeholder="e.g., age > 30 and height < 180", id="where-input")
146
175
  yield from super().compose()
147
176
 
148
- def _handle_simple(self) -> None:
177
+ def handle_simple(self, view: bool = True) -> None:
149
178
  """Handle Yes button/Enter key press."""
150
179
  selections = self.query_one(SelectionList).selected
151
- columns = ", ".join(f"`{s}`" for s in selections) if selections else "*"
180
+ if not selections:
181
+ selections = [col for col in self.df.columns if col not in self.dftable.hidden_columns]
182
+
183
+ columns = ", ".join(f"`{s}`" for s in selections)
152
184
  where = self.query_one(Input).value.strip()
153
185
 
154
- return columns, where
186
+ return columns, where, view
155
187
 
156
188
 
157
189
  class AdvancedSqlScreen(SqlScreen):
@@ -184,7 +216,11 @@ class AdvancedSqlScreen(SqlScreen):
184
216
  Returns:
185
217
  None
186
218
  """
187
- super().__init__(dftable, on_yes_callback=self._handle_advanced)
219
+ super().__init__(
220
+ dftable,
221
+ on_yes_callback=self.handle_advanced,
222
+ on_maybe_callback=partial(self.handle_advanced, view=False),
223
+ )
188
224
 
189
225
  def compose(self) -> ComposeResult:
190
226
  """Compose the advanced SQL screen widget structure."""
@@ -197,6 +233,6 @@ class AdvancedSqlScreen(SqlScreen):
197
233
  )
198
234
  yield from super().compose()
199
235
 
200
- def _handle_advanced(self) -> None:
236
+ def handle_advanced(self, view: bool = True) -> None:
201
237
  """Handle Yes button/Enter key press."""
202
- return self.query_one(TextArea).text.strip()
238
+ return self.query_one(TextArea).text.strip(), view
@@ -97,60 +97,61 @@ class TableScreen(ModalScreen):
97
97
  self.build_table()
98
98
  event.stop()
99
99
 
100
- def _filter_or_highlight_selected_value(
101
- self, col_name_value: tuple[str, Any] | None, action: str = "filter"
102
- ) -> None:
103
- """Apply filter or highlight action by the selected value.
100
+ def filter_or_view_selected_value(self, cidx_name_value: tuple[int, str, Any] | None, action: str = "view") -> None:
101
+ """Apply filter or view action by the selected value.
104
102
 
105
- Filters or highlights rows in the main table based on a selected value from
103
+ Filters or views rows in the main table based on a selected value from
106
104
  this table (typically frequency or row detail). Updates the main table's display
107
105
  and notifies the user of the action.
108
106
 
109
107
  Args:
110
- col_name_value: Tuple of (column_name, column_value) to filter/highlight by, or None.
111
- action: Either "filter" to hide non-matching rows, or "highlight" to select matching rows. Defaults to "filter".
112
-
113
- Returns:
114
- None
108
+ col_name_value: Tuple of (column_name, column_value) to filter/view by, or None.
109
+ action: Either "filter" to hide non-matching rows, or "view" to show matching rows. Defaults to "view".
115
110
  """
116
- if col_name_value is None:
111
+ if cidx_name_value is None:
117
112
  return
118
- col_name, col_value = col_name_value
113
+ cidx, col_name, col_value = cidx_name_value
114
+ self.log(f"Filtering or viewing by {col_name} == {col_value}")
119
115
 
120
116
  # Handle NULL values
121
117
  if col_value == NULL:
122
118
  # Create expression for NULL values
123
119
  expr = pl.col(col_name).is_null()
124
- value_display = "[$success]NULL[/]"
120
+ value_display = f"[$success]{NULL_DISPLAY}[/]"
125
121
  else:
126
122
  # Create expression for the selected value
127
123
  expr = pl.col(col_name) == col_value
128
124
  value_display = f"[$success]{col_value}[/]"
129
125
 
130
- matched_indices = set(self.dftable.df.with_row_index(RIDX).filter(expr)[RIDX].to_list())
126
+ df_filtered = self.dftable.df.with_row_index(RIDX).filter(expr)
127
+ self.log(f"Filtered dataframe has {len(df_filtered)} rows")
128
+
129
+ matched_indices = set(df_filtered[RIDX].to_list())
130
+ if not matched_indices:
131
+ self.notify(
132
+ f"No matches found for [$warning]{col_name}[/] == {value_display}",
133
+ title="No Matches",
134
+ severity="warning",
135
+ )
136
+ return
131
137
 
132
138
  # Apply the action
133
139
  if action == "filter":
134
- # Update visible_rows to reflect the filter
135
- for i in range(len(self.dftable.visible_rows)):
136
- self.dftable.visible_rows[i] = i in matched_indices
137
- title = "Filter"
138
- message = f"Filtered by [$accent]{col_name}[/] == [$success]{value_display}[/]"
139
- else: # action == "highlight"
140
- # Update selected_rows to reflect the highlights
140
+ # Update selections
141
141
  for i in range(len(self.dftable.selected_rows)):
142
142
  self.dftable.selected_rows[i] = i in matched_indices
143
- title = "Highlight"
144
- message = f"Highlighted [$accent]{col_name}[/] == [$success]{value_display}[/]"
145
143
 
146
- # Recreate the table display with updated data in the main app
147
- self.dftable._setup_table()
144
+ # Update main table display
145
+ self.dftable.do_filter_rows()
146
+
147
+ else: # action == "view"
148
+ # Update visible rows
149
+ expr = [i in matched_indices for i in range(len(self.dftable.df))]
150
+ self.dftable.view_rows((expr, cidx, False, True))
148
151
 
149
152
  # Dismiss the frequency screen
150
153
  self.app.pop_screen()
151
154
 
152
- self.notify(message, title=title)
153
-
154
155
 
155
156
  class RowDetailScreen(TableScreen):
156
157
  """Modal screen to display a single row's details."""
@@ -199,30 +200,27 @@ class RowDetailScreen(TableScreen):
199
200
 
200
201
  Args:
201
202
  event: The key event object.
202
-
203
- Returns:
204
- None
205
203
  """
206
204
  if event.key == "v":
207
- # Filter the main table by the selected value
208
- self._filter_or_highlight_selected_value(self._get_col_name_value(), action="filter")
205
+ # View the main table by the selected value
206
+ self.filter_or_view_selected_value(self.get_cidx_name_value(), action="view")
209
207
  event.stop()
210
208
  elif event.key == "quotation_mark": # '"'
211
- # Highlight the main table by the selected value
212
- self._filter_or_highlight_selected_value(self._get_col_name_value(), action="highlight")
209
+ # Filter the main table by the selected value
210
+ self.filter_or_view_selected_value(self.get_cidx_name_value(), action="filter")
213
211
  event.stop()
214
212
  elif event.key == "comma":
215
213
  event.stop()
216
214
 
217
- def _get_col_name_value(self) -> tuple[str, Any] | None:
218
- row_idx = self.table.cursor_row
219
- if row_idx >= len(self.df.columns):
215
+ def get_cidx_name_value(self) -> tuple[int, str, Any] | None:
216
+ cidx = self.table.cursor_row
217
+ if cidx >= len(self.df.columns):
220
218
  return None # Invalid row
221
219
 
222
- col_name = self.df.columns[row_idx]
223
- col_value = self.df.item(self.ridx, row_idx)
220
+ col_name = self.df.columns[cidx]
221
+ col_value = self.df.item(self.ridx, cidx)
224
222
 
225
- return col_name, col_value
223
+ return cidx, col_name, col_value
226
224
 
227
225
 
228
226
  class StatisticsScreen(TableScreen):
@@ -244,14 +242,14 @@ class StatisticsScreen(TableScreen):
244
242
 
245
243
  if self.col_idx is None:
246
244
  # Dataframe statistics
247
- self._build_dataframe_stats()
245
+ self.build_dataframe_stats()
248
246
  self.table.cursor_type = "column"
249
247
  else:
250
248
  # Column statistics
251
- self._build_column_stats()
249
+ self.build_column_stats()
252
250
  self.table.cursor_type = "row"
253
251
 
254
- def _build_column_stats(self) -> None:
252
+ def build_column_stats(self) -> None:
255
253
  """Build statistics for a single column."""
256
254
  col_name = self.df.columns[self.col_idx]
257
255
  lf = self.df.lazy()
@@ -292,7 +290,7 @@ class StatisticsScreen(TableScreen):
292
290
  Text(value, style=dc.style, justify=dc.justify),
293
291
  )
294
292
 
295
- def _build_dataframe_stats(self) -> None:
293
+ def build_dataframe_stats(self) -> None:
296
294
  """Build statistics for the entire dataframe."""
297
295
  lf = self.df.lazy()
298
296
 
@@ -351,16 +349,16 @@ class FrequencyScreen(TableScreen):
351
349
 
352
350
  CSS = TableScreen.DEFAULT_CSS.replace("TableScreen", "FrequencyScreen")
353
351
 
354
- def __init__(self, col_idx: int, dftable: "DataFrameTable") -> None:
352
+ def __init__(self, cidx: int, dftable: "DataFrameTable") -> None:
355
353
  super().__init__(dftable)
356
- self.col_idx = col_idx
354
+ self.cidx = cidx
357
355
  self.sorted_columns = {
358
356
  1: True, # Count
359
357
  }
360
358
 
361
359
  df = dftable.df.filter(dftable.visible_rows) if False in dftable.visible_rows else dftable.df
362
360
  self.total_count = len(df)
363
- self.df: pl.DataFrame = df[df.columns[self.col_idx]].value_counts(sort=True).sort("count", descending=True)
361
+ self.df: pl.DataFrame = df[df.columns[self.cidx]].value_counts(sort=True).sort("count", descending=True)
364
362
 
365
363
  def on_mount(self) -> None:
366
364
  """Create the frequency table."""
@@ -369,19 +367,19 @@ class FrequencyScreen(TableScreen):
369
367
  def on_key(self, event):
370
368
  if event.key == "left_square_bracket": # '['
371
369
  # Sort by current column in ascending order
372
- self._sort_by_column(descending=False)
370
+ self.sort_by_column(descending=False)
373
371
  event.stop()
374
372
  elif event.key == "right_square_bracket": # ']'
375
373
  # Sort by current column in descending order
376
- self._sort_by_column(descending=True)
374
+ self.sort_by_column(descending=True)
377
375
  event.stop()
378
376
  elif event.key == "v":
379
377
  # Filter the main table by the selected value
380
- self._filter_or_highlight_selected_value(self._get_col_name_value(), action="filter")
378
+ self.filter_or_view_selected_value(self.get_cidx_name_value(), action="view")
381
379
  event.stop()
382
380
  elif event.key == "quotation_mark": # '"'
383
381
  # Highlight the main table by the selected value
384
- self._filter_or_highlight_selected_value(self._get_col_name_value(), action="highlight")
382
+ self.filter_or_view_selected_value(self.get_cidx_name_value(), action="filter")
385
383
  event.stop()
386
384
 
387
385
  def build_table(self) -> None:
@@ -389,8 +387,8 @@ class FrequencyScreen(TableScreen):
389
387
  self.table.clear(columns=True)
390
388
 
391
389
  # Create frequency table
392
- column = self.dftable.df.columns[self.col_idx]
393
- dtype = self.dftable.df.dtypes[self.col_idx]
390
+ column = self.dftable.df.columns[self.cidx]
391
+ dtype = self.dftable.df.dtypes[self.cidx]
394
392
  dc = DtypeConfig(dtype)
395
393
 
396
394
  # Add column headers with sort indicators
@@ -468,7 +466,7 @@ class FrequencyScreen(TableScreen):
468
466
  key="total",
469
467
  )
470
468
 
471
- def _sort_by_column(self, descending: bool) -> None:
469
+ def sort_by_column(self, descending: bool) -> None:
472
470
  """Sort the dataframe by the selected column and refresh the main table."""
473
471
  row_idx, col_idx = self.table.cursor_coordinate
474
472
  col_sort = col_idx if col_idx == 0 else 1
@@ -493,15 +491,15 @@ class FrequencyScreen(TableScreen):
493
491
  # order = "desc" if descending else "asc"
494
492
  # self.notify(f"Sorted by [on $primary]{col_name}[/] ({order})", title="Sort")
495
493
 
496
- def _get_col_name_value(self) -> tuple[str, str] | None:
494
+ def get_cidx_name_value(self) -> tuple[str, str, str] | None:
497
495
  row_idx = self.table.cursor_row
498
496
  if row_idx >= len(self.df[:, 0]): # first column
499
497
  return None # Skip the last `Total` row
500
498
 
501
- col_name = self.dftable.df.columns[self.col_idx]
502
- col_dtype = self.dftable.df.dtypes[self.col_idx]
499
+ col_name = self.dftable.df.columns[self.cidx]
500
+ col_dtype = self.dftable.df.dtypes[self.cidx]
503
501
 
504
502
  cell_value = self.table.get_cell_at(Coordinate(row_idx, 0))
505
503
  col_value = NULL if cell_value.plain == NULL_DISPLAY else DtypeConfig(col_dtype).convert(cell_value.plain)
506
504
 
507
- return col_name, col_value
505
+ return self.cidx, col_name, col_value
@@ -5,11 +5,13 @@ from typing import TYPE_CHECKING
5
5
  if TYPE_CHECKING:
6
6
  from .data_frame_table import DataFrameTable
7
7
 
8
+
8
9
  import polars as pl
9
10
  from textual.app import ComposeResult
10
11
  from textual.containers import Container, Horizontal
11
12
  from textual.screen import ModalScreen
12
- from textual.widgets import Button, Checkbox, Input, Label, Static
13
+ from textual.widgets import Button, Checkbox, Input, Label, Static, TabPane
14
+ from textual.widgets.tabbed_content import ContentTab
13
15
 
14
16
  from .common import NULL, DtypeConfig, tentative_expr, validate_expr
15
17
 
@@ -254,7 +256,18 @@ class YesNoScreen(ModalScreen):
254
256
  def on_key(self, event) -> None:
255
257
  """Handle key press events in the table screen."""
256
258
  if event.key == "enter":
257
- self._handle_yes()
259
+ for button in self.query(Button):
260
+ if button.has_focus:
261
+ if button.id == "yes":
262
+ self._handle_yes()
263
+ elif button.id == "maybe":
264
+ self._handle_maybe()
265
+ elif button.id == "no":
266
+ self.dismiss(None)
267
+ break
268
+ else:
269
+ self._handle_yes()
270
+
258
271
  event.stop()
259
272
  elif event.key == "escape":
260
273
  self.dismiss(None)
@@ -282,18 +295,26 @@ class SaveFileScreen(YesNoScreen):
282
295
 
283
296
  CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "SaveFileScreen")
284
297
 
285
- def __init__(self, filename: str, title="Save Tab"):
298
+ def __init__(
299
+ self, filename: str, title: str = "Save to File", all_tabs: bool | None = None, multi_tab: bool = False
300
+ ):
301
+ self.all_tabs = all_tabs or (all_tabs is None and multi_tab)
286
302
  super().__init__(
287
303
  title=title,
304
+ label="Enter filename",
288
305
  input=filename,
306
+ yes="Save",
307
+ maybe="Save All Tabs" if self.all_tabs else None,
308
+ no="Cancel",
289
309
  on_yes_callback=self.handle_save,
310
+ on_maybe_callback=self.handle_save,
290
311
  )
291
312
 
292
313
  def handle_save(self):
293
314
  if self.input:
294
315
  input_filename = self.input.value.strip()
295
316
  if input_filename:
296
- return input_filename
317
+ return input_filename, self.all_tabs
297
318
  else:
298
319
  self.notify("Filename cannot be empty", title="Save", severity="error")
299
320
  return None
@@ -473,13 +494,13 @@ class FilterScreen(YesNoScreen):
473
494
 
474
495
  CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "FilterScreen")
475
496
 
476
- def __init__(self, df: pl.DataFrame, cidx: int, input_value: str | None = None):
497
+ def __init__(self, df: pl.DataFrame, cidx: int, term: str | None = None):
477
498
  self.df = df
478
499
  self.cidx = cidx
479
500
  super().__init__(
480
501
  title="Filter by Expression",
481
502
  label="e.g., NULL, $1 > 50, $name == 'text', $_ > 100, $a < $b, $_.str.contains('sub')",
482
- input=input_value,
503
+ input=term,
483
504
  checkbox="Match Nocase",
484
505
  checkbox2="Match Whole",
485
506
  on_yes_callback=self._get_input,
@@ -587,7 +608,7 @@ class AddColumnScreen(YesNoScreen):
587
608
  title="Add Column",
588
609
  label="Enter column name",
589
610
  input="Link" if link else "Column name",
590
- label2="Enter link template, e.g., https://example.com/$1/id/$id, PC/compound/$_"
611
+ label2="Enter link template, e.g., https://example.com/$_/id/$1, PC/compound/$cid"
591
612
  if link
592
613
  else "Enter value or Polars expression, e.g., abc, pl.lit(123), NULL, $_ * 2, $1 + $total, $_.str.to_uppercase(), pl.concat_str($_, pl.lit('-suffix'))",
593
614
  input2="Link template" if link else "Column value or expression",
@@ -694,3 +715,43 @@ class FindReplaceScreen(YesNoScreen):
694
715
  replace_all = True
695
716
 
696
717
  return term_find, term_replace, match_nocase, match_whole, replace_all
718
+
719
+
720
+ class RenameTabScreen(YesNoScreen):
721
+ """Modal screen to rename a tab."""
722
+
723
+ CSS = YesNoScreen.DEFAULT_CSS.replace("YesNoScreen", "RenameTabScreen")
724
+
725
+ def __init__(self, content_tab: ContentTab, existing_tabs: list[TabPane]):
726
+ self.content_tab = content_tab
727
+ self.existing_tabs = existing_tabs
728
+ tab_name = content_tab.label_text
729
+
730
+ super().__init__(
731
+ title="Rename Tab",
732
+ label="New tab name",
733
+ input={"value": tab_name},
734
+ on_yes_callback=self._validate_input,
735
+ )
736
+
737
+ def _validate_input(self) -> None:
738
+ """Validate and save the new tab name."""
739
+ new_name = self.input.value.strip()
740
+
741
+ # Check if name is empty
742
+ if not new_name:
743
+ self.notify("Tab name cannot be empty", title="Rename Tab", severity="error")
744
+ return None
745
+
746
+ # Check if name changed
747
+ if new_name == self.content_tab.label_text:
748
+ self.notify("No changes made", title="Rename Tab", severity="warning")
749
+ return None
750
+
751
+ # Check if name already exists
752
+ if new_name in self.existing_tabs:
753
+ self.notify(f"Tab [$accent]{new_name}[/] already exists", title="Rename Tab", severity="error")
754
+ return None
755
+
756
+ # Return new name
757
+ return self.content_tab, new_name