dataframe-textual 0.3.0__tar.gz → 0.3.2__tar.gz

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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dataframe-textual
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Interactive CSV/Excel viewer for the terminal (Textual TUI)
5
5
  Project-URL: Homepage, https://github.com/need47/dataframe-textual
6
6
  Project-URL: Repository, https://github.com/need47/dataframe-textual.git
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dataframe-textual"
7
- version = "0.3.0"
7
+ version = "0.3.2"
8
8
  description = "Interactive CSV/Excel viewer for the terminal (Textual TUI)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -549,7 +549,7 @@ class DataFrameTable(DataTable):
549
549
  return
550
550
 
551
551
  # Push the modal screen
552
- self.app.push_screen(RowDetailScreen(row_idx, self.df))
552
+ self.app.push_screen(RowDetailScreen(row_idx, self))
553
553
 
554
554
  def _show_frequency(self) -> None:
555
555
  """Show frequency distribution for the current column."""
@@ -558,9 +558,7 @@ class DataFrameTable(DataTable):
558
558
  return
559
559
 
560
560
  # Push the frequency modal screen
561
- self.app.push_screen(
562
- FrequencyScreen(col_idx, self.df.filter(self.visible_rows))
563
- )
561
+ self.app.push_screen(FrequencyScreen(col_idx, self))
564
562
 
565
563
  def _open_freeze_screen(self) -> None:
566
564
  """Open the freeze screen to set fixed rows and columns."""
@@ -579,7 +577,7 @@ class DataFrameTable(DataTable):
579
577
 
580
578
  # Add to history
581
579
  self._add_history(
582
- f"Pinned [on $primary]{fixed_rows}[/] rows and [on $primary]{fixed_columns}[/] columns"
580
+ f"Pinned [$accent]{fixed_rows}[/] rows and [$accent]{fixed_columns}[/] columns"
583
581
  )
584
582
 
585
583
  # Apply the pin settings to the table
@@ -589,7 +587,7 @@ class DataFrameTable(DataTable):
589
587
  self.fixed_columns = fixed_columns
590
588
 
591
589
  self.app.notify(
592
- f"Pinned [on $primary]{fixed_rows}[/] rows and [on $primary]{fixed_columns}[/] columns",
590
+ f"Pinned [$accent]{fixed_rows}[/] rows and [$accent]{fixed_columns}[/] columns",
593
591
  title="Pin",
594
592
  )
595
593
 
@@ -1109,7 +1107,7 @@ class DataFrameTable(DataTable):
1109
1107
  self.update_cell(row_key, col_key, cell_text)
1110
1108
 
1111
1109
  self.app.notify(
1112
- f"Found [on $success]{match_count}[/] matches for [on $primary]{term}[/] across all columns",
1110
+ f"Found [$accent]{match_count}[/] matches for [on $primary]{term}[/] across all columns",
1113
1111
  title="Global Search",
1114
1112
  )
1115
1113
 
@@ -1143,7 +1141,7 @@ class DataFrameTable(DataTable):
1143
1141
  # Check if we're highlighting or un-highlighting
1144
1142
  if new_selected_count := self.selected_rows.count(True):
1145
1143
  self.app.notify(
1146
- f"Toggled selection - now showing [on $primary]{new_selected_count}[/] rows",
1144
+ f"Toggled selection - now showing [$accent]{new_selected_count}[/] rows",
1147
1145
  title="Toggle",
1148
1146
  )
1149
1147
 
@@ -1167,7 +1165,7 @@ class DataFrameTable(DataTable):
1167
1165
  self._highlight_rows(clear=True)
1168
1166
 
1169
1167
  self.app.notify(
1170
- f"Cleared [on $primary]{selected_count}[/] selected rows", title="Clear"
1168
+ f"Cleared [$accent]{selected_count}[/] selected rows", title="Clear"
1171
1169
  )
1172
1170
 
1173
1171
  def _filter_selected_rows(self) -> None:
@@ -1190,7 +1188,7 @@ class DataFrameTable(DataTable):
1190
1188
  self._setup_table()
1191
1189
 
1192
1190
  self.app.notify(
1193
- f"Removed unselected rows. Now showing [on $primary]{selected_count}[/] rows",
1191
+ f"Removed unselected rows. Now showing [$accent]{selected_count}[/] rows",
1194
1192
  title="Filter",
1195
1193
  )
