dataframe-textual 1.0.0__py3-none-any.whl → 1.4.0__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,7 +1,6 @@
1
1
  """DataFrame Viewer application and utilities."""
2
2
 
3
3
  import os
4
- import sys
5
4
  from functools import partial
6
5
  from pathlib import Path
7
6
  from textwrap import dedent
@@ -11,9 +10,9 @@ from textual.app import App, ComposeResult
11
10
  from textual.css.query import NoMatches
12
11
  from textual.theme import BUILTIN_THEMES
13
12
  from textual.widgets import TabbedContent, TabPane
14
- from textual.widgets.tabbed_content import ContentTab, ContentTabs
13
+ from textual.widgets.tabbed_content import ContentTabs
15
14
 
16
- from .common import get_next_item
15
+ from .common import get_next_item, load_file
17
16
  from .data_frame_help_panel import DataFrameHelpPanel
18
17
  from .data_frame_table import DataFrameTable
19
18
  from .yes_no_screen import OpenFileScreen, SaveFileScreen
@@ -27,7 +26,7 @@ class DataFrameViewer(App):
27
26
 
28
27
  ## 🎯 File & Tab Management
29
28
  - **Ctrl+O** - 📁 Add a new tab
30
- - **Ctrl+Shift+S** - 💾 Save all tabs
29
+ - **Ctrl+A** - 💾 Save all tabs
31
30
  - **Ctrl+W** - ❌ Close current tab
32
31
  - **>** or **b** - ▶️ Next tab
33
32
  - **<** - ◀️ Previous tab
@@ -35,7 +34,7 @@ class DataFrameViewer(App):
35
34
  - **q** - 🚪 Quit application
36
35
 
37
36
  ## 🎨 View & Settings
38
- - **Ctrl+H** - ❓ Toggle this help panel
37
+ - **F1** - ❓ Toggle this help panel
39
38
  - **k** - 🌙 Cycle through themes
40
39
 
41
40
  ## ⭐ Features
@@ -52,48 +51,42 @@ class DataFrameViewer(App):
52
51
 
53
52
  BINDINGS = [
54
53
  ("q", "quit", "Quit"),
55
- ("ctrl+h", "toggle_help_panel", "Help"),
54
+ ("f1", "toggle_help_panel", "Help"),
56
55
  ("B", "toggle_tab_bar", "Toggle Tab Bar"),
57
56
  ("ctrl+o", "add_tab", "Add Tab"),
58
- ("ctrl+shift+s", "save_all_tabs", "Save All Tabs"),
57
+ ("ctrl+a", "save_all_tabs", "Save All Tabs"),
59
58
  ("ctrl+w", "close_tab", "Close Tab"),
60
59
  ("greater_than_sign,b", "next_tab(1)", "Next Tab"),
61
60
  ("less_than_sign", "next_tab(-1)", "Prev Tab"),
62
61
  ]
63
62
 
64
63
  CSS = """
65
- TabbedContent {
66
- height: 100%; /* Or a specific value, e.g., 20; */
67
- }
68
64
  TabbedContent > ContentTabs {
69
65
  dock: bottom;
70
66
  }
71
67
  TabbedContent > ContentSwitcher {
72
68
  overflow: auto;
73
- height: 1fr; /* Takes the remaining space below tabs */
69
+ height: 1fr;
74
70
  }
75
-
76
- TabbedContent ContentTab.active {
77
- background: $primary;
78
- color: $text;
71
+ ContentTab.-active {
72
+ background: $block-cursor-background; /* Same as underline */
79
73
  }
80
74
  """
81
75
 
82
- def __init__(self, *filenames: str, file_format: str | None = None, has_header: bool = True) -> None:
76
+ def __init__(self, *sources: str) -> None:
83
77
  """Initialize the DataFrame Viewer application.
84
78
 
85
- Loads dataframes from provided filenames and prepares the tabbed interface.
79
+ Loads data from provided sources and prepares the tabbed interface.
86
80
 
87
81
  Args:
88
- *filenames: Variable number of file paths to load (CSV, Excel, Parquet, etc).
89
- file_format: Optional format specifier for input files (e.g., 'csv', 'excel').
90
- has_header: Whether the input files have a header row. Defaults to True.
82
+ sources: sources to load dataframes from, each as a tuple of
83
+ (DataFrame, filename, tabname).
91
84
 
92
85
  Returns:
93
86
  None
94
87
  """
