csvpeek 0.9.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.
csvpeek/filters.py ADDED
@@ -0,0 +1,52 @@
1
+ """Filter utilities for CSV data (DuckDB backend)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Iterable
7
+
8
+
9
+ def _quote_ident(name: str) -> str:
10
+ return f'"{name.replace('"', '""')}"'
11
+
12
+
13
+ def build_where_clause(
14
+ filters: dict[str, str], valid_columns: Iterable[str]
15
+ ) -> tuple[str, list]:
16
+ """Build a DuckDB WHERE clause and parameters from filter definitions.
17
+
18
+ Literal filters use a case-insensitive substring match; filters prefixed with
19
+ '/' are treated as case-insensitive regex via regexp_matches.
20
+ """
21
+
22
+ clauses = []
23
+ params: list = []
24
+ valid = set(valid_columns)
25
+
26
+ for col, raw in filters.items():
27
+ if col not in valid:
28
+ continue
29
+ val = raw.strip()
30
+ if not val:
31
+ continue
32
+
33
+ ident = _quote_ident(col)
34
+
35
+ if val.startswith("/"):
36
+ pattern = val[1:]
37
+ if not pattern:
38
+ continue
39
+ try:
40
+ re.compile(pattern)
41
+ except re.error:
42
+ continue
43
+ clauses.append(f"regexp_matches({ident}, ?, 'i')")
44
+ params.append(pattern)
45
+ else:
46
+ clauses.append(f"lower({ident}) LIKE ?")
47
+ params.append(f"%{val.lower()}%")
48
+
49
+ if not clauses:
50
+ return "", []
51
+
52
+ return " WHERE " + " AND ".join(clauses), params
csvpeek/main.py ADDED
@@ -0,0 +1,58 @@
1
+ """Main entry point for csvpeek."""
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+
7
+ def parse_args(argv: list[str] | None = None):
8
+ parser = argparse.ArgumentParser(
9
+ description="csvpeek - A snappy, memory-efficient CSV viewer",
10
+ epilog="Example: csvpeek --color-columns data.csv",
11
+ )
12
+ parser.add_argument("csv_path", nargs="?", help="Path to the CSV file")
13
+ parser.add_argument(
14
+ "--color-columns",
15
+ action="store_true",
16
+ help="Color each column using alternating colors",
17
+ )
18
+ parser.add_argument(
19
+ "--column-colors",
20
+ help="Comma-separated list of urwid colors to cycle through for columns",
21
+ )
22
+
23
+ args = parser.parse_args(argv)
24
+
25
+ if not args.csv_path:
26
+ parser.error("CSV path required")
27
+
28
+ if args.csv_path.startswith("-"):
29
+ parser.error(
30
+ "Place options before the CSV path, e.g. csvpeek [OPTIONS] <file.csv>"
31
+ )
32
+ csv_path = args.csv_path
33
+ if not Path(csv_path).exists():
34
+ parser.error(f"File '{csv_path}' not found.")
35
+
36
+ colors = None
37
+ if args.column_colors:
38
+ colors = [c.strip() for c in args.column_colors.split(",") if c.strip()]
39
+
40
+ return args, csv_path, colors
41
+
42
+
43
+ def main(argv: list[str] | None = None):
44
+ """Main entry point."""
45
+ from csvpeek.csvpeek import CSVViewerApp
46
+
47
+ args, csv_path, colors = parse_args(argv)
48
+
49
+ app = CSVViewerApp(
50
+ csv_path,
51
+ color_columns=args.color_columns or bool(colors),
52
+ column_colors=colors,
53
+ )
54
+ app.run()
55
+
56
+
57
+ if __name__ == "__main__":
58
+ main()
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Callable
4
+
5
+
6
+ class ScreenBuffer:
7
+ """Manages paged row buffering without reaching into UI or app state."""
8
+
9
+ def __init__(self, fetch_rows: Callable[[int, int], list[tuple]]) -> None:
10
+ self.fetch_rows = fetch_rows
11
+ self.start = 0
12
+ self.rows: list[tuple] = []
13
+ self.num_rows = 0
14
+
15
+ def reset(self) -> None:
16
+ self.start = 0
17
+ self.rows = []
18
+ self.num_rows = 0
19
+
20
+ def get_page_rows(
21
+ self,
22
+ *,
23
+ desired_start: int,
24
+ page_size: int,
25
+ total_rows: int,
26
+ fetch_size: int,
27
+ ) -> tuple[list[tuple], int]:
28
+ """Return rows for the requested page and the clamped start offset."""
29
+
30
+ clamped_start = max(0, min(desired_start, max(0, total_rows - page_size)))
31
+ need_end = min(clamped_start + page_size, total_rows)
32
+ have_start = self.start
33
+ have_end = self.start + self.num_rows
34
+
35
+ if self.rows and clamped_start >= have_start and need_end <= have_end:
36
+ start = clamped_start - have_start
37
+ end = start + page_size
38
+ return self.rows[start:end], clamped_start
39
+
40
+ self._fill(clamped_start, fetch_size)
41
+ start = 0
42
+ end = min(page_size, self.num_rows)
43
+ return self.rows[start:end], clamped_start
44
+
45
+ def _fill(self, start_row: int, fetch_size: int) -> None:
46
+ self.rows = self.fetch_rows(start_row, fetch_size)
47
+ self.num_rows = len(self.rows)
48
+ self.start = start_row
@@ -0,0 +1,128 @@
1
+ """Selection utilities for csvpeek (DuckDB backend)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Sequence
6
+
7
+
8
+ class Selection:
9
+ """Tracks an anchored selection in absolute row/column coordinates."""
10
+
11
+ def __init__(self) -> None:
12
+ self.active = False
13
+ self.anchor_row: int | None = None
14
+ self.anchor_col: int | None = None
15
+ self.focus_row: int | None = None
16
+ self.focus_col: int | None = None
17
+
18
+ def clear(self) -> None:
19
+ self.active = False
20
+ self.anchor_row = None
21
+ self.anchor_col = None
22
+ self.focus_row = None
23
+ self.focus_col = None
24
+
25
+ def start(self, row: int, col: int) -> None:
26
+ self.active = True
27
+ self.anchor_row = row
28
+ self.anchor_col = col
29
+ self.focus_row = row
30
+ self.focus_col = col
31
+
32
+ def extend(self, row: int, col: int) -> None:
33
+ if not self.active or self.anchor_row is None or self.anchor_col is None:
34
+ self.start(row, col)
35
+ return
36
+ self.focus_row = row
37
+ self.focus_col = col
38
+
39
+ def bounds(self, fallback_row: int, fallback_col: int) -> tuple[int, int, int, int]:
40
+ """Return (row_start, row_end, col_start, col_end).
41
+
42
+ If inactive, falls back to the provided cursor position.
43
+ """
44
+
45
+ if not self.active or None in (
46
+ self.anchor_row,
47
+ self.anchor_col,
48
+ self.focus_row,
49
+ self.focus_col,
50
+ ):
51
+ return fallback_row, fallback_row, fallback_col, fallback_col
52
+
53
+ row_start = min(self.anchor_row, self.focus_row)
54
+ row_end = max(self.anchor_row, self.focus_row)
55
+ col_start = min(self.anchor_col, self.focus_col)
56
+ col_end = max(self.anchor_col, self.focus_col)
57
+ return row_start, row_end, col_start, col_end
58
+
59
+ def dimensions(self, fallback_row: int, fallback_col: int) -> tuple[int, int]:
60
+ row_start, row_end, col_start, col_end = self.bounds(fallback_row, fallback_col)
61
+ return row_end - row_start + 1, col_end - col_start + 1
62
+
63
+ def contains(
64
+ self, row: int, col: int, *, fallback_row: int, fallback_col: int
65
+ ) -> bool:
66
+ row_start, row_end, col_start, col_end = self.bounds(fallback_row, fallback_col)
67
+ return row_start <= row <= row_end and col_start <= col <= col_end
68
+
69
+
70
+ if TYPE_CHECKING: # pragma: no cover
71
+ from csvpeek.csvpeek import CSVViewerApp
72
+
73
+
74
+ def get_single_cell_value(app: "CSVViewerApp") -> str:
75
+ """Return the current cell value as a string."""
76
+ if not app.cached_rows:
77
+ return ""
78
+ row = app.cached_rows[app.cursor_row]
79
+ cell = row[app.cursor_col] if app.cursor_col < len(row) else None
80
+ return "" if cell is None else str(cell)
81
+
82
+
83
+ def get_selection_bounds(app: "CSVViewerApp") -> tuple[int, int, int, int]:
84
+ """Get selection bounds as (row_start, row_end, col_start, col_end)."""
85
+
86
+ cursor_abs_row = app.row_offset + app.cursor_row
87
+ return app.selection.bounds(cursor_abs_row, app.cursor_col)
88
+
89
+
90
+ def create_selected_dataframe(app: "CSVViewerApp") -> Sequence[Sequence]:
91
+ """Return selected rows for CSV export."""
92
+ if not app.db:
93
+ return []
94
+
95
+ row_start, row_end, col_start, col_end = get_selection_bounds(app)
96
+ fetch_count = row_end - row_start + 1
97
+
98
+ rows = app.db.fetch_rows(
99
+ app.filter_where,
100
+ list(app.filter_params),
101
+ app.sorted_column,
102
+ app.sorted_descending,
103
+ fetch_count,
104
+ row_start,
105
+ )
106
+
107
+ return [row[col_start : col_end + 1] for row in rows]
108
+
109
+
110
+ def clear_selection_and_update(app: "CSVViewerApp") -> None:
111
+ """Clear selection and refresh visuals."""
112
+ app.selection.clear()
113
+ app._refresh_rows()
114
+
115
+
116
+ def get_selection_dimensions(
117
+ app: "CSVViewerApp", as_bounds: bool = False
118
+ ) -> tuple[int, int] | tuple[int, int, int, int]:
119
+ """Get selection dimensions or bounds.
120
+
121
+ If `as_bounds` is True, returns (row_start, row_end, col_start, col_end).
122
+ Otherwise returns (num_rows, num_cols).
123
+ """
124
+
125
+ row_start, row_end, col_start, col_end = get_selection_bounds(app)
126
+ if as_bounds:
127
+ return row_start, row_end, col_start, col_end
128
+ return row_end - row_start + 1, col_end - col_start + 1
csvpeek/ui.py ADDED
@@ -0,0 +1,407 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Callable
4
+
5
+ import urwid
6
+
7
+ if TYPE_CHECKING: # pragma: no cover
8
+ from csvpeek.csvpeek import CSVViewerApp
9
+
10
+
11
+ def _truncate(text: str, width: int) -> str:
12
+ """Truncate text to a fixed width without padding."""
13
+ if len(text) > width:
14
+ return text[: width - 1] + "…"
15
+ return text
16
+
17
+
18
+ class FlowColumns(urwid.Columns):
19
+ """Columns that behave as a 1-line flow widget for ListBox rows."""
20
+
21
+ sizing = frozenset(["flow"])
22
+
23
+ def rows(self, size, focus=False): # noqa: ANN001, D401
24
+ return 1
25
+
26
+
27
+ class PagingListBox(urwid.ListBox):
28
+ """ListBox that routes page keys to app-level pagination."""
29
+
30
+ def __init__(self, app: "CSVViewerApp", body):
31
+ self.app = app
32
+ super().__init__(body)
33
+
34
+ def keypress(self, size, key): # noqa: ANN001
35
+ if getattr(self.app, "overlaying", False):
36
+ return super().keypress(size, key)
37
+ if key in ("page down", "ctrl d"):
38
+ self.app.next_page()
39
+ return None
40
+ if key in ("page up", "ctrl u"):
41
+ self.app.prev_page()
42
+ return None
43
+ return super().keypress(size, key)
44
+
45
+
46
+ class FilterDialog(urwid.WidgetWrap):
47
+ """Modal dialog to collect per-column filters."""
48
+
49
+ def __init__(
50
+ self,
51
+ columns: list[str],
52
+ current_filters: dict[str, str],
53
+ on_submit: Callable[[dict[str, str]], None],
54
+ on_cancel: Callable[[], None],
55
+ ) -> None:
56
+ self.columns = columns
57
+ self.current_filters = current_filters
58
+ self.on_submit = on_submit
59
+ self.on_cancel = on_cancel
60
+
61
+ self.edits: list[urwid.Edit] = []
62
+ edit_rows = []
63
+ pad_width = max((len(c) for c in self.columns), default=0) + 1
64
+ for col in self.columns:
65
+ label = f"{col.ljust(pad_width)}: "
66
+ edit = urwid.Edit(label, current_filters.get(col, ""))
67
+ self.edits.append(edit)
68
+ edit_rows.append(urwid.AttrMap(edit, None, focus_map="focus"))
69
+ self.walker = urwid.SimpleFocusListWalker(edit_rows)
70
+ listbox = urwid.ListBox(self.walker)
71
+ instructions = urwid.Padding(
72
+ urwid.Text("Tab to move, Enter to apply, Esc to cancel"), left=1, right=1
73
+ )
74
+ frame = urwid.Frame(body=listbox, header=instructions)
75
+ boxed = urwid.LineBox(frame, title="Filters")
76
+ super().__init__(boxed)
77
+
78
+ def keypress(self, size, key): # noqa: ANN001
79
+ if key == "tab":
80
+ self._move_focus(1)
81
+ return None
82
+ if key == "shift tab":
83
+ self._move_focus(-1)
84
+ return None
85
+ if key in ("enter",):
86
+ filters = {
87
+ col: edit.edit_text for col, edit in zip(self.columns, self.edits)
88
+ }
89
+ self.on_submit(filters)
90
+ return None
91
+ if key in ("esc", "ctrl g"):
92
+ self.on_cancel()
93
+ return None
94
+ return super().keypress(size, key)
95
+
96
+ def _move_focus(self, delta: int) -> None:
97
+ if not self.walker:
98
+ return
99
+ focus = self.walker.focus or 0
100
+ self.walker.focus = (focus + delta) % len(self.walker)
101
+
102
+
103
+ class FilenameDialog(urwid.WidgetWrap):
104
+ """Modal dialog for choosing a filename."""
105
+
106
+ def __init__(
107
+ self,
108
+ prompt: str,
109
+ on_submit: Callable[[str], None],
110
+ on_cancel: Callable[[], None],
111
+ ) -> None:
112
+ self.edit = urwid.Edit(f"{prompt}: ")
113
+ self.on_submit = on_submit
114
+ self.on_cancel = on_cancel
115
+ pile = urwid.Pile(
116
+ [
117
+ urwid.Text("Enter filename and press Enter"),
118
+ urwid.Divider(),
119
+ urwid.AttrMap(self.edit, None, focus_map="focus"),
120
+ ]
121
+ )
122
+ boxed = urwid.LineBox(pile, title="Save Selection")
123
+ super().__init__(urwid.Filler(boxed, valign="top"))
124
+
125
+ def keypress(self, size, key): # noqa: ANN001
126
+ if key in ("enter",):
127
+ self.on_submit(self.edit.edit_text.strip())
128
+ return None
129
+ if key in ("esc", "ctrl g"):
130
+ self.on_cancel()
131
+ return None
132
+ return super().keypress(size, key)
133
+
134
+
135
+ class HelpDialog(urwid.WidgetWrap):
136
+ """Modal dialog listing keyboard shortcuts."""
137
+
138
+ def __init__(self, on_close: Callable[[], None]) -> None:
139
+ shortcuts = [
140
+ ("?", "Show this help"),
141
+ ("q", "Quit"),
142
+ ("r", "Reset filters"),
143
+ ("/", "Open filter dialog"),
144
+ ("s", "Sort by current column (toggle asc/desc)"),
145
+ ("c", "Copy cell or selection"),
146
+ ("w", "Save selection to CSV"),
147
+ ("←/→/↑/↓", "Move cursor"),
148
+ ("Shift + arrows", "Extend selection"),
149
+ ("PgUp / Ctrl+U", "Previous page"),
150
+ ("PgDn / Ctrl+D", "Next page"),
151
+ ]
152
+ rows = [urwid.Text("Keyboard Shortcuts", align="center"), urwid.Divider()]
153
+ for key, desc in shortcuts:
154
+ rows.append(urwid.Columns([(12, urwid.Text(key)), urwid.Text(desc)]))
155
+ body = urwid.ListBox(urwid.SimpleFocusListWalker(rows))
156
+ boxed = urwid.LineBox(body)
157
+ self.on_close = on_close
158
+ super().__init__(boxed)
159
+
160
+ def keypress(self, size, key): # noqa: ANN001
161
+ if key in ("esc", "enter", "q", "?", "ctrl g"):
162
+ self.on_close()
163
+ return None
164
+ return super().keypress(size, key)
165
+
166
+
167
+ class ConfirmDialog(urwid.WidgetWrap):
168
+ """Simple yes/no confirmation dialog."""
169
+
170
+ def __init__(
171
+ self, message: str, on_yes: Callable[[], None], on_no: Callable[[], None]
172
+ ) -> None:
173
+ yes_btn = urwid.Button("Yes", on_press=lambda *_: on_yes())
174
+ no_btn = urwid.Button("No", on_press=lambda *_: on_no())
175
+ buttons = urwid.Columns(
176
+ [
177
+ urwid.Padding(
178
+ urwid.AttrMap(yes_btn, None, focus_map="focus"), left=1, right=1
179
+ ),
180
+ urwid.Padding(
181
+ urwid.AttrMap(no_btn, None, focus_map="focus"), left=1, right=1
182
+ ),
183
+ ]
184
+ )
185
+ pile = urwid.Pile([urwid.Text(message), urwid.Divider(), buttons])
186
+ boxed = urwid.LineBox(pile, title="Confirm")
187
+ self.on_yes = on_yes
188
+ self.on_no = on_no
189
+ super().__init__(boxed)
190
+
191
+ def keypress(self, size, key): # noqa: ANN001
192
+ if key in ("y", "Y"):
193
+ self.on_yes()
194
+ return None
195
+ if key in ("n", "N", "esc", "ctrl g", "q", "Q"):
196
+ self.on_no()
197
+ return None
198
+ return super().keypress(size, key)
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # Layout helpers
203
+ # ---------------------------------------------------------------------------
204
+
205
+
206
+ def current_screen_width(app: "CSVViewerApp") -> int:
207
+ if app.loop and app.loop.screen:
208
+ cols, _rows = app.loop.screen.get_cols_rows()
209
+ return max(cols, 40)
210
+ return 80
211
+
212
+
213
+ def visible_column_names(app: "CSVViewerApp", max_width: int) -> list[str]:
214
+ if not app.column_names:
215
+ return []
216
+ names = list(app.column_names)
217
+ widths = [app.column_widths.get(c, 12) for c in names]
218
+ divide = 1
219
+ start = min(app.col_offset, len(names) - 1 if names else 0)
220
+
221
+ ensure_cursor_visible(app, max_width, widths)
222
+ start = app.col_offset
223
+
224
+ chosen: list[str] = []
225
+ used = 0
226
+ for idx in range(start, len(names)):
227
+ w = widths[idx]
228
+ extra = w if not chosen else w + divide
229
+ if used + extra > max_width and chosen:
230
+ break
231
+ chosen.append(names[idx])
232
+ used += extra
233
+ if not chosen and names:
234
+ chosen.append(names[start])
235
+ return chosen
236
+
237
+
238
+ def build_header_row(
239
+ app: "CSVViewerApp", max_width: int | None = None
240
+ ) -> urwid.Columns:
241
+ if not app.column_names:
242
+ return urwid.Columns([])
243
+ if max_width is None:
244
+ max_width = current_screen_width(app)
245
+ cols = []
246
+ for col in visible_column_names(app, max_width):
247
+ label = col
248
+ if app.sorted_column == col:
249
+ label = f"{col} {'▼' if app.sorted_descending else '▲'}"
250
+ width = app.column_widths.get(col, 12)
251
+ header_text = urwid.Text(_truncate(label, width), wrap="clip")
252
+ attr = app._column_attr(app.column_names.index(col))
253
+ if attr:
254
+ header_text = urwid.AttrMap(header_text, attr)
255
+ cols.append((width, header_text))
256
+ return urwid.Columns(cols, dividechars=1)
257
+
258
+
259
+ def build_ui(app: "CSVViewerApp") -> urwid.Widget:
260
+ header_text = urwid.Text(f"csvpeek - {app.csv_path.name}", align="center")
261
+ header = urwid.AttrMap(header_text, "header")
262
+ app.table_header = build_header_row(app, current_screen_width(app))
263
+ body = urwid.Pile(
264
+ [
265
+ ("pack", app.table_header),
266
+ ("pack", urwid.Divider("─")),
267
+ app.listbox,
268
+ ]
269
+ )
270
+ footer = urwid.AttrMap(app.status_widget, "status")
271
+ return urwid.Frame(body=body, header=header, footer=footer)
272
+
273
+
274
+ # ---------------------------------------------------------------------------
275
+ # Cursor and selection helpers
276
+ # ---------------------------------------------------------------------------
277
+
278
+
279
+ def ensure_cursor_visible(
280
+ app: "CSVViewerApp", max_width: int, widths: list[int]
281
+ ) -> None:
282
+ if not widths:
283
+ return
284
+ divide = 1
285
+ col = min(app.cursor_col, len(widths) - 1)
286
+ if col < app.col_offset:
287
+ app.col_offset = col
288
+ return
289
+ while True:
290
+ total = 0
291
+ for idx in range(app.col_offset, col + 1):
292
+ total += widths[idx]
293
+ if idx > app.col_offset:
294
+ total += divide
295
+ if total <= max_width or app.col_offset == col:
296
+ break
297
+ app.col_offset += 1
298
+
299
+
300
+ def move_cursor(app: "CSVViewerApp", key: str) -> None:
301
+ extend = key.startswith("shift")
302
+
303
+ if extend and not app.selection.active:
304
+ app.selection.start(app.row_offset + app.cursor_row, app.cursor_col)
305
+
306
+ cols = len(app.column_names)
307
+ rows = len(app.cached_rows)
308
+
309
+ cursor_row = app.cursor_row
310
+ cursor_col = app.cursor_col
311
+ row_offset = app.row_offset
312
+
313
+ if key.endswith("left"):
314
+ cursor_col = max(0, cursor_col - 1)
315
+ if key.endswith("right"):
316
+ cursor_col = min(cols - 1, cursor_col + 1)
317
+ if key.endswith("up"):
318
+ if cursor_row > 0:
319
+ cursor_row -= 1
320
+ elif row_offset > 0:
321
+ row_offset -= 1
322
+ if key.endswith("down"):
323
+ if cursor_row < rows - 1:
324
+ cursor_row += 1
325
+ elif row_offset + cursor_row + 1 < app.total_filtered_rows:
326
+ row_offset += 1
327
+
328
+ app.cursor_row = cursor_row
329
+ app.cursor_col = cursor_col
330
+ app.row_offset = row_offset
331
+
332
+ abs_row = app.row_offset + app.cursor_row
333
+ if extend:
334
+ app.selection.extend(abs_row, app.cursor_col)
335
+ else:
336
+ app.selection.clear()
337
+
338
+ widths = [app.column_widths.get(c, 12) for c in app.column_names]
339
+ ensure_cursor_visible(app, current_screen_width(app), widths)
340
+ app._refresh_rows()
341
+
342
+
343
+ def show_overlay(
344
+ app: "CSVViewerApp",
345
+ widget: urwid.Widget,
346
+ *,
347
+ height: urwid.RelativeSizing | str | tuple = "pack",
348
+ width: urwid.RelativeSizing | str | tuple = ("relative", 80),
349
+ ) -> None:
350
+ if app.loop is None:
351
+ return
352
+ overlay = urwid.Overlay(
353
+ widget,
354
+ app.loop.widget,
355
+ align="center",
356
+ width=width,
357
+ valign="middle",
358
+ height=height,
359
+ )
360
+ app.loop.widget = overlay
361
+ app.overlaying = True
362
+
363
+
364
+ def close_overlay(app: "CSVViewerApp") -> None:
365
+ if app.loop is None:
366
+ return
367
+ if isinstance(app.loop.widget, urwid.Overlay):
368
+ app.loop.widget = app.loop.widget.bottom_w
369
+ app.overlaying = False
370
+ app._refresh_rows()
371
+
372
+
373
+ def update_status(app: "CSVViewerApp", *_args) -> None: # noqa: ANN002
374
+ if not app.db:
375
+ return
376
+ page_size = available_body_rows(app)
377
+ max_page = max(0, (app.total_filtered_rows - 1) // page_size)
378
+ row_number = app.row_offset + app.cursor_row
379
+ page_idx = row_number // page_size
380
+ selection_info = ""
381
+
382
+ if app.selection.active:
383
+ from csvpeek.selection_utils import get_selection_dimensions
384
+
385
+ rows, cols = get_selection_dimensions(app)
386
+ selection_info = f"SELECT {rows}x{cols} | "
387
+
388
+ page_info = f"Page {page_idx + 1}/{max_page + 1}"
389
+ row_info = f"Row: {row_number + 1}/{app.total_filtered_rows}"
390
+ col_info = f"Col: {app.cursor_col + 1}/{app.total_columns}"
391
+
392
+ status = f"{page_info} {row_info}, {col_info} {selection_info} Press ? for help"
393
+ app.status_widget.set_text(status)
394
+
395
+
396
+ def available_body_rows(app: "CSVViewerApp") -> int:
397
+ """Estimate usable body rows based on terminal height."""
398
+ if not app.loop or not app.loop.screen:
399
+ return app.PAGE_SIZE
400
+ _cols, rows = app.loop.screen.get_cols_rows()
401
+ reserved = 4 # header, divider, footer
402
+ return max(5, rows - reserved)
403
+
404
+
405
+ def buffer_size(app: "CSVViewerApp") -> int:
406
+ body = available_body_rows(app)
407
+ return max(body * 4, body + 5)