slab-cli 0.5.0__tar.gz → 0.7.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.5.0 → slab_cli-0.7.0}/PKG-INFO +27 -6
- {slab_cli-0.5.0 → slab_cli-0.7.0}/README.md +26 -5
- {slab_cli-0.5.0 → slab_cli-0.7.0}/pyproject.toml +1 -1
- slab_cli-0.7.0/src/slab_cli/browse.py +386 -0
- {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/commands/breaks.py +5 -9
- {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/commands/catalog.py +29 -3
- {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/commands/collection.py +15 -2
- {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/commands/custom_sets.py +10 -4
- {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/commands/lots.py +5 -8
- {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/commands/registry.py +7 -1
- slab_cli-0.7.0/src/slab_cli/commands/update.py +124 -0
- {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/display.py +219 -173
- {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/help.py +6 -1
- {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/main.py +38 -6
- slab_cli-0.7.0/src/slab_cli/updates.py +242 -0
- {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/__init__.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/banner.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/client.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/commands/__init__.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/commands/export.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/commands/pricing.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/commands/setup.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/config.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/context.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/flags.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/paging.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/picker.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/prompts.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/sources.py +0 -0
- {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/theme.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: slab-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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
|
|
@@ -56,11 +56,32 @@ to see a group's verbs.
|
|
|
56
56
|
|
|
57
57
|
## Configuration reference
|
|
58
58
|
|
|
59
|
-
| Env var
|
|
60
|
-
|
|
|
61
|
-
| `SLAB_API_KEY`
|
|
62
|
-
| `SLAB_API_URL`
|
|
63
|
-
| `SLAB_COLLECTOR
|
|
59
|
+
| Env var | Purpose | Default |
|
|
60
|
+
| ---------------------- | ------------------------------------------ | -------------------------------- |
|
|
61
|
+
| `SLAB_API_KEY` | API key for authentication (**required**) | — |
|
|
62
|
+
| `SLAB_API_URL` | Base URL of the slab API | `https://api.slab.dev-jeb.com` |
|
|
63
|
+
| `SLAB_COLLECTOR` | Active collector UUID, reused per command | — |
|
|
64
|
+
| `SLAB_NO_UPDATE_CHECK` | Set to `1` to silence update checks | — |
|
|
65
|
+
|
|
66
|
+
## Staying up to date
|
|
67
|
+
|
|
68
|
+
Nothing updates itself. At most once a day the CLI asks PyPI (in the background, while your command
|
|
69
|
+
runs) whether there's a newer release, and if so prints one line after the output:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
▍ slab-cli 0.7.0 is available — you have 0.6.0
|
|
73
|
+
Update when you like: slab update
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
slab --version # what you have
|
|
78
|
+
slab update check # ask PyPI right now; installs nothing
|
|
79
|
+
slab update # upgrade, after showing the command and asking
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`slab update` runs the upgrade that matches how you installed slab — `pipx upgrade`,
|
|
83
|
+
`uv tool upgrade`, or `pip install --upgrade` — and prints it first, so you can decline and run it
|
|
84
|
+
yourself. To turn the whole thing off: `export SLAB_NO_UPDATE_CHECK=1`.
|
|
64
85
|
|
|
65
86
|
## What it depends on
|
|
66
87
|
|
|
@@ -44,11 +44,32 @@ to see a group's verbs.
|
|
|
44
44
|
|
|
45
45
|
## Configuration reference
|
|
46
46
|
|
|
47
|
-
| Env var
|
|
48
|
-
|
|
|
49
|
-
| `SLAB_API_KEY`
|
|
50
|
-
| `SLAB_API_URL`
|
|
51
|
-
| `SLAB_COLLECTOR
|
|
47
|
+
| Env var | Purpose | Default |
|
|
48
|
+
| ---------------------- | ------------------------------------------ | -------------------------------- |
|
|
49
|
+
| `SLAB_API_KEY` | API key for authentication (**required**) | — |
|
|
50
|
+
| `SLAB_API_URL` | Base URL of the slab API | `https://api.slab.dev-jeb.com` |
|
|
51
|
+
| `SLAB_COLLECTOR` | Active collector UUID, reused per command | — |
|
|
52
|
+
| `SLAB_NO_UPDATE_CHECK` | Set to `1` to silence update checks | — |
|
|
53
|
+
|
|
54
|
+
## Staying up to date
|
|
55
|
+
|
|
56
|
+
Nothing updates itself. At most once a day the CLI asks PyPI (in the background, while your command
|
|
57
|
+
runs) whether there's a newer release, and if so prints one line after the output:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
▍ slab-cli 0.7.0 is available — you have 0.6.0
|
|
61
|
+
Update when you like: slab update
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
slab --version # what you have
|
|
66
|
+
slab update check # ask PyPI right now; installs nothing
|
|
67
|
+
slab update # upgrade, after showing the command and asking
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`slab update` runs the upgrade that matches how you installed slab — `pipx upgrade`,
|
|
71
|
+
`uv tool upgrade`, or `pip install --upgrade` — and prints it first, so you can decline and run it
|
|
72
|
+
yourself. To turn the whole thing off: `export SLAB_NO_UPDATE_CHECK=1`.
|
|
52
73
|
|
|
53
74
|
## What it depends on
|
|
54
75
|
|
|
@@ -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 (
|
|
@@ -605,6 +607,9 @@ def cmd_search(args: list[str]) -> None:
|
|
|
605
607
|
|
|
606
608
|
Sort keys: acquired_date, acquisition_cost, numbered, year, card_number, brand, set,
|
|
607
609
|
fmv, unrealized, roi (prefix - for descending).
|
|
610
|
+
|
|
611
|
+
In a terminal the results are a scrollable list — Enter drills into the highlighted
|
|
612
|
+
copy's market view, Esc climbs back. Piped output prints the plain table.
|
|
608
613
|
"""
|
|
609
614
|
if not args:
|
|
610
615
|
total = ctx.client.search_collection(ctx.collector, CollectionSearchQuery(limit=1)).total
|
|
@@ -616,7 +621,15 @@ def cmd_search(args: list[str]) -> None:
|
|
|
616
621
|
values, q = parse_flags(args, COLLECTION_SEARCH_FLAGS)
|
|
617
622
|
values.setdefault("limit", 25)
|
|
618
623
|
result = ctx.client.search_collection(ctx.collector, CollectionSearchQuery(q=q, **values))
|
|
619
|
-
|
|
624
|
+
|
|
625
|
+
def market_view(cp) -> None:
|
|
626
|
+
if cp.card is None:
|
|
627
|
+
console.print("[slab.dim]This copy isn't linked to a catalog card.[/]")
|
|
628
|
+
return
|
|
629
|
+
render_card_market(ctx.client.get_card_market(cp.card.uuid))
|
|
630
|
+
|
|
631
|
+
browse_collection(result, detail=market_view)
|
|
632
|
+
print_collection_summary(result)
|
|
620
633
|
|
|
621
634
|
|
|
622
635
|
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
|
)
|