95
88
  super().__init__()
96
- self.sources = _load_dataframe(filenames, file_format, has_header=has_header)
89
+ self.sources = sources
97
90
  self.tabs: dict[TabPane, DataFrameTable] = {}
98
91
  self.help_panel = None
99
92
 
@@ -111,19 +104,29 @@ class DataFrameViewer(App):
111
104
  with self.tabbed:
112
105
  seen_names = set()
113
106
  for idx, (df, filename, tabname) in enumerate(self.sources, start=1):
107
+ tab_id = f"tab_{idx}"
108
+
109
+ if not tabname:
110
+ tabname = Path(filename).stem or tab_id
111
+
114
112
  # Ensure unique tab names
115
- if tabname in seen_names:
116
- tabname = f"{tabname}_{idx}"
113
+ counter = 1
114
+ while tabname in seen_names:
115
+ tabname = f"{tabname}_{counter}"
116
+ counter += 1
117
117
  seen_names.add(tabname)
118
118
 
119
- tab_id = f"tab_{idx}"
120
119
  try:
121
120
  table = DataFrameTable(df, filename, name=tabname, id=tab_id, zebra_stripes=True)
122
121
  tab = TabPane(tabname, table, name=tabname, id=tab_id)
123
122
  self.tabs[tab] = table
124
123
  yield tab
125
124
  except Exception as e:
126
- self.notify(f"Error loading {tabname}: {e}", severity="error")
125
+ self.notify(
126
+ f"Error loading [$error]{filename}[/]: Try [$accent]-I[/] to disable schema inference",
127
+ severity="error",
128
+ )
129
+ self.log(f"Error loading `{filename}`: {str(e)}")
127
130
 
128
131
  def on_mount(self) -> None:
129
132
  """Set up the application when it starts.
@@ -174,12 +177,6 @@ class DataFrameViewer(App):
174
177
  if table.loaded_rows == 0:
175
178
  table._setup_table()
176
179
 
177
- # Apply background color to active tab
178
- event.tab.add_class("active")
179
- for tab in self.tabbed.query(ContentTab):
180
- if tab != event.tab:
181
- tab.remove_class("active")
182
-
183
180
  def action_toggle_help_panel(self) -> None:
184
181
  """Toggle the help panel on or off.
185
182
 
@@ -300,12 +297,12 @@ class DataFrameViewer(App):
300
297
  if filename and os.path.exists(filename):
301
298
  try:
302
299
  n_tab = 0
303
- for lf, filename, tabname in _load_file(filename, prefix_sheet=True):
304
- self._add_tab(lf.collect(), filename, tabname)
300
+ for lf, filename, tabname in load_file(filename, prefix_sheet=True):
301
+ self._add_tab(lf, filename, tabname)
305
302
  n_tab += 1
306
- self.notify(f"Added [$accent]{n_tab}[/] tab(s) for [$success]{filename}[/]", title="Open")
303
+ # self.notify(f"Added [$accent]{n_tab}[/] tab(s) for [$success]{filename}[/]", title="Open")
307
304
  except Exception as e:
308
- self.notify(f"Error: {e}", title="Open", severity="error")
305
+ self.notify(f"Error loading [$error]{filename}[/]: {str(e)}", title="Open", severity="error")
309
306
  else:
310
307
  self.notify(f"File does not exist: [$warning]{filename}[/]", title="Open", severity="warning")
311
308
 
@@ -317,15 +314,18 @@ class DataFrameViewer(App):
317
314
  if this is no longer the only tab.
318
315
 
319
316
  Args:
320
- df: The Polars DataFrame to display in the new tab.
317
+ lf: The Polars DataFrame to display in the new tab.
321
318
  filename: The source filename for this data (used in table metadata).
322
319
  tabname: The display name for the tab.
323
320
 
324
321
  Returns:
325
322
  None