1196
1194
 
@@ -1255,7 +1253,7 @@ class DataFrameTable(DataTable):
1255
1253
  self._setup_table()
1256
1254
 
1257
1255
  self.app.notify(
1258
- f"Filtered to [on $primary]{matched_count}[/] matching rows",
1256
+ f"Filtered to [$accent]{matched_count}[/] matching rows",
1259
1257
  title="Filter",
1260
1258
  )
1261
1259
 
@@ -117,7 +117,9 @@ class DataFrameViewer(App):
117
117
  def on_key(self, event):
118
118
  if event.key == "k":
119
119
  self.theme = _next(list(BUILTIN_THEMES.keys()), self.theme)
120
- self.notify(f"Switched to theme: [$primary]{self.theme}[/]", title="Theme")
120
+ self.notify(
121
+ f"Switched to theme: [on $primary]{self.theme}[/]", title="Theme"
122
+ )
121
123
 
122
124
  def on_tabbed_content_tab_activated(
123
125
  self, event: TabbedContent.TabActivated
@@ -202,12 +204,24 @@ class DataFrameViewer(App):
202
204
 
203
205
  def _add_tab(self, df: pl.DataFrame, filename: str) -> None:
204
206
  """Add new table tab. If single file, replace table; if multiple, add tab."""
205
- table = DataFrameTable(df, filename, zebra_stripes=True)
206
207
  tabname = Path(filename).stem
207
208
  if any(tab.name == tabname for tab in self.tabs):
208
209
  tabname = f"{tabname}_{len(self.tabs) + 1}"
209
210
 
210
- tab = TabPane(tabname, table, name=tabname, id=f"tab_{len(self.tabs) + 1}")
211
+ # Find an available tab index
212
+ tab_idx = f"tab_{len(self.tabs) + 1}"
213
+ for idx in range(len(self.tabs)):
214
+ pending_tab_idx = f"tab_{idx + 1}"
215
+ if any(tab.id == pending_tab_idx for tab in self.tabs):
216
+ continue
217
+
218
+ tab_idx = pending_tab_idx
219
+ break
220
+
221
+ table = DataFrameTable(
222
+ df, filename, zebra_stripes=True, id=tab_idx, name=tabname
223
+ )
224
+ tab = TabPane(tabname, table, name=tabname, id=tab_idx)
211
225
  self.tabbed.add_pane(tab)
212
226
  self.tabs[tab] = table
213
227
 
@@ -226,6 +240,7 @@ class DataFrameViewer(App):
226
240
  else:
227
241
  if active_pane := self.tabbed.active_pane:
228
242
  self.tabbed.remove_pane(active_pane.id)
243
+ self.tabs.pop(active_pane)
229
244
  self.notify(
230
245
  f"Closed tab [on $primary]{active_pane.name}[/]", title="Close"
231
246
  )
@@ -1,6 +1,9 @@
1
1
  """Modal screens for displaying data in tables (row details and frequency)."""
2
2
 
3
- from typing import Any
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ if TYPE_CHECKING:
6
+ from .data_frame_table import DataFrameTable
4
7
 
5
8
  import polars as pl
6
9
  from rich.text import Text
@@ -10,7 +13,7 @@ from textual.renderables.bar import Bar
10
13
  from textual.screen import ModalScreen
11
14
  from textual.widgets import DataTable
12
15
 
13
- from .common import BOOLS, DtypeConfig, _format_row
16
+ from .common import DtypeConfig, _format_row
14
17
 
15
18
 
16
19
  class TableScreen(ModalScreen):
@@ -33,14 +36,14 @@ class TableScreen(ModalScreen):
33
36
  }
