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/__init__.py +3 -0
- csvpeek/csvpeek.py +613 -0
- csvpeek/duck.py +84 -0
- csvpeek/filters.py +52 -0
- csvpeek/main.py +58 -0
- csvpeek/screen_buffer.py +48 -0
- csvpeek/selection_utils.py +128 -0
- csvpeek/ui.py +407 -0
- csvpeek-0.9.0.dist-info/METADATA +142 -0
- csvpeek-0.9.0.dist-info/RECORD +13 -0
- csvpeek-0.9.0.dist-info/WHEEL +4 -0
- csvpeek-0.9.0.dist-info/entry_points.txt +2 -0
- csvpeek-0.9.0.dist-info/licenses/LICENSE +21 -0
csvpeek/__init__.py
ADDED
csvpeek/csvpeek.py
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
csvpeek - A snappy, memory-efficient CSV viewer using DuckDB and Urwid.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import csv
|
|
9
|
+
import gc
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import pyperclip
|
|
14
|
+
import urwid
|
|
15
|
+
|
|
16
|
+
from csvpeek.duck import DuckBackend
|
|
17
|
+
from csvpeek.filters import build_where_clause
|
|
18
|
+
from csvpeek.screen_buffer import ScreenBuffer
|
|
19
|
+
from csvpeek.selection_utils import (
|
|
20
|
+
Selection,
|
|
21
|
+
clear_selection_and_update,
|
|
22
|
+
create_selected_dataframe,
|
|
23
|
+
get_selection_dimensions,
|
|
24
|
+
get_single_cell_value,
|
|
25
|
+
)
|
|
26
|
+
from csvpeek.ui import (
|
|
27
|
+
ConfirmDialog,
|
|
28
|
+
FilenameDialog,
|
|
29
|
+
FilterDialog,
|
|
30
|
+
FlowColumns,
|
|
31
|
+
HelpDialog,
|
|
32
|
+
PagingListBox,
|
|
33
|
+
_truncate,
|
|
34
|
+
available_body_rows,
|
|
35
|
+
buffer_size,
|
|
36
|
+
build_header_row,
|
|
37
|
+
build_ui,
|
|
38
|
+
current_screen_width,
|
|
39
|
+
update_status,
|
|
40
|
+
visible_column_names,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CSVViewerApp:
|
|
45
|
+
"""Urwid-based CSV viewer with filtering, sorting, and selection."""
|
|
46
|
+
|
|
47
|
+
PAGE_SIZE = 50
|
|
48
|
+
BASE_PALETTE = [
|
|
49
|
+
("header", "black", "light gray"),
|
|
50
|
+
("status", "light gray", "dark gray"),
|
|
51
|
+
("cell_selected", "black", "yellow"),
|
|
52
|
+
("filter", "light red", "default"),
|
|
53
|
+
("focus", "white", "dark blue"),
|
|
54
|
+
]
|
|
55
|
+
DEFAULT_COLUMN_COLORS = [
|
|
56
|
+
"light cyan",
|
|
57
|
+
"light magenta",
|
|
58
|
+
"light green",
|
|
59
|
+
"yellow",
|
|
60
|
+
"light blue",
|
|
61
|
+
"light red",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
csv_path: str,
|
|
67
|
+
*,
|
|
68
|
+
color_columns: bool = False,
|
|
69
|
+
column_colors: list[str] | None = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
self.csv_path = Path(csv_path)
|
|
72
|
+
self.db: DuckBackend | None = None
|
|
73
|
+
self.cached_rows: list[tuple] = []
|
|
74
|
+
self.column_names: list[str] = []
|
|
75
|
+
|
|
76
|
+
self.current_page = 0
|
|
77
|
+
self.total_rows = 0
|
|
78
|
+
self.total_filtered_rows = 0
|
|
79
|
+
|
|
80
|
+
self.current_filters: dict[str, str] = {}
|
|
81
|
+
self.filter_patterns: dict[str, tuple[str, bool]] = {}
|
|
82
|
+
self.filter_where: str = ""
|
|
83
|
+
self.filter_params: list = []
|
|
84
|
+
self.sorted_column: str | None = None
|
|
85
|
+
self.sorted_descending = False
|
|
86
|
+
self.column_widths: dict[str, int] = {}
|
|
87
|
+
self.col_offset = 0 # horizontal scroll offset (column index)
|
|
88
|
+
self.row_offset = 0 # vertical scroll offset (row index)
|
|
89
|
+
self.color_columns = color_columns or bool(column_colors)
|
|
90
|
+
self.column_colors = column_colors or []
|
|
91
|
+
self.column_color_attrs: list[str] = []
|
|
92
|
+
self.screen_buffer = ScreenBuffer(self._fetch_rows)
|
|
93
|
+
|
|
94
|
+
# Selection and cursor state
|
|
95
|
+
self.selection = Selection()
|
|
96
|
+
self.cursor_row = 0
|
|
97
|
+
self.cursor_col = 0
|
|
98
|
+
self.total_columns = 0
|
|
99
|
+
|
|
100
|
+
# UI state
|
|
101
|
+
self.loop: urwid.MainLoop | None = None
|
|
102
|
+
self.table_walker = urwid.SimpleFocusListWalker([])
|
|
103
|
+
self.table_header = urwid.Columns([])
|
|
104
|
+
self.listbox = PagingListBox(self, self.table_walker)
|
|
105
|
+
self.status_widget = urwid.Text("")
|
|
106
|
+
self.overlaying = False
|
|
107
|
+
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
# Data loading and preparation
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
def load_csv(self) -> None:
|
|
112
|
+
try:
|
|
113
|
+
self.db = DuckBackend(self.csv_path)
|
|
114
|
+
self.db.load()
|
|
115
|
+
self.column_names = list(self.db.column_names)
|
|
116
|
+
self.total_columns = len(self.column_names)
|
|
117
|
+
self.total_rows = self.db.total_rows
|
|
118
|
+
self.total_filtered_rows = self.total_rows
|
|
119
|
+
self.column_widths = self.db.column_widths()
|
|
120
|
+
self.screen_buffer.reset()
|
|
121
|
+
self.selection.clear()
|
|
122
|
+
except Exception as exc: # noqa: BLE001
|
|
123
|
+
raise SystemExit(f"Error loading CSV: {exc}") from exc
|
|
124
|
+
|
|
125
|
+
def _column_attr(self, col_idx: int) -> str | None:
|
|
126
|
+
if not self.color_columns or not self.column_color_attrs:
|
|
127
|
+
return None
|
|
128
|
+
if col_idx < len(self.column_color_attrs):
|
|
129
|
+
return self.column_color_attrs[col_idx]
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
def _build_palette(self) -> list[tuple]:
|
|
133
|
+
palette = list(self.BASE_PALETTE)
|
|
134
|
+
if not self.color_columns:
|
|
135
|
+
self.column_color_attrs = []
|
|
136
|
+
return palette
|
|
137
|
+
|
|
138
|
+
self.column_color_attrs = []
|
|
139
|
+
colors = self.column_colors or self.DEFAULT_COLUMN_COLORS
|
|
140
|
+
if not colors:
|
|
141
|
+
return palette
|
|
142
|
+
|
|
143
|
+
for idx, _col in enumerate(self.column_names):
|
|
144
|
+
attr = f"col{idx}"
|
|
145
|
+
color = colors[idx % len(colors)]
|
|
146
|
+
palette.append((attr, color, "default"))
|
|
147
|
+
self.column_color_attrs.append(attr)
|
|
148
|
+
return palette
|
|
149
|
+
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
# UI construction
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
def build_ui(self) -> urwid.Widget:
|
|
154
|
+
return build_ui(self)
|
|
155
|
+
|
|
156
|
+
def _fetch_rows(self, start_row: int, fetch_size: int) -> list[tuple]:
|
|
157
|
+
if not self.db:
|
|
158
|
+
return []
|
|
159
|
+
return self.db.fetch_rows(
|
|
160
|
+
self.filter_where,
|
|
161
|
+
list(self.filter_params),
|
|
162
|
+
self.sorted_column,
|
|
163
|
+
self.sorted_descending,
|
|
164
|
+
fetch_size,
|
|
165
|
+
start_row,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
# Rendering
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
def _refresh_rows(self) -> None:
|
|
172
|
+
if not self.db:
|
|
173
|
+
return
|
|
174
|
+
if not self.selection.active:
|
|
175
|
+
self.cached_rows = []
|
|
176
|
+
page_size = available_body_rows(self)
|
|
177
|
+
fetch_size = buffer_size(self)
|
|
178
|
+
rows, used_offset = self.screen_buffer.get_page_rows(
|
|
179
|
+
desired_start=self.row_offset,
|
|
180
|
+
page_size=page_size,
|
|
181
|
+
total_rows=self.total_filtered_rows,
|
|
182
|
+
fetch_size=fetch_size,
|
|
183
|
+
)
|
|
184
|
+
self.row_offset = used_offset
|
|
185
|
+
self.cached_rows = rows
|
|
186
|
+
gc.collect()
|
|
187
|
+
max_width = current_screen_width(self)
|
|
188
|
+
self.table_walker.clear()
|
|
189
|
+
# Clamp cursor within available data
|
|
190
|
+
self.cursor_row = min(self.cursor_row, max(0, len(self.cached_rows) - 1))
|
|
191
|
+
self.cursor_col = min(self.cursor_col, max(0, len(self.column_names) - 1))
|
|
192
|
+
|
|
193
|
+
visible_cols = visible_column_names(self, max_width)
|
|
194
|
+
vis_indices = [self.column_names.index(c) for c in visible_cols]
|
|
195
|
+
|
|
196
|
+
for row_idx, row in enumerate(self.cached_rows):
|
|
197
|
+
row_widget = self._build_row_widget(row_idx, row, vis_indices)
|
|
198
|
+
self.table_walker.append(row_widget)
|
|
199
|
+
|
|
200
|
+
if self.table_walker:
|
|
201
|
+
self.table_walker.set_focus(self.cursor_row)
|
|
202
|
+
self.table_header = build_header_row(self, max_width)
|
|
203
|
+
if self.loop:
|
|
204
|
+
frame_widget = self.loop.widget
|
|
205
|
+
if isinstance(frame_widget, urwid.Overlay):
|
|
206
|
+
frame_widget = frame_widget.bottom_w
|
|
207
|
+
if isinstance(frame_widget, urwid.Frame):
|
|
208
|
+
frame_widget.body.contents[0] = (
|
|
209
|
+
self.table_header,
|
|
210
|
+
frame_widget.body.options("pack"),
|
|
211
|
+
)
|
|
212
|
+
self._update_status()
|
|
213
|
+
|
|
214
|
+
def _build_row_widget(
|
|
215
|
+
self, row_idx: int, row: tuple, vis_indices: list[int]
|
|
216
|
+
) -> urwid.Widget:
|
|
217
|
+
if not self.column_names:
|
|
218
|
+
return urwid.Text("")
|
|
219
|
+
cells = []
|
|
220
|
+
for col_idx in vis_indices:
|
|
221
|
+
col_name = self.column_names[col_idx]
|
|
222
|
+
width = self.column_widths.get(col_name, 12)
|
|
223
|
+
cell = row[col_idx]
|
|
224
|
+
is_selected = self._cell_selected(row_idx, col_idx)
|
|
225
|
+
filter_info = self.filter_patterns.get(col_name)
|
|
226
|
+
markup = self._cell_markup(str(cell or ""), width, filter_info, is_selected)
|
|
227
|
+
text = urwid.Text(markup, wrap="clip")
|
|
228
|
+
attr = self._column_attr(col_idx)
|
|
229
|
+
if attr:
|
|
230
|
+
text = urwid.AttrMap(text, attr)
|
|
231
|
+
cells.append((width, text))
|
|
232
|
+
return FlowColumns(cells, dividechars=1)
|
|
233
|
+
|
|
234
|
+
def _cell_selected(self, row_idx: int, col_idx: int) -> bool:
|
|
235
|
+
abs_row = self.row_offset + row_idx
|
|
236
|
+
if self.selection.active and self.selection.contains(
|
|
237
|
+
abs_row,
|
|
238
|
+
col_idx,
|
|
239
|
+
fallback_row=self.row_offset + self.cursor_row,
|
|
240
|
+
fallback_col=self.cursor_col,
|
|
241
|
+
):
|
|
242
|
+
return True
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
abs_row == self.row_offset + self.cursor_row and col_idx == self.cursor_col
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def _cell_markup(
|
|
249
|
+
self,
|
|
250
|
+
cell_str: str,
|
|
251
|
+
width: int,
|
|
252
|
+
filter_info: tuple[str, bool] | None,
|
|
253
|
+
is_selected: bool,
|
|
254
|
+
):
|
|
255
|
+
truncated = _truncate(cell_str, width)
|
|
256
|
+
if is_selected:
|
|
257
|
+
return [("cell_selected", truncated)]
|
|
258
|
+
|
|
259
|
+
if not filter_info:
|
|
260
|
+
return truncated
|
|
261
|
+
|
|
262
|
+
pattern, is_regex = filter_info
|
|
263
|
+
matches = []
|
|
264
|
+
if is_regex:
|
|
265
|
+
try:
|
|
266
|
+
for m in re.finditer(pattern, truncated, re.IGNORECASE):
|
|
267
|
+
matches.append((m.start(), m.end()))
|
|
268
|
+
except re.error:
|
|
269
|
+
matches = []
|
|
270
|
+
else:
|
|
271
|
+
lower_cell = truncated.lower()
|
|
272
|
+
lower_filter = pattern.lower()
|
|
273
|
+
start = 0
|
|
274
|
+
while True:
|
|
275
|
+
pos = lower_cell.find(lower_filter, start)
|
|
276
|
+
if pos == -1:
|
|
277
|
+
break
|
|
278
|
+
matches.append((pos, pos + len(lower_filter)))
|
|
279
|
+
start = pos + 1
|
|
280
|
+
|
|
281
|
+
if not matches:
|
|
282
|
+
return truncated
|
|
283
|
+
|
|
284
|
+
segments = []
|
|
285
|
+
last = 0
|
|
286
|
+
for start, end in matches:
|
|
287
|
+
if start > last:
|
|
288
|
+
segments.append(truncated[last:start])
|
|
289
|
+
segments.append(("filter", truncated[start:end]))
|
|
290
|
+
last = end
|
|
291
|
+
if last < len(truncated):
|
|
292
|
+
segments.append(truncated[last:])
|
|
293
|
+
return segments
|
|
294
|
+
|
|
295
|
+
# ------------------------------------------------------------------
|
|
296
|
+
# Interaction handlers
|
|
297
|
+
# ------------------------------------------------------------------
|
|
298
|
+
def handle_input(self, key: str) -> None:
|
|
299
|
+
if self.overlaying:
|
|
300
|
+
return
|
|
301
|
+
if key in ("q", "Q"):
|
|
302
|
+
self.confirm_quit()
|
|
303
|
+
return
|
|
304
|
+
if key in ("r", "R"):
|
|
305
|
+
self.reset_filters()
|
|
306
|
+
return
|
|
307
|
+
if key == "s":
|
|
308
|
+
self.sort_current_column()
|
|
309
|
+
return
|
|
310
|
+
if key in ("/",):
|
|
311
|
+
self.open_filter_dialog()
|
|
312
|
+
return
|
|
313
|
+
if key in ("ctrl d", "page down"):
|
|
314
|
+
self.next_page()
|
|
315
|
+
return
|
|
316
|
+
if key in ("ctrl u", "page up"):
|
|
317
|
+
self.prev_page()
|
|
318
|
+
return
|
|
319
|
+
if key in ("c", "C"):
|
|
320
|
+
self.copy_selection()
|
|
321
|
+
return
|
|
322
|
+
if key in ("w", "W"):
|
|
323
|
+
self.save_selection_dialog()
|
|
324
|
+
return
|
|
325
|
+
if key == "?":
|
|
326
|
+
self.open_help_dialog()
|
|
327
|
+
return
|
|
328
|
+
if key in (
|
|
329
|
+
"left",
|
|
330
|
+
"right",
|
|
331
|
+
"up",
|
|
332
|
+
"down",
|
|
333
|
+
"shift left",
|
|
334
|
+
"shift right",
|
|
335
|
+
"shift up",
|
|
336
|
+
"shift down",
|
|
337
|
+
):
|
|
338
|
+
self.move_cursor(key)
|
|
339
|
+
|
|
340
|
+
def confirm_quit(self) -> None:
|
|
341
|
+
if self.loop is None:
|
|
342
|
+
raise urwid.ExitMainLoop()
|
|
343
|
+
|
|
344
|
+
def _yes() -> None:
|
|
345
|
+
raise urwid.ExitMainLoop()
|
|
346
|
+
|
|
347
|
+
def _no() -> None:
|
|
348
|
+
from csvpeek.ui import close_overlay
|
|
349
|
+
|
|
350
|
+
close_overlay(self)
|
|
351
|
+
|
|
352
|
+
dialog = ConfirmDialog("Quit csvpeek?", _yes, _no)
|
|
353
|
+
from csvpeek.ui import show_overlay
|
|
354
|
+
|
|
355
|
+
show_overlay(self, dialog, width=("relative", 35))
|
|
356
|
+
|
|
357
|
+
def move_cursor(self, key: str) -> None:
|
|
358
|
+
from csvpeek.ui import move_cursor
|
|
359
|
+
|
|
360
|
+
move_cursor(self, key)
|
|
361
|
+
|
|
362
|
+
def next_page(self) -> None:
|
|
363
|
+
page_size = available_body_rows(self)
|
|
364
|
+
max_start = max(0, self.total_filtered_rows - page_size)
|
|
365
|
+
if self.row_offset < max_start:
|
|
366
|
+
self.row_offset = min(self.row_offset + page_size, max_start)
|
|
367
|
+
self.cursor_row = 0
|
|
368
|
+
self._refresh_rows()
|
|
369
|
+
|
|
370
|
+
def prev_page(self) -> None:
|
|
371
|
+
if self.row_offset > 0:
|
|
372
|
+
self.row_offset = max(0, self.row_offset - available_body_rows(self))
|
|
373
|
+
self.cursor_row = 0
|
|
374
|
+
self._refresh_rows()
|
|
375
|
+
|
|
376
|
+
# ------------------------------------------------------------------
|
|
377
|
+
# Filtering and sorting
|
|
378
|
+
# ------------------------------------------------------------------
|
|
379
|
+
def open_filter_dialog(self) -> None:
|
|
380
|
+
if not self.column_names or self.loop is None:
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
def _on_submit(filters: dict[str, str]) -> None:
|
|
384
|
+
from csvpeek.ui import close_overlay
|
|
385
|
+
|
|
386
|
+
close_overlay(self)
|
|
387
|
+
self.apply_filters(filters)
|
|
388
|
+
|
|
389
|
+
def _on_cancel() -> None:
|
|
390
|
+
from csvpeek.ui import close_overlay
|
|
391
|
+
|
|
392
|
+
close_overlay(self)
|
|
393
|
+
|
|
394
|
+
dialog = FilterDialog(
|
|
395
|
+
list(self.column_names), self.current_filters.copy(), _on_submit, _on_cancel
|
|
396
|
+
)
|
|
397
|
+
from csvpeek.ui import show_overlay
|
|
398
|
+
|
|
399
|
+
show_overlay(self, dialog, height=("relative", 80))
|
|
400
|
+
|
|
401
|
+
def open_help_dialog(self) -> None:
|
|
402
|
+
if self.loop is None:
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
def _on_close() -> None:
|
|
406
|
+
from csvpeek.ui import close_overlay
|
|
407
|
+
|
|
408
|
+
close_overlay(self)
|
|
409
|
+
|
|
410
|
+
dialog = HelpDialog(_on_close)
|
|
411
|
+
# Use relative height to avoid urwid sizing warnings on box widgets
|
|
412
|
+
from csvpeek.ui import show_overlay
|
|
413
|
+
|
|
414
|
+
show_overlay(self, dialog, height=("relative", 80))
|
|
415
|
+
|
|
416
|
+
def apply_filters(self, filters: dict[str, str] | None = None) -> None:
|
|
417
|
+
if not self.db:
|
|
418
|
+
return
|
|
419
|
+
if filters is not None:
|
|
420
|
+
self.current_filters = filters
|
|
421
|
+
self.filter_patterns = {}
|
|
422
|
+
for col, val in filters.items():
|
|
423
|
+
cleaned = val.strip()
|
|
424
|
+
if not cleaned:
|
|
425
|
+
continue
|
|
426
|
+
if cleaned.startswith("/") and len(cleaned) > 1:
|
|
427
|
+
self.filter_patterns[col] = (cleaned[1:], True)
|
|
428
|
+
else:
|
|
429
|
+
self.filter_patterns[col] = (cleaned, False)
|
|
430
|
+
|
|
431
|
+
where, params = build_where_clause(self.current_filters, self.column_names)
|
|
432
|
+
self.filter_where = where
|
|
433
|
+
self.filter_params = params
|
|
434
|
+
self.total_filtered_rows = self.db.count_filtered(where, params)
|
|
435
|
+
self.current_page = 0
|
|
436
|
+
self.row_offset = 0
|
|
437
|
+
self.screen_buffer.reset()
|
|
438
|
+
self.selection.clear()
|
|
439
|
+
self.cursor_row = 0
|
|
440
|
+
self._refresh_rows()
|
|
441
|
+
|
|
442
|
+
def reset_filters(self) -> None:
|
|
443
|
+
self.current_filters = {}
|
|
444
|
+
self.filter_patterns = {}
|
|
445
|
+
self.sorted_column = None
|
|
446
|
+
self.sorted_descending = False
|
|
447
|
+
self.filter_where = ""
|
|
448
|
+
self.filter_params = []
|
|
449
|
+
self.current_page = 0
|
|
450
|
+
self.row_offset = 0
|
|
451
|
+
self.screen_buffer.reset()
|
|
452
|
+
self.selection.clear()
|
|
453
|
+
self.cursor_row = 0
|
|
454
|
+
self.total_filtered_rows = self.total_rows
|
|
455
|
+
self._refresh_rows()
|
|
456
|
+
self.notify("Filters cleared")
|
|
457
|
+
|
|
458
|
+
def sort_current_column(self) -> None:
|
|
459
|
+
if not self.column_names or not self.db:
|
|
460
|
+
return
|
|
461
|
+
col_name = self.column_names[self.cursor_col]
|
|
462
|
+
if self.sorted_column == col_name:
|
|
463
|
+
self.sorted_descending = not self.sorted_descending
|
|
464
|
+
else:
|
|
465
|
+
self.sorted_column = col_name
|
|
466
|
+
self.sorted_descending = False
|
|
467
|
+
self.current_page = 0
|
|
468
|
+
self.row_offset = 0
|
|
469
|
+
self.screen_buffer.reset()
|
|
470
|
+
self.selection.clear()
|
|
471
|
+
self.cursor_row = 0
|
|
472
|
+
self._refresh_rows()
|
|
473
|
+
direction = "descending" if self.sorted_descending else "ascending"
|
|
474
|
+
self.notify(f"Sorted by {col_name} ({direction})")
|
|
475
|
+
|
|
476
|
+
# ------------------------------------------------------------------
|
|
477
|
+
# Selection, copy, save
|
|
478
|
+
# ------------------------------------------------------------------
|
|
479
|
+
def copy_selection(self) -> None:
|
|
480
|
+
if not self.cached_rows:
|
|
481
|
+
return
|
|
482
|
+
if not self.selection.active:
|
|
483
|
+
cell_str = get_single_cell_value(self)
|
|
484
|
+
try:
|
|
485
|
+
pyperclip.copy(cell_str)
|
|
486
|
+
except Exception as _ex:
|
|
487
|
+
self.notify("Failed to copy cell")
|
|
488
|
+
return
|
|
489
|
+
self.notify("Cell copied")
|
|
490
|
+
return
|
|
491
|
+
selected_rows = create_selected_dataframe(self)
|
|
492
|
+
num_rows, num_cols = get_selection_dimensions(self)
|
|
493
|
+
_row_start, _row_end, col_start, col_end = get_selection_dimensions(
|
|
494
|
+
self, as_bounds=True
|
|
495
|
+
)
|
|
496
|
+
headers = self.column_names[col_start : col_end + 1]
|
|
497
|
+
from io import StringIO
|
|
498
|
+
|
|
499
|
+
buffer = StringIO()
|
|
500
|
+
writer = csv.writer(buffer)
|
|
501
|
+
writer.writerow(headers)
|
|
502
|
+
writer.writerows(selected_rows)
|
|
503
|
+
try:
|
|
504
|
+
pyperclip.copy(buffer.getvalue())
|
|
505
|
+
except Exception as _ex:
|
|
506
|
+
self.notify("Failed to copy selection")
|
|
507
|
+
return
|
|
508
|
+
clear_selection_and_update(self)
|
|
509
|
+
self.notify(f"Copied {num_rows}x{num_cols}")
|
|
510
|
+
|
|
511
|
+
def save_selection_dialog(self) -> None:
|
|
512
|
+
if not self.cached_rows or self.loop is None:
|
|
513
|
+
return
|
|
514
|
+
|
|
515
|
+
def _on_submit(filename: str) -> None:
|
|
516
|
+
if not filename:
|
|
517
|
+
self.notify("Filename required")
|
|
518
|
+
return
|
|
519
|
+
from csvpeek.ui import close_overlay
|
|
520
|
+
|
|
521
|
+
close_overlay(self)
|
|
522
|
+
self._save_to_file(filename)
|
|
523
|
+
|
|
524
|
+
def _on_cancel() -> None:
|
|
525
|
+
from csvpeek.ui import close_overlay
|
|
526
|
+
|
|
527
|
+
close_overlay(self)
|
|
528
|
+
|
|
529
|
+
dialog = FilenameDialog("Save as", _on_submit, _on_cancel)
|
|
530
|
+
from csvpeek.ui import show_overlay
|
|
531
|
+
|
|
532
|
+
show_overlay(self, dialog)
|
|
533
|
+
|
|
534
|
+
def _save_to_file(self, file_path: str) -> None:
|
|
535
|
+
if not self.cached_rows:
|
|
536
|
+
self.notify("No data to save")
|
|
537
|
+
return
|
|
538
|
+
target = Path(file_path)
|
|
539
|
+
if target.exists():
|
|
540
|
+
self.notify(f"File {target} exists")
|
|
541
|
+
return
|
|
542
|
+
try:
|
|
543
|
+
selected_rows = create_selected_dataframe(self)
|
|
544
|
+
num_rows, num_cols = get_selection_dimensions(self)
|
|
545
|
+
_row_start, _row_end, col_start, col_end = get_selection_dimensions(
|
|
546
|
+
self, as_bounds=True
|
|
547
|
+
)
|
|
548
|
+
headers = self.column_names[col_start : col_end + 1]
|
|
549
|
+
with target.open("w", newline="", encoding="utf-8") as f:
|
|
550
|
+
writer = csv.writer(f)
|
|
551
|
+
writer.writerow(headers)
|
|
552
|
+
writer.writerows(selected_rows)
|
|
553
|
+
clear_selection_and_update(self)
|
|
554
|
+
self.notify(f"Saved {num_rows}x{num_cols} to {target.name}")
|
|
555
|
+
except Exception as exc: # noqa: BLE001
|
|
556
|
+
self.notify(f"Error saving file: {exc}")
|
|
557
|
+
|
|
558
|
+
# ------------------------------------------------------------------
|
|
559
|
+
# Overlay helpers
|
|
560
|
+
# ------------------------------------------------------------------
|
|
561
|
+
def notify(self, message: str, duration: float = 2.0) -> None:
|
|
562
|
+
self.status_widget.set_text(message)
|
|
563
|
+
if self.loop:
|
|
564
|
+
self.loop.set_alarm_in(duration, lambda *_: self._update_status())
|
|
565
|
+
|
|
566
|
+
def _update_status(self, *_args) -> None: # noqa: ANN002, D401
|
|
567
|
+
update_status(self, *_args)
|
|
568
|
+
|
|
569
|
+
# ------------------------------------------------------------------
|
|
570
|
+
# Main entry
|
|
571
|
+
# ------------------------------------------------------------------
|
|
572
|
+
def run(self) -> None:
|
|
573
|
+
self.load_csv()
|
|
574
|
+
root = self.build_ui()
|
|
575
|
+
screen = urwid.raw_display.Screen()
|
|
576
|
+
palette = self._build_palette()
|
|
577
|
+
self.loop = urwid.MainLoop(
|
|
578
|
+
root,
|
|
579
|
+
palette=palette,
|
|
580
|
+
screen=screen,
|
|
581
|
+
handle_mouse=False,
|
|
582
|
+
unhandled_input=self.handle_input,
|
|
583
|
+
)
|
|
584
|
+
# Disable mouse reporting so terminal selection works
|
|
585
|
+
self.loop.screen.set_mouse_tracking(False)
|
|
586
|
+
self._refresh_rows()
|
|
587
|
+
|
|
588
|
+
try:
|
|
589
|
+
self.loop.run()
|
|
590
|
+
finally:
|
|
591
|
+
# Ensure terminal modes are restored even on errors/interrupts
|
|
592
|
+
try:
|
|
593
|
+
self.loop.screen.clear()
|
|
594
|
+
self.loop.screen.reset_default_terminal_colors()
|
|
595
|
+
except Exception:
|
|
596
|
+
pass
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def main() -> None:
|
|
600
|
+
from csvpeek.main import parse_args
|
|
601
|
+
|
|
602
|
+
args, csv_path, colors = parse_args()
|
|
603
|
+
|
|
604
|
+
app = CSVViewerApp(
|
|
605
|
+
csv_path,
|
|
606
|
+
color_columns=args.color_columns or bool(colors),
|
|
607
|
+
column_colors=colors,
|
|
608
|
+
)
|
|
609
|
+
app.run()
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
if __name__ == "__main__":
|
|
613
|
+
main()
|
csvpeek/duck.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import duckdb
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DuckBackend:
|
|
9
|
+
"""DuckDB-backed data source for csvpeek."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, csv_path: Path, table_name: str = "data") -> None:
|
|
12
|
+
self.csv_path = Path(csv_path)
|
|
13
|
+
self.table_name = table_name
|
|
14
|
+
self.con: duckdb.DuckDBPyConnection | None = None
|
|
15
|
+
self.column_names: list[str] = []
|
|
16
|
+
self.total_rows: int = 0
|
|
17
|
+
|
|
18
|
+
def load(self) -> None:
|
|
19
|
+
"""Load the CSV into an in-memory DuckDB table and read schema/row count."""
|
|
20
|
+
self.con = duckdb.connect(database=":memory:")
|
|
21
|
+
self.con.execute(
|
|
22
|
+
f"""
|
|
23
|
+
CREATE TABLE {self.table_name} AS
|
|
24
|
+
SELECT * FROM read_csv_auto(?, ALL_VARCHAR=TRUE)
|
|
25
|
+
""",
|
|
26
|
+
[str(self.csv_path)],
|
|
27
|
+
)
|
|
28
|
+
info = self.con.execute(f"PRAGMA table_info('{self.table_name}')").fetchall()
|
|
29
|
+
self.column_names = [row[1] for row in info]
|
|
30
|
+
self.total_rows = self.con.execute(
|
|
31
|
+
f"SELECT count(*) FROM {self.table_name}"
|
|
32
|
+
).fetchone()[0] # type: ignore
|
|
33
|
+
|
|
34
|
+
def quote_ident(self, name: str) -> str:
|
|
35
|
+
escaped = name.replace('"', '""')
|
|
36
|
+
return f'"{escaped}"'
|
|
37
|
+
|
|
38
|
+
def column_widths(self) -> dict[str, int]:
|
|
39
|
+
if not self.con or not self.column_names:
|
|
40
|
+
return {}
|
|
41
|
+
selects = [
|
|
42
|
+
f"max(length({self.quote_ident(col)})) AS len_{idx}"
|
|
43
|
+
for idx, col in enumerate(self.column_names)
|
|
44
|
+
]
|
|
45
|
+
query = f"SELECT {', '.join(selects)} FROM {self.table_name}"
|
|
46
|
+
lengths = self.con.execute(query).fetchone()
|
|
47
|
+
if lengths is None:
|
|
48
|
+
lengths = [0] * len(self.column_names)
|
|
49
|
+
|
|
50
|
+
widths: dict[str, int] = {}
|
|
51
|
+
for idx, col in enumerate(self.column_names):
|
|
52
|
+
header_len = len(col) + 2
|
|
53
|
+
data_len = lengths[idx] or 0 # length() returns None if column is empty
|
|
54
|
+
max_len = max(header_len, int(data_len))
|
|
55
|
+
width = max(8, min(max_len, 40))
|
|
56
|
+
widths[col] = width
|
|
57
|
+
return widths
|
|
58
|
+
|
|
59
|
+
def _order_clause(self, sorted_column: str | None, sorted_descending: bool) -> str:
|
|
60
|
+
if not sorted_column:
|
|
61
|
+
return ""
|
|
62
|
+
direction = "DESC" if sorted_descending else "ASC"
|
|
63
|
+
return f" ORDER BY {self.quote_ident(sorted_column)} {direction}"
|
|
64
|
+
|
|
65
|
+
def count_filtered(self, where: str, params: list) -> int:
|
|
66
|
+
if not self.con:
|
|
67
|
+
return 0
|
|
68
|
+
count_query = f"SELECT count(*) FROM {self.table_name}{where}"
|
|
69
|
+
return self.con.execute(count_query, params).fetchone()[0] # type: ignore
|
|
70
|
+
|
|
71
|
+
def fetch_rows(
|
|
72
|
+
self,
|
|
73
|
+
where: str,
|
|
74
|
+
params: list,
|
|
75
|
+
sorted_column: str | None,
|
|
76
|
+
sorted_descending: bool,
|
|
77
|
+
limit: int,
|
|
78
|
+
offset: int,
|
|
79
|
+
) -> list[tuple]:
|
|
80
|
+
if not self.con:
|
|
81
|
+
return []
|
|
82
|
+
order_clause = self._order_clause(sorted_column, sorted_descending)
|
|
83
|
+
query = f"SELECT * FROM {self.table_name}{where}{order_clause} LIMIT ? OFFSET ?"
|
|
84
|
+
return self.con.execute(query, params + [limit, offset]).fetchall()
|