326
323
  """
327
- if any(tab.name == tabname for tab in self.tabs):
328
- tabname = f"{tabname}_{len(self.tabs) + 1}"
324
+ # Ensure unique tab names
325
+ counter = 1
326
+ while any(tab.name == tabname for tab in self.tabs):
327
+ tabname = f"{tabname}_{counter}"
328
+ counter += 1
329
329
 
330
330
  # Find an available tab index
331
331
  tab_idx = f"tab_{len(self.tabs) + 1}"
@@ -365,108 +365,6 @@ class DataFrameViewer(App):
365
365
  if active_pane := self.tabbed.active_pane:
366
366
  self.tabbed.remove_pane(active_pane.id)
367
367
  self.tabs.pop(active_pane)
368
- self.notify(f"Closed tab [$success]{active_pane.name}[/]", title="Close")
368
+ # self.notify(f"Closed tab [$success]{active_pane.name}[/]", title="Close")
369
369
  except NoMatches:
370
370
  pass
371
-
372
-
373
- def _load_dataframe(
374
- filenames: list[str], file_format: str | None = None, has_header: bool = True
375
- ) -> list[tuple[pl.LazyFrame, str, str]]:
376
- """Load DataFrames from file specifications.
377
-
378
- Handles loading from multiple files, single files, or stdin. For Excel files,
379
- loads all sheets as separate entries. For other formats, loads as single file.
380
-
381
- Args:
382
- filenames: List of filenames to load. If single filename is "-", read from stdin.
383
- file_format: Optional format specifier for input files (e.g., 'csv', 'excel').
384
- has_header: Whether the input files have a header row. Defaults to True.
385
-
386
- Returns:
387
- List of tuples of (LazyFrame, filename, tabname) ready for display.
388
- """
389
- sources = []
390
-
391
- prefix_sheet = len(filenames) > 1
392
-
393
- for filename in filenames:
394
- sources.extend(_load_file(filename, prefix_sheet=prefix_sheet, file_format=file_format, has_header=has_header))
395
- return sources
396
-
397
-
398
- def _load_file(
399
- filename: str,
400
- first_sheet: bool = False,
401
- prefix_sheet: bool = False,
402
- file_format: str | None = None,
403
- has_header: bool = True,
404
- ) -> list[tuple[pl.LazyFrame, str, str]]:
405
- """Load a single file and return list of sources.
406
-
407
- For Excel files, when `first_sheet` is True, returns only the first sheet. Otherwise, returns one entry per sheet.
408
- For other files or multiple files, returns one entry per file.
409
-
410
- Args:
411
- filename: Path to file to load.
412
- first_sheet: If True, only load first sheet for Excel files. Defaults to False.
413
- prefix_sheet: If True, prefix filename to sheet name as the tab name for Excel files. Defaults to False.
414
- file_format: Optional format specifier for input files (e.g., 'csv', 'excel', 'tsv', 'parquet', 'json', 'ndjson').
415
-
416
- Returns:
417
- List of tuples of (LazyFrame, filename, tabname).
418
- """
419
- sources = []
420
-
421
- if filename == "-":
422
- from io import StringIO
423
-
424
- # Read from stdin into memory first (stdin is not seekable)
425
- stdin_data = sys.stdin.read()
426
- lf = pl.scan_csv(StringIO(stdin_data), has_header=has_header, separator="," if file_format == "csv" else "\t")
427
-
428
- # Reopen stdin to /dev/tty for proper terminal interaction
429
- try:
430
- tty = open("/dev/tty")
431
- os.dup2(tty.fileno(), sys.stdin.fileno())
432
- except (OSError, FileNotFoundError):
433
- pass
434
-
435
- sources.append((lf, "stdin.tsv" if file_format == "tsv" else "stdin.csv", "stdin"))
436
- return sources
437
-
438
- filepath = Path(filename)
439
- ext = filepath.suffix.lower()
440
-
441
- if file_format == "csv" or ext == ".csv":
442
- lf = pl.scan_csv(filename, has_header=has_header)
443
- sources.append((lf, filename, filepath.stem))
444
- elif file_format == "excel" or ext in (".xlsx", ".xls"):
445
- if first_sheet:
446
- # Read only the first sheet for multiple files
447
- lf = pl.read_excel(filename).lazy()
448
- sources.append((lf, filename, filepath.stem))
449
- else:
450
- # For single file, expand all sheets
451
- sheets = pl.read_excel(filename, sheet_id=0)
452
- for sheet_name, df in sheets.items():
453
- tabname = f"{filepath.stem}_{sheet_name}" if prefix_sheet else sheet_name
454
- sources.append((df.lazy(), filename, tabname))
455
- elif file_format == "tsv" or ext in (".tsv", ".tab"):
456
- lf = pl.scan_csv(filename, has_header=has_header, separator="\t")
457
- sources.append((lf, filename, filepath.stem))
458
- elif file_format == "parquet" or ext == ".parquet":
459
- lf = pl.scan_parquet(filename)
460
- sources.append((lf, filename, filepath.stem))
461
- elif file_format == "json" or ext == ".json":
462
- df = pl.read_json(filename)
463
- sources.append((df, filename, filepath.stem))
464
- elif file_format == "ndjson" or ext == ".ndjson":
465
- lf = pl.scan_ndjson(filename)
466
- sources.append((lf, filename, filepath.stem))
467
- else:
468
- # Treat other formats as TSV
469
- lf = pl.scan_csv(filename, has_header=has_header, separator="\t")
470
- sources.append((lf, filename, filepath.stem))
471
-
472
- return sources
@@ -0,0 +1,202 @@
1
+ """Modal screens for Polars sql manipulation"""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from .data_frame_table import DataFrameTable
7
+
8
+ import polars as pl
9
+ from textual.app import ComposeResult
10
+ from textual.containers import Container, Horizontal
11
+ from textual.screen import ModalScreen
12
+ from textual.widgets import Button, Input, Label, SelectionList, TextArea
13
+ from textual.widgets.selection_list import Selection
14
+
15
+
16
+ class SqlScreen(ModalScreen):
17
+ """Base class for modal screens handling SQL query."""
18
+
19
+ DEFAULT_CSS = """
20
+ SqlScreen {
21
+ align: center middle;
22
+ }
23
+
24
+ SqlScreen > Container {
25
+ width: auto;
26
+ height: auto;
27
+ border: heavy $accent;
28
+ border-title-color: $accent;
29
+ border-title-background: $panel;
30
+ border-title-style: bold;
31
+ background: $background;
32
+ padding: 1 2;
33
+ overflow: auto;
34
+ }
35
+
36
+ #button-container {
37
+ width: auto;
38
+ margin: 1 0 0 0;
39
+ height: 3;
40
+ align: center middle;
41
+ }
42
+
43
+ Button {
44
+ margin: 0 2;
45
+ }
46
+
47
+ """
48
+
49
+ def __init__(self, dftable: "DataFrameTable", on_yes_callback=None) -> None:
50
+ """Initialize the SQL screen."""
51
+ super().__init__()
52
+ self.dftable = dftable # DataFrameTable
53
+ self.df: pl.DataFrame = dftable.df # Polars DataFrame
54
+ self.on_yes_callback = on_yes_callback
55
+
56
+ def compose(self) -> ComposeResult:
57
+ """Compose the SQL screen widget structure."""
58
+ # Shared by subclasses
59
+ with Horizontal(id="button-container"):
60
+ yield Button("Apply", id="yes", variant="success")
61
+ yield Button("Cancel", id="no", variant="error")
62
+
63
+ def on_key(self, event) -> None:
64
+ """Handle key press events in the SQL screen"""
65
+ if event.key in ("q", "escape"):
66
+ self.app.pop_screen()
67
+ event.stop()
68
+ elif event.key == "enter":
69
+ self._handle_yes()
70
+ event.stop()
71
+ elif event.key == "escape":
72
+ self.dismiss(None)
73
+ event.stop()
74
+
75
+ def on_button_pressed(self, event: Button.Pressed) -> None:
76
+ """Handle button press events in the SQL screen."""
77
+ if event.button.id == "yes":
78
+ self._handle_yes()
79
+ elif event.button.id == "no":
80
+ self.dismiss(None)
81
+
82
+ def _handle_yes(self) -> None:
83
+ """Handle Yes button/Enter key press."""
84
+ if self.on_yes_callback:
85
+ result = self.on_yes_callback()
86
+ self.dismiss(result)
87
+ else:
88
+ self.dismiss(True)
89
+
90
+
91
+ class SimpleSqlScreen(SqlScreen):
92
+ """Simple SQL query screen."""
93
+
94
+ DEFAULT_CSS = SqlScreen.DEFAULT_CSS.replace("SqlScreen", "SimpleSqlScreen")
95
+
96
+ CSS = """
97
+ SimpleSqlScreen SelectionList {
98
+ width: auto;
99
+ min-width: 40;
100
+ margin: 1 0;
101
+ }
102
+
103
+ SimpleSqlScreen SelectionList:blur {
104
+ border: solid $secondary;
105
+ }
106
+
107
+ SimpleSqlScreen Label {
108
+ width: auto;
109
+ }
110
+
111
+ SimpleSqlScreen Input {
112
+ width: auto;
113
+ }
114
+
115
+ SimpleSqlScreen Input:blur {
116
+ border: solid $secondary;
117
+ }
118
+
119
+ #button-container {
120
+ min-width: 40;
121
+ }
122
+ """
123
+
124
+ def __init__(self, dftable: "DataFrameTable") -> None:
125
+ """Initialize the simple SQL screen.
126
+
127
+ Sets up the modal screen with reference to the main DataFrameTable widget
128
+ and stores the DataFrame for display.
129
+
130
+ Args:
131
+ dftable: Reference to the parent DataFrameTable widget.
132
+
133
+ Returns:
134
+ None
135
+ """
136
+ super().__init__(dftable, on_yes_callback=self._handle_simple)
137
+
138
+ def compose(self) -> ComposeResult:
139
+ """Compose the simple SQL screen widget structure."""
140
+ with Container(id="sql-container") as container:
141
+ container.border_title = "SQL Query"
142
+ 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")
144
+ yield Label("Where condition (optional)", id="where-label")
145
+ yield Input(placeholder="e.g., age > 30 and height < 180", id="where-input")
146
+ yield from super().compose()
147
+
148
+ def _handle_simple(self) -> None:
149
+ """Handle Yes button/Enter key press."""
150
+ selections = self.query_one(SelectionList).selected
151
+ columns = ", ".join(f"`{s}`" for s in selections) if selections else "*"
152
+ where = self.query_one(Input).value.strip()
153
+
154
+ return columns, where
155
+
156
+
157
+ class AdvancedSqlScreen(SqlScreen):
158
+ """Advanced SQL query screen."""
159
+
160
+ DEFAULT_CSS = SqlScreen.DEFAULT_CSS.replace("SqlScreen", "AdvancedSqlScreen")
161
+
162
+ CSS = """
163
+ AdvancedSqlScreen TextArea {
164
+ width: auto;
165
+ min-width: 60;
166
+ height: auto;
167
+ min-height: 10;
168
+ }
169
+
170
+ #button-container {
171
+ min-width: 60;
172
+ }
173
+ """
174
+
175
+ def __init__(self, dftable: "DataFrameTable") -> None:
176
+ """Initialize the simple SQL screen.
177
+
178
+ Sets up the modal screen with reference to the main DataFrameTable widget
179
+ and stores the DataFrame for display.
180
+
181
+ Args:
182
+ dftable: Reference to the parent DataFrameTable widget.
183
+
184
+ Returns:
185
+ None
186
+ """
187
+ super().__init__(dftable, on_yes_callback=self._handle_advanced)
188
+
189
+ def compose(self) -> ComposeResult:
190
+ """Compose the advanced SQL screen widget structure."""
191
+ with Container(id="sql-container") as container:
192
+ container.border_title = "Advanced SQL Query"
193
+ yield TextArea.code_editor(
194
+ placeholder="Enter SQL query (use `self` as the table name), e.g., \n\nSELECT * \nFROM self \nWHERE age > 30",
195
+ id="sql-textarea",
196
+ language="sql",
197
+ )
198
+ yield from super().compose()
199
+
200
+ def _handle_advanced(self) -> None:
201
+ """Handle Yes button/Enter key press."""
202
+ return self.query_one(TextArea).text.strip()
@@ -13,7 +13,7 @@ from textual.renderables.bar import Bar
13
13
  from textual.screen import ModalScreen
14
14
  from textual.widgets import DataTable
15
15
 
16
- from .common import NULL, NULL_DISPLAY, RIDX, DtypeConfig, format_row
16
+ from .common import NULL, NULL_DISPLAY, RIDX, DtypeConfig, format_float, format_row
17
17
 
18
18
 
19
19
  class TableScreen(ModalScreen):
@@ -30,12 +30,14 @@ class TableScreen(ModalScreen):
30
30
 
31
31
  TableScreen > DataTable {
32
32
  width: auto;
33
- min-width: 30;
33
+ height: auto;
34
34
  border: solid $primary;
35
+ max-width: 100%;
36
+ overflow: auto;
35
37
  }
36
38
  """