34
37
  """
35
38
 
36
- def __init__(self, df: pl.DataFrame, id: str | None = None):
39
+ def __init__(self, dftable: DataFrameTable):
37
40
  super().__init__()
38
- self.df = df
39
- self.id = id
41
+ self.df: pl.DataFrame = dftable.df # Polars DataFrame
42
+ self.dftable = dftable # DataFrameTable
40
43
 
41
44
  def compose(self) -> ComposeResult:
42
45
  """Create the table. Must be overridden by subclasses."""
43
- self.table = DataTable(zebra_stripes=True, id=self.id)
46
+ self.table = DataTable(zebra_stripes=True)
44
47
  yield self.table
45
48
 
46
49
  def on_key(self, event):
@@ -87,27 +90,26 @@ class TableScreen(ModalScreen):
87
90
  expr = pl.col(col_name) == col_value
88
91
  value_display = f"[on $primary]{col_value}[/]"
89
92
 
90
- app = self.app
91
93
  matched_indices = set(
92
- app.df.with_row_index("__rid__").filter(expr)["__rid__"].to_list()
94
+ self.dftable.df.with_row_index("__rid__").filter(expr)["__rid__"].to_list()
93
95
  )
94
96
 
95
97
  # Apply the action
96
98
  if action == "filter":
97
99
  # Update visible_rows to reflect the filter
98
- for i in range(len(app.visible_rows)):
99
- app.visible_rows[i] = i in matched_indices
100
+ for i in range(len(self.dftable.visible_rows)):
101
+ self.dftable.visible_rows[i] = i in matched_indices
100
102
  title = "Filter"
101
103
  message = f"Filtered by [on $primary]{col_name}[/] = {value_display}"
102
104
  else: # action == "highlight"
103
105
  # Update selected_rows to reflect the highlights
104
- for i in range(len(app.selected_rows)):
105
- app.selected_rows[i] = i in matched_indices
106
+ for i in range(len(self.dftable.selected_rows)):
107
+ self.dftable.selected_rows[i] = i in matched_indices
106
108
  title = "Highlight"
107
109
  message = f"Highlighted [on $primary]{col_name}[/] = {value_display}"
108
110
 
109
111
  # Recreate the table display with updated data in the main app
110
- app._setup_table()
112
+ self.dftable._setup_table()
111
113
 
112
114
  # Dismiss the frequency screen
113
115
  self.app.pop_screen()
@@ -120,8 +122,8 @@ class RowDetailScreen(TableScreen):
120
122
 
121
123
  CSS = TableScreen.DEFAULT_CSS.replace("TableScreen", "RowDetailScreen")
122
124
 
123
- def __init__(self, row_idx: int, df: pl.DataFrame):
124
- super().__init__(df, id="row-detail-table")
125
+ def __init__(self, row_idx: int, dftable):
126
+ super().__init__(dftable)
125
127
  self.row_idx = row_idx
126
128
 
127
129
  def on_mount(self) -> None:
@@ -169,25 +171,54 @@ class FrequencyScreen(TableScreen):
169
171
 
170
172
  CSS = TableScreen.DEFAULT_CSS.replace("TableScreen", "FrequencyScreen")
171
173
 
172
- def __init__(self, col_idx: int, df: pl.DataFrame):
173
- super().__init__(df, id="frequency-table")
174
+ def __init__(self, col_idx: int, dftable: DataFrameTable):
175
+ super().__init__(dftable)
174
176
  self.col_idx = col_idx
175
177
  self.sorted_columns = {
176
178
  1: True, # Count
177
179
  2: True, # %
178
180
  }
181
+ self.df: pl.DataFrame = (
182
+ dftable.df[dftable.df.columns[self.col_idx]]
183
+ .value_counts(sort=True)
184
+ .sort("count", descending=True)
185
+ )
179
186
 
180
187
  def on_mount(self) -> None:
181
188
  """Create the frequency table."""
182
- column = self.df.columns[self.col_idx]
183
- dtype = str(self.df.dtypes[self.col_idx])
189
+ self.build_table()
190
+
191
+ def on_key(self, event):
192
+ if event.key == "left_square_bracket": # '['
193
+ # Sort by current column in ascending order
194
+ self._sort_by_column(descending=False)
195
+ event.stop()
196
+ elif event.key == "right_square_bracket": # ']'
197
+ # Sort by current column in descending order
198
+ self._sort_by_column(descending=True)
199
+ event.stop()
200
+ elif event.key == "v":
201
+ # Filter the main table by the selected value
202
+ self._filter_or_highlight_selected_value(
203
+ self._get_col_name_value(), action="filter"
204
+ )
205
+ event.stop()
206
+ elif event.key == "quotation_mark": # '"'
207
+ # Highlight the main table by the selected value
208
+ self._filter_or_highlight_selected_value(
209
+ self._get_col_name_value(), action="highlight"
210
+ )
211
+ event.stop()
212
+
213
+ def build_table(self) -> None:
214
+ # Create frequency table
215
+ column = self.dftable.df.columns[self.col_idx]
216
+ dtype = str(self.dftable.df.dtypes[self.col_idx])
184
217
  dc = DtypeConfig(dtype)
185
218
 
186
219
  # Calculate frequencies using Polars
187
- freq_df = self.df[column].value_counts(sort=True).sort("count", descending=True)
188
- total_count = len(self.df)
220
+ total_count = len(self.dftable.df)
189
221
 
190
- # Create frequency table
191
222
  self.table.add_column(Text(column, justify=dc.justify), key=column)
192
223
  self.table.add_column(Text("Count", justify="right"), key="Count")
193
224
  self.table.add_column(Text("%", justify="right"), key="%")
@@ -198,7 +229,7 @@ class FrequencyScreen(TableScreen):
198
229
  ds_float = DtypeConfig("Float64")
199
230
 
200
231
  # Add rows to the frequency table
201
- for row_idx, row in enumerate(freq_df.rows()):
232
+ for row_idx, row in enumerate(self.df.rows()):
202
233
  value, count = row
203
234
  percentage = (count / total_count) * 100
204
235
 
@@ -226,39 +257,22 @@ class FrequencyScreen(TableScreen):
226
257
  Text("Total", style="bold", justify=dc.justify),
227
258
  Text(f"{total_count:,}", style="bold", justify="right"),
228
259
  Text("100.00", style="bold", justify="right"),
260
+ Bar(
261
+ highlight_range=(0.0, 10),
262
+ width=10,
263
+ ),
229
264
  key="total",
230
265
  )
231
266
 
232
- def on_key(self, event):
233
- if event.key == "left_square_bracket": # '['
234
- # Sort by current column in ascending order
235
- self._sort_by_column(descending=False)
236
- event.stop()
237
- elif event.key == "right_square_bracket": # ']'
238
- # Sort by current column in descending order
239
- self._sort_by_column(descending=True)
240
- event.stop()
241
- elif event.key == "v":
242
- # Filter the main table by the selected value
243
- self._filter_or_highlight_selected_value(
244
- self._get_col_name_value(), action="filter"
245
- )
246
- event.stop()
247
- elif event.key == "quotation_mark": # '"'
248
- # Highlight the main table by the selected value
249
- self._filter_or_highlight_selected_value(
250
- self._get_col_name_value(), action="highlight"
251
- )
252
- event.stop()
253
-
254
267
  def _sort_by_column(self, descending: bool) -> None:
255
268
  """Sort the dataframe by the selected column and refresh the main table."""
256
- freq_table = self.query_one(DataTable)
257
269
 
258
- col_idx = freq_table.cursor_column
259
- col_dtype = "String"
270
+ self.log(self.df)
271
+
272
+ row_idx, col_idx = self.table.cursor_coordinate
273
+ col_sort = col_idx if col_idx == 0 else 1
260
274
 
261
- sort_dir = self.sorted_columns.get(col_idx)
275
+ sort_dir = self.sorted_columns.get(col_sort)
262
276
  if sort_dir is not None:
263
277
  # If already sorted in the same direction, do nothing
264
278
  if sort_dir == descending:
@@ -268,34 +282,16 @@ class FrequencyScreen(TableScreen):
268
282
  return
269
283
 
270
284
  self.sorted_columns.clear()
271
- self.sorted_columns[col_idx] = descending
272
-
273
- if col_idx == 0:
274
- col_name = self.df.columns[self.col_idx]
275
- col_dtype = str(self.df.dtypes[self.col_idx])
276
- elif col_idx == 1:
277
- col_name = "Count"
278
- col_dtype = "Int64"
279
- elif col_idx == 2:
280
- col_name = "%"
281
- col_dtype = "Float64"
282
-
283
- def key_fun(freq_col):
284
- col_value = freq_col.plain
285
-
286
- if col_dtype == "Int64":
287
- return int(col_value)
288
- elif col_dtype == "Float64":
289
- return float(col_value)
290
- elif col_dtype == "Boolean":
291
- return BOOLS[col_value]
292
- else:
293
- return col_value
294
-
295
- # Sort the table
296
- freq_table.sort(
297
- col_name, key=lambda freq_col: key_fun(freq_col), reverse=descending
298
- )
285
+ self.sorted_columns[col_sort] = descending
286
+
287
+ col_name = self.df.columns[col_sort]
288
+ self.df = self.df.sort(col_name, descending=descending)
289
+
290
+ # Rebuild the frequency table
291
+ self.table.clear(columns=True)
292
+ self.build_table()
293
+
294
+ self.table.move_cursor(row=row_idx, column=col_idx)
299
295
 
300
296
  # Notify the user
301
297
  order = "desc" if descending else "asc"
@@ -65,7 +65,7 @@ class YesNoScreen(ModalScreen):
65
65
  input: Optional input value to pre-fill an Input widget. If None, no Input is shown. If it is a 2-value tuple, the first value is the pre-filled input, and the second value is the type of input (e.g., "integer", "number", "text")
66
66
  yes: Text for the Yes button. If None, hides the Yes button
67
67
  no: Text for the No button. If None, hides the No button
68
- on_yes_callback: Optional callable that takes no args and returns the value to dismiss with
68
+ on_yes_callback: Optional callable that takes no args and returns the value to dismiss with when Yes is pressed
69
69
  """
