slab-cli 0.6.0__tar.gz → 0.8.0__tar.gz
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.
- {slab_cli-0.6.0 → slab_cli-0.8.0}/PKG-INFO +1 -1
- {slab_cli-0.6.0 → slab_cli-0.8.0}/pyproject.toml +1 -1
- slab_cli-0.8.0/src/slab_cli/browse.py +386 -0
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/commands/breaks.py +5 -9
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/commands/catalog.py +29 -3
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/commands/collection.py +18 -3
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/commands/custom_sets.py +10 -4
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/commands/lots.py +5 -8
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/display.py +219 -173
- {slab_cli-0.6.0 → slab_cli-0.8.0}/README.md +0 -0
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/__init__.py +0 -0
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/banner.py +0 -0
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/client.py +0 -0
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/commands/__init__.py +0 -0
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/commands/export.py +0 -0
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/commands/pricing.py +0 -0
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/commands/registry.py +0 -0
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/commands/setup.py +0 -0
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/commands/update.py +0 -0
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/config.py +0 -0
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/context.py +0 -0
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/flags.py +0 -0
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/help.py +0 -0
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/main.py +0 -0
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/paging.py +0 -0
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/picker.py +0 -0
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/prompts.py +0 -0
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/sources.py +0 -0
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/theme.py +0 -0
- {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/updates.py +0 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""An interactive, scrollable table — the browsable counterpart to a static `slab_table`.
|
|
2
|
+
|
|
3
|
+
`browse(columns, rows, ...)` shows the same columns and cells a static table would, but as a
|
|
4
|
+
keyboard-driven viewport: ↑/↓ (or j/k) move the highlight, PgUp/PgDn jump, `/` type-to-filters
|
|
5
|
+
across every column, and q leaves — printing the plain table on the way out so the data
|
|
6
|
+
survives in scrollback. Enter drills down: with a `detail` callback the highlighted row's
|
|
7
|
+
detail view REPLACES the list as a scrollable page (Esc/q climbs back up to the list, same row
|
|
8
|
+
highlighted); without one, Enter simply returns the row's item to the caller. Anywhere output
|
|
9
|
+
isn't an interactive terminal (a pipe, CI, redirection, a platform without termios) it
|
|
10
|
+
degrades to the static table, so `slab ... | grep` keeps working unchanged.
|
|
11
|
+
|
|
12
|
+
This is THE tabular display for result lists: a command builds `(item, cells)` rows once and
|
|
13
|
+
the same spec drives the interactive view, the static fallback, and the exit receipt.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from typing import Any, Callable, Sequence
|
|
21
|
+
|
|
22
|
+
from rich.console import Group
|
|
23
|
+
from rich.live import Live
|
|
24
|
+
from rich.text import Text
|
|
25
|
+
|
|
26
|
+
from .theme import console, heading, slab_table
|
|
27
|
+
|
|
28
|
+
# One row: the domain object Enter should act on, plus its cell markup strings (one per column).
|
|
29
|
+
Row = tuple[Any, Sequence[str]]
|
|
30
|
+
|
|
31
|
+
# The highlight bar — a slate a step brighter than the card border, so the selected row reads as
|
|
32
|
+
# lit without fighting the cells' own foreground colors (markup styles set color only, never bg).
|
|
33
|
+
_SELECTED_BG = "on #223052"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Static rendering — the fallback, and the receipt printed when a browse ends
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
def print_table(
|
|
41
|
+
columns: list[tuple[str, dict]],
|
|
42
|
+
rows: list[Row],
|
|
43
|
+
*,
|
|
44
|
+
title: str,
|
|
45
|
+
note: str | None = None,
|
|
46
|
+
footer: str | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""The classic non-interactive rendering: heading + slab_table + optional dim footer."""
|
|
49
|
+
table = slab_table(columns)
|
|
50
|
+
for _item, cells in rows:
|
|
51
|
+
table.add_row(*(str(c) for c in cells))
|
|
52
|
+
console.print(heading(title, note))
|
|
53
|
+
console.print(table)
|
|
54
|
+
if footer:
|
|
55
|
+
console.print(f" [slab.dim]{footer}[/]")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Pure helpers (unit-tested offline)
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def row_plain(cells: Sequence[str]) -> str:
|
|
63
|
+
"""A row's cells flattened to lowercase plain text — what `/` filtering matches against."""
|
|
64
|
+
return Text.from_markup(" ".join(str(c) for c in cells)).plain.lower()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def matches(query: str, plain: str) -> bool:
|
|
68
|
+
"""Every whitespace-separated token of the query must appear somewhere in the row."""
|
|
69
|
+
return all(tok in plain for tok in query.lower().split())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def scroll_top(sel: int, top: int, viewport: int, n: int) -> int:
|
|
73
|
+
"""The first visible row index keeping `sel` inside a `viewport`-row window over `n` rows."""
|
|
74
|
+
if n <= viewport:
|
|
75
|
+
return 0
|
|
76
|
+
if sel < top:
|
|
77
|
+
top = sel
|
|
78
|
+
elif sel >= top + viewport:
|
|
79
|
+
top = sel - viewport + 1
|
|
80
|
+
return max(0, min(top, n - viewport))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def pager_top(top: int, viewport: int, n: int) -> int:
|
|
84
|
+
"""Clamp a pager's first visible line so the window never runs past the content."""
|
|
85
|
+
return max(0, min(top, n - viewport))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_SEQUENCES = {
|
|
89
|
+
b"\x1b[A": "up", b"\x1b[B": "down",
|
|
90
|
+
b"\x1bOA": "up", b"\x1bOB": "down", # application cursor mode
|
|
91
|
+
b"\x1b[5~": "pgup", b"\x1b[6~": "pgdn",
|
|
92
|
+
b"\x1b[H": "home", b"\x1b[F": "end",
|
|
93
|
+
b"\x1b[1~": "home", b"\x1b[4~": "end",
|
|
94
|
+
b"\x1b[D": "left", b"\x1bOD": "left", # ← also climbs back up a level
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def split_keys(data: bytes) -> list[str]:
|
|
99
|
+
"""A raw input burst -> ordered key names ('up', 'enter', 'esc', …) and printable chars.
|
|
100
|
+
|
|
101
|
+
Terminals deliver fast typing, pastes, and escape sequences as multi-byte chunks; parsing
|
|
102
|
+
the whole burst (instead of assuming one read = one key) is what keeps a pasted filter
|
|
103
|
+
query intact and an arrow key from being mistaken for its component bytes."""
|
|
104
|
+
keys: list[str] = []
|
|
105
|
+
i = 0
|
|
106
|
+
while i < len(data):
|
|
107
|
+
if data[i:i + 1] == b"\x1b":
|
|
108
|
+
for seq, name in _SEQUENCES.items():
|
|
109
|
+
if data.startswith(seq, i):
|
|
110
|
+
keys.append(name)
|
|
111
|
+
i += len(seq)
|
|
112
|
+
break
|
|
113
|
+
else:
|
|
114
|
+
if data[i + 1:i + 2] in (b"[", b"O"):
|
|
115
|
+
# An escape sequence we don't drive anything with: swallow through its
|
|
116
|
+
# final byte (0x40–0x7e) so its payload doesn't leak in as typed text.
|
|
117
|
+
j = i + 2
|
|
118
|
+
while j < len(data) and not 0x40 <= data[j] <= 0x7E:
|
|
119
|
+
j += 1
|
|
120
|
+
i = j + 1
|
|
121
|
+
else:
|
|
122
|
+
i += 1 # bare Escape
|
|
123
|
+
keys.append("esc")
|
|
124
|
+
continue
|
|
125
|
+
j = data.find(b"\x1b", i)
|
|
126
|
+
run = data[i:] if j == -1 else data[i:j]
|
|
127
|
+
i = len(data) if j == -1 else j
|
|
128
|
+
for ch in run.decode(errors="ignore"):
|
|
129
|
+
if ch in ("\r", "\n"):
|
|
130
|
+
keys.append("enter")
|
|
131
|
+
elif ch in ("\x7f", "\x08"):
|
|
132
|
+
keys.append("backspace")
|
|
133
|
+
elif ch == "\x03":
|
|
134
|
+
keys.append("quit") # Ctrl-C when ISIG doesn't get there first
|
|
135
|
+
elif ch.isprintable():
|
|
136
|
+
keys.append(ch)
|
|
137
|
+
return keys
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# The interactive machinery
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def _interactive_ok() -> bool:
|
|
145
|
+
"""True when we can actually run a keyboard loop: a real terminal on a termios platform."""
|
|
146
|
+
if not (console.is_terminal and sys.stdin.isatty()):
|
|
147
|
+
return False
|
|
148
|
+
try:
|
|
149
|
+
import termios # noqa: F401 (Unix-only; absent on Windows)
|
|
150
|
+
import tty # noqa: F401
|
|
151
|
+
except ImportError:
|
|
152
|
+
return False
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _read_keys(fd: int) -> list[str]:
|
|
157
|
+
return split_keys(os.read(fd, 64))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _frame(
|
|
161
|
+
columns: list[tuple[str, dict]],
|
|
162
|
+
view: list[Row],
|
|
163
|
+
sel: int,
|
|
164
|
+
top: int,
|
|
165
|
+
viewport: int,
|
|
166
|
+
*,
|
|
167
|
+
title: str,
|
|
168
|
+
note: str | None,
|
|
169
|
+
hint: str,
|
|
170
|
+
total: int,
|
|
171
|
+
query: str,
|
|
172
|
+
typing: bool,
|
|
173
|
+
) -> Group:
|
|
174
|
+
"""One rendered list frame: heading + the visible window of rows + the status footer."""
|
|
175
|
+
table = slab_table(columns)
|
|
176
|
+
for i in range(top, min(top + viewport, len(view))):
|
|
177
|
+
_item, cells = view[i]
|
|
178
|
+
first, rest = str(cells[0]), [str(c) for c in cells[1:]]
|
|
179
|
+
if i == sel:
|
|
180
|
+
table.add_row(f"[slab.foil]▸[/] {first}", *rest, style=_SELECTED_BG)
|
|
181
|
+
else:
|
|
182
|
+
table.add_row(f" {first}", *rest)
|
|
183
|
+
|
|
184
|
+
pos = f"{sel + 1}/{len(view)}" if view else "0 matches"
|
|
185
|
+
if query and len(view) != total:
|
|
186
|
+
pos += f" [slab.muted](of {total})[/]"
|
|
187
|
+
if typing:
|
|
188
|
+
status = (f" [slab.accent]/{query}▌[/][slab.dim] {pos} · ⏎ done · esc clear[/]")
|
|
189
|
+
elif query:
|
|
190
|
+
status = (f" [slab.dim]{pos} · [/][slab.accent]/{query}[/]"
|
|
191
|
+
f"[slab.dim] · ↑↓ move · ⏎ {hint} · esc clear · q quit[/]")
|
|
192
|
+
else:
|
|
193
|
+
status = f" [slab.dim]{pos} · ↑↓ move · ⏎ {hint} · / filter · q quit[/]"
|
|
194
|
+
|
|
195
|
+
return Group(heading(title, note), table, Text.from_markup(status))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _run_list(
|
|
199
|
+
fd: int,
|
|
200
|
+
columns: list[tuple[str, dict]],
|
|
201
|
+
rows: list[Row],
|
|
202
|
+
*,
|
|
203
|
+
title: str,
|
|
204
|
+
note: str | None,
|
|
205
|
+
hint: str,
|
|
206
|
+
start: int,
|
|
207
|
+
) -> tuple[Any | None, int]:
|
|
208
|
+
"""The list screen. Returns (chosen item, its index in `rows`) on Enter, or (None, last
|
|
209
|
+
highlighted index) on quit — the index lets a drill-down return to the same row."""
|
|
210
|
+
plains = [row_plain(cells) for _item, cells in rows]
|
|
211
|
+
query, typing = "", False
|
|
212
|
+
sel = min(max(start, 0), len(rows) - 1)
|
|
213
|
+
top = 0
|
|
214
|
+
pending: list[str] = []
|
|
215
|
+
|
|
216
|
+
def row_index(view_row: Row) -> int:
|
|
217
|
+
return next(i for i, r in enumerate(rows) if r is view_row)
|
|
218
|
+
|
|
219
|
+
with Live(console=console, transient=True, auto_refresh=False) as live:
|
|
220
|
+
while True:
|
|
221
|
+
view = ([r for r, p in zip(rows, plains) if matches(query, p)]
|
|
222
|
+
if query else rows)
|
|
223
|
+
if sel >= len(view):
|
|
224
|
+
sel = max(0, len(view) - 1)
|
|
225
|
+
# Leave room for the shell prompt, heading, table chrome, and the footer.
|
|
226
|
+
viewport = max(3, min(len(view) or 1, console.size.height - 8))
|
|
227
|
+
top = scroll_top(sel, top, viewport, len(view))
|
|
228
|
+
live.update(
|
|
229
|
+
_frame(columns, view, sel, top, viewport, title=title, note=note,
|
|
230
|
+
hint=hint, total=len(rows), query=query, typing=typing),
|
|
231
|
+
refresh=True,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if not pending:
|
|
235
|
+
pending = _read_keys(fd)
|
|
236
|
+
if not pending:
|
|
237
|
+
continue
|
|
238
|
+
key = pending.pop(0)
|
|
239
|
+
|
|
240
|
+
if key == "up" or (not typing and key == "k"):
|
|
241
|
+
sel = max(0, sel - 1)
|
|
242
|
+
elif key == "down" or (not typing and key == "j"):
|
|
243
|
+
sel = min(len(view) - 1, sel + 1) if view else 0
|
|
244
|
+
elif key == "pgup":
|
|
245
|
+
sel = max(0, sel - viewport)
|
|
246
|
+
elif key == "pgdn":
|
|
247
|
+
sel = min(len(view) - 1, sel + viewport) if view else 0
|
|
248
|
+
elif key == "home" or (not typing and key == "g"):
|
|
249
|
+
sel = 0
|
|
250
|
+
elif key == "end" or (not typing and key == "G"):
|
|
251
|
+
sel = max(0, len(view) - 1)
|
|
252
|
+
elif key == "enter":
|
|
253
|
+
if typing:
|
|
254
|
+
typing = False # keep the filter, back to moving
|
|
255
|
+
elif view:
|
|
256
|
+
return view[sel][0], row_index(view[sel])
|
|
257
|
+
elif key == "esc":
|
|
258
|
+
if typing or query:
|
|
259
|
+
typing, query = False, ""
|
|
260
|
+
else:
|
|
261
|
+
return None, sel
|
|
262
|
+
elif key == "quit" or (not typing and key == "q"):
|
|
263
|
+
return None, (row_index(view[sel]) if view else 0)
|
|
264
|
+
elif typing:
|
|
265
|
+
if key == "backspace":
|
|
266
|
+
if query:
|
|
267
|
+
query = query[:-1]
|
|
268
|
+
else:
|
|
269
|
+
typing = False
|
|
270
|
+
elif len(key) == 1 and key.isprintable():
|
|
271
|
+
query += key
|
|
272
|
+
sel = 0 # a narrowed list re-anchors at its best (first) match
|
|
273
|
+
elif key == "/":
|
|
274
|
+
typing = True
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _run_pager(fd: int, lines: list[Text], *, back_hint: str) -> None:
|
|
278
|
+
"""The detail screen: a captured view as a scrollable page. Esc/q/←/backspace climbs back
|
|
279
|
+
up to the list; ↑↓, PgUp/PgDn, and space scroll when the content is taller than the
|
|
280
|
+
terminal. Returns when the user leaves."""
|
|
281
|
+
top = 0
|
|
282
|
+
pending: list[str] = []
|
|
283
|
+
with Live(console=console, transient=True, auto_refresh=False) as live:
|
|
284
|
+
while True:
|
|
285
|
+
viewport = max(3, console.size.height - 3)
|
|
286
|
+
top = pager_top(top, viewport, len(lines))
|
|
287
|
+
window: list[Any] = list(lines[top:top + viewport])
|
|
288
|
+
if len(lines) > viewport:
|
|
289
|
+
pos = f"{top + 1}–{min(top + viewport, len(lines))}/{len(lines)}"
|
|
290
|
+
status = f" [slab.dim]{pos} · ↑↓ scroll · esc {back_hint}[/]"
|
|
291
|
+
else:
|
|
292
|
+
status = f" [slab.dim]esc {back_hint}[/]"
|
|
293
|
+
window.append(Text.from_markup(status))
|
|
294
|
+
live.update(Group(*window), refresh=True)
|
|
295
|
+
|
|
296
|
+
if not pending:
|
|
297
|
+
pending = _read_keys(fd)
|
|
298
|
+
if not pending:
|
|
299
|
+
continue
|
|
300
|
+
key = pending.pop(0)
|
|
301
|
+
|
|
302
|
+
if key in ("up", "k"):
|
|
303
|
+
top -= 1
|
|
304
|
+
elif key in ("down", "j", "enter"):
|
|
305
|
+
top += 1
|
|
306
|
+
elif key in ("pgup",):
|
|
307
|
+
top -= viewport
|
|
308
|
+
elif key in ("pgdn", " "):
|
|
309
|
+
top += viewport
|
|
310
|
+
elif key in ("home", "g"):
|
|
311
|
+
top = 0
|
|
312
|
+
elif key in ("end", "G"):
|
|
313
|
+
top = len(lines)
|
|
314
|
+
elif key in ("esc", "q", "left", "backspace", "quit"):
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _capture_lines(render: Callable[[], None]) -> list[Text]:
|
|
319
|
+
"""Run a print-to-console renderer and capture its output as styled lines for the pager."""
|
|
320
|
+
with console.capture() as cap:
|
|
321
|
+
render()
|
|
322
|
+
lines = [Text.from_ansi(line) for line in cap.get().splitlines()]
|
|
323
|
+
while lines and not lines[-1].plain.strip():
|
|
324
|
+
lines.pop()
|
|
325
|
+
while lines and not lines[0].plain.strip():
|
|
326
|
+
lines.pop(0)
|
|
327
|
+
return lines or [Text.from_markup("[slab.dim]Nothing to show.[/]")]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def browse(
|
|
331
|
+
columns: list[tuple[str, dict]],
|
|
332
|
+
rows: list[Row],
|
|
333
|
+
*,
|
|
334
|
+
title: str,
|
|
335
|
+
note: str | None = None,
|
|
336
|
+
hint: str = "open",
|
|
337
|
+
footer: str | None = None,
|
|
338
|
+
start: int = 0,
|
|
339
|
+
detail: Callable[[Any], None] | None = None,
|
|
340
|
+
) -> Any | None:
|
|
341
|
+
"""Browse `rows` interactively. `columns` is the same `(header, kwargs)` spec `slab_table`
|
|
342
|
+
takes; each row is `(item, cells)` with one markup string per column. `hint` names what
|
|
343
|
+
Enter does in the footer (e.g. "market view"); `start` pre-selects a row.
|
|
344
|
+
|
|
345
|
+
With a `detail` callback, Enter drills down: `detail(item)` runs (it may fetch and must
|
|
346
|
+
print to the console), its output replaces the list as a scrollable page, and leaving the
|
|
347
|
+
page returns to the list on the same row — the loop lives here, and browse returns None
|
|
348
|
+
when the user finally quits. Without `detail`, Enter returns the chosen item directly.
|
|
349
|
+
|
|
350
|
+
Quitting from the list prints the plain table (with `footer`) as a scrollback receipt. On
|
|
351
|
+
a non-interactive stdout/stdin the static table is printed instead and None returned.
|
|
352
|
+
"""
|
|
353
|
+
if not rows:
|
|
354
|
+
return None
|
|
355
|
+
if not _interactive_ok():
|
|
356
|
+
print_table(columns, rows, title=title, note=note, footer=footer)
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
import termios
|
|
360
|
+
import tty
|
|
361
|
+
|
|
362
|
+
fd = sys.stdin.fileno()
|
|
363
|
+
saved = termios.tcgetattr(fd)
|
|
364
|
+
picked: Any | None = None
|
|
365
|
+
sel = start
|
|
366
|
+
try:
|
|
367
|
+
tty.setcbreak(fd)
|
|
368
|
+
while True:
|
|
369
|
+
item, sel = _run_list(fd, columns, rows, title=title, note=note,
|
|
370
|
+
hint=hint, start=sel)
|
|
371
|
+
if item is None:
|
|
372
|
+
break
|
|
373
|
+
if detail is None:
|
|
374
|
+
picked = item
|
|
375
|
+
break
|
|
376
|
+
_run_pager(fd, _capture_lines(lambda: detail(item)), back_hint="back to list")
|
|
377
|
+
except KeyboardInterrupt:
|
|
378
|
+
pass
|
|
379
|
+
finally:
|
|
380
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
|
|
381
|
+
|
|
382
|
+
if picked is not None:
|
|
383
|
+
return picked
|
|
384
|
+
# Quit: leave the classic table behind so the results live on in scrollback.
|
|
385
|
+
print_table(columns, rows, title=title, note=note, footer=footer)
|
|
386
|
+
return None
|
|
@@ -10,7 +10,7 @@ from slab_schemas.enums import BreakType
|
|
|
10
10
|
|
|
11
11
|
from ..client import SlabClient
|
|
12
12
|
from ..context import ctx
|
|
13
|
-
from ..display import
|
|
13
|
+
from ..display import browse_breaks, console, render_break_pulls
|
|
14
14
|
from ..prompts import (
|
|
15
15
|
ask_date,
|
|
16
16
|
ask_decimal,
|
|
@@ -85,15 +85,11 @@ def create_break_interactive(client: SlabClient, collector: str) -> BreakOut | N
|
|
|
85
85
|
|
|
86
86
|
|
|
87
87
|
def cmd_break_list(args: list[str]) -> None:
|
|
88
|
-
"""`slab break list` — your breaks
|
|
89
|
-
the top pulls, the rarest pulls)
|
|
88
|
+
"""`slab break list` — your breaks as a scrollable list; Enter drills into what one
|
|
89
|
+
delivered (value vs cost, the top pulls, the rarest pulls), Esc climbs back, q leaves the
|
|
90
|
+
plain table behind. Piped output prints the plain table."""
|
|
90
91
|
result = ctx.client.search_breaks(ctx.collector, BreakSearchQuery(limit=25))
|
|
91
|
-
|
|
92
|
-
if not result.items:
|
|
93
|
-
return
|
|
94
|
-
choice = select_break(result, allow_new=False)
|
|
95
|
-
if isinstance(choice, BreakOut):
|
|
96
|
-
_show_break_pulls(choice)
|
|
92
|
+
browse_breaks(result, detail=_show_break_pulls)
|
|
97
93
|
|
|
98
94
|
|
|
99
95
|
def _show_break_pulls(brk: BreakOut) -> None:
|
|
@@ -7,7 +7,14 @@ import re
|
|
|
7
7
|
from slab_schemas.cards import CardSearchQuery, SetSearchQuery
|
|
8
8
|
|
|
9
9
|
from ..context import ctx
|
|
10
|
-
from ..display import
|
|
10
|
+
from ..display import (
|
|
11
|
+
browse_cards,
|
|
12
|
+
browse_sets,
|
|
13
|
+
console,
|
|
14
|
+
render_card_market,
|
|
15
|
+
render_community_board,
|
|
16
|
+
render_set_market,
|
|
17
|
+
)
|
|
11
18
|
from ..flags import CARD_SEARCH_FLAGS, SET_SEARCH_FLAGS, parse_flags
|
|
12
19
|
from ..picker import pick_card
|
|
13
20
|
from ..prompts import ask_text
|
|
@@ -31,6 +38,9 @@ def cmd_search(args: list[str]) -> None:
|
|
|
31
38
|
Every flag combines with AND; repeat an any-of flag (--team, --brand, --finish…) to widen it.
|
|
32
39
|
|
|
33
40
|
Sort keys: year, card_number, numbered, subject, brand, set (prefix - for descending).
|
|
41
|
+
|
|
42
|
+
In a terminal the results are a scrollable list — Enter drills into the highlighted
|
|
43
|
+
card's market view, Esc climbs back. Piped output prints the plain table.
|
|
34
44
|
"""
|
|
35
45
|
if not args:
|
|
36
46
|
args = [ask_text("Search cards:")]
|
|
@@ -38,7 +48,11 @@ def cmd_search(args: list[str]) -> None:
|
|
|
38
48
|
values, q = parse_flags(args, CARD_SEARCH_FLAGS)
|
|
39
49
|
values.setdefault("limit", 25)
|
|
40
50
|
result = ctx.client.search_cards(CardSearchQuery(q=q, **values))
|
|
41
|
-
|
|
51
|
+
|
|
52
|
+
def market_view(c) -> None:
|
|
53
|
+
render_card_market(ctx.client.get_card_market(c.uuid))
|
|
54
|
+
|
|
55
|
+
browse_cards(result, detail=market_view)
|
|
42
56
|
|
|
43
57
|
|
|
44
58
|
cmd_search.flags = CARD_SEARCH_FLAGS
|
|
@@ -47,6 +61,12 @@ cmd_search.flags = CARD_SEARCH_FLAGS
|
|
|
47
61
|
def cmd_sets(args: list[str]) -> None:
|
|
48
62
|
"""Browse sets / products, optionally narrowed by brand, year, or sport.
|
|
49
63
|
|
|
64
|
+
In a terminal the results are a scrollable list: ↑↓ (or j/k) move, / type-to-filters
|
|
65
|
+
across every column, Enter drills into the highlighted set's market view (sealed FMV +
|
|
66
|
+
top cards) as its own page — Esc climbs back up to the list, same row — and q quits,
|
|
67
|
+
leaving the plain table behind. Piped or redirected output prints the plain table
|
|
68
|
+
directly, so scripting is unchanged.
|
|
69
|
+
|
|
50
70
|
Examples:
|
|
51
71
|
slab set search "sp authentic"
|
|
52
72
|
slab set search --brand "Upper Deck" --year 2025
|
|
@@ -54,7 +74,13 @@ def cmd_sets(args: list[str]) -> None:
|
|
|
54
74
|
values, q = parse_flags(args, SET_SEARCH_FLAGS)
|
|
55
75
|
values.setdefault("limit", 25)
|
|
56
76
|
result = ctx.client.search_sets(SetSearchQuery(q=q, **values))
|
|
57
|
-
|
|
77
|
+
|
|
78
|
+
def market_view(s) -> None:
|
|
79
|
+
sealed = ctx.client.get_set_sealed(s.uuid)
|
|
80
|
+
top = ctx.client.get_set_top_cards(s.uuid, limit=10)
|
|
81
|
+
render_set_market(s, sealed, top)
|
|
82
|
+
|
|
83
|
+
browse_sets(result, detail=market_view)
|
|
58
84
|
|
|
59
85
|
|
|
60
86
|
cmd_sets.flags = SET_SEARCH_FLAGS
|
|
@@ -22,10 +22,12 @@ from ..flags import COLLECTION_SEARCH_FLAGS, parse_flags
|
|
|
22
22
|
from ..paging import fetch_all_pages
|
|
23
23
|
from ..picker import pick_card
|
|
24
24
|
from ..display import (
|
|
25
|
+
browse_collection,
|
|
25
26
|
console,
|
|
26
27
|
format_card_summary,
|
|
27
28
|
format_grade_delta,
|
|
28
|
-
|
|
29
|
+
print_collection_summary,
|
|
30
|
+
render_card_market,
|
|
29
31
|
render_dashboard,
|
|
30
32
|
)
|
|
31
33
|
from ..prompts import (
|
|
@@ -140,7 +142,9 @@ def _collect_grade_details() -> tuple[str | None, str | None, str | None, str |
|
|
|
140
142
|
grading_company = grade = cert_number = None
|
|
141
143
|
if confirm("Is it professionally graded?", default=False):
|
|
142
144
|
grading_company = select_grading_company()
|
|
143
|
-
|
|
145
|
+
# Required with the company: the (company, grade) pair is the copy's price key — the API
|
|
146
|
+
# rejects one without the other (a half-entered slab would price as RAW).
|
|
147
|
+
grade = ask_text("Pro grade (e.g. 10, 9.5, Authentic):")
|
|
144
148
|
cert_number = ask_text_optional("Cert number (enter to skip):")
|
|
145
149
|
|
|
146
150
|
return self_grade, grading_company, grade, cert_number
|
|
@@ -605,6 +609,9 @@ def cmd_search(args: list[str]) -> None:
|
|
|
605
609
|
|
|
606
610
|
Sort keys: acquired_date, acquisition_cost, numbered, year, card_number, brand, set,
|
|
607
611
|
fmv, unrealized, roi (prefix - for descending).
|
|
612
|
+
|
|
613
|
+
In a terminal the results are a scrollable list — Enter drills into the highlighted
|
|
614
|
+
copy's market view, Esc climbs back. Piped output prints the plain table.
|
|
608
615
|
"""
|
|
609
616
|
if not args:
|
|
610
617
|
total = ctx.client.search_collection(ctx.collector, CollectionSearchQuery(limit=1)).total
|
|
@@ -616,7 +623,15 @@ def cmd_search(args: list[str]) -> None:
|
|
|
616
623
|
values, q = parse_flags(args, COLLECTION_SEARCH_FLAGS)
|
|
617
624
|
values.setdefault("limit", 25)
|
|
618
625
|
result = ctx.client.search_collection(ctx.collector, CollectionSearchQuery(q=q, **values))
|
|
619
|
-
|
|
626
|
+
|
|
627
|
+
def market_view(cp) -> None:
|
|
628
|
+
if cp.card is None:
|
|
629
|
+
console.print("[slab.dim]This copy isn't linked to a catalog card.[/]")
|
|
630
|
+
return
|
|
631
|
+
render_card_market(ctx.client.get_card_market(cp.card.uuid))
|
|
632
|
+
|
|
633
|
+
browse_collection(result, detail=market_view)
|
|
634
|
+
print_collection_summary(result)
|
|
620
635
|
|
|
621
636
|
|
|
622
637
|
cmd_search.flags = COLLECTION_SEARCH_FLAGS
|
|
@@ -16,10 +16,10 @@ from slab_schemas.enums import CustomSetType, MatchMode, Visibility
|
|
|
16
16
|
from ..client import SlabClient
|
|
17
17
|
from ..context import ctx
|
|
18
18
|
from ..display import (
|
|
19
|
+
browse_custom_sets,
|
|
19
20
|
console,
|
|
20
21
|
format_card_summary,
|
|
21
22
|
print_custom_set_detail,
|
|
22
|
-
print_custom_sets,
|
|
23
23
|
)
|
|
24
24
|
from ..picker import search_and_pick_subject
|
|
25
25
|
from ..prompts import (
|
|
@@ -348,10 +348,16 @@ def cmd_chase_edit(args: list[str]) -> None:
|
|
|
348
348
|
# slab chase list — your custom sets
|
|
349
349
|
# ---------------------------------------------------------------------------
|
|
350
350
|
|
|
351
|
+
def _chase_detail(s) -> None:
|
|
352
|
+
"""The drill-in for a browsed chase set: its full card list + completion, owner-aware."""
|
|
353
|
+
print_custom_set_detail(ctx.client.get_custom_set(s.uuid, ctx.collector))
|
|
354
|
+
|
|
355
|
+
|
|
351
356
|
def cmd_chase_list(args: list[str]) -> None:
|
|
352
|
-
"""List your custom sets (created + subscribed)
|
|
357
|
+
"""List your custom sets (created + subscribed) as a scrollable list; Enter drills into a
|
|
358
|
+
set's cards + completion, Esc climbs back."""
|
|
353
359
|
sets = ctx.client.collector_custom_sets(ctx.collector)
|
|
354
|
-
|
|
360
|
+
browse_custom_sets(sets, detail=_chase_detail)
|
|
355
361
|
|
|
356
362
|
|
|
357
363
|
# ---------------------------------------------------------------------------
|
|
@@ -375,7 +381,7 @@ def cmd_chase_find(args: list[str]) -> None:
|
|
|
375
381
|
if not sets:
|
|
376
382
|
console.print("[yellow]No matching sets found.[/yellow]")
|
|
377
383
|
return
|
|
378
|
-
|
|
384
|
+
browse_custom_sets(sets, detail=_chase_detail)
|
|
379
385
|
console.print(
|
|
380
386
|
"[dim]`slab chase subscribe` to track one · `slab chase view <uuid>` to see its cards.[/dim]"
|
|
381
387
|
)
|
|
@@ -12,7 +12,7 @@ from slab_schemas.collection import CollectionSearchQuery, LotCreate, LotOut, Lo
|
|
|
12
12
|
|
|
13
13
|
from ..client import SlabClient
|
|
14
14
|
from ..context import ctx
|
|
15
|
-
from ..display import
|
|
15
|
+
from ..display import browse_lots, console, render_lot_contents
|
|
16
16
|
from ..prompts import ask_date, ask_decimal, confirm, select_lot
|
|
17
17
|
from ..sources import pick_source
|
|
18
18
|
|
|
@@ -60,14 +60,11 @@ def create_lot_interactive(client: SlabClient, collector: str) -> LotOut | None:
|
|
|
60
60
|
|
|
61
61
|
|
|
62
62
|
def cmd_lot_list(args: list[str]) -> None:
|
|
63
|
-
"""`slab lot list` — your purchases
|
|
63
|
+
"""`slab lot list` — your purchases as a scrollable list; Enter drills into one's value vs
|
|
64
|
+
cost + its cards, Esc climbs back, q leaves the plain table behind. Piped output prints
|
|
65
|
+
the plain table."""
|
|
64
66
|
result = ctx.client.search_lots(ctx.collector, LotSearchQuery(limit=25))
|
|
65
|
-
|
|
66
|
-
if not result.items:
|
|
67
|
-
return
|
|
68
|
-
choice = select_lot(result, allow_new=False)
|
|
69
|
-
if isinstance(choice, LotOut):
|
|
70
|
-
_show_lot_contents(choice)
|
|
67
|
+
browse_lots(result, detail=_show_lot_contents)
|
|
71
68
|
|
|
72
69
|
|
|
73
70
|
def _show_lot_contents(lot: LotOut) -> None:
|
|
@@ -24,6 +24,7 @@ from slab_schemas.pricing import CardComps, CardMarket, PortfolioSummary, SetTop
|
|
|
24
24
|
from slab_schemas.sealed import SealedPriceHistory, SealedProductOut
|
|
25
25
|
from slab_schemas.timeseries import CardPriceHistory
|
|
26
26
|
|
|
27
|
+
from .browse import browse
|
|
27
28
|
from .theme import (
|
|
28
29
|
bar,
|
|
29
30
|
console,
|
|
@@ -88,162 +89,195 @@ def format_card_summary(card: CardOut) -> str:
|
|
|
88
89
|
return f"{card.card_number} {subjects} — {card.brand or ''} {card.set_name or ''} — {slot}{pr}{attrs}"
|
|
89
90
|
|
|
90
91
|
|
|
91
|
-
def
|
|
92
|
-
"""Standard 'N of M shown' footer — one place so every list truncates the same way."""
|
|
93
|
-
if total > shown
|
|
94
|
-
console.print(f" [slab.dim]{shown} of {total} shown — {hint}[/]")
|
|
92
|
+
def _shown_footer(shown: int, total: int, hint: str) -> str | None:
|
|
93
|
+
"""Standard 'N of M shown' footer text — one place so every list truncates the same way."""
|
|
94
|
+
return f"{shown} of {total} shown — {hint}" if total > shown else None
|
|
95
95
|
|
|
96
96
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
97
|
+
CARD_COLUMNS = [
|
|
98
|
+
("Number", {"style": "slab.value"}),
|
|
99
|
+
("Subject(s)", {}),
|
|
100
|
+
("Set", {}),
|
|
101
|
+
("Subset", {}),
|
|
102
|
+
("Finish", {"style": "slab.accent"}),
|
|
103
|
+
("Attrs", {"style": "slab.dim"}),
|
|
104
|
+
("Run", {"justify": "right", "style": "slab.dim"}),
|
|
105
|
+
("UUID", {"style": "slab.muted"}),
|
|
106
|
+
]
|
|
101
107
|
|
|
102
|
-
table = slab_table([
|
|
103
|
-
("Number", {"style": "slab.value"}),
|
|
104
|
-
("Subject(s)", {}),
|
|
105
|
-
("Set", {}),
|
|
106
|
-
("Subset", {}),
|
|
107
|
-
("Finish", {"style": "slab.accent"}),
|
|
108
|
-
("Attrs", {"style": "slab.dim"}),
|
|
109
|
-
("Run", {"justify": "right", "style": "slab.dim"}),
|
|
110
|
-
("UUID", {"style": "slab.muted"}),
|
|
111
|
-
])
|
|
112
108
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
finish, attrs, pr, c.uuid[:8],
|
|
121
|
-
)
|
|
109
|
+
def card_cells(c: CardOut) -> list[str]:
|
|
110
|
+
"""One catalog card as its CARD_COLUMNS cells."""
|
|
111
|
+
subjects = ", ".join(s.name for s in c.subjects)
|
|
112
|
+
attrs = ", ".join(a.name for a in c.attributes)
|
|
113
|
+
finish = c.finish or "[slab.dim]Base[/]"
|
|
114
|
+
pr = f"/{c.print_run}" if c.print_run else ""
|
|
115
|
+
return [c.card_number, subjects, c.set_name or "", c.subset or "", finish, attrs, pr, c.uuid[:8]]
|
|
122
116
|
|
|
123
|
-
console.print(heading("Cards", f"{result.total} total"))
|
|
124
|
-
console.print(table)
|
|
125
|
-
_more(len(result.items), result.total, "narrow your search, or `--limit N` to see more.")
|
|
126
117
|
|
|
118
|
+
def browse_cards(result: CardSearchResult, *, detail=None) -> CardOut | None:
|
|
119
|
+
"""Browse a card search. With `detail` (a `CardOut -> None` renderer), Enter drills into
|
|
120
|
+
that view as a page; Esc climbs back. Static table on non-interactive output."""
|
|
121
|
+
if not result.items:
|
|
122
|
+
console.print("[slab.dim]No cards found.[/]")
|
|
123
|
+
return None
|
|
124
|
+
return browse(
|
|
125
|
+
CARD_COLUMNS, [(c, card_cells(c)) for c in result.items],
|
|
126
|
+
title="Cards", note=f"{result.total} total", hint="market view",
|
|
127
|
+
footer=_shown_footer(len(result.items), result.total,
|
|
128
|
+
"narrow your search, or `--limit N` to see more."),
|
|
129
|
+
detail=detail,
|
|
130
|
+
)
|
|
127
131
|
|
|
128
|
-
|
|
132
|
+
|
|
133
|
+
SET_COLUMNS = [
|
|
134
|
+
("Name", {"style": "slab.value"}),
|
|
135
|
+
("Brand", {}),
|
|
136
|
+
("Cards", {"justify": "right", "style": "slab.dim"}),
|
|
137
|
+
("Priced", {"justify": "right"}),
|
|
138
|
+
("Sales/90d", {"justify": "right", "style": "slab.dim"}),
|
|
139
|
+
("Box FMV", {"justify": "right"}),
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def set_cells(s: SetOut) -> list[str]:
|
|
144
|
+
"""One set as its SET_COLUMNS cells — shared by the static table and the browse view."""
|
|
145
|
+
# Priced = printings with a sale in the past 90 days; the % against the full card
|
|
146
|
+
# count says how much of the set the market actually prices (color = health band).
|
|
147
|
+
priced = "[slab.dim]—[/]"
|
|
148
|
+
if s.priced_count:
|
|
149
|
+
pct = f" ({pct_coverage(100 * s.priced_count / s.card_count)})" if s.card_count else ""
|
|
150
|
+
priced = f"{s.priced_count:,}{pct}"
|
|
151
|
+
box = f"[slab.foil]{money(s.box_price)}[/]" if s.box_price is not None else "[slab.dim]—[/]"
|
|
152
|
+
return [
|
|
153
|
+
s.name or "", s.brand or "",
|
|
154
|
+
f"{s.card_count or 0:,}",
|
|
155
|
+
priced,
|
|
156
|
+
f"{s.sales_90d:,}" if s.sales_90d else "[slab.dim]—[/]",
|
|
157
|
+
box,
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _set_footer(result: SetSearchResult) -> str | None:
|
|
162
|
+
if result.total > len(result.items):
|
|
163
|
+
return (f"{len(result.items)} of {result.total} shown — "
|
|
164
|
+
"narrow with a query (e.g. `slab set search platinum`).")
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def browse_sets(result: SetSearchResult, *, detail=None) -> SetOut | None:
|
|
169
|
+
"""Browse a set search interactively. With `detail` (a `SetOut -> None` renderer), Enter
|
|
170
|
+
drills into that view as a page and Esc climbs back to the list; the loop lives inside
|
|
171
|
+
`browse`. Falls back to the static table on non-interactive output."""
|
|
129
172
|
if not result.items:
|
|
130
173
|
console.print("[slab.dim]No sets found.[/]")
|
|
131
|
-
return
|
|
174
|
+
return None
|
|
175
|
+
return browse(
|
|
176
|
+
SET_COLUMNS, [(s, set_cells(s)) for s in result.items],
|
|
177
|
+
title="Sets", note=f"{result.total} total", hint="market view",
|
|
178
|
+
footer=_set_footer(result), detail=detail,
|
|
179
|
+
)
|
|
132
180
|
|
|
133
|
-
table = slab_table([
|
|
134
|
-
("Name", {"style": "slab.value"}),
|
|
135
|
-
("Brand", {}),
|
|
136
|
-
("Cards", {"justify": "right", "style": "slab.dim"}),
|
|
137
|
-
("Priced", {"justify": "right"}),
|
|
138
|
-
("Sales/90d", {"justify": "right", "style": "slab.dim"}),
|
|
139
|
-
("Box FMV", {"justify": "right"}),
|
|
140
|
-
])
|
|
141
181
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
f"{s.card_count or 0:,}",
|
|
153
|
-
priced,
|
|
154
|
-
f"{s.sales_90d:,}" if s.sales_90d else "[slab.dim]—[/]",
|
|
155
|
-
box,
|
|
156
|
-
)
|
|
182
|
+
COLLECTION_COLUMNS = [
|
|
183
|
+
("Card #", {"style": "slab.value"}),
|
|
184
|
+
("Subject(s)", {}),
|
|
185
|
+
("Set", {}),
|
|
186
|
+
("Finish", {"style": "slab.accent"}),
|
|
187
|
+
("Grade", {"style": "slab.dim"}),
|
|
188
|
+
("Cost Basis", {"justify": "right"}),
|
|
189
|
+
("FMV", {"justify": "right"}),
|
|
190
|
+
("Gain/Loss", {"justify": "right"}),
|
|
191
|
+
]
|
|
157
192
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
193
|
+
|
|
194
|
+
def collection_cells(cp) -> list[str]:
|
|
195
|
+
"""One owned copy as its COLLECTION_COLUMNS cells."""
|
|
196
|
+
card = cp.card
|
|
197
|
+
subjects = ", ".join(s.name for s in card.subjects) if card and card.subjects else "—"
|
|
198
|
+
set_name = (card.set_name or "") if card else ""
|
|
199
|
+
finish = ((card.finish if card else None) or "[slab.dim]Base[/]")
|
|
200
|
+
card_num = card.card_number if card else "—"
|
|
201
|
+
grade = _format_collection_grade(cp)
|
|
202
|
+
basis = money(cp.cost_basis) if cp.cost_basis is not None else "[slab.dim]—[/]"
|
|
203
|
+
fmv = "[slab.dim]—[/]"
|
|
204
|
+
gain = "[slab.dim]—[/]"
|
|
205
|
+
if cp.market and cp.market.fair_market_value is not None:
|
|
206
|
+
fmv = f"[slab.foil]{money(cp.market.fair_market_value)}[/]"
|
|
207
|
+
gain = signed_money(cp.market.unrealized_gain_loss)
|
|
208
|
+
return [card_num, subjects, set_name, finish, grade, basis, fmv, gain]
|
|
161
209
|
|
|
162
210
|
|
|
163
|
-
def
|
|
211
|
+
def print_collection_summary(result: CollectionResult) -> None:
|
|
212
|
+
"""The financial totals line under a collection search — from the server's summary block."""
|
|
213
|
+
if not result.summary:
|
|
214
|
+
return
|
|
215
|
+
s = result.summary
|
|
216
|
+
parts = []
|
|
217
|
+
if s.total_acquisition_cost is not None:
|
|
218
|
+
parts.append(f"[slab.label]Acquisition[/] {money(s.total_acquisition_cost)}")
|
|
219
|
+
if s.total_additional_costs is not None:
|
|
220
|
+
parts.append(f"[slab.label]Additional[/] {money(s.total_additional_costs)}")
|
|
221
|
+
if s.total_cost_basis is not None:
|
|
222
|
+
parts.append(f"[slab.label]Cost Basis[/] {money(s.total_cost_basis)}")
|
|
223
|
+
if isinstance(s, PortfolioSummary) and s.portfolio_value is not None:
|
|
224
|
+
parts.append(f"[slab.label]FMV[/] [slab.foil]{money(s.portfolio_value)}[/]")
|
|
225
|
+
if s.total_unrealized_gain_loss is not None:
|
|
226
|
+
parts.append(f"[slab.label]Unrealized[/] {signed_money(s.total_unrealized_gain_loss)}")
|
|
227
|
+
if parts:
|
|
228
|
+
console.print(" " + " ".join(parts))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def browse_collection(result: CollectionResult, *, detail=None) -> None:
|
|
232
|
+
"""Browse a collection search. With `detail` (a copy -> None renderer), Enter drills into
|
|
233
|
+
the copy's market view; Esc climbs back. Static table on non-interactive output."""
|
|
164
234
|
if not result.items:
|
|
165
235
|
console.print("[dim]No cards in your collection matching that search.[/dim]")
|
|
166
236
|
if result.total == 0:
|
|
167
237
|
console.print(" [dim]Use `slab collection add` to start cataloging cards.[/dim]")
|
|
168
238
|
return
|
|
239
|
+
matches = f"{result.total} match{'' if result.total == 1 else 'es'}"
|
|
240
|
+
browse(
|
|
241
|
+
COLLECTION_COLUMNS, [(cp, collection_cells(cp)) for cp in result.items],
|
|
242
|
+
title="Collection", note=matches, hint="market view",
|
|
243
|
+
footer=_shown_footer(len(result.items), result.total,
|
|
244
|
+
"narrow your search, or `slab export` for the full list."),
|
|
245
|
+
detail=detail,
|
|
246
|
+
)
|
|
169
247
|
|
|
170
|
-
table = slab_table([
|
|
171
|
-
("Card #", {"style": "slab.value"}),
|
|
172
|
-
("Subject(s)", {}),
|
|
173
|
-
("Set", {}),
|
|
174
|
-
("Finish", {"style": "slab.accent"}),
|
|
175
|
-
("Grade", {"style": "slab.dim"}),
|
|
176
|
-
("Cost Basis", {"justify": "right"}),
|
|
177
|
-
("FMV", {"justify": "right"}),
|
|
178
|
-
("Gain/Loss", {"justify": "right"}),
|
|
179
|
-
])
|
|
180
248
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if cp.market and cp.market.fair_market_value is not None:
|
|
192
|
-
fmv = f"[slab.foil]{money(cp.market.fair_market_value)}[/]"
|
|
193
|
-
gain = signed_money(cp.market.unrealized_gain_loss)
|
|
194
|
-
table.add_row(card_num, subjects, set_name, finish, grade, basis, fmv, gain)
|
|
249
|
+
BREAK_COLUMNS = [
|
|
250
|
+
("Set", {"style": "slab.value"}),
|
|
251
|
+
("Type", {"style": "slab.dim"}),
|
|
252
|
+
("Cost", {"justify": "right"}),
|
|
253
|
+
("Cards", {"justify": "right", "style": "slab.dim"}),
|
|
254
|
+
("$/Card", {"justify": "right", "style": "slab.dim"}),
|
|
255
|
+
("Date", {"style": "slab.dim"}),
|
|
256
|
+
("Source", {"style": "slab.dim"}),
|
|
257
|
+
("UUID", {"style": "slab.muted"}),
|
|
258
|
+
]
|
|
195
259
|
|
|
196
|
-
matches = f"{result.total} match{'' if result.total == 1 else 'es'}"
|
|
197
|
-
console.print(heading("Collection", matches))
|
|
198
|
-
console.print(table)
|
|
199
260
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
parts.append(f"[slab.label]FMV[/] [slab.foil]{money(s.portfolio_value)}[/]")
|
|
211
|
-
if s.total_unrealized_gain_loss is not None:
|
|
212
|
-
parts.append(f"[slab.label]Unrealized[/] {signed_money(s.total_unrealized_gain_loss)}")
|
|
213
|
-
if parts:
|
|
214
|
-
console.print(" " + " ".join(parts))
|
|
215
|
-
|
|
216
|
-
_more(len(result.items), result.total, "narrow your search, or `slab export` for the full list.")
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
def print_breaks(result: BreakSearchResult) -> None:
|
|
261
|
+
def break_cells(b) -> list[str]:
|
|
262
|
+
"""One break as its BREAK_COLUMNS cells."""
|
|
263
|
+
cpc = money(b.cost_per_card) if b.cost_per_card is not None else "—"
|
|
264
|
+
return [b.set_name or "", b.break_type, money(b.total_cost), str(b.copy_count), cpc,
|
|
265
|
+
str(b.break_date or "—"), b.source or "—", b.uuid[:8]]
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def browse_breaks(result: BreakSearchResult, *, detail=None) -> None:
|
|
269
|
+
"""Browse your breaks. With `detail` (a BreakOut -> None renderer), Enter drills into the
|
|
270
|
+
break's report card (value vs cost + top pulls); Esc climbs back."""
|
|
220
271
|
if not result.items:
|
|
221
272
|
console.print("[slab.dim]No breaks found.[/]")
|
|
222
273
|
console.print(" [slab.dim]Use `slab break new` to record opening a pack, box, or case.[/]")
|
|
223
274
|
return
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
(
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
("$/Card", {"justify": "right", "style": "slab.dim"}),
|
|
231
|
-
("Date", {"style": "slab.dim"}),
|
|
232
|
-
("Source", {"style": "slab.dim"}),
|
|
233
|
-
("UUID", {"style": "slab.muted"}),
|
|
234
|
-
])
|
|
235
|
-
|
|
236
|
-
for b in result.items:
|
|
237
|
-
cpc = money(b.cost_per_card) if b.cost_per_card is not None else "—"
|
|
238
|
-
table.add_row(
|
|
239
|
-
b.set_name or "", b.break_type, money(b.total_cost),
|
|
240
|
-
str(b.copy_count), cpc, str(b.break_date or "—"),
|
|
241
|
-
b.source or "—", b.uuid[:8],
|
|
242
|
-
)
|
|
243
|
-
|
|
244
|
-
console.print(heading("Breaks", f"{result.total} total"))
|
|
245
|
-
console.print(table)
|
|
246
|
-
_more(len(result.items), result.total, "refine with filters.")
|
|
275
|
+
browse(
|
|
276
|
+
BREAK_COLUMNS, [(b, break_cells(b)) for b in result.items],
|
|
277
|
+
title="Breaks", note=f"{result.total} total", hint="break report",
|
|
278
|
+
footer=_shown_footer(len(result.items), result.total, "refine with filters."),
|
|
279
|
+
detail=detail,
|
|
280
|
+
)
|
|
247
281
|
|
|
248
282
|
|
|
249
283
|
def _pull_row(cp) -> tuple[str, str, str, str, str]:
|
|
@@ -322,31 +356,36 @@ def render_break_pulls(brk, result: CollectionResult) -> None:
|
|
|
322
356
|
console.print()
|
|
323
357
|
|
|
324
358
|
|
|
325
|
-
|
|
359
|
+
LOT_COLUMNS = [
|
|
360
|
+
("Date", {"style": "slab.dim"}),
|
|
361
|
+
("Cost", {"justify": "right"}),
|
|
362
|
+
("Cards", {"justify": "right", "style": "slab.dim"}),
|
|
363
|
+
("$/Card", {"justify": "right", "style": "slab.dim"}),
|
|
364
|
+
("Source", {"style": "slab.dim"}),
|
|
365
|
+
("UUID", {"style": "slab.muted"}),
|
|
366
|
+
]
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def lot_cells(lot) -> list[str]:
|
|
370
|
+
"""One purchase lot as its LOT_COLUMNS cells."""
|
|
371
|
+
cpc = money(lot.cost_per_card) if lot.cost_per_card is not None else "—"
|
|
372
|
+
return [str(lot.lot_date or "—"), money(lot.total_cost), str(lot.copy_count), cpc,
|
|
373
|
+
lot.source or "—", lot.uuid[:8]]
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def browse_lots(result: LotSearchResult, *, detail=None) -> None:
|
|
377
|
+
"""Browse your purchases. With `detail` (a LotOut -> None renderer), Enter drills into the
|
|
378
|
+
purchase's report card (value vs cost + its cards); Esc climbs back."""
|
|
326
379
|
if not result.items:
|
|
327
380
|
console.print("[slab.dim]No purchases found.[/]")
|
|
328
381
|
console.print(" [slab.dim]Use `slab lot new` to record a multi-card buy (an eBay bundle, a show pickup).[/]")
|
|
329
382
|
return
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
(
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
("Source", {"style": "slab.dim"}),
|
|
337
|
-
("UUID", {"style": "slab.muted"}),
|
|
338
|
-
])
|
|
339
|
-
|
|
340
|
-
for lot in result.items:
|
|
341
|
-
cpc = money(lot.cost_per_card) if lot.cost_per_card is not None else "—"
|
|
342
|
-
table.add_row(
|
|
343
|
-
str(lot.lot_date or "—"), money(lot.total_cost),
|
|
344
|
-
str(lot.copy_count), cpc, lot.source or "—", lot.uuid[:8],
|
|
345
|
-
)
|
|
346
|
-
|
|
347
|
-
console.print(heading("Purchases", f"{result.total} total"))
|
|
348
|
-
console.print(table)
|
|
349
|
-
_more(len(result.items), result.total, "refine with filters.")
|
|
383
|
+
browse(
|
|
384
|
+
LOT_COLUMNS, [(lot, lot_cells(lot)) for lot in result.items],
|
|
385
|
+
title="Purchases", note=f"{result.total} total", hint="purchase report",
|
|
386
|
+
footer=_shown_footer(len(result.items), result.total, "refine with filters."),
|
|
387
|
+
detail=detail,
|
|
388
|
+
)
|
|
350
389
|
|
|
351
390
|
|
|
352
391
|
def render_lot_contents(lot, result: CollectionResult) -> None:
|
|
@@ -724,34 +763,41 @@ def render_community_board(b: CommunityBoard) -> None:
|
|
|
724
763
|
# Custom sets
|
|
725
764
|
# ---------------------------------------------------------------------------
|
|
726
765
|
|
|
727
|
-
|
|
766
|
+
CUSTOM_SET_COLUMNS = [
|
|
767
|
+
("Name", {"style": "slab.value"}),
|
|
768
|
+
("Type", {"style": "slab.dim"}),
|
|
769
|
+
("Vis", {"justify": "center"}),
|
|
770
|
+
("Cards", {"justify": "right", "style": "slab.dim"}),
|
|
771
|
+
("Subs", {"justify": "right", "style": "slab.dim"}),
|
|
772
|
+
("Creator", {}),
|
|
773
|
+
("UUID", {"style": "slab.muted"}),
|
|
774
|
+
]
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def custom_set_cells(s: CustomSetOut) -> list[str]:
|
|
778
|
+
"""One chase set as its CUSTOM_SET_COLUMNS cells."""
|
|
779
|
+
vis = "🔒" if s.visibility == "private" else "🌐"
|
|
780
|
+
sub_marker = " [slab.gain]✓[/]" if s.is_subscribed else ""
|
|
781
|
+
return [s.name, s.set_type, vis, str(s.card_count),
|
|
782
|
+
f"{s.subscriber_count}{sub_marker}", s.creator_name or "—", s.uuid[:8]]
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def browse_custom_sets(
|
|
786
|
+
result: CustomSetSearchResult | list[CustomSetOut], *, detail=None,
|
|
787
|
+
) -> CustomSetOut | None:
|
|
788
|
+
"""Browse chase sets. With `detail` (a CustomSetOut -> None renderer), Enter drills into
|
|
789
|
+
the set's card list + completion; Esc climbs back."""
|
|
728
790
|
items = result.items if isinstance(result, CustomSetSearchResult) else result
|
|
729
791
|
if not items:
|
|
730
792
|
console.print("[dim]No custom sets found.[/dim]")
|
|
731
793
|
console.print(" [dim]Use `slab chase create` to make one.[/dim]")
|
|
732
|
-
return
|
|
733
|
-
|
|
794
|
+
return None
|
|
734
795
|
total = result.total if isinstance(result, CustomSetSearchResult) else len(items)
|
|
735
|
-
|
|
736
|
-
(
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
("Subs", {"justify": "right", "style": "slab.dim"}),
|
|
741
|
-
("Creator", {}),
|
|
742
|
-
("UUID", {"style": "slab.muted"}),
|
|
743
|
-
])
|
|
744
|
-
|
|
745
|
-
for s in items:
|
|
746
|
-
vis = "🔒" if s.visibility == "private" else "🌐"
|
|
747
|
-
sub_marker = " [slab.gain]✓[/]" if s.is_subscribed else ""
|
|
748
|
-
table.add_row(
|
|
749
|
-
s.name, s.set_type, vis, str(s.card_count),
|
|
750
|
-
f"{s.subscriber_count}{sub_marker}", s.creator_name or "—", s.uuid[:8],
|
|
751
|
-
)
|
|
752
|
-
|
|
753
|
-
console.print(heading("Custom Sets", f"{total} total"))
|
|
754
|
-
console.print(table)
|
|
796
|
+
return browse(
|
|
797
|
+
CUSTOM_SET_COLUMNS, [(s, custom_set_cells(s)) for s in items],
|
|
798
|
+
title="Custom Sets", note=f"{total} total", hint="set detail",
|
|
799
|
+
detail=detail,
|
|
800
|
+
)
|
|
755
801
|
|
|
756
802
|
|
|
757
803
|
def print_custom_set_detail(detail: CustomSetDetail) -> None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|