37
39
 
38
- def __init__(self, dftable: DataFrameTable) -> None:
40
+ def __init__(self, dftable: "DataFrameTable") -> None:
39
41
  """Initialize the table screen.
40
42
 
41
43
  Sets up the base modal screen with reference to the main DataFrameTable widget
@@ -48,8 +50,8 @@ class TableScreen(ModalScreen):
48
50
  None
49
51
  """
50
52
  super().__init__()
51
- self.df: pl.DataFrame = dftable.df # Polars DataFrame
52
53
  self.dftable = dftable # DataFrameTable
54
+ self.df: pl.DataFrame = dftable.df # Polars DataFrame
53
55
  self.thousand_separator = False # Whether to use thousand separators in numbers
54
56
 
55
57
  def compose(self) -> ComposeResult:
@@ -179,7 +181,12 @@ class RowDetailScreen(TableScreen):
179
181
  # Get all columns and values from the dataframe row
180
182
  for col, val, dtype in zip(self.df.columns, self.df.row(self.ridx), self.df.dtypes):
181
183
  self.table.add_row(
182
- *format_row([col, val], [None, dtype], apply_justify=False, thousand_separator=self.thousand_separator)
184
+ *format_row(
185
+ [col, val],
186
+ [None, dtype],
187
+ apply_justify=False,
188
+ thousand_separator=self.thousand_separator,
189
+ )
183
190
  )
