draftomen 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. draftomen/__init__.py +17 -0
  2. draftomen/assets/draftomen.icns +0 -0
  3. draftomen/assets/draftomen.ico +0 -0
  4. draftomen/assets/draftomen_logo.png +0 -0
  5. draftomen/audit.py +606 -0
  6. draftomen/backtest.py +449 -0
  7. draftomen/benchmark.py +834 -0
  8. draftomen/carddb.py +1869 -0
  9. draftomen/cardimages.py +313 -0
  10. draftomen/cli.py +891 -0
  11. draftomen/config.py +133 -0
  12. draftomen/deckbuilder.py +2723 -0
  13. draftomen/events.py +772 -0
  14. draftomen/logfollow.py +451 -0
  15. draftomen/mock_session.py +745 -0
  16. draftomen/paths.py +120 -0
  17. draftomen/pickengine.py +1117 -0
  18. draftomen/pool.py +1259 -0
  19. draftomen/preferences.py +289 -0
  20. draftomen/qml/AboutDialog.qml +156 -0
  21. draftomen/qml/AppBar.qml +120 -0
  22. draftomen/qml/BacktestView.qml +316 -0
  23. draftomen/qml/BuildView.qml +1151 -0
  24. draftomen/qml/CardPreview.qml +434 -0
  25. draftomen/qml/DimensionalButton.qml +42 -0
  26. draftomen/qml/DimensionalComboBox.qml +182 -0
  27. draftomen/qml/DimensionalSurface.qml +118 -0
  28. draftomen/qml/DimensionalTabButton.qml +41 -0
  29. draftomen/qml/LiveDraftView.qml +468 -0
  30. draftomen/qml/Main.qml +175 -0
  31. draftomen/qml/NavigationRail.qml +226 -0
  32. draftomen/qml/PoolSummaryPanel.qml +322 -0
  33. draftomen/qml/PrivacyDialog.qml +96 -0
  34. draftomen/qml/RecentPickThumbnail.qml +83 -0
  35. draftomen/qml/RecentPicksGallery.qml +333 -0
  36. draftomen/qml/RecommendationRow.qml +404 -0
  37. draftomen/qml/SettingsSwitch.qml +141 -0
  38. draftomen/qml/SettingsView.qml +531 -0
  39. draftomen/qml/StateBanner.qml +189 -0
  40. draftomen/qml/StatusStrip.qml +84 -0
  41. draftomen/qml/Theme.qml +71 -0
  42. draftomen/qml/qmldir +23 -0
  43. draftomen/qt_adapter.py +884 -0
  44. draftomen/qt_gui.py +338 -0
  45. draftomen/qt_mock.py +63 -0
  46. draftomen/ranking.py +126 -0
  47. draftomen/replay.py +480 -0
  48. draftomen/session.py +3668 -0
  49. draftomen/setinfo.py +26 -0
  50. draftomen/seventeen.py +2766 -0
  51. draftomen/splash.py +617 -0
  52. draftomen/tui.py +4007 -0
  53. draftomen/watch.py +336 -0
  54. draftomen-0.3.0.dist-info/METADATA +156 -0
  55. draftomen-0.3.0.dist-info/RECORD +59 -0
  56. draftomen-0.3.0.dist-info/WHEEL +5 -0
  57. draftomen-0.3.0.dist-info/entry_points.txt +6 -0
  58. draftomen-0.3.0.dist-info/licenses/LICENSE +21 -0
  59. draftomen-0.3.0.dist-info/top_level.txt +1 -0