70
70
  super().__init__()
71
71
  self.title = title
@@ -179,7 +179,7 @@ class ConfirmScreen(YesNoScreen):
179
179
  )
180
180
 
181
181
  def handle_confirm(self) -> None:
182
- self.dismiss(True)
182
+ return True
183
183
 
184
184
 
185
185
  class EditCellScreen(YesNoScreen):
@@ -218,20 +218,18 @@ class EditCellScreen(YesNoScreen):
218
218
 
219
219
  # Check if value changed
220
220
  if new_value_str == self.input_value:
221
- self.dismiss(None)
222
221
  self.notify("No changes made", title="Edit", severity="warning")
223
- return
222
+ return None
224
223
 
225
224
  # Parse and validate based on column dtype
226
225
  try:
227
226
  new_value = DtypeConfig(self.col_dtype).convert(new_value_str)
228
227
  except Exception as e:
229
- self.dismiss(None) # Dismiss without changes
230
228
  self.notify(f"Invalid value: {str(e)}", title="Edit", severity="error")
231
- return
229
+ return None
232
230
 
233
- # Dismiss with the new value
234
- self.dismiss((self.row_key, self.col_idx, new_value))
231
+ # New value
232
+ return self.row_key, self.col_idx, new_value
235
233
 
236
234
 
