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.
Files changed (30) hide show
  1. {slab_cli-0.6.0 → slab_cli-0.8.0}/PKG-INFO +1 -1
  2. {slab_cli-0.6.0 → slab_cli-0.8.0}/pyproject.toml +1 -1
  3. slab_cli-0.8.0/src/slab_cli/browse.py +386 -0
  4. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/commands/breaks.py +5 -9
  5. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/commands/catalog.py +29 -3
  6. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/commands/collection.py +18 -3
  7. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/commands/custom_sets.py +10 -4
  8. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/commands/lots.py +5 -8
  9. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/display.py +219 -173
  10. {slab_cli-0.6.0 → slab_cli-0.8.0}/README.md +0 -0
  11. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/__init__.py +0 -0
  12. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/banner.py +0 -0
  13. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/client.py +0 -0
  14. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/commands/__init__.py +0 -0
  15. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/commands/export.py +0 -0
  16. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/commands/pricing.py +0 -0
  17. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/commands/registry.py +0 -0
  18. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/commands/setup.py +0 -0
  19. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/commands/update.py +0 -0
  20. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/config.py +0 -0
  21. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/context.py +0 -0
  22. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/flags.py +0 -0
  23. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/help.py +0 -0
  24. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/main.py +0 -0
  25. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/paging.py +0 -0
  26. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/picker.py +0 -0
  27. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/prompts.py +0 -0
  28. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/sources.py +0 -0
  29. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/theme.py +0 -0
  30. {slab_cli-0.6.0 → slab_cli-0.8.0}/src/slab_cli/updates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: slab-cli
3
- Version: 0.6.0
3
+ Version: 0.8.0
4
4
  Summary: CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal.
5
5
  Author: dev_jeb
6
6
  Requires-Dist: slab-schemas>=0.1.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "slab-cli"
3
- version = "0.6.0"
3
+ version = "0.8.0"
4
4
  description = "CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal."
5
5
  readme = "README.md"
6
6
  authors = [{name = "dev_jeb"}]
@@ -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 console, print_breaks, render_break_pulls
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, then pick one to see what it delivered (value vs cost,
89
- the top pulls, the rarest pulls). Skippable — the list stays on screen either way."""
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
- print_breaks(result)
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 console, print_cards, print_sets, render_community_board
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
- print_cards(result)
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
- print_sets(result)
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
- print_collection,
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
- grade = ask_text_optional("Pro grade (e.g. 10, 9.5, Authentic — enter to skip):")
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
- print_collection(result)
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
- print_custom_sets(sets)
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
- print_custom_sets(sets)
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 console, print_lots, render_lot_contents
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, then pick one to see value vs cost + its cards."""
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
- print_lots(result)
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 _more(shown: int, total: int, hint: str) -> None:
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
- def print_cards(result: CardSearchResult) -> None:
98
- if not result.items:
99
- console.print("[slab.dim]No cards found.[/]")
100
- return
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
- for c in result.items:
114
- subjects = ", ".join(s.name for s in c.subjects)
115
- attrs = ", ".join(a.name for a in c.attributes)
116
- finish = c.finish or "[slab.dim]Base[/]"
117
- pr = f"/{c.print_run}" if c.print_run else ""
118
- table.add_row(
119
- c.card_number, subjects, c.set_name or "", c.subset or "",
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
- def print_sets(result: SetSearchResult) -> None:
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
- for s in result.items:
143
- # Priced = printings with a sale in the past 90 days; the % against the full card
144
- # count says how much of the set the market actually prices (color = health band).
145
- priced = "[slab.dim]—[/]"
146
- if s.priced_count:
147
- pct = f" ({pct_coverage(100 * s.priced_count / s.card_count)})" if s.card_count else ""
148
- priced = f"{s.priced_count:,}{pct}"
149
- box = f"[slab.foil]{money(s.box_price)}[/]" if s.box_price is not None else "[slab.dim]—[/]"
150
- table.add_row(
151
- s.name or "", s.brand or "",
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
- console.print(heading("Sets", f"{result.total} total"))
159
- console.print(table)
160
- _more(len(result.items), result.total, "narrow with a query (e.g. `slab set search platinum`).")
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 print_collection(result: CollectionResult) -> None:
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
- for cp in result.items:
182
- card = cp.card
183
- subjects = ", ".join(s.name for s in card.subjects) if card and card.subjects else "—"
184
- set_name = (card.set_name or "") if card else ""
185
- finish = ((card.finish if card else None) or "[slab.dim]Base[/]")
186
- card_num = card.card_number if card else "—"
187
- grade = _format_collection_grade(cp)
188
- basis = money(cp.cost_basis) if cp.cost_basis is not None else "[slab.dim]—[/]"
189
- fmv = "[slab.dim]—[/]"
190
- gain = "[slab.dim]—[/]"
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
- if result.summary:
201
- s = result.summary
202
- parts = []
203
- if s.total_acquisition_cost is not None:
204
- parts.append(f"[slab.label]Acquisition[/] {money(s.total_acquisition_cost)}")
205
- if s.total_additional_costs is not None:
206
- parts.append(f"[slab.label]Additional[/] {money(s.total_additional_costs)}")
207
- if s.total_cost_basis is not None:
208
- parts.append(f"[slab.label]Cost Basis[/] {money(s.total_cost_basis)}")
209
- if isinstance(s, PortfolioSummary) and s.portfolio_value is not None:
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
- table = slab_table([
226
- ("Set", {"style": "slab.value"}),
227
- ("Type", {"style": "slab.dim"}),
228
- ("Cost", {"justify": "right"}),
229
- ("Cards", {"justify": "right", "style": "slab.dim"}),
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
- def print_lots(result: LotSearchResult) -> None:
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
- table = slab_table([
332
- ("Date", {"style": "slab.dim"}),
333
- ("Cost", {"justify": "right"}),
334
- ("Cards", {"justify": "right", "style": "slab.dim"}),
335
- ("$/Card", {"justify": "right", "style": "slab.dim"}),
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
- def print_custom_sets(result: CustomSetSearchResult | list[CustomSetOut]) -> None:
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
- table = slab_table([
736
- ("Name", {"style": "slab.value"}),
737
- ("Type", {"style": "slab.dim"}),
738
- ("Vis", {"justify": "center"}),
739
- ("Cards", {"justify": "right", "style": "slab.dim"}),
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