draftomen/tui.py ADDED
@@ -0,0 +1,4007 @@
1
+ """Textual live interface for Draftomen watch mode.
2
+ Render ranked packs and status updates without blocking fetches.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ from collections import Counter
9
+ from collections.abc import Callable, Iterable, Mapping
10
+ from dataclasses import replace
11
+ from os import PathLike
12
+ from pathlib import Path
13
+ from threading import Lock, get_ident
14
+ from typing import TypeAlias
15
+
16
+ from rich.align import Align
17
+ from rich.console import Group
18
+ from rich.text import Text
19
+ from textual import events, work
20
+ from textual.app import App, ComposeResult
21
+ from textual.binding import Binding
22
+ from textual.containers import Horizontal, Vertical, VerticalScroll
23
+ from textual.css.query import NoMatches
24
+ from textual.screen import ModalScreen
25
+ from textual.widgets import (
26
+ Button,
27
+ DataTable,
28
+ Footer,
29
+ Header,
30
+ ProgressBar,
31
+ Select,
32
+ Static,
33
+ Switch,
34
+ )
35
+ from textual.worker import get_current_worker
36
+
37
+ try: # pragma: no cover - import availability depends on optional terminal extras.
38
+ from textual_image.renderable.tgp import Image as TgpImage
39
+ except Exception: # pragma: no cover - graceful fallback when unavailable.
40
+ TgpImage = None
41
+
42
+ from draftomen.carddb import CardDatabase, CardInfo
43
+ from draftomen.cardimages import CardImageService, card_image_cache_dir
44
+ from draftomen.config import COLOR_PAIRS, POLL_INTERVAL_SECONDS
45
+ from draftomen.deckbuilder import BuildPool, ManaBase, PairSelection, SpellSelection
46
+ from draftomen.events import (
47
+ AccountEvent,
48
+ DraftCompletedEvent,
49
+ DraftStartedEvent,
50
+ PackOfferedEvent,
51
+ PickMadeEvent,
52
+ QuickDraftDetectedEvent,
53
+ )
54
+ from draftomen.pickengine import (
55
+ ScoredCard,
56
+ ScoredPack,
57
+ recommendation_confidence_summary,
58
+ )
59
+ from draftomen.preferences import (
60
+ TuiVisibilityPreferences,
61
+ load_tui_preferences,
62
+ save_tui_preferences,
63
+ )
64
+ from draftomen.ranking import (
65
+ DEFAULT_RANKING_MODE,
66
+ RANKING_LABELS,
67
+ RANKING_MODES,
68
+ rank_scored_cards,
69
+ ranking_label,
70
+ )
71
+ from draftomen.session import (
72
+ ApplicationPhase,
73
+ BacktestPickResult,
74
+ BacktestResult,
75
+ BuildResult,
76
+ CardView,
77
+ ChangeRanking,
78
+ ChangeSplashPreference,
79
+ ChooseAccount,
80
+ DataLoadPhase,
81
+ LiveSession,
82
+ LiveSessionCommand,
83
+ LiveSessionEvent,
84
+ LiveSessionSnapshot,
85
+ OperationKind,
86
+ PoolState,
87
+ RatingsProgressLoader,
88
+ RequestBacktest,
89
+ RequestBuild,
90
+ RequestRatingsDownload,
91
+ SessionError,
92
+ )
93
+ from draftomen.seventeen import (
94
+ SEVENTEEN_LANDS_ATTRIBUTION,
95
+ DownloadProgressCallback,
96
+ SeventeenLandsData,
97
+ )
98
+ from draftomen.setinfo import format_set_label
99
+
100
+ PathInput: TypeAlias = str | PathLike[str]
101
+ RatingsLoader: TypeAlias = Callable[[str], SeventeenLandsData]
102
+ CardDatabaseLoader: TypeAlias = Callable[[], CardDatabase]
103
+ RatingsLoaderFactory: TypeAlias = Callable[[CardDatabase], RatingsLoader]
104
+ RatingsProgressLoaderFactory: TypeAlias = Callable[
105
+ [CardDatabase],
106
+ RatingsProgressLoader,
107
+ ]
108
+ RatingsCacheChecker: TypeAlias = Callable[[str], bool]
109
+ TuiCardQuantityKey: TypeAlias = tuple[str, str]
110
+ TuiCardQuantityGroup: TypeAlias = tuple[ScoredCard, int]
111
+
112
+ PRIMARY_COLUMN_KEYS = ("rank", "win_rate", "grade", "score", "card", "colors")
113
+ SECONDARY_COLUMN_KEYS = ("fit", "alsa", "mv", "source")
114
+ SORT_MODES = RANKING_MODES
115
+ BUILD_SPELL_SORT_MODES = ("curve", "score", "name")
116
+ SECONDARY_COLUMN_MIN_WIDTH = 88
117
+ SIDEBAR_MIN_WIDTH = 56
118
+ COLOR_ORDER = ("W", "U", "B", "R", "G")
119
+ COLOR_NAMES = {
120
+ "W": "White",
121
+ "U": "Blue",
122
+ "B": "Black",
123
+ "R": "Red",
124
+ "G": "Green",
125
+ }
126
+ BASIC_LAND_NAMES = {
127
+ "W": "Plains",
128
+ "U": "Island",
129
+ "B": "Swamp",
130
+ "R": "Mountain",
131
+ "G": "Forest",
132
+ }
133
+ COLORLESS_KEY = "C"
134
+ UNKNOWN_COLOR_KEY = "?"
135
+ CURVE_BUCKET_LABELS = ("0", "1", "2", "3", "4", "5", "6+")
136
+ SPARKLINE_GLYPHS = "▁▂▃▄▅▆▇█"
137
+ CARD_IMAGE_PREVIEW_ENV = "DRAFTOMEN_CARD_IMAGES"
138
+ MANA_ICON_GLYPHS = {
139
+ "W": "\ue600",
140
+ "U": "\ue601",
141
+ "B": "\ue602",
142
+ "R": "\ue603",
143
+ "G": "\ue604",
144
+ "C": "\ue904",
145
+ }
146
+ MANA_CARD_TYPE_GLYPHS = {
147
+ "Artifact": "\ue61e",
148
+ "Creature": "\ue61f",
149
+ "Enchantment": "\ue620",
150
+ "Instant": "\ue621",
151
+ "Land": "\ue622",
152
+ "Planeswalker": "\ue623",
153
+ "Sorcery": "\ue624",
154
+ }
155
+
156
+ COLOR_STYLES = {
157
+ "W": "bold bright_white",
158
+ "U": "bold dodger_blue1",
159
+ "B": "bold grey50",
160
+ "R": "bold red3",
161
+ "G": "bold green3",
162
+ }
163
+
164
+ COLUMN_LABELS = {
165
+ "rank": "#",
166
+ "win_rate": "17L WR",
167
+ "grade": "17L Grade",
168
+ "score": "DO",
169
+ "card": "Card",
170
+ "colors": "Colors",
171
+ "fit": "Fit",
172
+ "gih": "GIH WR",
173
+ "alsa": "ALSA",
174
+ "mv": "MV",
175
+ "source": "Source",
176
+ }
177
+
178
+ COLUMN_WIDTHS = {
179
+ "rank": 3,
180
+ "win_rate": 8,
181
+ "grade": 9,
182
+ "score": 5,
183
+ "card": None,
184
+ "colors": 10,
185
+ "fit": 10,
186
+ "gih": 8,
187
+ "alsa": 7,
188
+ "mv": 5,
189
+ "source": 9,
190
+ }
191
+
192
+ SORT_LABELS = RANKING_LABELS
193
+
194
+
195
+ class CardDetailsPanel(Static, can_focus=False):
196
+ """Sidebar panel for the highlighted card.
197
+ Keep focus styling ready for future actions, but do not enter it with Tab yet.
198
+ """
199
+
200
+
201
+ _VISIBILITY_BOOLEAN_OPTIONS = (
202
+ (
203
+ "splash_enabled",
204
+ "Splash recommendations — consider one supported third color for "
205
+ "exceptionally strong cards.",
206
+ ),
207
+ (
208
+ "secondary_columns",
209
+ "Secondary pack columns — show Fit, ALSA, mana value, and source on wide "
210
+ "screens.",
211
+ ),
212
+ (
213
+ "build_details",
214
+ "Build details — show the picked pool, pair reasoning, checks, and bench "
215
+ "cuts.",
216
+ ),
217
+ (
218
+ "pool_metadata",
219
+ "Pool metadata — show set, event, pool size, pairs, and metadata status.",
220
+ ),
221
+ (
222
+ "pool_color_distribution",
223
+ "Pool color distribution — show the sidebar color bar.",
224
+ ),
225
+ (
226
+ "pool_mana_curve",
227
+ "Mana curve — show pool and detailed-build mana curves.",
228
+ ),
229
+ (
230
+ "account_identifier",
231
+ "Account identifier — show the active account and detailed-build account ID.",
232
+ ),
233
+ (
234
+ "draft_identifier",
235
+ "Draft identifier — show draft IDs in pool and detailed-build metadata.",
236
+ ),
237
+ (
238
+ "mana_pips_and_sources",
239
+ "Mana pips and sources — show detailed-build mana requirements and sources.",
240
+ ),
241
+ (
242
+ "attribution",
243
+ "17Lands attribution — show the data-source attribution in the status bar.",
244
+ ),
245
+ (
246
+ "focused_card_details",
247
+ "Focused card details — show statistics for the highlighted card in the "
248
+ "sidebar.",
249
+ ),
250
+ )
251
+
252
+
253
+ class TuiVisibilityScreen(ModalScreen[TuiVisibilityPreferences | None]):
254
+ """Modal editor for persisted optional TUI elements.
255
+ Saving returns the selected preferences to the calling application.
256
+ """
257
+
258
+ CSS = """
259
+ TuiVisibilityScreen {
260
+ align: center middle;
261
+ }
262
+
263
+ #visibility-dialog {
264
+ width: 76;
265
+ height: 90%;
266
+ border: thick $accent;
267
+ background: $surface;
268
+ padding: 1 2;
269
+ }
270
+
271
+ #visibility-options {
272
+ height: 1fr;
273
+ }
274
+
275
+ .visibility-label {
276
+ height: auto;
277
+ margin-top: 1;
278
+ }
279
+
280
+ #visibility-actions {
281
+ height: auto;
282
+ margin-top: 1;
283
+ }
284
+ """
285
+
286
+ BINDINGS = [Binding("escape", "cancel", "Cancel", show=False)]
287
+
288
+ def __init__(self, *, preferences: TuiVisibilityPreferences) -> None:
289
+ super().__init__()
290
+ self.preferences = preferences
291
+
292
+ def compose(self) -> ComposeResult:
293
+ """Compose descriptive controls for every persisted visibility preference.
294
+ The scrollable form fits short terminals without hiding any option.
295
+ """
296
+
297
+ with Vertical(id="visibility-dialog"):
298
+ yield Static("TUI config", classes="visibility-label")
299
+ yield Static(
300
+ "Choose optional elements. Responsive terminal width can still hide "
301
+ "enabled sections.",
302
+ classes="visibility-label",
303
+ )
304
+ with VerticalScroll(id="visibility-options"):
305
+ for field_name, label in _VISIBILITY_BOOLEAN_OPTIONS:
306
+ yield Static(label, classes="visibility-label")
307
+ yield Switch(
308
+ value=getattr(self.preferences, field_name),
309
+ id=_visibility_control_id(field_name=field_name),
310
+ )
311
+ yield Static(
312
+ "Card image preview — Auto follows terminal detection; Show "
313
+ "requests previews; Hide prevents image loading.",
314
+ classes="visibility-label",
315
+ )
316
+ yield Select(
317
+ (
318
+ ("Auto", "auto"),
319
+ ("Show", "show"),
320
+ ("Hide", "hide"),
321
+ ),
322
+ value=self.preferences.card_image_preview,
323
+ allow_blank=False,
324
+ id=_visibility_control_id(field_name="card_image_preview"),
325
+ )
326
+ with Horizontal(id="visibility-actions"):
327
+ yield Button("Save", id="save-visibility", variant="primary")
328
+ yield Button("Cancel", id="cancel-visibility")
329
+ yield Button("Reset defaults", id="reset-visibility")
330
+
331
+ def action_cancel(self) -> None:
332
+ """Dismiss the dialog without changing active or persisted preferences.
333
+ Escape follows the explicit Cancel action.
334
+ """
335
+
336
+ self.dismiss(None)
337
+
338
+ def on_button_pressed(self, event: Button.Pressed) -> None:
339
+ """Save, cancel, or reset the form according to its selected action.
340
+ Reset changes the form only until the user confirms Save.
341
+ """
342
+
343
+ if event.button.id == "save-visibility":
344
+ self.dismiss(self._selected_preferences())
345
+ elif event.button.id == "cancel-visibility":
346
+ self.dismiss(None)
347
+ elif event.button.id == "reset-visibility":
348
+ self._restore_defaults()
349
+
350
+ def _selected_preferences(self) -> TuiVisibilityPreferences:
351
+ values = {
352
+ field_name: self.query_one(
353
+ f"#{_visibility_control_id(field_name=field_name)}",
354
+ Switch,
355
+ ).value
356
+ for field_name, _ in _VISIBILITY_BOOLEAN_OPTIONS
357
+ }
358
+ card_image_preview = self.query_one(
359
+ f"#{_visibility_control_id(field_name='card_image_preview')}",
360
+ Select,
361
+ ).value
362
+ return TuiVisibilityPreferences(
363
+ **values,
364
+ card_image_preview=str(card_image_preview),
365
+ )
366
+
367
+ def _restore_defaults(self) -> None:
368
+ defaults = TuiVisibilityPreferences()
369
+ for field_name, _ in _VISIBILITY_BOOLEAN_OPTIONS:
370
+ self.query_one(
371
+ f"#{_visibility_control_id(field_name=field_name)}",
372
+ Switch,
373
+ ).value = getattr(defaults, field_name)
374
+ self.query_one(
375
+ f"#{_visibility_control_id(field_name='card_image_preview')}",
376
+ Select,
377
+ ).value = defaults.card_image_preview
378
+
379
+
380
+ def _visibility_control_id(*, field_name: str) -> str:
381
+ return f"visibility-{field_name.replace('_', '-')}"
382
+
383
+
384
+ class MissingRatingsScreen(ModalScreen[bool]):
385
+ """Ask before the first 17Lands download for an uncached set.
386
+ The active draft continues using neutral priors until the user confirms.
387
+ """
388
+
389
+ CSS = """
390
+ MissingRatingsScreen {
391
+ align: center middle;
392
+ }
393
+
394
+ #missing-ratings-dialog {
395
+ width: 68;
396
+ height: auto;
397
+ border: thick $warning;
398
+ background: $surface;
399
+ padding: 1 2;
400
+ }
401
+
402
+ #missing-ratings-title {
403
+ height: auto;
404
+ text-style: bold;
405
+ margin-bottom: 1;
406
+ }
407
+
408
+ #missing-ratings-message {
409
+ height: auto;
410
+ margin-bottom: 1;
411
+ }
412
+
413
+ #missing-ratings-actions {
414
+ height: auto;
415
+ }
416
+ """
417
+
418
+ BINDINGS = [Binding("escape", "cancel", "Not now", show=False)]
419
+
420
+ def __init__(self, *, set_code: str) -> None:
421
+ super().__init__()
422
+ self.set_code = set_code.upper()
423
+
424
+ def compose(self) -> ComposeResult:
425
+ """Compose the missing-data warning and explicit download choice.
426
+ Downloaded Quick and Premier data will be cached for later drafts.
427
+ """
428
+
429
+ with Vertical(id="missing-ratings-dialog"):
430
+ yield Static(
431
+ f"No local 17Lands data for {self.set_code}",
432
+ id="missing-ratings-title",
433
+ )
434
+ yield Static(
435
+ "Draft Omen is using neutral-prior scores. Download the all-time "
436
+ "Quick Draft and Premier fallback ratings now? Progress will be "
437
+ "shown, then the current pack will be rescored automatically.",
438
+ id="missing-ratings-message",
439
+ )
440
+ with Horizontal(id="missing-ratings-actions"):
441
+ yield Button(
442
+ "Download data",
443
+ id="download-ratings",
444
+ variant="primary",
445
+ )
446
+ yield Button("Not now", id="cancel-ratings-download")
447
+
448
+ def action_cancel(self) -> None:
449
+ """Keep neutral-prior scores without starting a network request.
450
+ The data action remains available if the user changes their mind.
451
+ """
452
+
453
+ self.dismiss(False)
454
+
455
+ def on_button_pressed(self, event: Button.Pressed) -> None:
456
+ """Return whether the user explicitly approved the download.
457
+ The app owns the worker and progress state after this screen closes.
458
+ """
459
+
460
+ self.dismiss(event.button.id == "download-ratings")
461
+
462
+
463
+ class DraftomenTuiApp(App[None]):
464
+ """Textual app for live Quick Draft recommendations.
465
+ The app can tail a real log or accept fixture lines in tests.
466
+ """
467
+
468
+ TITLE = "Draft Omen"
469
+
470
+ CSS = """
471
+ Screen {
472
+ layout: vertical;
473
+ }
474
+
475
+ #main {
476
+ height: 1fr;
477
+ }
478
+
479
+ #pack-panel {
480
+ width: 3fr;
481
+ min-width: 0;
482
+ padding: 0 1;
483
+ }
484
+
485
+ #sidebar {
486
+ width: 1fr;
487
+ min-width: 24;
488
+ padding: 0 1;
489
+ border-left: solid $accent;
490
+ }
491
+
492
+ #pack-title {
493
+ height: 1;
494
+ text-style: bold;
495
+ }
496
+
497
+ #pre-draft-readiness {
498
+ height: 1fr;
499
+ content-align: center middle;
500
+ text-align: center;
501
+ text-style: bold;
502
+ }
503
+
504
+ #pack-table,
505
+ #build-scroll,
506
+ #focused-card {
507
+ border: blank $surface;
508
+ }
509
+
510
+ #pack-table:focus,
511
+ #build-scroll:focus,
512
+ #focused-card:focus {
513
+ border: solid $accent;
514
+ }
515
+
516
+ #pack-table {
517
+ height: 1fr;
518
+ }
519
+
520
+ #build-scroll {
521
+ height: 1fr;
522
+ overflow-y: auto;
523
+ }
524
+
525
+ #build-view {
526
+ height: auto;
527
+ }
528
+
529
+ #pool-summary,
530
+ #focused-card,
531
+ #card-image-preview {
532
+ height: auto;
533
+ margin-bottom: 1;
534
+ }
535
+
536
+ #card-image-preview {
537
+ content-align: center top;
538
+ margin-top: 1;
539
+ text-align: center;
540
+ }
541
+
542
+ #status-bar {
543
+ height: 1;
544
+ background: $accent;
545
+ color: $text;
546
+ padding: 0 1;
547
+ }
548
+
549
+ #ratings-download-panel {
550
+ height: auto;
551
+ display: none;
552
+ background: $panel;
553
+ padding: 0 1;
554
+ }
555
+
556
+ #ratings-download-label {
557
+ height: 1;
558
+ }
559
+
560
+ #ratings-download-progress {
561
+ height: 1;
562
+ }
563
+ """
564
+
565
+ BINDINGS = [
566
+ Binding("q", "quit", "Quit", show=True),
567
+ Binding("c", "open_config", "Config", show=True),
568
+ Binding("s", "cycle_sort", "Rank", show=True),
569
+ Binding("b", "open_build_view", "Build", show=True),
570
+ Binding("t", "open_backtest_report", "Backtest", show=True),
571
+ Binding("a", "cycle_account", "Account", show=True),
572
+ Binding("p", "rebuild_with_pair_override", "Pair", show=True),
573
+ Binding("m", "toggle_mana_icons", "Mana", show=True),
574
+ Binding("d", "download_ratings", "Data", show=True),
575
+ Binding("up", "navigate_previous_card", "Previous", show=False, priority=True),
576
+ Binding("left", "navigate_previous_card", "Previous", show=False, priority=True),
577
+ Binding("k", "navigate_previous_card", "Previous", show=False, priority=True),
578
+ Binding("down", "navigate_next_card", "Next", show=False, priority=True),
579
+ Binding("right", "navigate_next_card", "Next", show=False, priority=True),
580
+ Binding("j", "navigate_next_card", "Next", show=False, priority=True),
581
+ Binding("pageup", "navigate_page_up", "Page", show=False, priority=True),
582
+ Binding("pagedown", "navigate_page_down", "Page", show=False, priority=True),
583
+ Binding("home", "navigate_home", "Home", show=False, priority=True),
584
+ Binding("end", "navigate_end", "End", show=False, priority=True),
585
+ ]
586
+
587
+ def __init__(
588
+ self,
589
+ *,
590
+ log_path: PathInput,
591
+ card_database: CardDatabase | None = None,
592
+ card_database_loader: CardDatabaseLoader | None = None,
593
+ app_dir: PathInput | None = None,
594
+ poll_interval: float = POLL_INTERVAL_SECONDS,
595
+ previous_log_path: PathInput | None = None,
596
+ ratings_loader: RatingsLoader | None = None,
597
+ ratings_loader_factory: RatingsLoaderFactory | None = None,
598
+ ratings_progress_loader: RatingsProgressLoader | None = None,
599
+ ratings_progress_loader_factory: RatingsProgressLoaderFactory | None = None,
600
+ ratings_cache_checker: RatingsCacheChecker | None = None,
601
+ startup_scan: bool = False,
602
+ once: bool = False,
603
+ poll_enabled: bool = True,
604
+ image_preview_enabled: bool | None = None,
605
+ mana_icons_enabled: bool = False,
606
+ card_image_service: CardImageService | None = None,
607
+ visibility_preferences: TuiVisibilityPreferences | None = None,
608
+ splash_enabled: bool | None = None,
609
+ ) -> None:
610
+ super().__init__()
611
+ if card_database is None and card_database_loader is None:
612
+ raise ValueError("card_database or card_database_loader is required.")
613
+ if card_database is not None and card_database_loader is not None:
614
+ raise ValueError("card_database and card_database_loader are mutually exclusive.")
615
+ configured_ratings_loaders = sum(
616
+ loader is not None
617
+ for loader in (
618
+ ratings_loader,
619
+ ratings_loader_factory,
620
+ ratings_progress_loader,
621
+ ratings_progress_loader_factory,
622
+ )
623
+ )
624
+ if configured_ratings_loaders > 1:
625
+ raise ValueError("Configure exactly one ratings loader or loader factory.")
626
+ self._ratings_operations_may_block = configured_ratings_loaders > 0
627
+
628
+ self.log_path = Path(log_path).expanduser().resolve(strict=False)
629
+ self._preferences_app_dir = app_dir
630
+ if visibility_preferences is None:
631
+ (
632
+ self.visibility_preferences,
633
+ self._preferences_load_warning,
634
+ ) = load_tui_preferences(app_dir=app_dir)
635
+ else:
636
+ self.visibility_preferences = visibility_preferences
637
+ self._preferences_load_warning = None
638
+ if splash_enabled is not None:
639
+ self.visibility_preferences = replace(
640
+ self.visibility_preferences,
641
+ splash_enabled=splash_enabled,
642
+ )
643
+ self._preferences_save_warning: str | None = None
644
+ self._card_database_error: str | None = None
645
+ self._log_processing_started = False
646
+ self._ingestion_lock = Lock()
647
+ self._textual_thread_id: int | None = None
648
+ self.session = LiveSession(
649
+ log_path=self.log_path,
650
+ card_database=card_database,
651
+ card_database_loader=card_database_loader,
652
+ app_dir=app_dir,
653
+ poll_interval=poll_interval,
654
+ previous_log_path=previous_log_path,
655
+ snapshot_publisher=self._publish_session_snapshot,
656
+ event_publisher=self._publish_session_event,
657
+ ratings_loader=ratings_loader,
658
+ ratings_loader_factory=ratings_loader_factory,
659
+ ratings_progress_loader=ratings_progress_loader,
660
+ ratings_progress_loader_factory=ratings_progress_loader_factory,
661
+ ratings_cache_checker=ratings_cache_checker,
662
+ splash_enabled=self.visibility_preferences.splash_enabled,
663
+ )
664
+ self.startup_scan = startup_scan
665
+ self.once = once
666
+ self.poll_enabled = poll_enabled
667
+ self.poll_interval = poll_interval
668
+ self._card_image_service = card_image_service or CardImageService(
669
+ cache_dir=card_image_cache_dir(app_dir=app_dir),
670
+ )
671
+ self._card_image_preview_enabled = (
672
+ _card_image_preview_enabled(env=os.environ)
673
+ if image_preview_enabled is None
674
+ else image_preview_enabled
675
+ )
676
+ self.mana_icons_enabled = mana_icons_enabled
677
+ self._card_image_paths_by_uri: dict[str, Path] = {}
678
+ self._card_image_failures_by_uri: dict[str, str] = {}
679
+ self._loading_card_image_uris: set[str] = set()
680
+ self._card_image_uris_by_grp_id: dict[int, str] = {}
681
+
682
+ self.show_secondary_columns = self.visibility_preferences.secondary_columns
683
+ self.sort_mode = DEFAULT_RANKING_MODE
684
+ self._view_mode = "pack"
685
+ self._visible_column_keys: tuple[str, ...] = ()
686
+ self._active_account_id: str | None = None
687
+ self._active_account_label = "unknown"
688
+ self._event_name: str | None = None
689
+ self._set_code: str | None = None
690
+ self._draft_id: str | None = None
691
+ self._pick_label = "—"
692
+ self._pool_size = 0
693
+ self._pool_grp_ids: tuple[int, ...] = ()
694
+ self._pair_label = "open"
695
+ self._commitment_label = "0% open"
696
+ self._data_source = "unknown"
697
+ self._last_error: str | None = None
698
+ self._session_error: str | None = None
699
+ self._forced_pair: str | None = None
700
+ self._build_pair_label = "—"
701
+ self._build_text = "Build view: no picked cards yet."
702
+ self._build_error: str | None = None
703
+ self._build_action_status: str | None = None
704
+ self._pending_build_success_message: str | None = None
705
+ self._build_spell_sort_mode = "curve"
706
+ self._build_show_details = self.visibility_preferences.build_details
707
+ self._build_focus_cards: tuple[TuiCardQuantityGroup, ...] = ()
708
+ self._build_focused_card_index = 0
709
+ self._build_result: BuildResult | None = None
710
+ self._backtest_text = "Backtest view: complete a draft, then press t."
711
+ self._backtest_error: str | None = None
712
+ self._backtest_action_status: str | None = None
713
+ self._pending_backtest_success_message: str | None = None
714
+ self._open_backtest_when_ready = False
715
+ self._current_pack_event: PackOfferedEvent | None = None
716
+ self._current_pack: ScoredPack | None = None
717
+ self._rating_prompted_sets: set[str] = set()
718
+ self._rating_prompt_open_sets: set[str] = set()
719
+ self._rating_notices_by_set: dict[str, str] = {}
720
+ self._rating_download_requested_sets: set[str] = set()
721
+
722
+ @property
723
+ def card_database(self) -> CardDatabase:
724
+ """Return loaded card metadata for scoring and rendering.
725
+ Startup code must wait for the loader worker before accessing it.
726
+ """
727
+
728
+ database = self.session.card_database
729
+ if database is None:
730
+ raise RuntimeError("Card metadata is not ready.")
731
+
732
+ return database
733
+
734
+ @property
735
+ def card_database_loading(self) -> bool:
736
+ """Return whether a card metadata worker is currently running.
737
+ Tests use this to verify startup work stays outside the render loop.
738
+ """
739
+
740
+ return self.session.snapshot.card_data.phase in {
741
+ DataLoadPhase.IDLE,
742
+ DataLoadPhase.LOADING,
743
+ }
744
+
745
+ @property
746
+ def visible_column_keys(self) -> tuple[str, ...]:
747
+ """Return current pack-table columns for tests.
748
+ The value reflects width-based degradation and user toggles.
749
+ """
750
+
751
+ return self._visible_column_keys
752
+
753
+ @property
754
+ def loading_rating_sets(self) -> frozenset[str]:
755
+ """Return set codes currently refreshing in a worker.
756
+ Tests use this to confirm slow loads stay off the render loop.
757
+ """
758
+
759
+ ratings = self.session.snapshot.ratings
760
+ if ratings.phase != DataLoadPhase.LOADING or ratings.set_code is None:
761
+ return frozenset()
762
+
763
+ return frozenset((ratings.set_code,))
764
+
765
+ @property
766
+ def build_view_text(self) -> str:
767
+ """Return the rendered build view text for pilot tests.
768
+ This mirrors the Static widget content after a build refresh.
769
+ """
770
+
771
+ return self._build_text
772
+
773
+ @property
774
+ def backtest_view_text(self) -> str:
775
+ """Return the rendered backtest report text for pilot tests.
776
+ This mirrors the Static widget content after a report refresh.
777
+ """
778
+
779
+ return self._backtest_text
780
+
781
+ def compose(self) -> ComposeResult:
782
+ """Compose the pack table, sidebar, status bar, and key footer.
783
+ Textual's Footer automatically renders the declared keybindings.
784
+ """
785
+
786
+ yield Header(show_clock=False)
787
+ with Horizontal(id="main"):
788
+ with Vertical(id="pack-panel"):
789
+ yield Static("Waiting for a Quick Draft pack…", id="pack-title")
790
+ yield Static(
791
+ "Quick Draft set not detected yet.",
792
+ id="pre-draft-readiness",
793
+ )
794
+ yield DataTable(id="pack-table")
795
+ with VerticalScroll(id="build-scroll", can_focus=True):
796
+ yield Static("Build view: no picked cards yet.", id="build-view")
797
+ with Vertical(id="sidebar"):
798
+ yield Static("Pool: no draft yet", id="pool-summary")
799
+ yield Static("", id="card-image-preview")
800
+ yield CardDetailsPanel("Focused card: none", id="focused-card")
801
+ with Vertical(id="ratings-download-panel"):
802
+ yield Static("", id="ratings-download-label")
803
+ yield ProgressBar(
804
+ total=4,
805
+ show_eta=False,
806
+ id="ratings-download-progress",
807
+ )
808
+ yield Static("", id="status-bar")
809
+ yield Footer()
810
+
811
+ def on_mount(self) -> None:
812
+ """Render the shell before starting metadata and log workers.
813
+ Log processing begins only after card data is available for scoring.
814
+ """
815
+
816
+ self._textual_thread_id = get_ident()
817
+ table = self.query_one("#pack-table", DataTable)
818
+ table.cursor_type = "row"
819
+ table.zebra_stripes = True
820
+ table.focus()
821
+ self._render_all()
822
+
823
+ if self.card_database_loading:
824
+ self._load_card_database_worker()
825
+ return
826
+
827
+ self._start_log_processing()
828
+
829
+ def _start_log_processing(self) -> None:
830
+ """Start polling only once card metadata is safe to consume.
831
+ Deferring this preserves all events for normal scoring and recommendations.
832
+ """
833
+
834
+ if not self.poll_enabled or self._log_processing_started:
835
+ return
836
+
837
+ self._log_processing_started = True
838
+ if self.startup_scan:
839
+ self._scan_startup_files_worker(exit_after=self.once)
840
+ return
841
+
842
+ self._start_polling()
843
+
844
+ def _start_polling(self) -> None:
845
+ """Begin polling only after optional startup recovery has finished.
846
+ The first poll catches lines appended while startup recovery was running.
847
+ """
848
+
849
+ self._poll_log_worker(exit_after=self.once)
850
+ if not self.once:
851
+ self.set_interval(self.poll_interval, self._poll_log_worker)
852
+
853
+ def on_resize(self, event: events.Resize) -> None:
854
+ """Rebuild the table when width crosses a degradation threshold.
855
+ Secondary columns are hidden first on narrow terminals.
856
+ """
857
+
858
+ self._render_all()
859
+
860
+ def on_data_table_row_highlighted(
861
+ self,
862
+ event: DataTable.RowHighlighted,
863
+ ) -> None:
864
+ """Refresh card details when keyboard navigation changes rows.
865
+ Periodic log polls should not be needed before details update.
866
+ """
867
+
868
+ if event.data_table.id == "pack-table":
869
+ self._render_focused_card_details()
870
+
871
+ def action_open_config(self) -> None:
872
+ """Open the in-app editor for persisted optional TUI elements.
873
+ The callback applies only settings that the user explicitly saves.
874
+ """
875
+
876
+ self.push_screen(
877
+ TuiVisibilityScreen(preferences=self.visibility_preferences),
878
+ self._apply_visibility_preferences,
879
+ )
880
+
881
+ def _apply_visibility_preferences(
882
+ self,
883
+ preferences: TuiVisibilityPreferences | None,
884
+ ) -> None:
885
+ if preferences is None:
886
+ return
887
+
888
+ splash_changed = (
889
+ self.visibility_preferences.splash_enabled != preferences.splash_enabled
890
+ )
891
+ had_build_result = self._build_result is not None
892
+ self.visibility_preferences = preferences
893
+ self.show_secondary_columns = preferences.secondary_columns
894
+ self._build_show_details = preferences.build_details
895
+ self._save_visibility_preferences()
896
+ self.session.dispatch(
897
+ command=ChangeSplashPreference(enabled=preferences.splash_enabled),
898
+ )
899
+ if had_build_result and splash_changed:
900
+ self._request_build_view(success_message=None)
901
+ elif self._build_result is not None:
902
+ self._refresh_build_text_from_result()
903
+ self._render_all()
904
+
905
+ def _save_visibility_preferences(self) -> None:
906
+ self._preferences_save_warning = save_tui_preferences(
907
+ preferences=self.visibility_preferences,
908
+ app_dir=self._preferences_app_dir,
909
+ )
910
+
911
+ def action_toggle_mana_icons(self) -> None:
912
+ """Toggle opt-in Mana font icon rendering.
913
+ Terminals need Andrew Gioia's Mana font installed before enabling it.
914
+ """
915
+
916
+ self.mana_icons_enabled = not self.mana_icons_enabled
917
+ if self._view_mode == "build":
918
+ self._refresh_build_text_from_result()
919
+
920
+ self._render_all()
921
+
922
+ def action_download_ratings(self) -> None:
923
+ """Offer or retry the ratings download for the active draft set.
924
+ Existing ready data is left untouched.
925
+ """
926
+
927
+ set_code = self._set_code
928
+ if set_code is None:
929
+ self._last_error = "No active draft set is available for a data download."
930
+ self._render_all()
931
+ return
932
+
933
+ ratings = self.session.snapshot.ratings
934
+ if ratings.phase == DataLoadPhase.LOADING:
935
+ return
936
+
937
+ if ratings.phase == DataLoadPhase.READY:
938
+ self._rating_notices_by_set[set_code] = (
939
+ f"17Lands data is already ready for {set_code}."
940
+ )
941
+ self._render_all()
942
+ return
943
+
944
+ self._show_missing_ratings_prompt(set_code=set_code, force=True)
945
+
946
+ def action_cycle_sort(self) -> None:
947
+ """Cycle pack ranking or build spell grouping.
948
+ Backtest reports are rebuilt with the selected recommendation ranking.
949
+ """
950
+
951
+ if self._view_mode == "build":
952
+ index = BUILD_SPELL_SORT_MODES.index(self._build_spell_sort_mode)
953
+ self._build_spell_sort_mode = BUILD_SPELL_SORT_MODES[
954
+ (index + 1) % len(BUILD_SPELL_SORT_MODES)
955
+ ]
956
+ self._refresh_build_text_from_result()
957
+ else:
958
+ index = SORT_MODES.index(self.sort_mode)
959
+ self.sort_mode = SORT_MODES[(index + 1) % len(SORT_MODES)]
960
+ self.session.dispatch(
961
+ command=ChangeRanking(ranking_mode=self.sort_mode),
962
+ )
963
+ if self._view_mode == "backtest":
964
+ self._request_backtest_view(
965
+ success_message=(
966
+ f"rebuilt {ranking_label(ranking_mode=self.sort_mode)} "
967
+ "recommendation comparison"
968
+ ),
969
+ open_when_ready=False,
970
+ )
971
+
972
+ self._render_all()
973
+
974
+ def action_open_build_view(self) -> None:
975
+ """Request a build for the current pool and show the shared result."""
976
+
977
+ current_build = self.session.snapshot.build
978
+ if (
979
+ self._view_mode == "build"
980
+ and self._build_error is None
981
+ and current_build is not None
982
+ and current_build.pair_override == self._forced_pair
983
+ ):
984
+ self.query_one("#build-scroll", VerticalScroll).scroll_home(animate=False)
985
+ self._last_error = None
986
+ self._build_action_status = "no build needed — current pool already shown"
987
+ self._render_all()
988
+ return
989
+
990
+ self._request_build_view(success_message="rebuilt current pool")
991
+ self._render_all()
992
+
993
+ def action_open_backtest_report(self) -> None:
994
+ """Request the shared persisted-pick recommendation comparison."""
995
+
996
+ self._build_action_status = None
997
+ self._request_backtest_view(
998
+ success_message=(
999
+ f"rebuilt {ranking_label(ranking_mode=self.sort_mode)} "
1000
+ "recommendation comparison"
1001
+ ),
1002
+ open_when_ready=True,
1003
+ )
1004
+ self._render_all()
1005
+
1006
+ def action_navigate_previous_card(self) -> None:
1007
+ """Move to the previous card in the active card list.
1008
+ Left, Up, and k share this action for predictable keyboard browsing.
1009
+ """
1010
+
1011
+ if self._view_mode == "build":
1012
+ self._move_build_card_cursor(delta=-1)
1013
+ return
1014
+
1015
+ if self._view_mode == "backtest":
1016
+ self.query_one("#build-scroll", VerticalScroll).scroll_page_up(animate=False)
1017
+ return
1018
+
1019
+ self._move_pack_cursor(delta=-1)
1020
+
1021
+ def action_navigate_next_card(self) -> None:
1022
+ """Move to the next card in the active card list.
1023
+ Right, Down, and j share this action for predictable keyboard browsing.
1024
+ """
1025
+
1026
+ if self._view_mode == "build":
1027
+ self._move_build_card_cursor(delta=1)
1028
+ return
1029
+
1030
+ if self._view_mode == "backtest":
1031
+ self.query_one("#build-scroll", VerticalScroll).scroll_page_down(animate=False)
1032
+ return
1033
+
1034
+ self._move_pack_cursor(delta=1)
1035
+
1036
+ def action_navigate_page_up(self) -> None:
1037
+ """Page up in the current keyboard-navigation context.
1038
+ Pack view moves the card cursor; build view scrolls the deck sheet.
1039
+ """
1040
+
1041
+ if self._view_mode in {"build", "backtest"}:
1042
+ self.query_one("#build-scroll", VerticalScroll).scroll_page_up(animate=False)
1043
+ return
1044
+
1045
+ self._move_pack_cursor(delta=-self._pack_cursor_page_size())
1046
+
1047
+ def action_navigate_page_down(self) -> None:
1048
+ """Page down in the current keyboard-navigation context.
1049
+ Pack view moves the card cursor; build view scrolls the deck sheet.
1050
+ """
1051
+
1052
+ if self._view_mode in {"build", "backtest"}:
1053
+ self.query_one("#build-scroll", VerticalScroll).scroll_page_down(animate=False)
1054
+ return
1055
+
1056
+ self._move_pack_cursor(delta=self._pack_cursor_page_size())
1057
+
1058
+ def action_navigate_home(self) -> None:
1059
+ """Jump to the first pack card or the top of the build view.
1060
+ This keeps Home useful in both major TUI modes.
1061
+ """
1062
+
1063
+ if self._view_mode in {"build", "backtest"}:
1064
+ self.query_one("#build-scroll", VerticalScroll).scroll_home(animate=False)
1065
+ return
1066
+
1067
+ self._move_pack_cursor_to(row=0)
1068
+
1069
+ def action_navigate_end(self) -> None:
1070
+ """Jump to the last pack card or the bottom of the build view.
1071
+ This keeps End useful in both major TUI modes.
1072
+ """
1073
+
1074
+ if self._view_mode in {"build", "backtest"}:
1075
+ self.query_one("#build-scroll", VerticalScroll).scroll_end(animate=False)
1076
+ return
1077
+
1078
+ table = self.query_one("#pack-table", DataTable)
1079
+ self._move_pack_cursor_to(row=table.row_count - 1)
1080
+
1081
+ def action_cycle_account(self) -> None:
1082
+ """Cycle known accounts and use the latest recovered draft when available.
1083
+ Multiple saved drafts for one account must not create duplicate stops.
1084
+ """
1085
+
1086
+ accounts = self.session.known_accounts()
1087
+ if not accounts:
1088
+ self._record_error("no known accounts to switch to")
1089
+ return
1090
+
1091
+ ordered_account_ids = tuple(account.account_id for account in accounts)
1092
+ if self._active_account_id in ordered_account_ids:
1093
+ index = (ordered_account_ids.index(self._active_account_id) + 1) % len(
1094
+ ordered_account_ids
1095
+ )
1096
+ else:
1097
+ index = 0
1098
+
1099
+ account_id = ordered_account_ids[index]
1100
+ command = ChooseAccount(account_id=account_id)
1101
+ if self._ratings_operations_may_block:
1102
+ self._dispatch_session_command_worker(command)
1103
+ else:
1104
+ self.session.dispatch(command=command)
1105
+
1106
+ def action_rebuild_with_pair_override(self) -> None:
1107
+ """Request a shared build after cycling the TUI pair override."""
1108
+
1109
+ self._forced_pair = self._next_forced_pair()
1110
+ self._request_build_view(
1111
+ success_message=f"rebuilt with forced pair {self._forced_pair}",
1112
+ )
1113
+ self._render_all()
1114
+
1115
+ def process_lines(
1116
+ self,
1117
+ *,
1118
+ lines: Iterable[str],
1119
+ include_pre_draft_detection: bool = True,
1120
+ ) -> None:
1121
+ """Process complete Player.log lines and refresh the TUI.
1122
+ Tests call this directly to simulate a live fixture stream.
1123
+ """
1124
+
1125
+ if self.card_database_loading or self._card_database_error is not None:
1126
+ return
1127
+
1128
+ line_batch = tuple(lines)
1129
+ if (
1130
+ self.is_running
1131
+ and line_batch
1132
+ and self._ratings_operations_may_block
1133
+ ):
1134
+ self._process_lines_worker(
1135
+ lines=line_batch,
1136
+ include_pre_draft_detection=include_pre_draft_detection,
1137
+ )
1138
+ return
1139
+
1140
+ try:
1141
+ with self._ingestion_lock:
1142
+ self.session.process_lines(
1143
+ lines=line_batch,
1144
+ include_pre_draft_detection=include_pre_draft_detection,
1145
+ )
1146
+ except Exception as error: # pragma: no cover - defensive UI boundary.
1147
+ self._record_error(str(error))
1148
+ return
1149
+
1150
+ self._render_all()
1151
+
1152
+ @work(thread=True, exclusive=True, group="session-ingestion")
1153
+ def _process_lines_worker(
1154
+ self,
1155
+ *,
1156
+ lines: tuple[str, ...],
1157
+ include_pre_draft_detection: bool,
1158
+ ) -> None:
1159
+ """Run shared ingestion and ratings work away from Textual's thread.
1160
+ Session publications marshal immutable state back to the adapter.
1161
+ """
1162
+
1163
+ worker = get_current_worker()
1164
+ try:
1165
+ with self._ingestion_lock:
1166
+ if worker.is_cancelled:
1167
+ return
1168
+ self.session.process_lines(
1169
+ lines=lines,
1170
+ include_pre_draft_detection=include_pre_draft_detection,
1171
+ )
1172
+ except Exception as error: # pragma: no cover - defensive UI boundary.
1173
+ self.call_from_thread(self._record_error, str(error))
1174
+
1175
+ @work(thread=True, exclusive=True, group="card-database")
1176
+ def _load_card_database_worker(self) -> None:
1177
+ """Ask the shared session to load metadata outside the Textual thread.
1178
+ Published readiness and errors are marshalled back as immutable state.
1179
+ """
1180
+
1181
+ worker = get_current_worker()
1182
+ if worker.is_cancelled:
1183
+ return
1184
+
1185
+ try:
1186
+ self.session.load_card_data()
1187
+ except Exception as error: # pragma: no cover - defensive UI boundary.
1188
+ self.call_from_thread(self._record_error, str(error))
1189
+
1190
+ @work(thread=True, exclusive=True, group="session-ingestion")
1191
+ def _poll_log_worker(self, *, exit_after: bool = False) -> None:
1192
+ """Schedule one shared-session polling cycle in a Textual worker.
1193
+ Session publications marshal state changes onto the Textual thread.
1194
+ """
1195
+
1196
+ worker = get_current_worker()
1197
+ try:
1198
+ with self._ingestion_lock:
1199
+ if worker.is_cancelled:
1200
+ return
1201
+ self.session.poll_once()
1202
+ except Exception as error: # pragma: no cover - defensive UI boundary.
1203
+ self.call_from_thread(self._record_error, str(error))
1204
+ if exit_after:
1205
+ self.call_from_thread(self.exit)
1206
+ return
1207
+
1208
+ if exit_after:
1209
+ self.call_from_thread(self.exit)
1210
+
1211
+ @work(thread=True, exclusive=True, group="session-ingestion")
1212
+ def _scan_startup_files_worker(self, *, exit_after: bool = False) -> None:
1213
+ """Schedule shared startup recovery in a Textual worker.
1214
+ Historical pre-draft detection remains excluded from the live surface.
1215
+ """
1216
+
1217
+ worker = get_current_worker()
1218
+ try:
1219
+ with self._ingestion_lock:
1220
+ if worker.is_cancelled:
1221
+ return
1222
+ self.session.scan_startup_files(
1223
+ include_previous=True,
1224
+ include_pre_draft_detection=False,
1225
+ )
1226
+ except Exception as error: # pragma: no cover - defensive UI boundary.
1227
+ self.call_from_thread(self._record_error, str(error))
1228
+ if exit_after:
1229
+ self.call_from_thread(self.exit)
1230
+ return
1231
+
1232
+ if exit_after:
1233
+ self.call_from_thread(self.exit)
1234
+ return
1235
+
1236
+ self.call_from_thread(self._start_polling)
1237
+
1238
+ @work(thread=True, group="session-commands")
1239
+ def _dispatch_session_command_worker(
1240
+ self,
1241
+ command: LiveSessionCommand,
1242
+ ) -> None:
1243
+ """Dispatch a potentially blocking shared command in a worker.
1244
+ Session progress and results return through immutable publications.
1245
+ """
1246
+
1247
+ worker = get_current_worker()
1248
+ if worker.is_cancelled:
1249
+ return
1250
+
1251
+ try:
1252
+ self.session.dispatch(command=command)
1253
+ except Exception as error: # pragma: no cover - defensive UI boundary.
1254
+ self.call_from_thread(self._record_error, str(error))
1255
+
1256
+ def _session_publication_is_allowed(self) -> bool:
1257
+ """Allow presentation updates only while Textual is running."""
1258
+ return self.is_running
1259
+
1260
+ def _publish_session_snapshot(self, snapshot: LiveSessionSnapshot) -> None:
1261
+ """Marshal one immutable shared snapshot onto Textual's thread.
1262
+ Pack projections travel with the snapshot so account state stays coherent.
1263
+ """
1264
+
1265
+ if not self._session_publication_is_allowed():
1266
+ return
1267
+ if (
1268
+ self.is_running
1269
+ and self._textual_thread_id is not None
1270
+ and get_ident() != self._textual_thread_id
1271
+ ):
1272
+ self.call_from_thread(
1273
+ self._apply_session_snapshot,
1274
+ snapshot,
1275
+ )
1276
+ return
1277
+
1278
+ self._apply_session_snapshot(snapshot)
1279
+
1280
+ def _publish_session_event(self, published: LiveSessionEvent) -> None:
1281
+ """Marshal an ordered domain event onto Textual's presentation thread.
1282
+ Events drive view transitions without re-owning application state.
1283
+ """
1284
+
1285
+ if not self._session_publication_is_allowed():
1286
+ return
1287
+ if (
1288
+ self.is_running
1289
+ and self._textual_thread_id is not None
1290
+ and get_ident() != self._textual_thread_id
1291
+ ):
1292
+ self.call_from_thread(self._apply_session_event, published)
1293
+ return
1294
+
1295
+ self._apply_session_event(published)
1296
+
1297
+ def _apply_session_snapshot(
1298
+ self,
1299
+ snapshot: LiveSessionSnapshot,
1300
+ ) -> None:
1301
+ if not self._session_publication_is_allowed():
1302
+ return
1303
+ previous_identity = (self._active_account_id, self._draft_id)
1304
+ account = snapshot.active_account
1305
+ draft = snapshot.draft
1306
+ self._active_account_id = None if account is None else account.account_id
1307
+ self._active_account_label = self._account_label(
1308
+ account_id=self._active_account_id,
1309
+ snapshot=snapshot,
1310
+ )
1311
+ self._event_name = None if draft is None else draft.event_name
1312
+ self._set_code = snapshot.ratings.set_code
1313
+ if self._set_code is None and draft is not None:
1314
+ self._set_code = draft.set_code
1315
+ self._draft_id = None if draft is None else draft.draft_id
1316
+ self._current_pack_event = snapshot.current_pack_event
1317
+ self._current_pack = snapshot.current_scored_pack
1318
+ if snapshot.status.phase == ApplicationPhase.DRAFT_COMPLETE:
1319
+ self._pick_label = "complete"
1320
+ elif snapshot.current_pack_event is not None:
1321
+ self._pick_label = (
1322
+ f"P{snapshot.current_pack_event.pack_number + 1}"
1323
+ f"P{snapshot.current_pack_event.pick_number + 1}"
1324
+ )
1325
+ elif draft is not None:
1326
+ self._pick_label = "recovered"
1327
+ elif snapshot.ratings.set_code is not None:
1328
+ self._pick_label = "preparing"
1329
+ else:
1330
+ self._pick_label = "—"
1331
+ self._pool_grp_ids = self._snapshot_pool_grp_ids(snapshot=snapshot)
1332
+ self._pool_size = snapshot.pool.total_cards
1333
+ self._pair_label = snapshot.pool.inferred_pair or "open"
1334
+ self._commitment_label = _commitment_label(
1335
+ commitment=snapshot.pool.commitment,
1336
+ )
1337
+ self.sort_mode = snapshot.recommendations.ranking_mode
1338
+ self._data_source = self._session_data_source(snapshot=snapshot)
1339
+ self._apply_card_data_state(snapshot=snapshot)
1340
+ self._apply_ratings_state(snapshot=snapshot)
1341
+ general_errors = tuple(
1342
+ error
1343
+ for error in snapshot.errors
1344
+ if error.operation not in {OperationKind.BUILD, OperationKind.BACKTEST}
1345
+ )
1346
+ self._session_error = (
1347
+ None if not general_errors else general_errors[-1].message
1348
+ )
1349
+
1350
+ identity = (self._active_account_id, self._draft_id)
1351
+ if identity != previous_identity:
1352
+ self._adopt_changed_session_identity(snapshot=snapshot)
1353
+ self._apply_secondary_results(snapshot=snapshot)
1354
+ if snapshot.card_data.phase == DataLoadPhase.READY:
1355
+ self._start_log_processing()
1356
+
1357
+ self._render_all()
1358
+
1359
+ def _apply_session_event(self, published: LiveSessionEvent) -> None:
1360
+ if not self._session_publication_is_allowed():
1361
+ return
1362
+ event = published.event
1363
+ if isinstance(
1364
+ event,
1365
+ (
1366
+ QuickDraftDetectedEvent,
1367
+ DraftStartedEvent,
1368
+ PackOfferedEvent,
1369
+ PickMadeEvent,
1370
+ DraftCompletedEvent,
1371
+ ),
1372
+ ):
1373
+ self._event_name = event.event_name
1374
+ self._set_code = event.set_code
1375
+
1376
+ if isinstance(event, (QuickDraftDetectedEvent, DraftStartedEvent)):
1377
+ self._reset_secondary_view_state()
1378
+ self._view_mode = "pack"
1379
+ self._pick_label = (
1380
+ "preparing"
1381
+ if isinstance(event, QuickDraftDetectedEvent)
1382
+ else "waiting"
1383
+ )
1384
+ elif isinstance(event, PackOfferedEvent):
1385
+ self._view_mode = "pack"
1386
+ self._pick_label = f"P{event.pack_number + 1}P{event.pick_number + 1}"
1387
+ self._build_action_status = None
1388
+ self._backtest_action_status = None
1389
+ self._build_result = None
1390
+ self._backtest_error = None
1391
+ self._clear_build_render_state()
1392
+ elif isinstance(event, PickMadeEvent):
1393
+ self._pick_label = (
1394
+ f"P{event.pack_number + 1}P{event.pick_number + 1} picked"
1395
+ )
1396
+ self._build_action_status = None
1397
+ self._backtest_action_status = None
1398
+ self._build_result = None
1399
+ self._backtest_error = None
1400
+ self._clear_build_render_state()
1401
+ elif isinstance(event, DraftCompletedEvent):
1402
+ self._pick_label = "complete"
1403
+ self._build_action_status = None
1404
+ self._backtest_action_status = None
1405
+ self._request_build_view(success_message=None)
1406
+
1407
+ self._render_all()
1408
+
1409
+ def _apply_secondary_results(self, *, snapshot: LiveSessionSnapshot) -> None:
1410
+ progress = snapshot.progress
1411
+ build_in_progress = (
1412
+ progress is not None and progress.operation == OperationKind.BUILD
1413
+ )
1414
+ backtest_in_progress = (
1415
+ progress is not None and progress.operation == OperationKind.BACKTEST
1416
+ )
1417
+
1418
+ if not build_in_progress:
1419
+ build_error = _operation_error(
1420
+ snapshot=snapshot,
1421
+ operation=OperationKind.BUILD,
1422
+ )
1423
+ if build_error is not None:
1424
+ message = build_error.message.removeprefix("Deck build failed: ")
1425
+ self._adopt_build_error(snapshot=snapshot, message=message)
1426
+ elif snapshot.build is not None:
1427
+ self._adopt_build_result(result=snapshot.build)
1428
+
1429
+ if not backtest_in_progress:
1430
+ backtest_error = _operation_error(
1431
+ snapshot=snapshot,
1432
+ operation=OperationKind.BACKTEST,
1433
+ )
1434
+ if backtest_error is not None:
1435
+ message = backtest_error.message.removeprefix("Backtest failed: ")
1436
+ self._backtest_error = message
1437
+ self._backtest_text = f"Backtest view unavailable: {message}"
1438
+ self._backtest_action_status = f"cannot backtest — {message}"
1439
+ self._pending_backtest_success_message = None
1440
+ self._open_backtest_when_ready = False
1441
+ elif snapshot.backtest is not None:
1442
+ self._adopt_backtest_result(result=snapshot.backtest)
1443
+
1444
+ def _apply_card_data_state(self, *, snapshot: LiveSessionSnapshot) -> None:
1445
+ if snapshot.card_data.phase != DataLoadPhase.FAILED:
1446
+ self._card_database_error = None
1447
+ return
1448
+
1449
+ error = next(
1450
+ (
1451
+ candidate
1452
+ for candidate in snapshot.errors
1453
+ if candidate.operation == OperationKind.CARD_DATA
1454
+ ),
1455
+ None,
1456
+ )
1457
+ detail = snapshot.card_data.message if error is None else error.message
1458
+ detail = detail.removeprefix("Card metadata failed to load: ").removesuffix(
1459
+ "."
1460
+ )
1461
+ self._card_database_error = (
1462
+ f"{detail}. Run `draftomen-tui refresh-data` after checking your "
1463
+ "network connection and local card-data cache."
1464
+ )
1465
+
1466
+ def _apply_ratings_state(self, *, snapshot: LiveSessionSnapshot) -> None:
1467
+ ratings = snapshot.ratings
1468
+ set_code = ratings.set_code
1469
+ if set_code is None:
1470
+ return
1471
+
1472
+ progress = snapshot.progress
1473
+ if (
1474
+ ratings.phase == DataLoadPhase.LOADING
1475
+ and progress is not None
1476
+ and progress.operation == OperationKind.RATINGS
1477
+ ):
1478
+ completed = 0 if progress.completed is None else progress.completed
1479
+ total = "?" if progress.total is None else str(progress.total)
1480
+ self._rating_notices_by_set[set_code] = (
1481
+ f"{progress.message} for {set_code} ({completed}/{total})"
1482
+ )
1483
+ return
1484
+
1485
+ if ratings.phase == DataLoadPhase.MISSING:
1486
+ self._rating_notices_by_set[set_code] = (
1487
+ f"No local 17Lands data for {set_code}; neutral-prior scores are "
1488
+ "active. Choose Download data, or press d later."
1489
+ )
1490
+ self._show_missing_ratings_prompt(set_code=set_code)
1491
+ return
1492
+
1493
+ if ratings.phase == DataLoadPhase.FAILED:
1494
+ self._rating_notices_by_set[set_code] = (
1495
+ f"{ratings.message} Press d to retry."
1496
+ )
1497
+ return
1498
+
1499
+ if ratings.phase != DataLoadPhase.READY:
1500
+ return
1501
+
1502
+ if set_code in self._rating_download_requested_sets:
1503
+ self._rating_notices_by_set[set_code] = self._ratings_ready_notice(
1504
+ snapshot=snapshot,
1505
+ )
1506
+ return
1507
+
1508
+ self._rating_notices_by_set.pop(set_code, None)
1509
+
1510
+ def _ratings_ready_notice(self, *, snapshot: LiveSessionSnapshot) -> str:
1511
+ ratings = snapshot.ratings
1512
+ set_code = ratings.set_code or "unknown set"
1513
+ total_cards = ratings.total_cards
1514
+ rated_cards = ratings.rated_cards
1515
+ if total_cards is None or rated_cards is None:
1516
+ return f"17Lands data ready for {set_code}; future scores will use it."
1517
+ if rated_cards == total_cards:
1518
+ return (
1519
+ f"17Lands data ready for {set_code}; scores recalculated. "
1520
+ f"All {total_cards} offered cards have usable ratings."
1521
+ )
1522
+
1523
+ return (
1524
+ f"17Lands data ready for {set_code}; scores recalculated. "
1525
+ f"{rated_cards}/{total_cards} offered cards have usable ratings; "
1526
+ "neutral priors remain where 17Lands samples are unavailable or thin."
1527
+ )
1528
+
1529
+ def _adopt_changed_session_identity(
1530
+ self,
1531
+ *,
1532
+ snapshot: LiveSessionSnapshot,
1533
+ ) -> None:
1534
+ self._reset_secondary_view_state()
1535
+ if self._current_pack_event is not None:
1536
+ self._view_mode = "pack"
1537
+ return
1538
+ if snapshot.draft is None or snapshot.pool.total_cards == 0:
1539
+ self._view_mode = "pack"
1540
+ return
1541
+
1542
+ self._request_build_view(success_message=None)
1543
+
1544
+ def _reset_secondary_view_state(self) -> None:
1545
+ self._forced_pair = None
1546
+ self._build_pair_label = "—"
1547
+ self._build_text = "Build view: no picked cards yet."
1548
+ self._build_error = None
1549
+ self._build_action_status = None
1550
+ self._pending_build_success_message = None
1551
+ self._build_spell_sort_mode = "curve"
1552
+ self._build_show_details = self.visibility_preferences.build_details
1553
+ self._build_result = None
1554
+ self._backtest_text = "Backtest view: complete a draft, then press t."
1555
+ self._backtest_error = None
1556
+ self._backtest_action_status = None
1557
+ self._pending_backtest_success_message = None
1558
+ self._open_backtest_when_ready = False
1559
+ self._clear_build_render_state()
1560
+
1561
+ def _snapshot_pool_grp_ids(
1562
+ self,
1563
+ *,
1564
+ snapshot: LiveSessionSnapshot,
1565
+ ) -> tuple[int, ...]:
1566
+ return tuple(
1567
+ pool_card.card.grp_id
1568
+ for pool_card in snapshot.pool.cards
1569
+ for _ in range(pool_card.quantity)
1570
+ )
1571
+
1572
+ def _session_data_source(self, *, snapshot: LiveSessionSnapshot) -> str:
1573
+ source = snapshot.recommendations.source_summary
1574
+ if source is None:
1575
+ return "unknown"
1576
+ if snapshot.ratings.phase == DataLoadPhase.LOADING:
1577
+ return f"{source} (downloading ratings)"
1578
+ if snapshot.ratings.phase == DataLoadPhase.FAILED:
1579
+ return f"{source} (ratings unavailable)"
1580
+ if snapshot.ratings.phase == DataLoadPhase.MISSING:
1581
+ return f"{source} (no local 17Lands data)"
1582
+ if (
1583
+ snapshot.ratings.phase == DataLoadPhase.READY
1584
+ and snapshot.ratings.total_cards
1585
+ and snapshot.ratings.rated_cards == 0
1586
+ ):
1587
+ return f"{source} (17Lands cached; samples unavailable or thin)"
1588
+
1589
+ return source
1590
+
1591
+ def _show_missing_ratings_prompt(
1592
+ self,
1593
+ *,
1594
+ set_code: str,
1595
+ force: bool = False,
1596
+ ) -> None:
1597
+ if not self.is_running or set_code in self._rating_prompt_open_sets:
1598
+ return
1599
+
1600
+ if not force and set_code in self._rating_prompted_sets:
1601
+ return
1602
+
1603
+ self._rating_prompted_sets.add(set_code)
1604
+ self._rating_prompt_open_sets.add(set_code)
1605
+ self._render_all()
1606
+ self.push_screen(
1607
+ MissingRatingsScreen(set_code=set_code),
1608
+ lambda approved: self._handle_ratings_download_choice(
1609
+ set_code=set_code,
1610
+ approved=approved,
1611
+ ),
1612
+ )
1613
+
1614
+ def _handle_ratings_download_choice(
1615
+ self,
1616
+ *,
1617
+ set_code: str,
1618
+ approved: bool,
1619
+ ) -> None:
1620
+ self._rating_prompt_open_sets.discard(set_code)
1621
+ if approved:
1622
+ self._start_ratings_load(set_code=set_code)
1623
+ return
1624
+
1625
+ self._rating_notices_by_set[set_code] = (
1626
+ f"No local 17Lands data for {set_code}; neutral-prior scores remain. "
1627
+ "Press d to download."
1628
+ )
1629
+ self._render_all()
1630
+
1631
+ def _start_ratings_load(self, *, set_code: str) -> None:
1632
+ if self.session.snapshot.ratings.phase == DataLoadPhase.LOADING:
1633
+ return
1634
+
1635
+ self._rating_download_requested_sets.add(set_code)
1636
+ self._rating_notices_by_set[set_code] = (
1637
+ f"Checking 17Lands data for {set_code}…"
1638
+ )
1639
+ self._render_all()
1640
+ self._dispatch_session_command_worker(
1641
+ RequestRatingsDownload(set_code=set_code),
1642
+ )
1643
+
1644
+
1645
+ def _render_all(self) -> None:
1646
+ if not self.is_mounted:
1647
+ return
1648
+ try:
1649
+ self.query_one("#sidebar", Vertical)
1650
+ except NoMatches:
1651
+ return
1652
+
1653
+ self._update_responsive_visibility()
1654
+ self._render_pack_title()
1655
+ self._render_pre_draft_readiness()
1656
+ self._render_pack_table()
1657
+ self._render_sidebar()
1658
+ self._ensure_visible_focus()
1659
+ self._render_ratings_download_status()
1660
+ self._render_status_bar()
1661
+
1662
+ def _render_ratings_download_status(self) -> None:
1663
+ panel = self.query_one("#ratings-download-panel", Vertical)
1664
+ label = self.query_one("#ratings-download-label", Static)
1665
+ progress_bar = self.query_one("#ratings-download-progress", ProgressBar)
1666
+ set_code = self._set_code
1667
+ notice = (
1668
+ None
1669
+ if set_code is None
1670
+ else self._rating_notices_by_set.get(set_code)
1671
+ )
1672
+ panel.display = notice is not None
1673
+ if notice is None:
1674
+ return
1675
+
1676
+ label.update(notice)
1677
+ snapshot = self.session.snapshot
1678
+ downloading = (
1679
+ set_code is not None
1680
+ and snapshot.ratings.set_code == set_code
1681
+ and snapshot.ratings.phase == DataLoadPhase.LOADING
1682
+ )
1683
+ progress_bar.display = downloading
1684
+ if not downloading or set_code is None:
1685
+ return
1686
+
1687
+ progress = snapshot.progress
1688
+ if progress is None or progress.operation != OperationKind.RATINGS:
1689
+ return
1690
+ progress_bar.update(
1691
+ total=1 if progress.total is None else progress.total,
1692
+ progress=0 if progress.completed is None else progress.completed,
1693
+ )
1694
+
1695
+ def _update_responsive_visibility(self) -> None:
1696
+ sidebar = self.query_one("#sidebar", Vertical)
1697
+ sidebar.display = (
1698
+ self.size.width >= SIDEBAR_MIN_WIDTH
1699
+ and self._sidebar_has_enabled_content()
1700
+ )
1701
+
1702
+ def _sidebar_has_enabled_content(self) -> bool:
1703
+ if self._view_mode not in {"build", "backtest"} and any(
1704
+ (
1705
+ self.visibility_preferences.pool_metadata,
1706
+ self.visibility_preferences.pool_color_distribution,
1707
+ self.visibility_preferences.pool_mana_curve,
1708
+ )
1709
+ ):
1710
+ return True
1711
+
1712
+ return (
1713
+ self.visibility_preferences.focused_card_details
1714
+ or self._card_image_preview_is_enabled()
1715
+ )
1716
+
1717
+ def _card_image_preview_is_enabled(self) -> bool:
1718
+ mode = self.visibility_preferences.card_image_preview
1719
+ if mode == "hide":
1720
+ return False
1721
+ if mode == "show":
1722
+ return TgpImage is not None
1723
+
1724
+ return self._card_image_preview_enabled
1725
+
1726
+ def _render_pack_title(self) -> None:
1727
+ title = self.query_one("#pack-title", Static)
1728
+ if self.card_database_loading:
1729
+ title.update("Loading card metadata…")
1730
+ return
1731
+
1732
+ if self._card_database_error is not None:
1733
+ title.update("Card metadata unavailable — check the status bar")
1734
+ return
1735
+
1736
+ if self._view_mode == "build":
1737
+ if self._build_error is not None and self._build_pair_label == "—":
1738
+ title.update("Build view unavailable — metadata or playable count issue")
1739
+ return
1740
+
1741
+ build_pair_label = _format_pair_label(
1742
+ pair=self._build_pair_label,
1743
+ mana_icons_enabled=self.mana_icons_enabled,
1744
+ )
1745
+ override = "automatic"
1746
+ if self._forced_pair is not None:
1747
+ forced_pair = _format_pair_label(
1748
+ pair=self._forced_pair,
1749
+ mana_icons_enabled=self.mana_icons_enabled,
1750
+ )
1751
+ override = f"forced {forced_pair}"
1752
+
1753
+ detail = "details" if self._build_show_details else "compact"
1754
+ title.update(
1755
+ f"Build view — pair {build_pair_label} ({override}); "
1756
+ f"spells {self._build_spell_sort_mode}; {detail}; "
1757
+ "scroll ↑/↓ PgUp/PgDn"
1758
+ )
1759
+ return
1760
+
1761
+ if self._view_mode == "backtest":
1762
+ if self._backtest_error is not None:
1763
+ title.update("Backtest view unavailable — saved draft state issue")
1764
+ else:
1765
+ title.update(
1766
+ "Backtest view — "
1767
+ f"{ranking_label(ranking_mode=self.sort_mode)} "
1768
+ "recommendations vs actual picks; scroll ↑/↓ PgUp/PgDn"
1769
+ )
1770
+ return
1771
+
1772
+ if self._current_pack_event is None:
1773
+ if self._pick_label == "complete":
1774
+ title.update("Draft complete")
1775
+ elif self._set_code is not None:
1776
+ title.update(
1777
+ "Quick Draft detected — "
1778
+ f"{format_set_label(set_code=self._set_code)}; "
1779
+ "waiting for the first pack…"
1780
+ )
1781
+ else:
1782
+ title.update("Waiting for a Quick Draft pack…")
1783
+ return
1784
+
1785
+ event = self._current_pack_event
1786
+ title.update(
1787
+ "Available cards — Pack "
1788
+ f"{event.pack_number + 1} "
1789
+ "Pick "
1790
+ f"{event.pick_number + 1} "
1791
+ f"— ranked by {SORT_LABELS[self.sort_mode]}"
1792
+ )
1793
+
1794
+ def _render_pre_draft_readiness(self) -> None:
1795
+ readiness = self.query_one("#pre-draft-readiness", Static)
1796
+ readiness.display = self._show_pre_draft_readiness()
1797
+ if not readiness.display:
1798
+ return
1799
+ status = self.session.snapshot.status
1800
+ if status.setup_guidance:
1801
+ readiness.update(status.message)
1802
+ return
1803
+
1804
+ set_code = self._set_code
1805
+ if set_code is None:
1806
+ readiness.update(
1807
+ "Quick Draft set not detected yet.\n"
1808
+ "Waiting for Arena to report a Quick Draft entry."
1809
+ )
1810
+ return
1811
+
1812
+ set_label = format_set_label(set_code=set_code)
1813
+ prefix = f"17Lands reliability for {set_label} Quick Draft:"
1814
+ ratings = self.session.snapshot.ratings
1815
+ if ratings.phase == DataLoadPhase.LOADING:
1816
+ readiness.update(f"{prefix} Checking…")
1817
+ return
1818
+
1819
+ if ratings.phase == DataLoadPhase.FAILED:
1820
+ readiness.update(f"{prefix} Unavailable")
1821
+ return
1822
+
1823
+ ratings_data = self.session.ratings_data(set_code=set_code)
1824
+ if ratings_data is not None:
1825
+ reliability = ratings_data.set_reliability
1826
+ readiness.update(
1827
+ f"{prefix} {reliability.tier} — {reliability.score}/100"
1828
+ )
1829
+ return
1830
+
1831
+ if ratings.phase == DataLoadPhase.MISSING:
1832
+ readiness.update(f"{prefix} Not checked — press d to download data")
1833
+ return
1834
+
1835
+ readiness.update(f"{prefix} Not checked")
1836
+
1837
+ def _show_pre_draft_readiness(self) -> bool:
1838
+ return (
1839
+ self._view_mode == "pack"
1840
+ and self._current_pack_event is None
1841
+ and self._pick_label != "complete"
1842
+ )
1843
+
1844
+ def _render_pack_table(self) -> None:
1845
+ table = self.query_one("#pack-table", DataTable)
1846
+ build_scroll = self.query_one("#build-scroll", VerticalScroll)
1847
+ build_view = self.query_one("#build-view", Static)
1848
+ table.loading = self.card_database_loading
1849
+ table.display = (
1850
+ self._view_mode == "pack" and not self._show_pre_draft_readiness()
1851
+ )
1852
+ build_scroll.display = self._view_mode in {"build", "backtest"}
1853
+ build_view.update(self._active_text_view())
1854
+ if self._view_mode in {"build", "backtest"}:
1855
+ self._visible_column_keys = ()
1856
+ return
1857
+
1858
+ previous_row_key, previous_row_index = self._capture_table_cursor(table=table)
1859
+ table.clear(columns=True)
1860
+ column_keys = self._column_keys_for_width()
1861
+ self._visible_column_keys = column_keys
1862
+ for column_key in column_keys:
1863
+ table.add_column(
1864
+ COLUMN_LABELS[column_key],
1865
+ width=COLUMN_WIDTHS[column_key],
1866
+ key=column_key,
1867
+ )
1868
+
1869
+ if self._current_pack is None:
1870
+ return
1871
+
1872
+ for rank, scored_card in enumerate(self._sorted_cards(), start=1):
1873
+ row = _row_cells(
1874
+ rank=rank,
1875
+ scored_card=scored_card,
1876
+ column_keys=column_keys,
1877
+ mana_icons_enabled=self.mana_icons_enabled,
1878
+ )
1879
+ table.add_row(
1880
+ *row,
1881
+ key=f"{rank}-{scored_card.card.grp_id}-{scored_card.original_index}",
1882
+ )
1883
+
1884
+ self._restore_table_cursor(
1885
+ table=table,
1886
+ row_key=previous_row_key,
1887
+ row_index=previous_row_index,
1888
+ )
1889
+
1890
+ def _capture_table_cursor(self, *, table: DataTable) -> tuple[str | None, int]:
1891
+ if table.row_count == 0:
1892
+ return None, 0
1893
+
1894
+ row_index = max(0, table.cursor_coordinate.row)
1895
+ try:
1896
+ cell_key = table.coordinate_to_cell_key(table.cursor_coordinate)
1897
+ except Exception: # pragma: no cover - defensive Textual boundary.
1898
+ return None, row_index
1899
+
1900
+ return str(cell_key.row_key.value), row_index
1901
+
1902
+ def _restore_table_cursor(
1903
+ self,
1904
+ *,
1905
+ table: DataTable,
1906
+ row_key: str | None,
1907
+ row_index: int,
1908
+ ) -> None:
1909
+ if table.row_count == 0:
1910
+ return
1911
+
1912
+ target_row = min(max(row_index, 0), table.row_count - 1)
1913
+ if row_key is not None:
1914
+ try:
1915
+ target_row = table.get_row_index(row_key)
1916
+ except Exception: # pragma: no cover - defensive Textual boundary.
1917
+ pass
1918
+
1919
+ table.move_cursor(row=target_row, column=0, animate=False)
1920
+
1921
+ def _move_pack_cursor(self, *, delta: int) -> None:
1922
+ table = self.query_one("#pack-table", DataTable)
1923
+ self._move_pack_cursor_to(row=table.cursor_coordinate.row + delta)
1924
+
1925
+ def _move_pack_cursor_to(self, *, row: int) -> None:
1926
+ if self._view_mode != "pack":
1927
+ return
1928
+
1929
+ table = self.query_one("#pack-table", DataTable)
1930
+ if table.row_count == 0:
1931
+ return
1932
+
1933
+ target_row = min(max(row, 0), table.row_count - 1)
1934
+ table.focus()
1935
+ table.move_cursor(row=target_row, column=0, animate=False)
1936
+ self._render_focused_card_details()
1937
+
1938
+ def _move_build_card_cursor(self, *, delta: int) -> None:
1939
+ if not self._build_focus_cards:
1940
+ return
1941
+
1942
+ target_index = self._build_focused_card_index + delta
1943
+ self._build_focused_card_index = min(
1944
+ max(target_index, 0),
1945
+ len(self._build_focus_cards) - 1,
1946
+ )
1947
+ self._refresh_build_text_from_result()
1948
+ self.query_one("#build-scroll", VerticalScroll).focus()
1949
+ self.query_one("#build-view", Static).update(self._build_text)
1950
+ self._render_focused_card_details()
1951
+
1952
+ def _pack_cursor_page_size(self) -> int:
1953
+ table = self.query_one("#pack-table", DataTable)
1954
+ return max(1, table.size.height - 3)
1955
+
1956
+ def _render_sidebar(self) -> None:
1957
+ pool_summary = self.query_one("#pool-summary", Static)
1958
+ show_pool_summary = self._view_mode not in {"build", "backtest"} and any(
1959
+ (
1960
+ self.visibility_preferences.pool_metadata,
1961
+ self.visibility_preferences.pool_color_distribution,
1962
+ self.visibility_preferences.pool_mana_curve,
1963
+ )
1964
+ )
1965
+ pool_summary.display = show_pool_summary and self.query_one(
1966
+ "#sidebar",
1967
+ Vertical,
1968
+ ).display
1969
+ if self.card_database_loading:
1970
+ if pool_summary.display:
1971
+ pool_summary.update("Card metadata\nLoading local cache and Scryfall data…")
1972
+ self._render_focused_card_details()
1973
+ return
1974
+
1975
+ if self._card_database_error is not None:
1976
+ if pool_summary.display:
1977
+ pool_summary.update(
1978
+ "Card metadata\nLoad failed — check the status bar for next steps."
1979
+ )
1980
+ self._render_focused_card_details()
1981
+ return
1982
+
1983
+ if pool_summary.display:
1984
+ lines = ["Pool summary"]
1985
+ if self.visibility_preferences.pool_metadata:
1986
+ event_text = self._event_name or "unknown event"
1987
+ override_label = self._build_override_label()
1988
+ inferred_pair = _format_pair_label(
1989
+ pair=self._pair_label,
1990
+ mana_icons_enabled=self.mana_icons_enabled,
1991
+ )
1992
+ build_pair = _format_pair_label(
1993
+ pair=self._build_pair_label,
1994
+ mana_icons_enabled=self.mana_icons_enabled,
1995
+ )
1996
+ override = _format_pair_label(
1997
+ pair=override_label,
1998
+ mana_icons_enabled=self.mana_icons_enabled,
1999
+ )
2000
+ lines.extend(
2001
+ (
2002
+ f"Set: {format_set_label(set_code=self._set_code)}",
2003
+ f"Event: {event_text}",
2004
+ )
2005
+ )
2006
+ if self.visibility_preferences.draft_identifier:
2007
+ lines.append(f"Draft: {self._draft_id or 'unknown draft'}")
2008
+ lines.extend(
2009
+ (
2010
+ f"Pool size: {self._pool_size}",
2011
+ f"Inferred pair: {inferred_pair}",
2012
+ f"Build pair: {build_pair}",
2013
+ f"Override: {override}",
2014
+ f"Build action: {self._build_action_label()}",
2015
+ self._metadata_status_text(),
2016
+ )
2017
+ )
2018
+ if self.visibility_preferences.pool_color_distribution:
2019
+ lines.append(
2020
+ _pool_color_distribution_bar(
2021
+ pool_grp_ids=self._pool_grp_ids,
2022
+ card_database=self.card_database,
2023
+ mana_icons_enabled=self.mana_icons_enabled,
2024
+ )
2025
+ )
2026
+ if self.visibility_preferences.pool_mana_curve:
2027
+ lines.append(
2028
+ _pool_curve_sparkline(
2029
+ pool_grp_ids=self._pool_grp_ids,
2030
+ card_database=self.card_database,
2031
+ )
2032
+ )
2033
+ pool_summary.update("\n".join(lines))
2034
+
2035
+ self._render_focused_card_details()
2036
+
2037
+ def _render_focused_card_details(self) -> None:
2038
+ try:
2039
+ focused_card = self.query_one("#focused-card", Static)
2040
+ except NoMatches:
2041
+ return
2042
+
2043
+ sidebar = self.query_one("#sidebar", Vertical)
2044
+ focused_card.display = (
2045
+ self.visibility_preferences.focused_card_details and sidebar.display
2046
+ )
2047
+ selected = self._focused_card_details()
2048
+ if selected is None:
2049
+ self._render_card_image_preview(card=None)
2050
+ if focused_card.display:
2051
+ focused_card.update(
2052
+ "Focused card details\n"
2053
+ "Use ↑/↓/←/→ in the card list to browse card details here."
2054
+ )
2055
+ return
2056
+
2057
+ section, rank, total_count, scored_card, quantity = selected
2058
+ card = scored_card.card
2059
+ self._render_card_image_preview(card=card)
2060
+ if not focused_card.display:
2061
+ return
2062
+
2063
+ type_line = _format_card_types(
2064
+ card=card,
2065
+ mana_icons_enabled=self.mana_icons_enabled,
2066
+ )
2067
+ quantity_line = f"Quantity: {quantity}\n" if quantity > 1 else ""
2068
+ color_label = _format_card_colors(
2069
+ card=card,
2070
+ mana_icons_enabled=self.mana_icons_enabled,
2071
+ long_colorless=True,
2072
+ )
2073
+ focused_card.update(
2074
+ "Focused card details\n"
2075
+ f"{section} {rank}/{total_count}\n"
2076
+ f"{_format_card_name(card=card)}\n"
2077
+ f"{quantity_line}"
2078
+ f"Colors: {color_label}\n"
2079
+ f"Mana value: {_format_mana_value(card=card)}\n"
2080
+ f"Type: {type_line}\n"
2081
+ f"17L WR: {_format_win_rate(scored_card=scored_card)}\n"
2082
+ f"17L Grade: {_format_letter_grade(scored_card=scored_card)}\n"
2083
+ f"DO Score: {scored_card.score}\n"
2084
+ f"Color fit: {_format_color_fit(scored_card=scored_card)}\n"
2085
+ f"{_format_splash_details(scored_card=scored_card)}"
2086
+ f"ALSA (avg last seen): {_format_alsa(scored_card=scored_card)}\n"
2087
+ f"Data source: {_format_tui_source_label(scored_card=scored_card)}"
2088
+ )
2089
+
2090
+ def _render_card_image_preview(self, *, card: CardInfo | None) -> None:
2091
+ image_panel = self.query_one("#card-image-preview", Static)
2092
+ if not self._card_image_preview_is_enabled():
2093
+ image_panel.display = False
2094
+ return
2095
+
2096
+ sidebar = self.query_one("#sidebar", Vertical)
2097
+ if not sidebar.display:
2098
+ image_panel.display = False
2099
+ return
2100
+
2101
+ if TgpImage is None:
2102
+ image_panel.display = True
2103
+ image_panel.update(
2104
+ "Image preview unavailable\n"
2105
+ "Install the textual-image package to render Kitty images."
2106
+ )
2107
+ return
2108
+
2109
+ if card is None:
2110
+ image_panel.display = False
2111
+ return
2112
+
2113
+ image_panel.display = True
2114
+ image_uri = self._card_image_uri_for_card(card=card)
2115
+ if image_uri is None:
2116
+ if card.unknown:
2117
+ image_panel.display = False
2118
+ return
2119
+
2120
+ image_panel.update(
2121
+ "Image preview unavailable\n"
2122
+ f"{_format_card_name(card=card)}\n"
2123
+ "Image URL is not in the local Scryfall cache. Run refresh-data."
2124
+ )
2125
+ return
2126
+ image_path = self._card_image_paths_by_uri.get(image_uri)
2127
+ if image_path is not None and image_path.exists():
2128
+ self._show_card_image(
2129
+ image_panel=image_panel,
2130
+ image_path=image_path,
2131
+ card=card,
2132
+ image_uri=image_uri,
2133
+ )
2134
+ return
2135
+
2136
+ cached_path = self._card_image_service.cached_path(
2137
+ image_uri=image_uri,
2138
+ )
2139
+ if cached_path.exists():
2140
+ self._card_image_paths_by_uri[image_uri] = cached_path
2141
+ self._show_card_image(
2142
+ image_panel=image_panel,
2143
+ image_path=cached_path,
2144
+ card=card,
2145
+ image_uri=image_uri,
2146
+ )
2147
+ return
2148
+
2149
+ failure = self._card_image_failures_by_uri.get(image_uri)
2150
+ if failure is not None:
2151
+ image_panel.update(
2152
+ "Image preview unavailable\n"
2153
+ f"{_format_card_name(card=card)}\n"
2154
+ f"{failure}"
2155
+ )
2156
+ return
2157
+
2158
+ image_panel.update(
2159
+ "Loading image preview…\n"
2160
+ f"{_format_card_name(card=card)}"
2161
+ )
2162
+ if image_uri not in self._loading_card_image_uris:
2163
+ self._loading_card_image_uris.add(image_uri)
2164
+ self._fetch_card_image_worker(image_uri)
2165
+
2166
+ def _card_image_uri_for_card(self, *, card: CardInfo) -> str | None:
2167
+ image_uri = self._card_image_uris_by_grp_id.get(card.grp_id)
2168
+ if image_uri is None:
2169
+ image_uri = self._card_image_service.resolve_image_uri(
2170
+ card=card,
2171
+ card_database=self.card_database,
2172
+ )
2173
+ if image_uri is not None:
2174
+ self._card_image_uris_by_grp_id[card.grp_id] = image_uri
2175
+
2176
+ return image_uri
2177
+
2178
+ def _show_card_image(
2179
+ self,
2180
+ *,
2181
+ image_panel: Static,
2182
+ image_path: Path,
2183
+ card: CardInfo,
2184
+ image_uri: str,
2185
+ ) -> None:
2186
+ if TgpImage is None:
2187
+ return
2188
+
2189
+ width = max(20, min(30, image_panel.size.width or 28))
2190
+ try:
2191
+ preview = TgpImage(
2192
+ str(image_path),
2193
+ width=width,
2194
+ height="auto",
2195
+ )
2196
+ except Exception as error: # pragma: no cover - defensive renderer boundary.
2197
+ self._card_image_failures_by_uri[image_uri] = str(error)
2198
+ image_panel.update(
2199
+ "Image preview unavailable\n"
2200
+ f"{_format_card_name(card=card)}\n"
2201
+ f"{error}"
2202
+ )
2203
+ return
2204
+
2205
+ image_panel.update(
2206
+ Group(
2207
+ Align.center(Text(f"Preview: {_format_card_name(card=card)}")),
2208
+ "",
2209
+ Align.center(preview),
2210
+ )
2211
+ )
2212
+
2213
+ @work(thread=True, group="card-images")
2214
+ def _fetch_card_image_worker(self, image_uri: str) -> None:
2215
+ worker = get_current_worker()
2216
+ if worker.is_cancelled:
2217
+ return
2218
+
2219
+ image_path = None
2220
+ error_message = None
2221
+ try:
2222
+ image_path = self._card_image_service.fetch(image_uri=image_uri)
2223
+ except Exception as error: # pragma: no cover - defensive network boundary.
2224
+ error_message = str(error)
2225
+
2226
+ if worker.is_cancelled:
2227
+ return
2228
+
2229
+ self.call_from_thread(
2230
+ self._finish_card_image_load,
2231
+ image_uri,
2232
+ image_path,
2233
+ error_message,
2234
+ )
2235
+
2236
+ def _finish_card_image_load(
2237
+ self,
2238
+ image_uri: str,
2239
+ image_path: Path | None,
2240
+ error_message: str | None,
2241
+ ) -> None:
2242
+ self._loading_card_image_uris.discard(image_uri)
2243
+ if image_path is None:
2244
+ self._card_image_failures_by_uri[image_uri] = error_message or "fetch failed"
2245
+ elif not image_path.exists():
2246
+ self._card_image_failures_by_uri[image_uri] = "fetch failed"
2247
+ else:
2248
+ self._card_image_paths_by_uri[image_uri] = image_path
2249
+ self._card_image_failures_by_uri.pop(image_uri, None)
2250
+
2251
+ self._render_focused_card_details()
2252
+
2253
+ def _focused_card_details(
2254
+ self,
2255
+ ) -> tuple[str, int, int, ScoredCard, int] | None:
2256
+ if self._view_mode == "build":
2257
+ return self._focused_build_card()
2258
+
2259
+ selected = self._focused_pack_card()
2260
+ if selected is None:
2261
+ return None
2262
+
2263
+ rank, scored_card = selected
2264
+ return "Available card", rank, len(self._sorted_cards()), scored_card, 1
2265
+
2266
+ def _focused_build_card(self) -> tuple[str, int, int, ScoredCard, int] | None:
2267
+ if self._view_mode != "build" or not self._build_focus_cards:
2268
+ return None
2269
+
2270
+ self._build_focused_card_index = min(
2271
+ max(self._build_focused_card_index, 0),
2272
+ len(self._build_focus_cards) - 1,
2273
+ )
2274
+ card, quantity = self._build_focus_cards[self._build_focused_card_index]
2275
+ return (
2276
+ "Selected card",
2277
+ self._build_focused_card_index + 1,
2278
+ len(self._build_focus_cards),
2279
+ card,
2280
+ quantity,
2281
+ )
2282
+
2283
+ def _focused_pack_card(self) -> tuple[int, ScoredCard] | None:
2284
+ if self._view_mode != "pack" or self._current_pack is None:
2285
+ return None
2286
+
2287
+ table = self.query_one("#pack-table", DataTable)
2288
+ row_index = table.cursor_coordinate.row
2289
+ cards = self._sorted_cards()
2290
+ if row_index < 0 or row_index >= len(cards):
2291
+ return None
2292
+
2293
+ return row_index + 1, cards[row_index]
2294
+
2295
+ def _ensure_visible_focus(self) -> None:
2296
+ focused_id = None if self.focused is None else self.focused.id
2297
+ if focused_id is None:
2298
+ self._focus_primary_card_section()
2299
+ return
2300
+
2301
+ if focused_id == "pack-table" and self._view_mode != "pack":
2302
+ self._focus_primary_card_section()
2303
+ return
2304
+
2305
+ if focused_id == "build-scroll" and self._view_mode not in {"build", "backtest"}:
2306
+ self._focus_primary_card_section()
2307
+
2308
+ def _focus_primary_card_section(self) -> None:
2309
+ if self._view_mode in {"build", "backtest"}:
2310
+ self.query_one("#build-scroll", VerticalScroll).focus()
2311
+ return
2312
+
2313
+ self.query_one("#pack-table", DataTable).focus()
2314
+
2315
+ def _build_override_label(self) -> str:
2316
+ if self._forced_pair is not None:
2317
+ return self._forced_pair
2318
+
2319
+ if self._build_error is not None and self._build_pair_label == "—":
2320
+ return "unavailable"
2321
+
2322
+ return "automatic"
2323
+
2324
+ def _build_action_label(self) -> str:
2325
+ if self._build_action_status is None:
2326
+ return "not requested"
2327
+
2328
+ return self._build_action_status
2329
+
2330
+ def _render_status_bar(self) -> None:
2331
+ status = self.query_one("#status-bar", Static)
2332
+ if self.card_database_loading:
2333
+ status.update("Loading card metadata… the watch UI remains available.")
2334
+ return
2335
+
2336
+ if self._card_database_error is not None:
2337
+ status.update(f"Card metadata failed to load: {self._card_database_error}")
2338
+ return
2339
+
2340
+ pair_label = self._pair_label
2341
+ if self._view_mode == "build" and self._build_pair_label != "—":
2342
+ pair_label = self._build_pair_label
2343
+ pair_label = _format_pair_label(
2344
+ pair=pair_label,
2345
+ mana_icons_enabled=self.mana_icons_enabled,
2346
+ )
2347
+
2348
+ if self._view_mode == "build":
2349
+ sort_label = f"Build sort: {self._build_spell_sort_mode}"
2350
+ elif self._view_mode == "backtest":
2351
+ sort_label = f"Backtest ranking: {SORT_LABELS[self.sort_mode]}"
2352
+ else:
2353
+ sort_label = f"Ranking: {SORT_LABELS[self.sort_mode]}"
2354
+
2355
+ segments: list[str] = []
2356
+ if self.visibility_preferences.account_identifier:
2357
+ segments.append(f"Account: {self._active_account_label}")
2358
+ segments.extend(
2359
+ (
2360
+ f"View: {self._view_mode}",
2361
+ f"Pair: {pair_label} ({self._commitment_label})",
2362
+ f"Pick: {self._pick_label}",
2363
+ f"Pool: {self._pool_size}",
2364
+ f"Data: {self._data_source}",
2365
+ sort_label,
2366
+ )
2367
+ )
2368
+ confidence_label = self._recommendation_confidence_label()
2369
+ if confidence_label is not None:
2370
+ segments.append(f"Confidence: {confidence_label}")
2371
+ segments.append(self._splash_status_label())
2372
+ icon_label = "on" if self.mana_icons_enabled else "off"
2373
+ segments.append(f"Mana icons: {icon_label}")
2374
+ if self.visibility_preferences.attribution:
2375
+ segments.append(SEVENTEEN_LANDS_ATTRIBUTION)
2376
+ if self._forced_pair is not None:
2377
+ override = _format_pair_label(
2378
+ pair=self._forced_pair,
2379
+ mana_icons_enabled=self.mana_icons_enabled,
2380
+ )
2381
+ segments.insert(0, f"Override: {override}")
2382
+
2383
+ unresolved_count = self._unresolved_metadata_count()
2384
+ if unresolved_count > 0:
2385
+ segments.insert(0, f"Warning: {unresolved_count} unresolved card metadata")
2386
+ if self._build_error is not None:
2387
+ segments.insert(0, f"Build: {self._build_error}")
2388
+ if self._build_action_status is not None:
2389
+ segments.insert(0, f"Build action: {self._build_action_status}")
2390
+ if self._backtest_action_status is not None:
2391
+ segments.insert(0, f"Backtest action: {self._backtest_action_status}")
2392
+ if self._backtest_error is not None and self._view_mode == "backtest":
2393
+ segments.insert(0, f"Backtest: {self._backtest_error}")
2394
+ if self._last_error is not None:
2395
+ segments.insert(0, f"Error: {self._last_error}")
2396
+ if self._session_error is not None:
2397
+ segments.insert(0, f"Error: {self._session_error}")
2398
+ if self._preferences_save_warning is not None:
2399
+ segments.insert(0, self._preferences_save_warning)
2400
+ if self._preferences_load_warning is not None:
2401
+ segments.insert(0, self._preferences_load_warning)
2402
+
2403
+ status.update(" | ".join(segments))
2404
+
2405
+ def _splash_status_label(self) -> str:
2406
+ enabled_label = "On" if self.visibility_preferences.splash_enabled else "Off"
2407
+ return f"Splash: {enabled_label}"
2408
+
2409
+ def _column_keys_for_width(self) -> tuple[str, ...]:
2410
+ show_secondary = (
2411
+ self.show_secondary_columns and self.size.width >= SECONDARY_COLUMN_MIN_WIDTH
2412
+ )
2413
+ if show_secondary:
2414
+ return PRIMARY_COLUMN_KEYS + SECONDARY_COLUMN_KEYS
2415
+
2416
+ return PRIMARY_COLUMN_KEYS
2417
+
2418
+ def _sorted_cards(self) -> tuple[ScoredCard, ...]:
2419
+ if self._current_pack is None:
2420
+ return ()
2421
+
2422
+ return rank_scored_cards(
2423
+ cards=self._current_pack.cards,
2424
+ ranking_mode=self.sort_mode,
2425
+ )
2426
+
2427
+ def _recommendation_confidence_label(self) -> str | None:
2428
+ if self._view_mode != "pack" or self._current_pack is None:
2429
+ return None
2430
+
2431
+ return recommendation_confidence_summary(
2432
+ cards=self._sorted_cards(),
2433
+ ranking_mode=self.sort_mode,
2434
+ phase=self._current_pack.commitment.phase,
2435
+ )
2436
+
2437
+
2438
+ def _metadata_status_text(self) -> str:
2439
+ visible_count = len(self._visible_metadata_grp_ids())
2440
+ if visible_count == 0:
2441
+ return "Metadata: waiting"
2442
+
2443
+ unresolved_count = self._unresolved_metadata_count()
2444
+ if unresolved_count == 0:
2445
+ return "Metadata: complete"
2446
+
2447
+ return f"Metadata warning: {unresolved_count} unresolved card metadata"
2448
+
2449
+ def _unresolved_metadata_count(self) -> int:
2450
+ unresolved_grp_ids = self.card_database.unresolved_grp_ids(
2451
+ grp_ids=self._visible_metadata_grp_ids(),
2452
+ )
2453
+ return len(unresolved_grp_ids)
2454
+
2455
+ def _visible_metadata_grp_ids(self) -> tuple[int, ...]:
2456
+ grp_ids = list(self._pool_grp_ids)
2457
+ if self._current_pack_event is not None:
2458
+ grp_ids.extend(self._current_pack_event.offered_grp_ids)
2459
+
2460
+ return tuple(grp_ids)
2461
+
2462
+ def _active_text_view(self) -> str:
2463
+ if self._view_mode == "backtest":
2464
+ return self._backtest_text
2465
+
2466
+ return self._build_text
2467
+
2468
+ def _request_build_view(self, *, success_message: str | None) -> None:
2469
+ if self.session.snapshot.pool.total_cards == 0 or self._set_code is None:
2470
+ self._build_error = "no picked cards yet"
2471
+ self._build_text = "Build view: no picked cards yet."
2472
+ self._build_pair_label = "—"
2473
+ self._build_result = None
2474
+ self._clear_build_render_state()
2475
+ if success_message is not None:
2476
+ self._build_action_status = "cannot build — no picked cards yet"
2477
+ return
2478
+
2479
+ self._pending_build_success_message = success_message
2480
+ if success_message is not None:
2481
+ self._build_action_status = None
2482
+ self._build_error = None
2483
+ self._view_mode = "build"
2484
+ self._dispatch_session_command_worker(
2485
+ RequestBuild(
2486
+ pair_override=self._forced_pair,
2487
+ allow_splash=self.visibility_preferences.splash_enabled,
2488
+ )
2489
+ )
2490
+
2491
+ def _request_backtest_view(
2492
+ self,
2493
+ *,
2494
+ success_message: str,
2495
+ open_when_ready: bool,
2496
+ ) -> None:
2497
+ self._pending_backtest_success_message = success_message
2498
+ self._backtest_action_status = None
2499
+ self._backtest_error = None
2500
+ self._open_backtest_when_ready = open_when_ready
2501
+ self._dispatch_session_command_worker(
2502
+ RequestBacktest(
2503
+ account_id=self._active_account_id,
2504
+ draft_id=self._draft_id,
2505
+ )
2506
+ )
2507
+
2508
+ def _adopt_build_result(self, *, result: BuildResult) -> None:
2509
+ if (
2510
+ result.domain_pool is None
2511
+ or result.domain_selection is None
2512
+ or result.domain_spell_selection is None
2513
+ or result.domain_mana_base is None
2514
+ ):
2515
+ self._adopt_build_error(
2516
+ snapshot=self.session.snapshot,
2517
+ message="Shared build result is missing detailed build data.",
2518
+ )
2519
+ return
2520
+
2521
+ self._build_result = result
2522
+ self._build_error = None
2523
+ self._last_error = None
2524
+ self._build_pair_label = result.selected_pair
2525
+ if self._pending_build_success_message is not None:
2526
+ self._build_action_status = self._pending_build_success_message
2527
+ self._pending_build_success_message = None
2528
+ self._refresh_build_text_from_result()
2529
+
2530
+ def _adopt_build_error(
2531
+ self,
2532
+ *,
2533
+ snapshot: LiveSessionSnapshot,
2534
+ message: str,
2535
+ ) -> None:
2536
+ self._build_error = message
2537
+ self._build_text = _format_tui_build_error(
2538
+ pool=snapshot.pool,
2539
+ error=message,
2540
+ width=self._build_text_width(),
2541
+ mana_icons_enabled=self.mana_icons_enabled,
2542
+ )
2543
+ self._build_pair_label = "—"
2544
+ self._build_result = None
2545
+ self._clear_build_render_state()
2546
+ self._pending_build_success_message = None
2547
+ self._build_action_status = f"cannot build — {message}"
2548
+
2549
+ def _adopt_backtest_result(self, *, result: BacktestResult) -> None:
2550
+ self._backtest_error = None
2551
+ self._backtest_text = _format_tui_backtest_result(result=result).rstrip("\n")
2552
+ if self._pending_backtest_success_message is not None:
2553
+ self._backtest_action_status = self._pending_backtest_success_message
2554
+ self._pending_backtest_success_message = None
2555
+ if self._open_backtest_when_ready:
2556
+ self._view_mode = "backtest"
2557
+ self.query_one("#build-scroll", VerticalScroll).scroll_home(animate=False)
2558
+ self._last_error = None
2559
+ self._open_backtest_when_ready = False
2560
+
2561
+ def _refresh_build_text_from_result(self) -> None:
2562
+ result = self._build_result
2563
+ if (
2564
+ result is None
2565
+ or result.domain_pool is None
2566
+ or result.domain_selection is None
2567
+ or result.domain_spell_selection is None
2568
+ or result.domain_mana_base is None
2569
+ ):
2570
+ return
2571
+
2572
+ spell_selection = result.domain_spell_selection
2573
+ self._build_focus_cards = _tui_selected_spell_groups(
2574
+ spell_selection=spell_selection,
2575
+ spell_sort_mode=self._build_spell_sort_mode,
2576
+ )
2577
+ if not self._build_focus_cards:
2578
+ self._build_focused_card_index = 0
2579
+ else:
2580
+ self._build_focused_card_index = min(
2581
+ max(self._build_focused_card_index, 0),
2582
+ len(self._build_focus_cards) - 1,
2583
+ )
2584
+
2585
+ self._build_text = _format_tui_build_result(
2586
+ pool=result.domain_pool,
2587
+ selection=result.domain_selection,
2588
+ spell_selection=spell_selection,
2589
+ mana_base=result.domain_mana_base,
2590
+ card_database=self.card_database,
2591
+ spell_sort_mode=self._build_spell_sort_mode,
2592
+ show_details=self._build_show_details,
2593
+ focused_card_index=self._build_focused_card_index,
2594
+ width=self._build_text_width(),
2595
+ visibility_preferences=self.visibility_preferences,
2596
+ mana_icons_enabled=self.mana_icons_enabled,
2597
+ )
2598
+
2599
+ def _clear_build_render_state(self) -> None:
2600
+ self._build_focus_cards = ()
2601
+ self._build_focused_card_index = 0
2602
+
2603
+ def _build_text_width(self) -> int:
2604
+ sidebar_width = max(0, self.query_one("#sidebar", Vertical).size.width)
2605
+ return max(60, self.size.width - sidebar_width - 4)
2606
+
2607
+ def _next_forced_pair(self) -> str:
2608
+ current_pair = self._forced_pair
2609
+ if current_pair is None and self._build_pair_label in COLOR_PAIRS:
2610
+ current_pair = self._build_pair_label
2611
+
2612
+ if current_pair not in COLOR_PAIRS:
2613
+ return COLOR_PAIRS[0]
2614
+
2615
+ index = COLOR_PAIRS.index(current_pair)
2616
+ return COLOR_PAIRS[(index + 1) % len(COLOR_PAIRS)]
2617
+
2618
+ def _account_label(
2619
+ self,
2620
+ *,
2621
+ account_id: str | None,
2622
+ snapshot: LiveSessionSnapshot | None = None,
2623
+ ) -> str:
2624
+ client_id = account_id or self._active_account_id
2625
+ if client_id is None:
2626
+ return "unknown"
2627
+
2628
+ current = self.session.snapshot if snapshot is None else snapshot
2629
+ identity = next(
2630
+ (
2631
+ account
2632
+ for account in current.accounts
2633
+ if account.account_id == client_id
2634
+ ),
2635
+ None,
2636
+ )
2637
+ if identity is None or identity.screen_name is None:
2638
+ return client_id
2639
+
2640
+ return identity.screen_name
2641
+
2642
+ def _record_error(self, message: str) -> None:
2643
+ self._last_error = message
2644
+ self._render_all()
2645
+
2646
+
2647
+ def _card_image_preview_enabled(*, env: Mapping[str, str]) -> bool:
2648
+ if TgpImage is None:
2649
+ return False
2650
+
2651
+ override = env.get(CARD_IMAGE_PREVIEW_ENV)
2652
+ if override is not None:
2653
+ return override.strip().casefold() in {"1", "true", "yes", "on"}
2654
+
2655
+ term_program = env.get("TERM_PROGRAM", "").casefold()
2656
+ if term_program in {"ghostty", "kitty", "wezterm"}:
2657
+ return True
2658
+
2659
+ term = env.get("TERM", "").casefold()
2660
+ if "kitty" in term or "ghostty" in term:
2661
+ return True
2662
+
2663
+ return bool(
2664
+ env.get("KITTY_WINDOW_ID")
2665
+ or env.get("GHOSTTY_RESOURCES_DIR")
2666
+ or env.get("WEZTERM_EXECUTABLE")
2667
+ )
2668
+
2669
+ def _operation_error(
2670
+ *,
2671
+ snapshot: LiveSessionSnapshot,
2672
+ operation: OperationKind,
2673
+ ) -> SessionError | None:
2674
+ return next(
2675
+ (
2676
+ error
2677
+ for error in reversed(snapshot.errors)
2678
+ if error.operation == operation
2679
+ ),
2680
+ None,
2681
+ )
2682
+
2683
+
2684
+ def _format_tui_backtest_result(*, result: BacktestResult) -> str:
2685
+ account = result.account_id or "unknown"
2686
+ if result.account_screen_name is not None:
2687
+ account = f"{result.account_screen_name} ({account})"
2688
+ completed = (
2689
+ "unknown" if result.completed is None else _format_tui_yes_no(result.completed)
2690
+ )
2691
+ chosen_pick_count = (
2692
+ "unknown"
2693
+ if result.chosen_pick_count is None
2694
+ else str(result.chosen_pick_count)
2695
+ )
2696
+ data_sources = "none" if not result.data_sources else "; ".join(result.data_sources)
2697
+ lines = [
2698
+ "Draft Omen backtest",
2699
+ f"Account: {account}",
2700
+ f"Set: {result.set_code or 'unknown'}",
2701
+ f"Event: {result.event_name or 'unknown'}",
2702
+ f"Draft: {result.draft_id or 'unknown'}",
2703
+ f"Completed: {completed}",
2704
+ f"Ranking: {ranking_label(ranking_mode=result.ranking_mode)}",
2705
+ (
2706
+ f"Picks: {chosen_pick_count} chosen, "
2707
+ f"{result.compared_count} compared, {result.skipped_count} skipped"
2708
+ ),
2709
+ f"Data sources: {data_sources}",
2710
+ "",
2711
+ ]
2712
+ lines.extend(_format_tui_backtest_rows(result=result))
2713
+ lines.append("")
2714
+ lines.extend(_format_tui_backtest_summary(result=result))
2715
+ return "\n".join(lines).rstrip() + "\n"
2716
+
2717
+
2718
+ def _format_tui_backtest_rows(*, result: BacktestResult) -> list[str]:
2719
+ if not result.rows:
2720
+ return ["No saved picks were found for this draft."]
2721
+
2722
+ recommended_values = tuple(
2723
+ _format_tui_backtest_recommended(row=row) for row in result.rows
2724
+ )
2725
+ actual_values = tuple(_format_tui_backtest_actual(row=row) for row in result.rows)
2726
+ recommended_width = max(
2727
+ len("Recommended"),
2728
+ *(len(value) for value in recommended_values),
2729
+ )
2730
+ actual_width = max(len("Actual"), *(len(value) for value in actual_values))
2731
+ lines = [
2732
+ "Pack Pick Pool 17L WR DO "
2733
+ f"{'Recommended':<{recommended_width}} "
2734
+ f"{'Actual':<{actual_width}} "
2735
+ "Match"
2736
+ ]
2737
+ for row, recommended, actual in zip(
2738
+ result.rows,
2739
+ recommended_values,
2740
+ actual_values,
2741
+ strict=True,
2742
+ ):
2743
+ lines.append(
2744
+ f"{row.pack_number + 1:>4} "
2745
+ f"{row.pick_number + 1:>4} "
2746
+ f"{_format_tui_optional_int(row.pool_size):>4} "
2747
+ f"{_format_tui_backtest_win_rate(row=row):>6} "
2748
+ f"{_format_tui_backtest_score(row=row):>3} "
2749
+ f"{recommended:<{recommended_width}} "
2750
+ f"{actual:<{actual_width}} "
2751
+ f"{_format_tui_backtest_match(row=row)}"
2752
+ )
2753
+
2754
+ return lines
2755
+
2756
+
2757
+ def _format_tui_backtest_summary(*, result: BacktestResult) -> list[str]:
2758
+ if result.compared_count == 0:
2759
+ lines = [
2760
+ f"Summary: no comparable picks; {result.skipped_count} skipped."
2761
+ ]
2762
+ else:
2763
+ match_rate = result.match_count / result.compared_count
2764
+ lines = [
2765
+ "Summary: "
2766
+ f"{result.match_count}/{result.compared_count} recommendations matched "
2767
+ f"actual picks ({match_rate:.1%})."
2768
+ ]
2769
+
2770
+ if result.skipped_count:
2771
+ lines.append(
2772
+ "Skipped picks were not scored when saved offered-card or "
2773
+ "pool-before-pick history was missing."
2774
+ )
2775
+
2776
+ return lines
2777
+
2778
+
2779
+ def _format_tui_backtest_recommended(*, row: BacktestPickResult) -> str:
2780
+ if row.recommended is not None:
2781
+ return _format_tui_card_view(card=row.recommended)
2782
+
2783
+ reason = row.skipped_reason or "not scored"
2784
+ return f"skipped: {reason}"
2785
+
2786
+
2787
+ def _format_tui_backtest_actual(*, row: BacktestPickResult) -> str:
2788
+ if row.actual is not None:
2789
+ return _format_tui_card_view(card=row.actual)
2790
+
2791
+ return row.skipped_reason or "missing actual selected card"
2792
+
2793
+
2794
+ def _format_tui_backtest_win_rate(*, row: BacktestPickResult) -> str:
2795
+ if row.recommended is None or row.recommended_win_rate is None:
2796
+ return "—"
2797
+
2798
+ return f"{row.recommended_win_rate:.1%}"
2799
+
2800
+
2801
+ def _format_tui_backtest_score(*, row: BacktestPickResult) -> str:
2802
+ if row.recommended is None or row.recommended_score is None:
2803
+ return "—"
2804
+
2805
+ return str(row.recommended_score)
2806
+
2807
+
2808
+ def _format_tui_backtest_match(*, row: BacktestPickResult) -> str:
2809
+ if row.match is None:
2810
+ return "skipped"
2811
+
2812
+ return _format_tui_yes_no(row.match)
2813
+
2814
+
2815
+ def _format_tui_card_view(*, card: CardView) -> str:
2816
+ colors = "Unknown" if _card_view_is_unknown(card=card) else (
2817
+ "".join(card.colors) if card.colors else "Colorless"
2818
+ )
2819
+ return f"{card.name} [{colors}] (grpId {card.grp_id})"
2820
+
2821
+
2822
+ def _format_tui_optional_int(value: int | None) -> str:
2823
+ return "—" if value is None else str(value)
2824
+
2825
+
2826
+ def _format_tui_yes_no(value: bool) -> str:
2827
+ return "yes" if value else "no"
2828
+
2829
+
2830
+ def _card_info_from_view(*, card: CardView) -> CardInfo:
2831
+ return CardInfo(
2832
+ grp_id=card.grp_id,
2833
+ name=card.name,
2834
+ colors=card.colors,
2835
+ mana_value=card.mana_value,
2836
+ rarity=card.rarity,
2837
+ types=card.types,
2838
+ mana_cost=card.mana_cost,
2839
+ unknown=_card_view_is_unknown(card=card),
2840
+ )
2841
+
2842
+
2843
+ def _card_view_is_unknown(*, card: CardView) -> bool:
2844
+ return card.rarity == "unknown" and card.types == ("Unknown",)
2845
+
2846
+
2847
+
2848
+ _BUILD_COLUMN_MIN_WIDTH = 34
2849
+ _BUILD_COLUMN_MAX_WIDTH = 80
2850
+
2851
+
2852
+ def _format_tui_build_result(
2853
+ *,
2854
+ pool: BuildPool,
2855
+ selection: PairSelection,
2856
+ spell_selection: SpellSelection,
2857
+ mana_base: ManaBase,
2858
+ card_database: CardDatabase,
2859
+ spell_sort_mode: str,
2860
+ show_details: bool,
2861
+ focused_card_index: int,
2862
+ width: int,
2863
+ visibility_preferences: TuiVisibilityPreferences,
2864
+ mana_icons_enabled: bool = False,
2865
+ ) -> str:
2866
+ chosen_label = "forced" if selection.forced_pair is not None else "automatic"
2867
+ color_pair = _format_pair_label(
2868
+ pair=selection.chosen.pair,
2869
+ mana_icons_enabled=mana_icons_enabled,
2870
+ )
2871
+ counts = spell_selection.counts
2872
+ lines = [
2873
+ "[bold]Suggested deck[/bold]",
2874
+ "",
2875
+ f"Set: {format_set_label(set_code=pool.set_code)}",
2876
+ f"Color pair: {color_pair} ({chosen_label})",
2877
+ (
2878
+ f"Deck: {mana_base.deck_size} cards — "
2879
+ f"{mana_base.spell_count} spells, {mana_base.land_count} lands"
2880
+ ),
2881
+ f"Average mana value: {mana_base.average_mana_value:.2f}",
2882
+ (
2883
+ f"Creatures: {counts.creatures}; "
2884
+ f"Noncreatures: {counts.noncreatures}; Lands: {mana_base.land_count}"
2885
+ ),
2886
+ (
2887
+ "Ratings: rows show 17Lands WR and 17Lands-style grade from "
2888
+ "each source format."
2889
+ ),
2890
+ (
2891
+ "Keys: b checks build status; ↑/↓/←/→ or j/k browse cards; "
2892
+ "PgUp/PgDn page; s changes spell sort; c opens config; "
2893
+ "p changes pair; m toggles Mana icons"
2894
+ ),
2895
+ "",
2896
+ ]
2897
+ lines.extend(
2898
+ _format_tui_selected_spells(
2899
+ spell_selection=spell_selection,
2900
+ spell_sort_mode=spell_sort_mode,
2901
+ focused_card_index=focused_card_index,
2902
+ width=width,
2903
+ mana_icons_enabled=mana_icons_enabled,
2904
+ )
2905
+ )
2906
+ lines.append("")
2907
+ lines.extend(
2908
+ _format_tui_lands(
2909
+ mana_base=mana_base,
2910
+ mana_icons_enabled=mana_icons_enabled,
2911
+ )
2912
+ )
2913
+ if show_details:
2914
+ lines.append("")
2915
+ lines.extend(
2916
+ _format_tui_build_context(
2917
+ pool=pool,
2918
+ selection=selection,
2919
+ spell_selection=spell_selection,
2920
+ mana_base=mana_base,
2921
+ visibility_preferences=visibility_preferences,
2922
+ mana_icons_enabled=mana_icons_enabled,
2923
+ )
2924
+ )
2925
+ lines.append("")
2926
+ lines.extend(
2927
+ _format_tui_spell_counts(
2928
+ spell_selection=spell_selection,
2929
+ mana_icons_enabled=mana_icons_enabled,
2930
+ )
2931
+ )
2932
+ lines.append("")
2933
+ lines.extend(
2934
+ _format_tui_picked_pool(
2935
+ pool=pool,
2936
+ card_database=card_database,
2937
+ width=width,
2938
+ mana_icons_enabled=mana_icons_enabled,
2939
+ )
2940
+ )
2941
+ lines.append("")
2942
+ lines.extend(
2943
+ _format_tui_pair_scores(
2944
+ selection=selection,
2945
+ mana_icons_enabled=mana_icons_enabled,
2946
+ )
2947
+ )
2948
+ lines.append("")
2949
+ lines.extend(
2950
+ _format_tui_bench(
2951
+ selection=spell_selection,
2952
+ width=width,
2953
+ mana_icons_enabled=mana_icons_enabled,
2954
+ )
2955
+ )
2956
+ else:
2957
+ lines.append("")
2958
+ lines.append(
2959
+ "Details hidden: open config with c to show build context, picked pool, "
2960
+ "color-pair reasoning, structure checks, and bench cuts."
2961
+ )
2962
+
2963
+ return "\n".join(lines) + "\n"
2964
+
2965
+
2966
+ def _format_tui_build_context(
2967
+ *,
2968
+ pool: BuildPool,
2969
+ selection: PairSelection,
2970
+ spell_selection: SpellSelection,
2971
+ mana_base: ManaBase,
2972
+ visibility_preferences: TuiVisibilityPreferences,
2973
+ mana_icons_enabled: bool = False,
2974
+ ) -> list[str]:
2975
+ lines = ["Build context"]
2976
+ if visibility_preferences.pool_mana_curve:
2977
+ lines.append(_format_tui_curve_summary(spells=spell_selection.spells))
2978
+ if visibility_preferences.pool_metadata:
2979
+ lines.extend(
2980
+ (
2981
+ f"Pool: {pool.source_label}",
2982
+ f"Pool size: {selection.pool_size} cards",
2983
+ )
2984
+ )
2985
+ if visibility_preferences.account_identifier and pool.account_id is not None:
2986
+ lines.append(f"Account: {pool.account_id}")
2987
+ if visibility_preferences.draft_identifier and pool.draft_id is not None:
2988
+ lines.append(f"Draft: {pool.draft_id}")
2989
+ if visibility_preferences.mana_pips_and_sources:
2990
+ mana_pips = _format_plain_color_counts(
2991
+ mana_base.pip_counts,
2992
+ mana_icons_enabled=mana_icons_enabled,
2993
+ )
2994
+ mana_sources = _format_plain_color_counts(
2995
+ mana_base.source_counts,
2996
+ mana_icons_enabled=mana_icons_enabled,
2997
+ )
2998
+ lines.extend([
2999
+ f"Mana pips: {mana_pips}",
3000
+ f"Mana sources: {mana_sources}",
3001
+ ])
3002
+ return lines
3003
+
3004
+
3005
+ def _format_tui_build_error(
3006
+ *,
3007
+ pool: PoolState,
3008
+ error: str,
3009
+ width: int,
3010
+ mana_icons_enabled: bool = False,
3011
+ ) -> str:
3012
+ lines = [
3013
+ f"Build view unavailable: {error}",
3014
+ "",
3015
+ ]
3016
+ lines.extend(
3017
+ _format_tui_snapshot_pool(
3018
+ pool=pool,
3019
+ width=width,
3020
+ mana_icons_enabled=mana_icons_enabled,
3021
+ )
3022
+ )
3023
+ return "\n".join(lines) + "\n"
3024
+
3025
+
3026
+ def _format_tui_snapshot_pool(
3027
+ *,
3028
+ pool: PoolState,
3029
+ width: int,
3030
+ mana_icons_enabled: bool = False,
3031
+ ) -> list[str]:
3032
+ lines = [f"Picked pool ({pool.total_cards})"]
3033
+ if not pool.cards:
3034
+ return lines + ["- none"]
3035
+
3036
+ index = 0
3037
+ for pool_card in pool.cards:
3038
+ card = _card_info_from_view(card=pool_card.card)
3039
+ for _ in range(pool_card.quantity):
3040
+ index += 1
3041
+ lines.append(
3042
+ _clip(
3043
+ text=_format_tui_picked_card(
3044
+ index=index,
3045
+ card=card,
3046
+ mana_icons_enabled=mana_icons_enabled,
3047
+ ),
3048
+ width=width,
3049
+ )
3050
+ )
3051
+
3052
+ return lines
3053
+
3054
+
3055
+ def _format_tui_picked_pool(
3056
+ *,
3057
+ pool: BuildPool,
3058
+ card_database: CardDatabase,
3059
+ width: int,
3060
+ mana_icons_enabled: bool = False,
3061
+ ) -> list[str]:
3062
+ lines = [f"Picked pool ({len(pool.pool_grp_ids)})"]
3063
+ if not pool.pool_grp_ids:
3064
+ return lines + ["- none"]
3065
+
3066
+ for index, grp_id in enumerate(pool.pool_grp_ids, start=1):
3067
+ card = card_database.lookup(grp_id=grp_id)
3068
+ lines.append(
3069
+ _clip(
3070
+ text=_format_tui_picked_card(
3071
+ index=index,
3072
+ card=card,
3073
+ mana_icons_enabled=mana_icons_enabled,
3074
+ ),
3075
+ width=width,
3076
+ )
3077
+ )
3078
+
3079
+ return lines
3080
+
3081
+
3082
+ def _format_tui_picked_card(
3083
+ *,
3084
+ index: int,
3085
+ card: CardInfo,
3086
+ mana_icons_enabled: bool = False,
3087
+ ) -> str:
3088
+ marker = "[unresolved] " if card.unknown else ""
3089
+ color_label = _format_card_colors(
3090
+ card=card,
3091
+ mana_icons_enabled=mana_icons_enabled,
3092
+ long_colorless=True,
3093
+ )
3094
+ mana_value = _format_mana_value(card=card)
3095
+ card_name = _format_card_name(card=card)
3096
+ return f"{index:02d}. {marker}{card_name} | Colors {color_label} | MV {mana_value}"
3097
+
3098
+
3099
+ def _format_tui_curve_summary(*, spells: tuple[ScoredCard, ...]) -> str:
3100
+ counts = [0 for _ in CURVE_BUCKET_LABELS]
3101
+ unknown_count = 0
3102
+ for card in spells:
3103
+ mana_value = card.card.mana_value
3104
+ if mana_value is None:
3105
+ unknown_count += 1
3106
+ continue
3107
+
3108
+ counts[_curve_bucket(mana_value=mana_value)] += 1
3109
+
3110
+ parts = [
3111
+ f"{label}: {counts[index]}"
3112
+ for index, label in enumerate(CURVE_BUCKET_LABELS)
3113
+ ]
3114
+ if unknown_count > 0:
3115
+ parts.append(f"?: {unknown_count}")
3116
+
3117
+ return "Mana curve: " + " | ".join(parts)
3118
+
3119
+
3120
+ def _format_tui_selected_spells(
3121
+ *,
3122
+ spell_selection: SpellSelection,
3123
+ spell_sort_mode: str,
3124
+ focused_card_index: int,
3125
+ width: int,
3126
+ mana_icons_enabled: bool = False,
3127
+ ) -> list[str]:
3128
+ groups = _tui_selected_spell_groups(
3129
+ spell_selection=spell_selection,
3130
+ spell_sort_mode=spell_sort_mode,
3131
+ )
3132
+ if spell_sort_mode == "score":
3133
+ return _format_tui_spell_columns(
3134
+ title="Selected spells by score",
3135
+ groups=groups,
3136
+ total_count=len(spell_selection.spells),
3137
+ focused_card_index=focused_card_index,
3138
+ width=width,
3139
+ mana_icons_enabled=mana_icons_enabled,
3140
+ )
3141
+
3142
+ if spell_sort_mode == "name":
3143
+ return _format_tui_spell_columns(
3144
+ title="Selected spells by name",
3145
+ groups=groups,
3146
+ total_count=len(spell_selection.spells),
3147
+ focused_card_index=focused_card_index,
3148
+ width=width,
3149
+ mana_icons_enabled=mana_icons_enabled,
3150
+ )
3151
+
3152
+ return _format_tui_spell_curve(
3153
+ groups=groups,
3154
+ total_count=len(spell_selection.spells),
3155
+ focused_card_index=focused_card_index,
3156
+ width=width,
3157
+ mana_icons_enabled=mana_icons_enabled,
3158
+ )
3159
+
3160
+
3161
+ def _tui_selected_spell_groups(
3162
+ *,
3163
+ spell_selection: SpellSelection,
3164
+ spell_sort_mode: str,
3165
+ ) -> tuple[TuiCardQuantityGroup, ...]:
3166
+ if spell_sort_mode == "score":
3167
+ return _group_tui_spell_cards(
3168
+ cards=tuple(sorted(
3169
+ spell_selection.spells,
3170
+ key=lambda card: (
3171
+ -card.score,
3172
+ _format_mana_value(card=card.card),
3173
+ card.card.name,
3174
+ ),
3175
+ )),
3176
+ )
3177
+
3178
+ if spell_sort_mode == "name":
3179
+ return _group_tui_spell_cards(
3180
+ cards=tuple(sorted(
3181
+ spell_selection.spells,
3182
+ key=lambda card: card.card.name,
3183
+ )),
3184
+ )
3185
+
3186
+ return _group_tui_spell_cards(
3187
+ cards=tuple(sorted(spell_selection.spells, key=_tui_spell_curve_sort_key)),
3188
+ )
3189
+
3190
+
3191
+ def _format_tui_spell_curve(
3192
+ *,
3193
+ groups: tuple[TuiCardQuantityGroup, ...],
3194
+ total_count: int,
3195
+ focused_card_index: int,
3196
+ width: int,
3197
+ mana_icons_enabled: bool = False,
3198
+ ) -> list[str]:
3199
+ groups_by_bucket: dict[str, list[tuple[int, TuiCardQuantityGroup]]] = {}
3200
+ for index, group in enumerate(groups):
3201
+ groups_by_bucket.setdefault(_mana_value_bucket(card=group[0]), []).append(
3202
+ (index, group),
3203
+ )
3204
+
3205
+ blocks: list[list[str]] = []
3206
+ for bucket in _ordered_mana_buckets(groups={
3207
+ key: [card for _, (card, _) in values]
3208
+ for key, values in groups_by_bucket.items()
3209
+ }):
3210
+ indexed_groups = groups_by_bucket[bucket]
3211
+ bucket_count = sum(quantity for _, (_, quantity) in indexed_groups)
3212
+ block = [f"MV {bucket} ({bucket_count})"]
3213
+ block.extend(
3214
+ _format_tui_spell_card(
3215
+ card=card,
3216
+ quantity=quantity,
3217
+ focused=index == focused_card_index,
3218
+ show_focus_marker=True,
3219
+ mana_icons_enabled=mana_icons_enabled,
3220
+ )
3221
+ for index, (card, quantity) in indexed_groups
3222
+ )
3223
+ blocks.append(block)
3224
+
3225
+ return [f"Selected spells by mana value ({total_count})"] + _columnize_blocks(
3226
+ blocks=blocks,
3227
+ width=width,
3228
+ )
3229
+
3230
+
3231
+ def _format_tui_spell_columns(
3232
+ *,
3233
+ title: str,
3234
+ groups: tuple[TuiCardQuantityGroup, ...],
3235
+ total_count: int,
3236
+ focused_card_index: int,
3237
+ width: int,
3238
+ mana_icons_enabled: bool = False,
3239
+ ) -> list[str]:
3240
+ blocks = [
3241
+ [
3242
+ _format_tui_spell_card(
3243
+ card=card,
3244
+ quantity=quantity,
3245
+ focused=index == focused_card_index,
3246
+ show_focus_marker=True,
3247
+ mana_icons_enabled=mana_icons_enabled,
3248
+ )
3249
+ ]
3250
+ for index, (card, quantity) in enumerate(groups)
3251
+ ]
3252
+ return [f"{title} ({total_count})"] + _columnize_blocks(blocks=blocks, width=width)
3253
+
3254
+
3255
+ def _columnize_blocks(*, blocks: list[list[str]], width: int) -> list[str]:
3256
+ if not blocks:
3257
+ return ["- none"]
3258
+
3259
+ column_count = max(1, min(2, width // _BUILD_COLUMN_MIN_WIDTH))
3260
+ column_width = min(
3261
+ _BUILD_COLUMN_MAX_WIDTH,
3262
+ max(_BUILD_COLUMN_MIN_WIDTH, width // column_count),
3263
+ )
3264
+ columns: list[list[str]] = [[] for _ in range(column_count)]
3265
+ heights = [0 for _ in range(column_count)]
3266
+ for block in blocks:
3267
+ column_index = min(range(column_count), key=lambda index: heights[index])
3268
+ if columns[column_index]:
3269
+ columns[column_index].append("")
3270
+ heights[column_index] += 1
3271
+
3272
+ columns[column_index].extend(block)
3273
+ heights[column_index] += len(block)
3274
+
3275
+ max_height = max(len(column) for column in columns)
3276
+ lines: list[str] = []
3277
+ for row_index in range(max_height):
3278
+ parts = []
3279
+ for column in columns:
3280
+ text = column[row_index] if row_index < len(column) else ""
3281
+ parts.append(_clip(text=text, width=column_width - 2).ljust(column_width))
3282
+
3283
+ lines.append("".join(parts).rstrip())
3284
+
3285
+ return lines
3286
+
3287
+
3288
+ def _format_tui_lands(
3289
+ *,
3290
+ mana_base: ManaBase,
3291
+ mana_icons_enabled: bool = False,
3292
+ ) -> list[str]:
3293
+ lines = [f"Lands: {mana_base.land_count} ({mana_base.reason})"]
3294
+ basics = ", ".join(
3295
+ f"{basic.count} {basic.name}" for basic in mana_base.basic_lands
3296
+ )
3297
+ if basics:
3298
+ lines.append(f"Basics: {basics}")
3299
+ else:
3300
+ lines.append("Basics: none")
3301
+
3302
+ if mana_base.nonbasic_lands:
3303
+ nonbasic_parts = []
3304
+ for land in mana_base.nonbasic_lands:
3305
+ source_label = _format_color_label(
3306
+ colors=land.source_colors,
3307
+ mana_icons_enabled=mana_icons_enabled,
3308
+ long_colorless=False,
3309
+ )
3310
+ nonbasic_parts.append(f"{land.card.name} ({source_label} source)")
3311
+
3312
+ lines.append(f"Nonbasics: {'; '.join(nonbasic_parts)}")
3313
+ else:
3314
+ lines.append("Nonbasics: none")
3315
+
3316
+ return lines
3317
+
3318
+
3319
+ def _format_tui_spell_counts(
3320
+ *,
3321
+ spell_selection: SpellSelection,
3322
+ mana_icons_enabled: bool = False,
3323
+ ) -> list[str]:
3324
+ counts = spell_selection.counts
3325
+ constraints = spell_selection.constraints
3326
+ pair = _format_pair_label(
3327
+ pair=spell_selection.pair,
3328
+ mana_icons_enabled=mana_icons_enabled,
3329
+ )
3330
+ return [
3331
+ "Structure checks",
3332
+ f"Eligible spells for {pair}: {spell_selection.eligible_count}",
3333
+ f"Selected spells: {counts.total}/{spell_selection.requested_spell_count}",
3334
+ (
3335
+ f"Creatures: {counts.creatures} "
3336
+ f"(target {constraints.creature_floor}-{constraints.creature_ceiling})"
3337
+ ),
3338
+ f"Two-drops: {counts.two_drops} (minimum {constraints.minimum_two_drops})",
3339
+ f"Expensive spells: {counts.expensive} (soft cap {constraints.maximum_expensive_spells})",
3340
+ f"Applied relaxations: {_format_tui_relaxations(spell_selection.applied_relaxations)}",
3341
+ ]
3342
+
3343
+
3344
+ def _format_tui_pair_scores(
3345
+ *,
3346
+ selection: PairSelection,
3347
+ mana_icons_enabled: bool = False,
3348
+ ) -> list[str]:
3349
+ lines = [
3350
+ "Color-pair reasoning",
3351
+ (
3352
+ "This diagnostic compares playable cards with 17Lands color-pair "
3353
+ "context; it is not another decklist."
3354
+ ),
3355
+ ]
3356
+ for score in selection.ranked_scores:
3357
+ pair = _format_pair_label(
3358
+ pair=score.pair,
3359
+ mana_icons_enabled=mana_icons_enabled,
3360
+ )
3361
+ lines.append(
3362
+ f"- {pair}: {score.playable_count} playable cards; "
3363
+ f"pair strength {score.blended_score:.2f}; "
3364
+ f"17Lands WR {_format_tui_win_rate(score.pair_win_rate)}; "
3365
+ f"top {selection.target_spell_count} sum {score.playable_score_sum:.2f}"
3366
+ )
3367
+
3368
+ return lines
3369
+
3370
+
3371
+ def _format_tui_bench(
3372
+ *,
3373
+ selection: SpellSelection,
3374
+ width: int,
3375
+ mana_icons_enabled: bool = False,
3376
+ ) -> list[str]:
3377
+ lines = ["Bench"]
3378
+ if not selection.bench:
3379
+ return lines + ["- none"]
3380
+
3381
+ for card, quantity in _group_tui_spell_cards(cards=selection.bench):
3382
+ lines.append(
3383
+ _clip(
3384
+ text=_format_tui_spell_card(
3385
+ card=card,
3386
+ quantity=quantity,
3387
+ mana_icons_enabled=mana_icons_enabled,
3388
+ ),
3389
+ width=width,
3390
+ )
3391
+ )
3392
+
3393
+ return lines
3394
+
3395
+
3396
+ def _format_tui_spell_card(
3397
+ *,
3398
+ card: ScoredCard,
3399
+ quantity: int = 1,
3400
+ focused: bool = False,
3401
+ show_focus_marker: bool = False,
3402
+ mana_icons_enabled: bool = False,
3403
+ ) -> str:
3404
+ quantity_suffix = _format_tui_quantity_suffix(quantity=quantity)
3405
+ focus_marker = ""
3406
+ if show_focus_marker:
3407
+ focus_marker = "▶ " if focused else " "
3408
+
3409
+ color_label = _format_card_colors(
3410
+ card=card.card,
3411
+ mana_icons_enabled=mana_icons_enabled,
3412
+ long_colorless=False,
3413
+ )
3414
+ return (
3415
+ f"{focus_marker}"
3416
+ f"{_format_win_rate(scored_card=card):>6} "
3417
+ f"{_format_letter_grade(scored_card=card):>2} "
3418
+ f"{card.score:>2} "
3419
+ f"{card.card.name} ({color_label})"
3420
+ f"{quantity_suffix}"
3421
+ )
3422
+
3423
+
3424
+ def _group_tui_spell_cards(
3425
+ *,
3426
+ cards: Iterable[ScoredCard],
3427
+ ) -> tuple[TuiCardQuantityGroup, ...]:
3428
+ grouped_cards: dict[TuiCardQuantityKey, TuiCardQuantityGroup] = {}
3429
+ for card in cards:
3430
+ quantity_key = _tui_card_quantity_key(card=card.card)
3431
+ existing_group = grouped_cards.get(quantity_key)
3432
+ if existing_group is None:
3433
+ grouped_cards[quantity_key] = (card, 1)
3434
+ continue
3435
+
3436
+ representative, quantity = existing_group
3437
+ grouped_cards[quantity_key] = (representative, quantity + 1)
3438
+
3439
+ return tuple(grouped_cards.values())
3440
+
3441
+
3442
+ def _tui_card_quantity_key(*, card: CardInfo) -> TuiCardQuantityKey:
3443
+ if card.unknown:
3444
+ return ("unknown", str(card.grp_id))
3445
+
3446
+ return ("name", " ".join(card.name.casefold().split()))
3447
+
3448
+
3449
+ def _format_tui_quantity_suffix(*, quantity: int) -> str:
3450
+ if quantity <= 1:
3451
+ return ""
3452
+
3453
+ return f" x{quantity}"
3454
+
3455
+
3456
+ def _tui_spell_curve_sort_key(card: ScoredCard) -> tuple[float, int, str, int]:
3457
+ mana_value = 99.0 if card.card.mana_value is None else card.card.mana_value
3458
+ return (mana_value, -card.score, card.card.name, card.original_index)
3459
+
3460
+
3461
+ def _mana_value_bucket(*, card: ScoredCard) -> str:
3462
+ mana_value = card.card.mana_value
3463
+ if mana_value is None:
3464
+ return "?"
3465
+
3466
+ if mana_value >= 6:
3467
+ return "6+"
3468
+
3469
+ return _format_mana_value(card=card.card)
3470
+
3471
+
3472
+ def _ordered_mana_buckets(*, groups: dict[str, list[ScoredCard]]) -> tuple[str, ...]:
3473
+ ordered = tuple(label for label in ("0", "1", "2", "3", "4", "5", "6+") if label in groups)
3474
+ if "?" in groups:
3475
+ return ordered + ("?",)
3476
+
3477
+ return ordered
3478
+
3479
+
3480
+ def _format_plain_color_counts(
3481
+ counts: tuple[tuple[str, int], ...],
3482
+ *,
3483
+ mana_icons_enabled: bool = False,
3484
+ ) -> str:
3485
+ if not counts:
3486
+ return "none"
3487
+
3488
+ parts = []
3489
+ for color, count in counts:
3490
+ label = _format_color_count_label(
3491
+ color=color,
3492
+ mana_icons_enabled=mana_icons_enabled,
3493
+ )
3494
+ parts.append(f"{label} {count}")
3495
+
3496
+ return ", ".join(parts)
3497
+
3498
+
3499
+ def _format_plain_colors(colors: tuple[str, ...]) -> str:
3500
+ return _format_color_label(colors=colors, mana_icons_enabled=False)
3501
+
3502
+
3503
+ def _format_card_colors(
3504
+ *,
3505
+ card: CardInfo,
3506
+ mana_icons_enabled: bool = False,
3507
+ long_colorless: bool = True,
3508
+ ) -> str:
3509
+ if card.unknown:
3510
+ return "Unknown"
3511
+
3512
+ return _format_color_label(
3513
+ colors=card.colors,
3514
+ mana_icons_enabled=mana_icons_enabled,
3515
+ long_colorless=long_colorless,
3516
+ )
3517
+
3518
+
3519
+ def _format_color_label(
3520
+ *,
3521
+ colors: tuple[str, ...],
3522
+ mana_icons_enabled: bool = False,
3523
+ long_colorless: bool = True,
3524
+ ) -> str:
3525
+ if not colors:
3526
+ return _format_colorless_label(
3527
+ mana_icons_enabled=mana_icons_enabled,
3528
+ long_colorless=long_colorless,
3529
+ )
3530
+
3531
+ if not mana_icons_enabled:
3532
+ return "".join(colors)
3533
+
3534
+ return "".join(
3535
+ _format_mana_symbol(symbol=color, mana_icons_enabled=mana_icons_enabled)
3536
+ for color in colors
3537
+ )
3538
+
3539
+
3540
+ def _format_pair_label(*, pair: str, mana_icons_enabled: bool = False) -> str:
3541
+ if not mana_icons_enabled:
3542
+ return pair
3543
+
3544
+ if not pair or any(symbol not in MANA_ICON_GLYPHS for symbol in pair):
3545
+ return pair
3546
+
3547
+ return "".join(
3548
+ _format_mana_symbol(symbol=symbol, mana_icons_enabled=mana_icons_enabled)
3549
+ for symbol in pair
3550
+ )
3551
+
3552
+
3553
+ def _format_color_count_label(*, color: str, mana_icons_enabled: bool = False) -> str:
3554
+ if color == UNKNOWN_COLOR_KEY:
3555
+ return UNKNOWN_COLOR_KEY
3556
+
3557
+ if color == COLORLESS_KEY:
3558
+ return _format_colorless_label(
3559
+ mana_icons_enabled=mana_icons_enabled,
3560
+ long_colorless=False,
3561
+ )
3562
+
3563
+ return _format_pair_label(pair=color, mana_icons_enabled=mana_icons_enabled)
3564
+
3565
+
3566
+ def _format_colorless_label(
3567
+ *,
3568
+ mana_icons_enabled: bool,
3569
+ long_colorless: bool,
3570
+ ) -> str:
3571
+ if not mana_icons_enabled:
3572
+ return "Colorless"
3573
+
3574
+ fallback = "Colorless" if long_colorless else COLORLESS_KEY
3575
+ return f"{MANA_ICON_GLYPHS[COLORLESS_KEY]} {fallback}"
3576
+
3577
+
3578
+ def _format_mana_symbol(*, symbol: str, mana_icons_enabled: bool) -> str:
3579
+ if not mana_icons_enabled:
3580
+ return symbol
3581
+
3582
+ return MANA_ICON_GLYPHS.get(symbol, symbol)
3583
+
3584
+
3585
+ def _format_card_types(
3586
+ *,
3587
+ card: CardInfo,
3588
+ mana_icons_enabled: bool = False,
3589
+ ) -> str:
3590
+ type_line = " ".join(card.types) if card.types else "Unknown"
3591
+ if card.unknown or not mana_icons_enabled:
3592
+ return type_line
3593
+
3594
+ icons = [
3595
+ glyph
3596
+ for card_type, glyph in MANA_CARD_TYPE_GLYPHS.items()
3597
+ if any(card_type in type_part for type_part in card.types)
3598
+ ]
3599
+ if not icons:
3600
+ return type_line
3601
+
3602
+ return f"{''.join(icons)} {type_line}"
3603
+
3604
+
3605
+ def _format_tui_relaxations(relaxations: tuple[str, ...]) -> str:
3606
+ return ", ".join(relaxations) if relaxations else "none"
3607
+
3608
+
3609
+ def _format_tui_win_rate(value: float | None) -> str:
3610
+ if value is None:
3611
+ return "unknown"
3612
+
3613
+ return f"{value:.1%}"
3614
+
3615
+
3616
+ def _clip(*, text: str, width: int) -> str:
3617
+ if len(text) <= width:
3618
+ return text
3619
+
3620
+ if width <= 1:
3621
+ return "…"
3622
+
3623
+ protected_suffix = _clip_protected_suffix(text=text)
3624
+ if protected_suffix and width > len(protected_suffix) + 1:
3625
+ prefix_width = width - len(protected_suffix) - 1
3626
+ return text[:prefix_width] + "…" + protected_suffix
3627
+
3628
+ return text[: width - 1] + "…"
3629
+
3630
+
3631
+ def _clip_protected_suffix(*, text: str) -> str:
3632
+ quantity_index = text.rfind(" x")
3633
+ if quantity_index == -1 or not text[quantity_index + 2 :].isdigit():
3634
+ return ""
3635
+
3636
+ color_close_index = quantity_index - 1
3637
+ if color_close_index < 0 or text[color_close_index] != ")":
3638
+ return ""
3639
+
3640
+ color_open_index = text.rfind("(", 0, color_close_index)
3641
+ if color_open_index == -1:
3642
+ return ""
3643
+
3644
+ if color_open_index > 0 and text[color_open_index - 1] == " ":
3645
+ return text[color_open_index - 1 :]
3646
+
3647
+ return text[color_open_index:]
3648
+
3649
+
3650
+ _ERROR_EXIT_CODE = 1
3651
+
3652
+
3653
+ def run_tui_watch(
3654
+ *,
3655
+ log_path: PathInput,
3656
+ card_database: CardDatabase | None = None,
3657
+ card_database_loader: CardDatabaseLoader | None = None,
3658
+ app_dir: PathInput | None = None,
3659
+ poll_interval: float = POLL_INTERVAL_SECONDS,
3660
+ once: bool = False,
3661
+ startup_scan: bool = False,
3662
+ ratings_loader: RatingsLoader | None = None,
3663
+ ratings_loader_factory: RatingsLoaderFactory | None = None,
3664
+ ratings_progress_loader: RatingsProgressLoader | None = None,
3665
+ ratings_progress_loader_factory: RatingsProgressLoaderFactory | None = None,
3666
+ ratings_cache_checker: RatingsCacheChecker | None = None,
3667
+ mana_icons_enabled: bool = False,
3668
+ splash_enabled: bool | None = None,
3669
+ ) -> int:
3670
+ """Run Textual watch mode and return a process-style exit code.
3671
+ Metadata may load in a worker after the initial shell has rendered.
3672
+ """
3673
+
3674
+ app = DraftomenTuiApp(
3675
+ log_path=log_path,
3676
+ card_database=card_database,
3677
+ card_database_loader=card_database_loader,
3678
+ app_dir=app_dir,
3679
+ poll_interval=poll_interval,
3680
+ ratings_loader=ratings_loader,
3681
+ ratings_loader_factory=ratings_loader_factory,
3682
+ ratings_progress_loader=ratings_progress_loader,
3683
+ ratings_progress_loader_factory=ratings_progress_loader_factory,
3684
+ ratings_cache_checker=ratings_cache_checker,
3685
+ startup_scan=startup_scan,
3686
+ once=once,
3687
+ mana_icons_enabled=mana_icons_enabled,
3688
+ splash_enabled=splash_enabled,
3689
+ )
3690
+ try:
3691
+ app.run(headless=once)
3692
+ except KeyboardInterrupt:
3693
+ return 130
3694
+ except Exception:
3695
+ return _ERROR_EXIT_CODE
3696
+
3697
+ return 0
3698
+
3699
+
3700
+
3701
+
3702
+ def _row_cells(
3703
+ *,
3704
+ rank: int,
3705
+ scored_card: ScoredCard,
3706
+ column_keys: tuple[str, ...],
3707
+ mana_icons_enabled: bool = False,
3708
+ ) -> tuple[object, ...]:
3709
+ values = {
3710
+ "rank": f"{rank:02d}",
3711
+ "win_rate": _format_win_rate(scored_card=scored_card),
3712
+ "grade": _format_letter_grade(scored_card=scored_card),
3713
+ "score": str(scored_card.score),
3714
+ "card": _format_pick_card_name(scored_card=scored_card),
3715
+ "colors": _styled_colors(
3716
+ card=scored_card.card,
3717
+ mana_icons_enabled=mana_icons_enabled,
3718
+ ),
3719
+ "fit": _format_color_fit(scored_card=scored_card),
3720
+ "gih": _format_win_rate(scored_card=scored_card),
3721
+ "alsa": _format_alsa(scored_card=scored_card),
3722
+ "mv": _format_mana_value(card=scored_card.card),
3723
+ "source": scored_card.source_label,
3724
+ }
3725
+ return tuple(values[column_key] for column_key in column_keys)
3726
+
3727
+
3728
+
3729
+
3730
+ def _commitment_label(*, commitment: float) -> str:
3731
+ if commitment <= 0.0:
3732
+ phase = "open"
3733
+ elif commitment >= 1.0:
3734
+ phase = "locked"
3735
+ else:
3736
+ phase = "building"
3737
+
3738
+ return f"{int(round(commitment * 100))}% {phase}"
3739
+
3740
+
3741
+ def _format_card_name(*, card: CardInfo) -> str:
3742
+ if card.unknown:
3743
+ return f"{card.name} (grpId {card.grp_id})"
3744
+
3745
+ return card.name
3746
+
3747
+
3748
+ def _format_pick_card_name(*, scored_card: ScoredCard) -> str:
3749
+ card_name = _format_card_name(card=scored_card.card)
3750
+ splash_color = scored_card.splash.splash_color
3751
+ if splash_color is None:
3752
+ return card_name
3753
+
3754
+ color_name = COLOR_NAMES.get(splash_color, splash_color).upper()
3755
+ if scored_card.color_fit == "splash-ready":
3756
+ return f"SPLASH {color_name} — {card_name}"
3757
+ if scored_card.color_fit == "splash-speculative":
3758
+ return f"POSSIBLE SPLASH {color_name} — {card_name}"
3759
+ if scored_card.color_fit == "splash-fixer":
3760
+ return f"{color_name} SPLASH MANA — {card_name}"
3761
+
3762
+ return card_name
3763
+
3764
+
3765
+ def _styled_colors(*, card: CardInfo, mana_icons_enabled: bool = False) -> Text:
3766
+ if card.unknown:
3767
+ return Text("Unknown", style="bold yellow")
3768
+
3769
+ if not card.colors:
3770
+ colorless = _format_colorless_label(
3771
+ mana_icons_enabled=mana_icons_enabled,
3772
+ long_colorless=False,
3773
+ )
3774
+ return Text(colorless, style="grey50")
3775
+
3776
+ text = Text()
3777
+ for color in card.colors:
3778
+ symbol = _format_mana_symbol(
3779
+ symbol=color,
3780
+ mana_icons_enabled=mana_icons_enabled,
3781
+ )
3782
+ text.append(symbol, style=COLOR_STYLES.get(color, "bold"))
3783
+ return text
3784
+
3785
+
3786
+ def _format_color_fit(*, scored_card: ScoredCard) -> str:
3787
+ if scored_card.color_fit == "on-color":
3788
+ return "On"
3789
+
3790
+ if scored_card.color_fit == "off-color":
3791
+ return "Off!"
3792
+
3793
+ if scored_card.color_fit == "colorless":
3794
+ return "Any"
3795
+
3796
+ if scored_card.color_fit == "unknown":
3797
+ return "?"
3798
+
3799
+ if scored_card.color_fit == "splash-ready":
3800
+ return f"Splash {scored_card.splash.splash_color}"
3801
+
3802
+ if scored_card.color_fit == "splash-speculative":
3803
+ return f"Splash? {scored_card.splash.splash_color}"
3804
+
3805
+ if scored_card.color_fit == "splash-fixer":
3806
+ return f"Fix {scored_card.splash.splash_color}"
3807
+
3808
+ return "Open"
3809
+
3810
+
3811
+ def _format_splash_details(*, scored_card: ScoredCard) -> str:
3812
+ splash = scored_card.splash
3813
+ if splash.splash_color is None:
3814
+ return ""
3815
+
3816
+ color_name = COLOR_NAMES.get(splash.splash_color, splash.splash_color)
3817
+ classification = {
3818
+ "splash-ready": "Recommended — mana is supported",
3819
+ "splash-speculative": "Speculative — more fixing is needed",
3820
+ "splash-fixer": "Fixing pick for the active splash",
3821
+ "off-color": "Not recommended as a splash",
3822
+ }.get(splash.classification, splash.classification.replace("-", " ").title())
3823
+ source_line = ""
3824
+ if splash.required_sources > 0:
3825
+ source_parts = [f"{splash.fixing_sources} fixing lands"]
3826
+ if splash.planned_basic_sources:
3827
+ basic_name = BASIC_LAND_NAMES.get(
3828
+ splash.splash_color,
3829
+ "basic land",
3830
+ )
3831
+ source_parts.append(
3832
+ f"{splash.planned_basic_sources} planned {basic_name}"
3833
+ )
3834
+ source_line = (
3835
+ f"Mana support: {splash.available_sources} of "
3836
+ f"{splash.required_sources} sources "
3837
+ f"({', '.join(source_parts)})\n"
3838
+ )
3839
+ reason = "; ".join(splash.reasons)
3840
+ return (
3841
+ f"Splash recommendation: {color_name}\n"
3842
+ f"Splash assessment: {classification}\n"
3843
+ f"{source_line}"
3844
+ f"Reason: {reason}\n"
3845
+ )
3846
+
3847
+
3848
+ def _format_win_rate(*, scored_card: ScoredCard) -> str:
3849
+ if scored_card.rating.gih_win_rate is None:
3850
+ return "—"
3851
+
3852
+ return f"{scored_card.rating.gih_win_rate:.1%}"
3853
+
3854
+
3855
+ def _format_letter_grade(*, scored_card: ScoredCard) -> str:
3856
+ return scored_card.rating.letter_grade or "—"
3857
+
3858
+
3859
+ def _format_alsa(*, scored_card: ScoredCard) -> str:
3860
+ if scored_card.rating.average_last_seen_at is None:
3861
+ return "—"
3862
+
3863
+ return f"{scored_card.rating.average_last_seen_at:.2f}"
3864
+
3865
+
3866
+ def _format_tui_source_label(*, scored_card: ScoredCard) -> str:
3867
+ labels = {
3868
+ "Quick": "Quick Draft",
3869
+ "Premier": "Premier Draft fallback",
3870
+ "Prior": "neutral prior",
3871
+ }
3872
+ return labels.get(scored_card.source_label, scored_card.source_label)
3873
+
3874
+
3875
+ def _format_mana_value(*, card: CardInfo) -> str:
3876
+ if card.mana_value is None:
3877
+ return "—"
3878
+
3879
+ if card.mana_value.is_integer():
3880
+ return str(int(card.mana_value))
3881
+
3882
+ return f"{card.mana_value:.1f}"
3883
+
3884
+
3885
+ def _pool_color_distribution_bar(
3886
+ *,
3887
+ pool_grp_ids: tuple[int, ...],
3888
+ card_database: CardDatabase,
3889
+ mana_icons_enabled: bool = False,
3890
+ ) -> str:
3891
+ if not pool_grp_ids:
3892
+ return "Colors: none"
3893
+
3894
+ counts = _pool_color_counts(
3895
+ pool_grp_ids=pool_grp_ids,
3896
+ card_database=card_database,
3897
+ )
3898
+ max_count = max(counts.values(), default=0)
3899
+ keys = COLOR_ORDER + (COLORLESS_KEY,)
3900
+ parts = []
3901
+ for color in keys:
3902
+ bar = _scaled_bar(count=counts[color], max_count=max_count)
3903
+ label = _format_color_count_label(
3904
+ color=color,
3905
+ mana_icons_enabled=mana_icons_enabled,
3906
+ )
3907
+ parts.append(f"{label} {bar} {counts[color]}")
3908
+ if counts[UNKNOWN_COLOR_KEY] > 0:
3909
+ parts.append(
3910
+ f"? {_scaled_bar(count=counts[UNKNOWN_COLOR_KEY], max_count=max_count)} "
3911
+ f"{counts[UNKNOWN_COLOR_KEY]}"
3912
+ )
3913
+
3914
+ return "Colors: " + " | ".join(parts)
3915
+
3916
+
3917
+ def _pool_color_counts(
3918
+ *,
3919
+ pool_grp_ids: tuple[int, ...],
3920
+ card_database: CardDatabase,
3921
+ ) -> Counter[str]:
3922
+ counts: Counter[str] = Counter({color: 0 for color in COLOR_ORDER})
3923
+ counts[COLORLESS_KEY] = 0
3924
+ counts[UNKNOWN_COLOR_KEY] = 0
3925
+ for grp_id in pool_grp_ids:
3926
+ card = card_database.lookup(grp_id=grp_id)
3927
+ if card.unknown:
3928
+ counts[UNKNOWN_COLOR_KEY] += 1
3929
+ continue
3930
+
3931
+ if not card.colors:
3932
+ counts[COLORLESS_KEY] += 1
3933
+ continue
3934
+
3935
+ for color in card.colors:
3936
+ if color in COLOR_ORDER:
3937
+ counts[color] += 1
3938
+ else:
3939
+ counts[UNKNOWN_COLOR_KEY] += 1
3940
+
3941
+ return counts
3942
+
3943
+
3944
+ def _pool_curve_sparkline(
3945
+ *,
3946
+ pool_grp_ids: tuple[int, ...],
3947
+ card_database: CardDatabase,
3948
+ ) -> str:
3949
+ if not pool_grp_ids:
3950
+ return "Curve: none"
3951
+
3952
+ counts = _mana_curve_counts(
3953
+ pool_grp_ids=pool_grp_ids,
3954
+ card_database=card_database,
3955
+ )
3956
+ max_count = max(counts, default=0)
3957
+ parts = []
3958
+ for index, label in enumerate(CURVE_BUCKET_LABELS):
3959
+ glyph = _sparkline_glyph(count=counts[index], max_count=max_count)
3960
+ parts.append(f"{label}{glyph}{counts[index]}")
3961
+ return "Curve: " + " ".join(parts)
3962
+
3963
+
3964
+ def _mana_curve_counts(
3965
+ *,
3966
+ pool_grp_ids: tuple[int, ...],
3967
+ card_database: CardDatabase,
3968
+ ) -> list[int]:
3969
+ counts = [0 for _ in CURVE_BUCKET_LABELS]
3970
+ for grp_id in pool_grp_ids:
3971
+ card = card_database.lookup(grp_id=grp_id)
3972
+ if card.unknown or card.mana_value is None or _is_land_card(card=card):
3973
+ continue
3974
+
3975
+ bucket = _curve_bucket(mana_value=card.mana_value)
3976
+ counts[bucket] += 1
3977
+
3978
+ return counts
3979
+
3980
+
3981
+ def _curve_bucket(*, mana_value: float) -> int:
3982
+ rounded = max(0, int(mana_value))
3983
+ return min(rounded, len(CURVE_BUCKET_LABELS) - 1)
3984
+
3985
+
3986
+ def _is_land_card(*, card: CardInfo) -> bool:
3987
+ return any("Land" in type_line for type_line in card.types)
3988
+
3989
+
3990
+ def _is_creature_card(*, card: CardInfo) -> bool:
3991
+ return any("Creature" in type_line for type_line in card.types)
3992
+
3993
+
3994
+ def _scaled_bar(*, count: int, max_count: int, width: int = 5) -> str:
3995
+ if count <= 0 or max_count <= 0:
3996
+ return "·"
3997
+
3998
+ filled = max(1, round((count / max_count) * width))
3999
+ return "█" * filled
4000
+
4001
+
4002
+ def _sparkline_glyph(*, count: int, max_count: int) -> str:
4003
+ if count <= 0 or max_count <= 0:
4004
+ return "·"
4005
+
4006
+ index = max(0, round((count / max_count) * (len(SPARKLINE_GLYPHS) - 1)))
4007
+ return SPARKLINE_GLYPHS[index]