237
235
  class SearchScreen(YesNoScreen):
@@ -265,8 +263,8 @@ class SearchScreen(YesNoScreen):
265
263
  self.notify("Search term cannot be empty", title="Search", severity="error")
266
264
  return
267
265
 
268
- # Dismiss with the search term
269
- self.dismiss((term, self.col_dtype, self.col_name))
266
+ # Search term
267
+ return term, self.col_dtype, self.col_name
270
268
 
271
269
 
272
270
  class FilterScreen(YesNoScreen):
@@ -303,20 +301,20 @@ class FilterScreen(YesNoScreen):
303
301
  # Test the expression by evaluating it
304
302
  expr = eval(expr_str, {"pl": pl})
305
303
 
306
- # Dismiss with the expression
307
- self.dismiss((expr, expr_str))
304
+ # Expression is valid
305
+ return expr, expr_str
308
306
  except Exception as e:
309
307
  self.notify(
310
308
  f"Error evaluating expression: {str(e)}",
311
309
  title="Filter",
312
310
  severity="error",
313
311
  )
314
- self.dismiss(None)
315
312
  except ValueError as ve:
316
313
  self.notify(
317
314
  f"Invalid expression: {str(ve)}", title="Filter", severity="error"
318
315
  )
319
- self.dismiss(None)
316
+
317
+ return None
320
318
 
321
319
 
322
320
  class FreezeScreen(YesNoScreen):
@@ -171,7 +171,7 @@ wheels = [
171
171
 
172
172
  [[package]]
173
173
  name = "dataframe-textual"
174
- version = "0.3.0"
174
+ version = "0.3.2"
175
175
  source = { editable = "." }
176
176
  dependencies = [
177
177
  { name = "polars" },