dataframe-textual 0.3.2__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.
@@ -0,0 +1,335 @@
1
+ """DataFrame Viewer application and utilities."""
2
+
3
+ import os
4
+ import sys
5
+ from functools import partial
6
+ from pathlib import Path
7
+ from textwrap import dedent
8
+
9
+ import polars as pl
10
+ from textual.app import App, ComposeResult
11
+ from textual.css.query import NoMatches
12
+ from textual.theme import BUILTIN_THEMES
13
+ from textual.widgets import TabbedContent, TabPane
14
+ from textual.widgets.tabbed_content import ContentTab, ContentTabs
15
+
16
+ from .common import _next
17
+ from .data_frame_help_panel import DataFrameHelpPanel
18
+ from .data_frame_table import DataFrameTable
19
+ from .yes_no_screen import OpenFileScreen, SaveFileScreen
20
+
21
+
22
+ class DataFrameViewer(App):
23
+ """A Textual app to interact with multiple Polars DataFrames via tabbed interface."""
24
+
25
+ HELP = dedent("""
26
+ # 📊 DataFrame Viewer - App Controls
27
+
28
+ ## 🎯 File & Tab Management
29
+ - **Ctrl+O** - 📁 Add a new tab
30
+ - **Ctrl+Shift+S** - 💾 Save all tabs
31
+ - **Ctrl+W** - ❌ Close current tab
32
+ - **>** or **b** - ▶️ Next tab
33
+ - **<** - ◀️ Previous tab
34
+ - **B** - 👁️ Toggle tab bar visibility
35
+ - **q** - 🚪 Quit application
36
+
37
+ ## 🎨 View & Settings
38
+ - **?** or **h** - ❓ Toggle this help panel
39
+ - **k** - 🌙 Cycle through themes
40
+
41
+ ## ⭐ Features
42
+ - **Multi-file support** - 📂 Open multiple CSV/Excel files as tabs
43
+ - **Excel sheets** - 📊 Excel files auto-expand sheets into tabs
44
+ - **Lazy loading** - ⚡ Large files load on demand
45
+ - **Sticky tabs** - 📌 Tab bar stays visible when scrolling
46
+ - **Rich formatting** - 🎨 Color-coded data types
47
+ - **Search & filter** - 🔍 Find and filter data quickly
48
+ - **Sort & reorder** - ⬆️ Multi-column sort, drag rows/columns
49
+ - **Undo/Redo** - 🔄 Full history of operations
50
+ - **Freeze rows/cols** - 🔒 Pin header rows and columns
51
+ """).strip()
52
+
53
+ BINDINGS = [
54
+ ("q", "quit", "Quit"),
55
+ ("h,?", "toggle_help_panel", "Help"),
56
+ ("B", "toggle_tab_bar", "Toggle Tab Bar"),
57
+ ("ctrl+o", "add_tab", "Add Tab"),
58
+ ("ctrl+shift+s", "save_all_tabs", "Save All Tabs"),
59
+ ("ctrl+w", "close_tab", "Close Tab"),
60
+ ("greater_than_sign,b", "next_tab(1)", "Next Tab"),
61
+ ("less_than_sign", "next_tab(-1)", "Prev Tab"),
62
+ ]
63
+
64
+ CSS = """
65
+ TabbedContent {
66
+ height: 100%; /* Or a specific value, e.g., 20; */
67
+ }
68
+ TabbedContent > ContentTabs {
69
+ dock: bottom;
70
+ }
71
+ TabbedContent > ContentSwitcher {
72
+ overflow: auto;
73
+ height: 1fr; /* Takes the remaining space below tabs */
74
+ }
75
+
76
+ TabbedContent ContentTab.active {
77
+ background: $primary;
78
+ color: $text;
79
+ }
80
+ """
81
+
82
+ def __init__(self, *filenames):
83
+ super().__init__()
84
+ self.sources = _load_dataframe(filenames)
85
+ self.tabs: dict[TabPane, DataFrameTable] = {}
86
+ self.help_panel = None
87
+
88
+ def compose(self) -> ComposeResult:
89
+ """Create tabbed interface for multiple files or direct table for single file."""
90
+ # Tabbed interface
91
+ self.tabbed = TabbedContent(id="main_tabs")
92
+ with self.tabbed:
93
+ seen_names = set()
94
+ for idx, (df, filename, tabname) in enumerate(self.sources, start=1):
95
+ # Ensure unique tab names
96
+ if tabname in seen_names:
97
+ tabname = f"{tabname}_{idx}"
98
+ seen_names.add(tabname)
99
+
100
+ tab_id = f"tab_{idx}"
101
+ try:
102
+ table = DataFrameTable(
103
+ df, filename, name=tabname, id=tab_id, zebra_stripes=True
104
+ )
105
+ tab = TabPane(tabname, table, name=tabname, id=tab_id)
106
+ self.tabs[tab] = table
107
+ yield tab
108
+ except Exception as e:
109
+ self.notify(f"Error loading {tabname}: {e}", severity="error")
110
+
111
+ def on_mount(self) -> None:
112
+ """Set up the app when it starts."""
113
+ if len(self.tabs) == 1:
114
+ self.query_one(ContentTabs).display = False
115
+ self._get_active_table().focus()
116
+
117
+ def on_key(self, event):
118
+ if event.key == "k":
119
+ self.theme = _next(list(BUILTIN_THEMES.keys()), self.theme)
120
+ self.notify(
121
+ f"Switched to theme: [on $primary]{self.theme}[/]", title="Theme"
122
+ )
123
+
124
+ def on_tabbed_content_tab_activated(
125
+ self, event: TabbedContent.TabActivated
126
+ ) -> None:
127
+ """Handle tab changes (only for multiple tabs)."""
128
+ # Only process if we have multiple files
129
+ if len(self.tabs) <= 1:
130
+ return
131
+
132
+ # Apply background color to active tab
133
+ event.tab.add_class("active")
134
+ for tab in self.tabbed.query(ContentTab):
135
+ if tab != event.tab:
136
+ tab.remove_class("active")
137
+
138
+ try:
139
+ # Focus the table in the newly activated tab
140
+ if table := self._get_active_table():
141
+ table.focus()
142
+ except NoMatches:
143
+ pass
144
+
145
+ def _get_active_table(self) -> DataFrameTable | None:
146
+ """Get the currently active table."""
147
+ try:
148
+ tabbed: TabbedContent = self.query_one(TabbedContent)
149
+ if active_pane := tabbed.active_pane:
150
+ return active_pane.query_one(DataFrameTable)
151
+ except (NoMatches, AttributeError):
152
+ pass
153
+ return None
154
+
155
+ def action_toggle_help_panel(self) -> None:
156
+ """Toggle the HelpPanel on/off."""
157
+ if self.help_panel:
158
+ self.help_panel.display = not self.help_panel.display
159
+ else:
160
+ self.help_panel = DataFrameHelpPanel()
161
+ self.mount(self.help_panel)
162
+
163
+ def action_add_tab(self) -> None:
164
+ """Open file dialog to load file to new tab."""
165
+ self.push_screen(OpenFileScreen(), self._handle_file_open)
166
+
167
+ def _handle_file_open(self, filename: str) -> None:
168
+ """Handle file selection from dialog."""
169
+ if filename and os.path.exists(filename):
170
+ try:
171
+ df = pl.read_csv(filename)
172
+ self._add_tab(df, filename)
173
+ self.notify(
174
+ f"Opened: [on $primary]{Path(filename).name}[/]", title="Open"
175
+ )
176
+ except Exception as e:
177
+ self.notify(f"Error: {e}", severity="error")
178
+
179
+ def action_save_all_tabs(self) -> None:
180
+ """Save all tabs to a Excel file."""
181
+ callback = partial(self._get_active_table()._on_save_file_screen, all_tabs=True)
182
+ self.push_screen(
183
+ SaveFileScreen("all-tabs.xlsx", title="Save All Tabs"),
184
+ callback=callback,
185
+ )
186
+
187
+ def action_close_tab(self) -> None:
188
+ """Close current tab (only for multiple files)."""
189
+ if len(self.tabs) <= 1:
190
+ self.app.exit()
191
+ return
192
+ self._close_tab()
193
+
194
+ def action_next_tab(self, offset: int = 1) -> str:
195
+ """Switch to next tab (only for multiple files)."""
196
+ if len(self.tabs) <= 1:
197
+ return
198
+ try:
199
+ tabs: list[TabPane] = list(self.tabs.keys())
200
+ next_tab = _next(tabs, self.tabbed.active_pane, offset)
201
+ self.tabbed.active = next_tab.id
202
+ except (NoMatches, ValueError):
203
+ pass
204
+
205
+ def _add_tab(self, df: pl.DataFrame, filename: str) -> None:
206
+ """Add new table tab. If single file, replace table; if multiple, add tab."""
207
+ tabname = Path(filename).stem
208
+ if any(tab.name == tabname for tab in self.tabs):
209
+ tabname = f"{tabname}_{len(self.tabs) + 1}"
210
+
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)
225
+ self.tabbed.add_pane(tab)
226
+ self.tabs[tab] = table
227
+
228
+ if len(self.tabs) > 1:
229
+ self.query_one(ContentTabs).display = True
230
+
231
+ # Activate the new tab
232
+ self.tabbed.active = tab.id
233
+ table.focus()
234
+
235
+ def _close_tab(self) -> None:
236
+ """Close current tab."""
237
+ try:
238
+ if len(self.tabs) == 1:
239
+ self.app.exit()
240
+ else:
241
+ if active_pane := self.tabbed.active_pane:
242
+ self.tabbed.remove_pane(active_pane.id)
243
+ self.tabs.pop(active_pane)
244
+ self.notify(
245
+ f"Closed tab [on $primary]{active_pane.name}[/]", title="Close"
246
+ )
247
+ except NoMatches:
248
+ pass
249
+
250
+ def action_toggle_tab_bar(self) -> None:
251
+ """Toggle tab bar visibility."""
252
+ tabs = self.query_one(ContentTabs)
253
+ tabs.display = not tabs.display
254
+ status = "shown" if tabs.display else "hidden"
255
+ self.notify(f"Tab bar [on $primary]{status}[/]", title="Toggle")
256
+
257
+
258
+ def _load_dataframe(filenames: list[str]) -> list[tuple[pl.DataFrame, str, str]]:
259
+ """Load a DataFrame from a file spec.
260
+
261
+ Args:
262
+ filenames: List of filenames to load. If single filename is "-", read from stdin.
263
+
264
+ Returns:
265
+ List of tuples of (DataFrame, filename, tabname)
266
+ """
267
+ sources = []
268
+
269
+ # Single file
270
+ if len(filenames) == 1:
271
+ filename = filenames[0]
272
+ filepath = Path(filename)
273
+ ext = filepath.suffix.lower()
274
+
275
+ # Handle stdin
276
+ if filename == "-" or not sys.stdin.isatty():
277
+ from io import StringIO
278
+
279
+ # Read CSV from stdin into memory first (stdin is not seekable)
280
+ stdin_data = sys.stdin.read()
281
+ df = pl.read_csv(StringIO(stdin_data))
282
+
283
+ # Reopen stdin to /dev/tty for proper terminal interaction
284
+ try:
285
+ tty = open("/dev/tty")
286
+ os.dup2(tty.fileno(), sys.stdin.fileno())
287
+ except (OSError, FileNotFoundError):
288
+ pass
289
+
290
+ sources.append((df, "stdin.csv", "stdin"))
291
+ # Handle Excel files with multiple sheets
292
+ elif ext in (".xlsx", ".xls"):
293
+ sheets = pl.read_excel(filename, sheet_id=0)
294
+ for sheet_name, df in sheets.items():
295
+ sources.append((df, filename, sheet_name))
296
+ # Handle TSV files
297
+ elif ext in (".tsv", ".tab"):
298
+ df = pl.read_csv(filename, separator="\t")
299
+ sources.append((df, filename, filepath.stem))
300
+ # Handle JSON files
301
+ elif ext == ".json":
302
+ df = pl.read_json(filename)
303
+ sources.append((df, filename, filepath.stem))
304
+ # Handle Parquet files
305
+ elif ext == ".parquet":
306
+ df = pl.read_parquet(filename)
307
+ sources.append((df, filename, filepath.stem))
308
+ # Handle regular CSV files
309
+ else:
310
+ df = pl.read_csv(filename)
311
+ sources.append((df, filename, filepath.stem))
312
+ # Multiple files
313
+ else:
314
+ for filename in filenames:
315
+ filepath = Path(filename)
316
+ ext = filepath.suffix.lower()
317
+
318
+ if ext in (".xlsx", ".xls"):
319
+ # Read only the first sheet for multiple files
320
+ df = pl.read_excel(filename)
321
+ sources.append((df, filename, filepath.stem))
322
+ elif ext in (".tsv", ".tab"):
323
+ df = pl.read_csv(filename, separator="\t")
324
+ sources.append((df, filename, filepath.stem))
325
+ elif ext == ".json":
326
+ df = pl.read_json(filename)
327
+ sources.append((df, filename, filepath.stem))
328
+ elif ext == ".parquet":
329
+ df = pl.read_parquet(filename)
330
+ sources.append((df, filename, filepath.stem))
331
+ else:
332
+ df = pl.read_csv(filename)
333
+ sources.append((df, filename, filepath.stem))
334
+
335
+ return sources
@@ -0,0 +1,311 @@
1
+ """Modal screens for displaying data in tables (row details and frequency)."""
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ if TYPE_CHECKING:
6
+ from .data_frame_table import DataFrameTable
7
+
8
+ import polars as pl
9
+ from rich.text import Text
10
+ from textual.app import ComposeResult
11
+ from textual.coordinate import Coordinate
12
+ from textual.renderables.bar import Bar
13
+ from textual.screen import ModalScreen
14
+ from textual.widgets import DataTable
15
+
16
+ from .common import DtypeConfig, _format_row
17
+
18
+
19
+ class TableScreen(ModalScreen):
20
+ """Base class for modal screens displaying data in a DataTable.
21
+
22
+ Provides common functionality for screens that show tabular data with
23
+ keyboard shortcuts and styling.
24
+ """
25
+
26
+ DEFAULT_CSS = """
27
+ TableScreen {
28
+ align: center middle;
29
+ }
30
+
31
+ TableScreen > DataTable {
32
+ width: auto;
33
+ min-width: 30;
34
+ height: auto;
35
+ border: solid $primary;
36
+ }
37
+ """
38
+
39
+ def __init__(self, dftable: DataFrameTable):
40
+ super().__init__()
41
+ self.df: pl.DataFrame = dftable.df # Polars DataFrame
42
+ self.dftable = dftable # DataFrameTable
43
+
44
+ def compose(self) -> ComposeResult:
45
+ """Create the table. Must be overridden by subclasses."""
46
+ self.table = DataTable(zebra_stripes=True)
47
+ yield self.table
48
+
49
+ def on_key(self, event):
50
+ if event.key in ("q", "escape"):
51
+ self.app.pop_screen()
52
+ event.stop()
53
+ # Prevent key events from propagating to parent screen,
54
+ # except for the following default key bindings for DataTable
55
+ elif event.key not in (
56
+ "up",
57
+ "down",
58
+ "right",
59
+ "left",
60
+ "pageup",
61
+ "pagedown",
62
+ "ctrl+home",
63
+ "ctrl+end",
64
+ "home",
65
+ "end",
66
+ ):
67
+ event.stop()
68
+
69
+ def _filter_or_highlight_selected_value(
70
+ self, col_name_value: tuple[str, str] | None, action: str = "filter"
71
+ ) -> None:
72
+ """Apply filter or highlight action by the selected value from the frequency table.
73
+
74
+ Args:
75
+ col_name: The name of the column to filter/highlight.
76
+ col_value: The value to filter/highlight by.
77
+ action: Either "filter" to filter visible rows, or "highlight" to select matching rows.
78
+ """
79
+ if col_name_value is None:
80
+ return
81
+ col_name, col_value = col_name_value
82
+
83
+ # Handle NULL values
84
+ if col_value == "-":
85
+ # Create expression for NULL values
86
+ expr = pl.col(col_name).is_null()
87
+ value_display = "[on $primary]NULL[/]"
88
+ else:
89
+ # Create expression for the selected value
90
+ expr = pl.col(col_name) == col_value
91
+ value_display = f"[on $primary]{col_value}[/]"
92
+
93
+ matched_indices = set(
94
+ self.dftable.df.with_row_index("__rid__").filter(expr)["__rid__"].to_list()
95
+ )
96
+
97
+ # Apply the action
98
+ if action == "filter":
99
+ # Update visible_rows to reflect the filter
100
+ for i in range(len(self.dftable.visible_rows)):
101
+ self.dftable.visible_rows[i] = i in matched_indices
102
+ title = "Filter"
103
+ message = f"Filtered by [on $primary]{col_name}[/] = {value_display}"
104
+ else: # action == "highlight"
105
+ # Update selected_rows to reflect the highlights
106
+ for i in range(len(self.dftable.selected_rows)):
107
+ self.dftable.selected_rows[i] = i in matched_indices
108
+ title = "Highlight"
109
+ message = f"Highlighted [on $primary]{col_name}[/] = {value_display}"
110
+
111
+ # Recreate the table display with updated data in the main app
112
+ self.dftable._setup_table()
113
+
114
+ # Dismiss the frequency screen
115
+ self.app.pop_screen()
116
+
117
+ self.notify(message, title=title)
118
+
119
+
120
+ class RowDetailScreen(TableScreen):
121
+ """Modal screen to display a single row's details."""
122
+
123
+ CSS = TableScreen.DEFAULT_CSS.replace("TableScreen", "RowDetailScreen")
124
+
125
+ def __init__(self, row_idx: int, dftable):
126
+ super().__init__(dftable)
127
+ self.row_idx = row_idx
128
+
129
+ def on_mount(self) -> None:
130
+ """Create the detail table."""
131
+ self.table.add_column("Column")
132
+ self.table.add_column("Value")
133
+
134
+ # Get all columns and values from the dataframe row
135
+ for col, val, dtype in zip(
136
+ self.df.columns, self.df.row(self.row_idx), self.df.dtypes
137
+ ):
138
+ self.table.add_row(
139
+ *_format_row([col, val], [None, dtype], apply_justify=False)
140
+ )
141
+
142
+ self.table.cursor_type = "row"
143
+
144
+ def on_key(self, event):
145
+ if event.key == "v":
146
+ # Filter the main table by the selected value
147
+ self._filter_or_highlight_selected_value(
148
+ self._get_col_name_value(), action="filter"
149
+ )
150
+ event.stop()
151
+ elif event.key == "quotation_mark": # '"'
152
+ # Highlight the main table by the selected value
153
+ self._filter_or_highlight_selected_value(
154
+ self._get_col_name_value(), action="highlight"
155
+ )
156
+ event.stop()
157
+
158
+ def _get_col_name_value(self) -> tuple[str, Any] | None:
159
+ row_idx = self.table.cursor_row
160
+ if row_idx >= len(self.df.columns):
161
+ return None # Invalid row
162
+
163
+ col_name = self.df.columns[row_idx]
164
+ col_value = self.df.item(self.row_idx, row_idx)
165
+
166
+ return col_name, col_value
167
+
168
+
169
+ class FrequencyScreen(TableScreen):
170
+ """Modal screen to display frequency of values in a column."""
171
+
172
+ CSS = TableScreen.DEFAULT_CSS.replace("TableScreen", "FrequencyScreen")
173
+
174
+ def __init__(self, col_idx: int, dftable: DataFrameTable):
175
+ super().__init__(dftable)
176
+ self.col_idx = col_idx
177
+ self.sorted_columns = {
178
+ 1: True, # Count
179
+ 2: True, # %
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
+ )
186
+
187
+ def on_mount(self) -> None:
188
+ """Create the frequency table."""
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])
217
+ dc = DtypeConfig(dtype)
218
+
219
+ # Calculate frequencies using Polars
220
+ total_count = len(self.dftable.df)
221
+
222
+ self.table.add_column(Text(column, justify=dc.justify), key=column)
223
+ self.table.add_column(Text("Count", justify="right"), key="Count")
224
+ self.table.add_column(Text("%", justify="right"), key="%")
225
+ self.table.add_column(Text("Histogram", justify="left"), key="Histogram")
226
+
227
+ # Get style config for Int64 and Float64
228
+ ds_int = DtypeConfig("Int64")
229
+ ds_float = DtypeConfig("Float64")
230
+
231
+ # Add rows to the frequency table
232
+ for row_idx, row in enumerate(self.df.rows()):
233
+ value, count = row
234
+ percentage = (count / total_count) * 100
235
+
236
+ self.table.add_row(
237
+ Text(
238
+ "-" if value is None else str(value),
239
+ style=dc.style,
240
+ justify=dc.justify,
241
+ ),
242
+ Text(str(count), style=ds_int.style, justify=ds_int.justify),
243
+ Text(
244
+ f"{percentage:.2f}",
245
+ style=ds_float.style,
246
+ justify=ds_float.justify,
247
+ ),
248
+ Bar(
249
+ highlight_range=(0.0, percentage / 100 * 10),
250
+ width=10,
251
+ ),
252
+ key=str(row_idx + 1),
253
+ )
254
+
255
+ # Add a total row
256
+ self.table.add_row(
257
+ Text("Total", style="bold", justify=dc.justify),
258
+ Text(f"{total_count:,}", style="bold", justify="right"),
259
+ Text("100.00", style="bold", justify="right"),
260
+ Bar(
261
+ highlight_range=(0.0, 10),
262
+ width=10,
263
+ ),
264
+ key="total",
265
+ )
266
+
267
+ def _sort_by_column(self, descending: bool) -> None:
268
+ """Sort the dataframe by the selected column and refresh the main table."""
269
+
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
274
+
275
+ sort_dir = self.sorted_columns.get(col_sort)
276
+ if sort_dir is not None:
277
+ # If already sorted in the same direction, do nothing
278
+ if sort_dir == descending:
279
+ self.notify(
280
+ "Already sorted in that order", title="Sort", severity="warning"
281
+ )
282
+ return
283
+
284
+ self.sorted_columns.clear()
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)
295
+
296
+ # Notify the user
297
+ order = "desc" if descending else "asc"
298
+ self.notify(f"Sorted by [on $primary]{col_name}[/] ({order})", title="Sort")
299
+
300
+ def _get_col_name_value(self) -> tuple[str, str] | None:
301
+ row_idx = self.table.cursor_row
302
+ if row_idx >= len(self.df.columns):
303
+ return None # Skip total row
304
+
305
+ col_name = self.df.columns[self.col_idx]
306
+ col_dtype = self.df.dtypes[self.col_idx]
307
+
308
+ cell_value = self.table.get_cell_at(Coordinate(row_idx, 0))
309
+ col_value = cell_value.plain
310
+
311
+ return col_name, DtypeConfig(col_dtype).convert(col_value)