184
191
 
185
192
  self.table.cursor_type = "row"
@@ -223,7 +230,7 @@ class StatisticsScreen(TableScreen):
223
230
 
224
231
  CSS = TableScreen.DEFAULT_CSS.replace("TableScreen", "StatisticsScreen")
225
232
 
226
- def __init__(self, dftable: DataFrameTable, col_idx: int | None = None):
233
+ def __init__(self, dftable: "DataFrameTable", col_idx: int | None = None):
227
234
  super().__init__(dftable)
228
235
  self.col_idx = col_idx # None for dataframe statistics, otherwise column index
229
236
 
@@ -238,9 +245,11 @@ class StatisticsScreen(TableScreen):
238
245
  if self.col_idx is None:
239
246
  # Dataframe statistics
240
247
  self._build_dataframe_stats()
248
+ self.table.cursor_type = "column"
241
249
  else:
242
250
  # Column statistics
243
251
  self._build_column_stats()
252
+ self.table.cursor_type = "row"
244
253
 
245
254
  def _build_column_stats(self) -> None:
246
255
  """Build statistics for a single column."""
@@ -271,10 +280,10 @@ class StatisticsScreen(TableScreen):
271
280
  value = stat_value
272
281
  if stat_value is None:
273
282
  value = NULL_DISPLAY
