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/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()
|
csvpeek/screen_buffer.py
ADDED
|
@@ -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)
|