274
- elif dc.gtype == "int" and self.thousand_separator:
283
+ elif dc.gtype == "integer" and self.thousand_separator:
275
284
  value = f"{stat_value:,}"
276
285
  elif dc.gtype == "float":
277
- value = f"{stat_value:,.2f}" if self.thousand_separator else f"{stat_value:.2f}"
286
+ value = format_float(stat_value, self.thousand_separator)
278
287
  else:
279
288
  value = str(stat_value)
280
289
 
@@ -291,6 +300,10 @@ class StatisticsScreen(TableScreen):
291
300
  if False in self.dftable.visible_rows:
292
301
  lf = lf.filter(self.dftable.visible_rows)
293
302
 
303
+ # Apply only to non-hidden columns
304
+ if self.dftable.hidden_columns:
305
+ lf = lf.select(pl.exclude(self.dftable.hidden_columns))
306
+
294
307
  # Get dataframe statistics
295
308
  stats_df = lf.collect().describe()
296
309
 
@@ -321,10 +334,10 @@ class StatisticsScreen(TableScreen):
321
334
  value = stat_value
322
335
  if stat_value is None:
323
336
  value = NULL_DISPLAY
324
- elif dc.gtype == "int" and self.thousand_separator:
337
+ elif dc.gtype == "integer" and self.thousand_separator:
325
338
  value = f"{stat_value:,}"
326
339
  elif dc.gtype == "float":
327
- value = f"{stat_value:,.2f}" if self.thousand_separator else f"{stat_value:.2f}"
340
+ value = format_float(stat_value, self.thousand_separator)
328
341
  else:
329
342
  value = str(stat_value)
330
343
 
@@ -338,15 +351,16 @@ class FrequencyScreen(TableScreen):
338
351
 
339
352
  CSS = TableScreen.DEFAULT_CSS.replace("TableScreen", "FrequencyScreen")
340
353
 
341
- def __init__(self, col_idx: int, dftable: DataFrameTable):
354
+ def __init__(self, col_idx: int, dftable: "DataFrameTable") -> None:
342
355
  super().__init__(dftable)
343
356
  self.col_idx = col_idx
344
357
  self.sorted_columns = {
345
358
  1: True, # Count
346
359
  }
347
- self.df: pl.DataFrame = (
348
- dftable.df[dftable.df.columns[self.col_idx]].value_counts(sort=True).sort("count", descending=True)
349
- )
360
+
361
+ df = dftable.df.filter(dftable.visible_rows) if False in dftable.visible_rows else dftable.df
362
+ self.total_count = len(df)
363
+ self.df: pl.DataFrame = df[df.columns[self.col_idx]].value_counts(sort=True).sort("count", descending=True)
350
364
 
351
365
  def on_mount(self) -> None:
352
366
  """Create the frequency table."""
@@ -379,9 +393,6 @@ class FrequencyScreen(TableScreen):
379
393
  dtype = self.dftable.df.dtypes[self.col_idx]
380
394
  dc = DtypeConfig(dtype)
381
395
 
382
- # Calculate frequencies using Polars
383
- total_count = len(self.dftable.df)
384
-
385
396
  # Add column headers with sort indicators
386
397
  columns = [
387
398
  (column, "Value", 0),
@@ -409,14 +420,14 @@ class FrequencyScreen(TableScreen):
409
420
  # Add rows to the frequency table
410
421
  for row_idx, row in enumerate(self.df.rows()):
411
422
  column, count = row
412
- percentage = (count / total_count) * 100
423
+ percentage = (count / self.total_count) * 100
413
424
 
414
425
  if column is None:
415
426
  value = NULL_DISPLAY
416
- elif dc.gtype == "int" and self.thousand_separator:
427
+ elif dc.gtype == "integer" and self.thousand_separator:
417
428
  value = f"{column:,}"
418
429
  elif dc.gtype == "float":
419
- value = f"{column:,.2f}" if self.thousand_separator else f"{column:.3f}"
430
+ value = format_float(column, self.thousand_separator)
420
431
  else:
421
432
  value = str(column)
422
433
 
@@ -426,7 +437,7 @@ class FrequencyScreen(TableScreen):
426
437
  f"{count:,}" if self.thousand_separator else str(count), style=ds_int.style, justify=ds_int.justify
427
438
  ),
428
439
  Text(
429
- f"{percentage:,.3f}" if self.thousand_separator else f"{percentage:.3f}",
440
+ format_float(percentage, self.thousand_separator),
430
441
  style=ds_float.style,
431
442
  justify=ds_float.justify,
432
443
  ),
@@ -440,8 +451,16 @@ class FrequencyScreen(TableScreen):
440
451
  # Add a total row
441
452
  self.table.add_row(
442
453
  Text("Total", style="bold", justify=dc.justify),
443
- Text(f"{total_count:,}", style="bold", justify="right"),
444
- Text("100.00", style="bold", justify="right"),
454
+ Text(
455
+ f"{self.total_count:,}" if self.thousand_separator else str(self.total_count),
456
+ style="bold",
457
+ justify="right",
458
+ ),
459
+ Text(
460
+ format_float(100.0, self.thousand_separator),
461
+ style="bold",
462
+ justify="right",
463
+ ),
445
464
  Bar(
446
465
  highlight_range=(0.0, 10),
447
466
  width=10,
@@ -454,12 +473,10 @@ class FrequencyScreen(TableScreen):
454
473
  row_idx, col_idx = self.table.cursor_coordinate
455
474
  col_sort = col_idx if col_idx == 0 else 1
456
475
 
457
- sort_dir = self.sorted_columns.get(col_sort)
458
- if sort_dir is not None:
476
+ if self.sorted_columns.get(col_sort) == descending:
459
477
  # If already sorted in the same direction, do nothing
460
- if sort_dir == descending:
461
- self.notify("Already sorted in that order", title="Sort", severity="warning")
462
- return
478
+ # self.notify("Already sorted in that order", title="Sort", severity="warning")
479
+ return
463
480
 
464
481
  self.sorted_columns.clear()
465
482
  self.sorted_columns[col_sort] = descending