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.
- draftomen/__init__.py +17 -0
- draftomen/assets/draftomen.icns +0 -0
- draftomen/assets/draftomen.ico +0 -0
- draftomen/assets/draftomen_logo.png +0 -0
- draftomen/audit.py +606 -0
- draftomen/backtest.py +449 -0
- draftomen/benchmark.py +834 -0
- draftomen/carddb.py +1869 -0
- draftomen/cardimages.py +313 -0
- draftomen/cli.py +891 -0
- draftomen/config.py +133 -0
- draftomen/deckbuilder.py +2723 -0
- draftomen/events.py +772 -0
- draftomen/logfollow.py +451 -0
- draftomen/mock_session.py +745 -0
- draftomen/paths.py +120 -0
- draftomen/pickengine.py +1117 -0
- draftomen/pool.py +1259 -0
- draftomen/preferences.py +289 -0
- draftomen/qml/AboutDialog.qml +156 -0
- draftomen/qml/AppBar.qml +120 -0
- draftomen/qml/BacktestView.qml +316 -0
- draftomen/qml/BuildView.qml +1151 -0
- draftomen/qml/CardPreview.qml +434 -0
- draftomen/qml/DimensionalButton.qml +42 -0
- draftomen/qml/DimensionalComboBox.qml +182 -0
- draftomen/qml/DimensionalSurface.qml +118 -0
- draftomen/qml/DimensionalTabButton.qml +41 -0
- draftomen/qml/LiveDraftView.qml +468 -0
- draftomen/qml/Main.qml +175 -0
- draftomen/qml/NavigationRail.qml +226 -0
- draftomen/qml/PoolSummaryPanel.qml +322 -0
- draftomen/qml/PrivacyDialog.qml +96 -0
- draftomen/qml/RecentPickThumbnail.qml +83 -0
- draftomen/qml/RecentPicksGallery.qml +333 -0
- draftomen/qml/RecommendationRow.qml +404 -0
- draftomen/qml/SettingsSwitch.qml +141 -0
- draftomen/qml/SettingsView.qml +531 -0
- draftomen/qml/StateBanner.qml +189 -0
- draftomen/qml/StatusStrip.qml +84 -0
- draftomen/qml/Theme.qml +71 -0
- draftomen/qml/qmldir +23 -0
- draftomen/qt_adapter.py +884 -0
- draftomen/qt_gui.py +338 -0
- draftomen/qt_mock.py +63 -0
- draftomen/ranking.py +126 -0
- draftomen/replay.py +480 -0
- draftomen/session.py +3668 -0
- draftomen/setinfo.py +26 -0
- draftomen/seventeen.py +2766 -0
- draftomen/splash.py +617 -0
- draftomen/tui.py +4007 -0
- draftomen/watch.py +336 -0
- draftomen-0.3.0.dist-info/METADATA +156 -0
- draftomen-0.3.0.dist-info/RECORD +59 -0
- draftomen-0.3.0.dist-info/WHEEL +5 -0
- draftomen-0.3.0.dist-info/entry_points.txt +6 -0
- draftomen-0.3.0.dist-info/licenses/LICENSE +21 -0
- draftomen-0.3.0.dist-info/top_level.txt +1 -0
draftomen/qt_adapter.py
ADDED
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
"""Translate shared live-session state and commands for Qt frontends.
|
|
2
|
+
Keep blocking session work in adapter-owned workers and QML values presentation-only.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import threading
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import fields, is_dataclass, replace
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from os import PathLike
|
|
12
|
+
from typing import Any, Literal, TypeAlias, cast
|
|
13
|
+
|
|
14
|
+
from PySide6.QtCore import (
|
|
15
|
+
QAbstractListModel,
|
|
16
|
+
QByteArray,
|
|
17
|
+
QCoreApplication,
|
|
18
|
+
QEvent,
|
|
19
|
+
QModelIndex,
|
|
20
|
+
QMetaObject,
|
|
21
|
+
QObject,
|
|
22
|
+
Property,
|
|
23
|
+
QThread,
|
|
24
|
+
QTimer,
|
|
25
|
+
QUrl,
|
|
26
|
+
Qt,
|
|
27
|
+
Signal,
|
|
28
|
+
Slot,
|
|
29
|
+
)
|
|
30
|
+
from PySide6.QtGui import QFontInfo, QGuiApplication
|
|
31
|
+
|
|
32
|
+
from draftomen.preferences import (
|
|
33
|
+
GuiDisplayPreferences,
|
|
34
|
+
load_gui_preferences,
|
|
35
|
+
save_gui_preferences,
|
|
36
|
+
)
|
|
37
|
+
from draftomen.ranking import RankingMode
|
|
38
|
+
from draftomen.session import (
|
|
39
|
+
CardImageFetchResult,
|
|
40
|
+
CardImageRequest,
|
|
41
|
+
ChangeRanking,
|
|
42
|
+
ChangeSplashPreference,
|
|
43
|
+
ChooseAccount,
|
|
44
|
+
ChooseRecommendation,
|
|
45
|
+
DismissError,
|
|
46
|
+
FocusBuildCard,
|
|
47
|
+
LiveSession,
|
|
48
|
+
LiveSessionCommand,
|
|
49
|
+
LiveSessionSnapshot,
|
|
50
|
+
RequestBacktest,
|
|
51
|
+
RequestBuild,
|
|
52
|
+
RequestRatingsDownload,
|
|
53
|
+
RetryError,
|
|
54
|
+
SnapshotPublisher,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
SessionFactory = Callable[[SnapshotPublisher], LiveSession]
|
|
58
|
+
_ImageRequestKind: TypeAlias = Literal["selected", "recommendation", "recent"]
|
|
59
|
+
_OMITTED_SNAPSHOT_FIELDS = frozenset(("current_pack_event", "current_scored_pack"))
|
|
60
|
+
|
|
61
|
+
_DEFAULT_APPLICATION_FONT_PIXEL_SIZE = 13
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_WORKER_ERROR_ID = "qt-worker-error"
|
|
65
|
+
|
|
66
|
+
_GUI_PREFERENCES_SAVE_LOCK = threading.Lock()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _to_qml_value(value: Any) -> Any:
|
|
70
|
+
"""Convert immutable application values into plain QML-safe values.
|
|
71
|
+
Domain-only and event payloads never cross the frontend boundary.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
if isinstance(value, Enum):
|
|
75
|
+
return value.value
|
|
76
|
+
if is_dataclass(value) and not isinstance(value, type):
|
|
77
|
+
converted = {
|
|
78
|
+
field.name: _to_qml_value(getattr(value, field.name))
|
|
79
|
+
for field in fields(value)
|
|
80
|
+
if not field.name.startswith("domain_")
|
|
81
|
+
and field.name not in _OMITTED_SNAPSHOT_FIELDS
|
|
82
|
+
}
|
|
83
|
+
image_path = converted.get("image_path")
|
|
84
|
+
if image_path is not None:
|
|
85
|
+
converted["image_path"] = QUrl.fromLocalFile(image_path).toString()
|
|
86
|
+
return converted
|
|
87
|
+
if isinstance(value, tuple):
|
|
88
|
+
return [_to_qml_value(item) for item in value]
|
|
89
|
+
if isinstance(value, dict):
|
|
90
|
+
return {
|
|
91
|
+
str(key): _to_qml_value(item)
|
|
92
|
+
for key, item in value.items()
|
|
93
|
+
}
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class RecommendationListModel(QAbstractListModel):
|
|
98
|
+
"""Publish recommendation rows through a narrow Qt item model.
|
|
99
|
+
Each row is the same plain mapping available in the immutable state projection.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
MODEL_DATA_ROLE = Qt.ItemDataRole.UserRole + 1
|
|
103
|
+
|
|
104
|
+
def __init__(self, *, parent: QObject | None = None) -> None:
|
|
105
|
+
super().__init__(parent)
|
|
106
|
+
self._rows: list[dict[str, Any]] = []
|
|
107
|
+
|
|
108
|
+
def roleNames(self) -> dict[int, QByteArray]:
|
|
109
|
+
return {self.MODEL_DATA_ROLE: QByteArray(b"modelData")}
|
|
110
|
+
|
|
111
|
+
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
|
|
112
|
+
if parent.isValid():
|
|
113
|
+
return 0
|
|
114
|
+
return len(self._rows)
|
|
115
|
+
|
|
116
|
+
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
|
|
117
|
+
if not index.isValid() or not 0 <= index.row() < len(self._rows):
|
|
118
|
+
return None
|
|
119
|
+
if role == self.MODEL_DATA_ROLE:
|
|
120
|
+
return self._rows[index.row()]
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
def replace(self, *, rows: list[dict[str, Any]]) -> None:
|
|
124
|
+
if rows == self._rows:
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
previous_count = len(self._rows)
|
|
128
|
+
next_count = len(rows)
|
|
129
|
+
shared_count = min(previous_count, next_count)
|
|
130
|
+
changed_rows = [
|
|
131
|
+
row
|
|
132
|
+
for row in range(shared_count)
|
|
133
|
+
if self._rows[row] != rows[row]
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
if changed_rows:
|
|
137
|
+
self._rows[:shared_count] = rows[:shared_count]
|
|
138
|
+
first_changed = changed_rows[0]
|
|
139
|
+
last_changed = changed_rows[-1]
|
|
140
|
+
self.dataChanged.emit(
|
|
141
|
+
self.index(first_changed, 0),
|
|
142
|
+
self.index(last_changed, 0),
|
|
143
|
+
[self.MODEL_DATA_ROLE],
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if next_count < previous_count:
|
|
147
|
+
self.beginRemoveRows(
|
|
148
|
+
QModelIndex(),
|
|
149
|
+
next_count,
|
|
150
|
+
previous_count - 1,
|
|
151
|
+
)
|
|
152
|
+
del self._rows[next_count:]
|
|
153
|
+
self.endRemoveRows()
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
if next_count > previous_count:
|
|
157
|
+
self.beginInsertRows(
|
|
158
|
+
QModelIndex(),
|
|
159
|
+
previous_count,
|
|
160
|
+
next_count - 1,
|
|
161
|
+
)
|
|
162
|
+
self._rows.extend(rows[previous_count:])
|
|
163
|
+
self.endInsertRows()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class _GuiPreferencesSaveThread(QThread):
|
|
167
|
+
"""Serialize immutable GUI preference snapshots on one worker thread.
|
|
168
|
+
At most one snapshot is active; pending work is coalesced to the newest.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
saveFinished = Signal(int, str)
|
|
172
|
+
|
|
173
|
+
def __init__(
|
|
174
|
+
self,
|
|
175
|
+
*,
|
|
176
|
+
app_dir: str | PathLike[str] | None,
|
|
177
|
+
parent: QObject | None = None,
|
|
178
|
+
) -> None:
|
|
179
|
+
super().__init__(parent)
|
|
180
|
+
self._app_dir = app_dir
|
|
181
|
+
self._condition = threading.Condition()
|
|
182
|
+
self._pending: tuple[int, GuiDisplayPreferences] | None = None
|
|
183
|
+
self._stop_requested = False
|
|
184
|
+
|
|
185
|
+
def enqueue(
|
|
186
|
+
self,
|
|
187
|
+
*,
|
|
188
|
+
generation: int,
|
|
189
|
+
preferences: GuiDisplayPreferences,
|
|
190
|
+
) -> None:
|
|
191
|
+
with self._condition:
|
|
192
|
+
if self._stop_requested:
|
|
193
|
+
return
|
|
194
|
+
self._pending = (generation, preferences)
|
|
195
|
+
self._condition.notify()
|
|
196
|
+
|
|
197
|
+
def request_stop(self) -> None:
|
|
198
|
+
with self._condition:
|
|
199
|
+
self._stop_requested = True
|
|
200
|
+
self._condition.notify()
|
|
201
|
+
|
|
202
|
+
def run(self) -> None:
|
|
203
|
+
while True:
|
|
204
|
+
with self._condition:
|
|
205
|
+
while self._pending is None and not self._stop_requested:
|
|
206
|
+
self._condition.wait()
|
|
207
|
+
if self._pending is None:
|
|
208
|
+
return
|
|
209
|
+
generation, preferences = self._pending
|
|
210
|
+
self._pending = None
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
with _GUI_PREFERENCES_SAVE_LOCK:
|
|
214
|
+
persistence_message = save_gui_preferences(
|
|
215
|
+
preferences=preferences,
|
|
216
|
+
app_dir=self._app_dir,
|
|
217
|
+
)
|
|
218
|
+
except Exception as error: # pragma: no cover - defensive boundary.
|
|
219
|
+
persistence_message = f"Could not save GUI preferences: {error}"
|
|
220
|
+
self.saveFinished.emit(generation, persistence_message or "Saved")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class GuiPreferencesAdapter(QObject):
|
|
224
|
+
"""Expose persisted display-only GUI choices through narrow Qt properties.
|
|
225
|
+
Ranking and splash choices remain explicit commands on SessionAdapter.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
preferencesChanged = Signal()
|
|
229
|
+
persistenceChanged = Signal()
|
|
230
|
+
applicationFontPixelSizeChanged = Signal()
|
|
231
|
+
|
|
232
|
+
def __init__(
|
|
233
|
+
self,
|
|
234
|
+
*,
|
|
235
|
+
app_dir: str | PathLike[str] | None = None,
|
|
236
|
+
parent: QObject | None = None,
|
|
237
|
+
) -> None:
|
|
238
|
+
super().__init__(parent)
|
|
239
|
+
self._app_dir = app_dir
|
|
240
|
+
self._preferences, self._persistence_message = load_gui_preferences(
|
|
241
|
+
app_dir=app_dir,
|
|
242
|
+
)
|
|
243
|
+
self._save_generation = 0
|
|
244
|
+
self._save_thread: _GuiPreferencesSaveThread | None = None
|
|
245
|
+
self._closing = False
|
|
246
|
+
|
|
247
|
+
application = QCoreApplication.instance()
|
|
248
|
+
if application is not None:
|
|
249
|
+
application.aboutToQuit.connect(self.shutdown)
|
|
250
|
+
if isinstance(application, QGuiApplication):
|
|
251
|
+
application.installEventFilter(self)
|
|
252
|
+
|
|
253
|
+
def __del__(self) -> None:
|
|
254
|
+
try:
|
|
255
|
+
self.shutdown()
|
|
256
|
+
except Exception:
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
def eventFilter(self, _watched: QObject, event: QEvent) -> bool:
|
|
260
|
+
if event.type() == QEvent.Type.ApplicationFontChange:
|
|
261
|
+
self.applicationFontPixelSizeChanged.emit()
|
|
262
|
+
return super().eventFilter(_watched, event)
|
|
263
|
+
|
|
264
|
+
@Property(bool, notify=preferencesChanged)
|
|
265
|
+
def compactDensity(self) -> bool:
|
|
266
|
+
return self._preferences.compact_density
|
|
267
|
+
|
|
268
|
+
@Property(bool, notify=preferencesChanged)
|
|
269
|
+
def secondaryStats(self) -> bool:
|
|
270
|
+
return self._preferences.secondary_stats
|
|
271
|
+
|
|
272
|
+
@Property(bool, notify=preferencesChanged)
|
|
273
|
+
def cardPreview(self) -> bool:
|
|
274
|
+
return self._preferences.card_preview
|
|
275
|
+
|
|
276
|
+
@Property(bool, notify=preferencesChanged)
|
|
277
|
+
def detailedBuildContext(self) -> bool:
|
|
278
|
+
return self._preferences.detailed_build_context
|
|
279
|
+
|
|
280
|
+
@Property(bool, notify=preferencesChanged)
|
|
281
|
+
def systemTextScaling(self) -> bool:
|
|
282
|
+
return self._preferences.system_text_scaling
|
|
283
|
+
|
|
284
|
+
@Property(bool, notify=preferencesChanged)
|
|
285
|
+
def showBacktest(self) -> bool:
|
|
286
|
+
return self._preferences.show_backtest
|
|
287
|
+
|
|
288
|
+
@Property(int, notify=applicationFontPixelSizeChanged)
|
|
289
|
+
def applicationFontPixelSize(self) -> int:
|
|
290
|
+
application = QGuiApplication.instance()
|
|
291
|
+
if not isinstance(application, QGuiApplication):
|
|
292
|
+
return _DEFAULT_APPLICATION_FONT_PIXEL_SIZE
|
|
293
|
+
pixel_size = QFontInfo(application.font()).pixelSize()
|
|
294
|
+
return (
|
|
295
|
+
pixel_size
|
|
296
|
+
if pixel_size > 0
|
|
297
|
+
else _DEFAULT_APPLICATION_FONT_PIXEL_SIZE
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
@Property(str, notify=persistenceChanged)
|
|
301
|
+
def persistenceMessage(self) -> str:
|
|
302
|
+
return self._persistence_message or "Saved"
|
|
303
|
+
|
|
304
|
+
@Slot(bool)
|
|
305
|
+
def setCompactDensity(self, enabled: bool) -> None:
|
|
306
|
+
self._replace_preferences(compact_density=enabled)
|
|
307
|
+
|
|
308
|
+
@Slot(bool)
|
|
309
|
+
def setSecondaryStats(self, enabled: bool) -> None:
|
|
310
|
+
self._replace_preferences(secondary_stats=enabled)
|
|
311
|
+
|
|
312
|
+
@Slot(bool)
|
|
313
|
+
def setCardPreview(self, enabled: bool) -> None:
|
|
314
|
+
self._replace_preferences(card_preview=enabled)
|
|
315
|
+
|
|
316
|
+
@Slot(bool)
|
|
317
|
+
def setDetailedBuildContext(self, enabled: bool) -> None:
|
|
318
|
+
self._replace_preferences(detailed_build_context=enabled)
|
|
319
|
+
|
|
320
|
+
@Slot(bool)
|
|
321
|
+
def setSystemTextScaling(self, enabled: bool) -> None:
|
|
322
|
+
self._replace_preferences(system_text_scaling=enabled)
|
|
323
|
+
|
|
324
|
+
@Slot(bool)
|
|
325
|
+
def setShowBacktest(self, enabled: bool) -> None:
|
|
326
|
+
self._replace_preferences(show_backtest=enabled)
|
|
327
|
+
|
|
328
|
+
@Slot()
|
|
329
|
+
def shutdown(self) -> None:
|
|
330
|
+
self._closing = True
|
|
331
|
+
thread = self._save_thread
|
|
332
|
+
if thread is None:
|
|
333
|
+
return
|
|
334
|
+
thread.request_stop()
|
|
335
|
+
thread.wait()
|
|
336
|
+
|
|
337
|
+
def _ensure_save_thread(self) -> _GuiPreferencesSaveThread:
|
|
338
|
+
thread = self._save_thread
|
|
339
|
+
if thread is None:
|
|
340
|
+
thread = _GuiPreferencesSaveThread(
|
|
341
|
+
app_dir=self._app_dir,
|
|
342
|
+
parent=self,
|
|
343
|
+
)
|
|
344
|
+
thread.saveFinished.connect(
|
|
345
|
+
self._apply_save_result,
|
|
346
|
+
Qt.ConnectionType.QueuedConnection,
|
|
347
|
+
)
|
|
348
|
+
self._save_thread = thread
|
|
349
|
+
if not thread.isRunning():
|
|
350
|
+
thread.start()
|
|
351
|
+
return thread
|
|
352
|
+
|
|
353
|
+
@Slot(int, str)
|
|
354
|
+
def _apply_save_result(
|
|
355
|
+
self,
|
|
356
|
+
generation: int,
|
|
357
|
+
persistence_message: str,
|
|
358
|
+
) -> None:
|
|
359
|
+
if self._closing or generation != self._save_generation:
|
|
360
|
+
return
|
|
361
|
+
self._persistence_message = persistence_message
|
|
362
|
+
self.persistenceChanged.emit()
|
|
363
|
+
|
|
364
|
+
def _replace_preferences(self, **changes: bool) -> None:
|
|
365
|
+
if self._closing:
|
|
366
|
+
return
|
|
367
|
+
updated = replace(self._preferences, **changes)
|
|
368
|
+
if updated == self._preferences:
|
|
369
|
+
return
|
|
370
|
+
self._preferences = updated
|
|
371
|
+
self._save_generation += 1
|
|
372
|
+
generation = self._save_generation
|
|
373
|
+
self._persistence_message = "Saving…"
|
|
374
|
+
self.preferencesChanged.emit()
|
|
375
|
+
self.persistenceChanged.emit()
|
|
376
|
+
self._ensure_save_thread().enqueue(
|
|
377
|
+
generation=generation,
|
|
378
|
+
preferences=updated,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class SessionAdapter(QObject):
|
|
383
|
+
"""Expose one QML-facing provider contract for mock and live sessions.
|
|
384
|
+
Subclasses choose synchronous mock dispatch or queued live dispatch.
|
|
385
|
+
"""
|
|
386
|
+
|
|
387
|
+
stateChanged = Signal()
|
|
388
|
+
scenarioChanged = Signal()
|
|
389
|
+
|
|
390
|
+
def __init__(
|
|
391
|
+
self,
|
|
392
|
+
*,
|
|
393
|
+
snapshot: LiveSessionSnapshot | None = None,
|
|
394
|
+
parent: QObject | None = None,
|
|
395
|
+
) -> None:
|
|
396
|
+
super().__init__(parent)
|
|
397
|
+
self._state: dict[str, Any] = {}
|
|
398
|
+
self._state_before_failure: dict[str, Any] | None = None
|
|
399
|
+
self._recommendations_model = RecommendationListModel(parent=self)
|
|
400
|
+
self._publish(snapshot=LiveSessionSnapshot() if snapshot is None else snapshot)
|
|
401
|
+
|
|
402
|
+
@Property("QVariantMap", notify=stateChanged)
|
|
403
|
+
def state(self) -> dict[str, Any]:
|
|
404
|
+
return self._state
|
|
405
|
+
|
|
406
|
+
@Property(QObject, constant=True)
|
|
407
|
+
def recommendationsModel(self) -> RecommendationListModel:
|
|
408
|
+
return self._recommendations_model
|
|
409
|
+
|
|
410
|
+
@Property(bool, constant=True)
|
|
411
|
+
def mockMode(self) -> bool:
|
|
412
|
+
return False
|
|
413
|
+
|
|
414
|
+
@Property(str, notify=scenarioChanged)
|
|
415
|
+
def scenario(self) -> str:
|
|
416
|
+
return ""
|
|
417
|
+
|
|
418
|
+
@Property("QStringList", notify=scenarioChanged)
|
|
419
|
+
def scenarios(self) -> list[str]:
|
|
420
|
+
return []
|
|
421
|
+
|
|
422
|
+
@Slot(str)
|
|
423
|
+
def selectScenario(self, scenario: str) -> None:
|
|
424
|
+
del scenario
|
|
425
|
+
|
|
426
|
+
@Slot(str)
|
|
427
|
+
def chooseAccount(self, account_id: str) -> None:
|
|
428
|
+
self._dispatch(command=ChooseAccount(account_id=account_id))
|
|
429
|
+
|
|
430
|
+
@Slot(int)
|
|
431
|
+
def chooseRecommendation(self, grp_id: int) -> None:
|
|
432
|
+
self._dispatch(command=ChooseRecommendation(grp_id=grp_id))
|
|
433
|
+
|
|
434
|
+
@Slot(int)
|
|
435
|
+
def focusBuildCard(self, grp_id: int) -> None:
|
|
436
|
+
self._dispatch(command=FocusBuildCard(grp_id=grp_id))
|
|
437
|
+
|
|
438
|
+
@Slot(str)
|
|
439
|
+
def changeRanking(self, ranking_mode: str) -> None:
|
|
440
|
+
self._dispatch(
|
|
441
|
+
command=ChangeRanking(
|
|
442
|
+
ranking_mode=cast(RankingMode, ranking_mode),
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
@Slot(bool)
|
|
447
|
+
def setSplashEnabled(self, enabled: bool) -> None:
|
|
448
|
+
self._dispatch(command=ChangeSplashPreference(enabled=enabled))
|
|
449
|
+
|
|
450
|
+
@Slot()
|
|
451
|
+
def requestRatings(self) -> None:
|
|
452
|
+
ratings = self._state.get("ratings", {})
|
|
453
|
+
set_code = ratings.get("set_code")
|
|
454
|
+
if not set_code:
|
|
455
|
+
return
|
|
456
|
+
self._dispatch(command=RequestRatingsDownload(set_code=set_code))
|
|
457
|
+
|
|
458
|
+
@Slot(str)
|
|
459
|
+
def requestBuild(self, pair_override: str) -> None:
|
|
460
|
+
recommendations = self._state.get("recommendations", {})
|
|
461
|
+
self._dispatch(
|
|
462
|
+
command=RequestBuild(
|
|
463
|
+
pair_override=pair_override or None,
|
|
464
|
+
allow_splash=bool(recommendations.get("splash_enabled", True)),
|
|
465
|
+
)
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
@Slot()
|
|
469
|
+
def requestBacktest(self) -> None:
|
|
470
|
+
self._dispatch(command=RequestBacktest())
|
|
471
|
+
|
|
472
|
+
@Slot(str)
|
|
473
|
+
def dismissError(self, error_id: str) -> None:
|
|
474
|
+
if error_id == _WORKER_ERROR_ID and self._state_before_failure is not None:
|
|
475
|
+
state = self._state_before_failure
|
|
476
|
+
self._state_before_failure = None
|
|
477
|
+
self._replace_state(state=state)
|
|
478
|
+
return
|
|
479
|
+
self._dispatch(command=DismissError(error_id=error_id))
|
|
480
|
+
|
|
481
|
+
@Slot(str)
|
|
482
|
+
def retryError(self, error_id: str) -> None:
|
|
483
|
+
self._dispatch(command=RetryError(error_id=error_id))
|
|
484
|
+
|
|
485
|
+
def _dispatch(self, *, command: LiveSessionCommand) -> None:
|
|
486
|
+
raise NotImplementedError
|
|
487
|
+
|
|
488
|
+
@Slot(object)
|
|
489
|
+
def _apply_snapshot(self, snapshot: LiveSessionSnapshot) -> None:
|
|
490
|
+
self._state_before_failure = None
|
|
491
|
+
self._publish(snapshot=snapshot)
|
|
492
|
+
|
|
493
|
+
@Slot(str)
|
|
494
|
+
def _apply_failure(self, message: str) -> None:
|
|
495
|
+
if self._state_before_failure is None:
|
|
496
|
+
self._state_before_failure = self._state
|
|
497
|
+
state = dict(self._state)
|
|
498
|
+
state["status"] = {
|
|
499
|
+
"phase": "error",
|
|
500
|
+
"message": "Draft Omen could not complete background work.",
|
|
501
|
+
}
|
|
502
|
+
state["progress"] = None
|
|
503
|
+
state["errors"] = [
|
|
504
|
+
{
|
|
505
|
+
"error_id": _WORKER_ERROR_ID,
|
|
506
|
+
"code": "qt_worker_error",
|
|
507
|
+
"message": message,
|
|
508
|
+
"recoverable": False,
|
|
509
|
+
"operation": None,
|
|
510
|
+
}
|
|
511
|
+
]
|
|
512
|
+
self._replace_state(state=state)
|
|
513
|
+
|
|
514
|
+
def _publish(self, *, snapshot: LiveSessionSnapshot) -> None:
|
|
515
|
+
self._replace_state(state=cast(dict[str, Any], _to_qml_value(snapshot)))
|
|
516
|
+
|
|
517
|
+
def _replace_state(self, *, state: dict[str, Any]) -> None:
|
|
518
|
+
if state == self._state:
|
|
519
|
+
return
|
|
520
|
+
self._state = state
|
|
521
|
+
recommendations = state.get("recommendations") or {}
|
|
522
|
+
rows = recommendations.get("cards") or []
|
|
523
|
+
self._recommendations_model.replace(rows=list(rows))
|
|
524
|
+
self.stateChanged.emit()
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
class _CardImageFetchWorker(QObject):
|
|
528
|
+
"""Execute one session-owned card image fetch outside the session thread."""
|
|
529
|
+
|
|
530
|
+
resultReady = Signal(object, object, str)
|
|
531
|
+
|
|
532
|
+
def __init__(self, *, session: LiveSession) -> None:
|
|
533
|
+
super().__init__()
|
|
534
|
+
self._session = session
|
|
535
|
+
|
|
536
|
+
@Slot(object)
|
|
537
|
+
def fetch(self, request: CardImageRequest) -> None:
|
|
538
|
+
try:
|
|
539
|
+
result = self._session.fetch_card_image(request=request)
|
|
540
|
+
except Exception as error: # pragma: no cover - network boundary.
|
|
541
|
+
self.resultReady.emit(request, None, str(error))
|
|
542
|
+
else:
|
|
543
|
+
self.resultReady.emit(request, result, "")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
class _LiveSessionWorker(QObject):
|
|
547
|
+
_imageFetchRequested = Signal(object)
|
|
548
|
+
_imageScheduleRequested = Signal()
|
|
549
|
+
snapshotReady = Signal(object)
|
|
550
|
+
failed = Signal(str)
|
|
551
|
+
finished = Signal()
|
|
552
|
+
|
|
553
|
+
def __init__(
|
|
554
|
+
self,
|
|
555
|
+
*,
|
|
556
|
+
session_factory: SessionFactory,
|
|
557
|
+
poll_interval_ms: int,
|
|
558
|
+
startup_scan: bool,
|
|
559
|
+
) -> None:
|
|
560
|
+
super().__init__()
|
|
561
|
+
self._session_factory = session_factory
|
|
562
|
+
self._poll_interval_ms = poll_interval_ms
|
|
563
|
+
self._startup_scan = startup_scan
|
|
564
|
+
self._session: LiveSession | None = None
|
|
565
|
+
self._timer: QTimer | None = None
|
|
566
|
+
self._stop_requested = False
|
|
567
|
+
self._stopped = False
|
|
568
|
+
self._startup_loading = False
|
|
569
|
+
self._startup_snapshot: LiveSessionSnapshot | None = None
|
|
570
|
+
self._image_thread: QThread | None = None
|
|
571
|
+
self._image_worker: _CardImageFetchWorker | None = None
|
|
572
|
+
self._image_request_in_flight: CardImageRequest | None = None
|
|
573
|
+
self._image_request_kind: _ImageRequestKind | None = None
|
|
574
|
+
|
|
575
|
+
def _publish_snapshot(self, snapshot: LiveSessionSnapshot) -> None:
|
|
576
|
+
if self._stop_requested:
|
|
577
|
+
return
|
|
578
|
+
if self._startup_loading:
|
|
579
|
+
self._startup_snapshot = snapshot
|
|
580
|
+
return
|
|
581
|
+
self.snapshotReady.emit(snapshot)
|
|
582
|
+
|
|
583
|
+
def _flush_startup_snapshot(self) -> None:
|
|
584
|
+
snapshot = self._startup_snapshot
|
|
585
|
+
self._startup_snapshot = None
|
|
586
|
+
self._startup_loading = False
|
|
587
|
+
if snapshot is not None and not self._stop_requested:
|
|
588
|
+
self.snapshotReady.emit(snapshot)
|
|
589
|
+
|
|
590
|
+
def _start_image_worker(self) -> None:
|
|
591
|
+
"""Start the dedicated worker used for blocking image retrieval."""
|
|
592
|
+
|
|
593
|
+
session = self._session
|
|
594
|
+
if session is None or self._image_thread is not None:
|
|
595
|
+
return
|
|
596
|
+
thread = QThread(parent=self)
|
|
597
|
+
image_worker = _CardImageFetchWorker(session=session)
|
|
598
|
+
image_worker.moveToThread(thread)
|
|
599
|
+
self._imageFetchRequested.connect(
|
|
600
|
+
image_worker.fetch,
|
|
601
|
+
Qt.ConnectionType.QueuedConnection,
|
|
602
|
+
)
|
|
603
|
+
# Queue completion so all LiveSession mutation stays on this worker.
|
|
604
|
+
image_worker.resultReady.connect(
|
|
605
|
+
self._image_fetch_finished,
|
|
606
|
+
Qt.ConnectionType.QueuedConnection,
|
|
607
|
+
)
|
|
608
|
+
self._imageScheduleRequested.connect(
|
|
609
|
+
self._request_one_card_image,
|
|
610
|
+
Qt.ConnectionType.QueuedConnection,
|
|
611
|
+
)
|
|
612
|
+
thread.finished.connect(image_worker.deleteLater)
|
|
613
|
+
self._image_thread = thread
|
|
614
|
+
self._image_worker = image_worker
|
|
615
|
+
thread.start()
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def request_stop(self) -> None:
|
|
619
|
+
"""Request cooperative stop without queuing behind busy session work."""
|
|
620
|
+
self._stop_requested = True
|
|
621
|
+
|
|
622
|
+
@Slot()
|
|
623
|
+
def start(self) -> None:
|
|
624
|
+
self._startup_loading = True
|
|
625
|
+
self._startup_snapshot = None
|
|
626
|
+
try:
|
|
627
|
+
self._session = self._session_factory(self._publish_snapshot)
|
|
628
|
+
self._start_image_worker()
|
|
629
|
+
self._publish_snapshot(self._session.snapshot)
|
|
630
|
+
if self._stop_requested:
|
|
631
|
+
self.stop()
|
|
632
|
+
return
|
|
633
|
+
if hasattr(self._session, "load_card_data"):
|
|
634
|
+
self._session.load_card_data()
|
|
635
|
+
if self._stop_requested:
|
|
636
|
+
self.stop()
|
|
637
|
+
return
|
|
638
|
+
if self._startup_scan:
|
|
639
|
+
self._session.scan_startup_files(
|
|
640
|
+
include_previous=True,
|
|
641
|
+
include_pre_draft_detection=False,
|
|
642
|
+
)
|
|
643
|
+
if self._stop_requested:
|
|
644
|
+
self.stop()
|
|
645
|
+
return
|
|
646
|
+
initial_poll_succeeded = self._poll()
|
|
647
|
+
if self._stop_requested:
|
|
648
|
+
self.stop()
|
|
649
|
+
return
|
|
650
|
+
if initial_poll_succeeded:
|
|
651
|
+
self._publish_snapshot(self._session.snapshot)
|
|
652
|
+
self._flush_startup_snapshot()
|
|
653
|
+
else:
|
|
654
|
+
self._startup_snapshot = None
|
|
655
|
+
self._startup_loading = False
|
|
656
|
+
self._timer = QTimer(self)
|
|
657
|
+
self._timer.setInterval(self._poll_interval_ms)
|
|
658
|
+
self._timer.timeout.connect(self._poll)
|
|
659
|
+
self._timer.start()
|
|
660
|
+
except Exception as error: # pragma: no cover - defensive UI boundary.
|
|
661
|
+
if not self._stop_requested:
|
|
662
|
+
self.failed.emit(str(error))
|
|
663
|
+
self.stop()
|
|
664
|
+
|
|
665
|
+
@Slot(object)
|
|
666
|
+
def dispatch(self, command: LiveSessionCommand) -> None:
|
|
667
|
+
if self._session is None or self._stop_requested:
|
|
668
|
+
return
|
|
669
|
+
try:
|
|
670
|
+
self._session.dispatch(command=command)
|
|
671
|
+
self._request_one_card_image()
|
|
672
|
+
except Exception as error: # pragma: no cover - defensive UI boundary.
|
|
673
|
+
self.failed.emit(str(error))
|
|
674
|
+
|
|
675
|
+
@Slot()
|
|
676
|
+
def _poll(self) -> bool:
|
|
677
|
+
if self._session is None:
|
|
678
|
+
return False
|
|
679
|
+
if self._stop_requested:
|
|
680
|
+
self.stop()
|
|
681
|
+
return False
|
|
682
|
+
try:
|
|
683
|
+
snapshot = self._session.poll_once()
|
|
684
|
+
self._publish_snapshot(snapshot)
|
|
685
|
+
self._request_one_card_image()
|
|
686
|
+
except Exception as error: # pragma: no cover - defensive UI boundary.
|
|
687
|
+
self.failed.emit(str(error))
|
|
688
|
+
return False
|
|
689
|
+
finally:
|
|
690
|
+
if self._stop_requested:
|
|
691
|
+
self.stop()
|
|
692
|
+
return True
|
|
693
|
+
|
|
694
|
+
def _request_one_card_image(self) -> None:
|
|
695
|
+
"""Schedule one image, prioritizing focus before pack thumbnails."""
|
|
696
|
+
|
|
697
|
+
session = self._session
|
|
698
|
+
if (
|
|
699
|
+
session is None
|
|
700
|
+
or self._stop_requested
|
|
701
|
+
or self._image_request_in_flight is not None
|
|
702
|
+
or self._image_thread is None
|
|
703
|
+
):
|
|
704
|
+
return
|
|
705
|
+
|
|
706
|
+
request: CardImageRequest | None = None
|
|
707
|
+
request_kind: _ImageRequestKind | None = None
|
|
708
|
+
for kind, method_name in (
|
|
709
|
+
("selected", "selected_card_image_request"),
|
|
710
|
+
("recommendation", "recommendation_image_request"),
|
|
711
|
+
("recent", "recent_pick_image_request"),
|
|
712
|
+
):
|
|
713
|
+
get_request = getattr(session, method_name, None)
|
|
714
|
+
if not callable(get_request):
|
|
715
|
+
continue
|
|
716
|
+
request = get_request()
|
|
717
|
+
if request is not None:
|
|
718
|
+
request_kind = cast(_ImageRequestKind, kind)
|
|
719
|
+
break
|
|
720
|
+
if request is None or request_kind is None or self._stop_requested:
|
|
721
|
+
return
|
|
722
|
+
|
|
723
|
+
self._image_request_in_flight = request
|
|
724
|
+
self._image_request_kind = request_kind
|
|
725
|
+
self._imageFetchRequested.emit(request)
|
|
726
|
+
|
|
727
|
+
@Slot(object, object, str)
|
|
728
|
+
def _image_fetch_finished(
|
|
729
|
+
self,
|
|
730
|
+
request: CardImageRequest,
|
|
731
|
+
result: object,
|
|
732
|
+
error_message: str,
|
|
733
|
+
) -> None:
|
|
734
|
+
"""Apply the result in the session thread and queue the next request."""
|
|
735
|
+
if request != self._image_request_in_flight:
|
|
736
|
+
return
|
|
737
|
+
request_kind = self._image_request_kind
|
|
738
|
+
self._image_request_in_flight = None
|
|
739
|
+
self._image_request_kind = None
|
|
740
|
+
session = self._session
|
|
741
|
+
if session is None or self._stop_requested:
|
|
742
|
+
return
|
|
743
|
+
|
|
744
|
+
try:
|
|
745
|
+
if error_message:
|
|
746
|
+
if request_kind == "recommendation":
|
|
747
|
+
session.fail_recommendation_image_request(
|
|
748
|
+
request=request,
|
|
749
|
+
error_message=error_message,
|
|
750
|
+
)
|
|
751
|
+
elif request_kind == "recent":
|
|
752
|
+
session.fail_recent_pick_image_request(
|
|
753
|
+
request=request,
|
|
754
|
+
error_message=error_message,
|
|
755
|
+
)
|
|
756
|
+
else:
|
|
757
|
+
session.fail_card_image_request(
|
|
758
|
+
request=request,
|
|
759
|
+
error_message=error_message,
|
|
760
|
+
)
|
|
761
|
+
else:
|
|
762
|
+
if not isinstance(result, CardImageFetchResult):
|
|
763
|
+
raise TypeError("Card image worker returned an invalid result.")
|
|
764
|
+
if request_kind == "recommendation":
|
|
765
|
+
session.complete_recommendation_image_request(
|
|
766
|
+
request=request,
|
|
767
|
+
image_path=result.image_path,
|
|
768
|
+
image_uri=result.image_uri,
|
|
769
|
+
)
|
|
770
|
+
elif request_kind == "recent":
|
|
771
|
+
session.complete_recent_pick_image_request(
|
|
772
|
+
request=request,
|
|
773
|
+
image_path=result.image_path,
|
|
774
|
+
image_uri=result.image_uri,
|
|
775
|
+
)
|
|
776
|
+
else:
|
|
777
|
+
session.complete_card_image_request(
|
|
778
|
+
request=request,
|
|
779
|
+
image_path=result.image_path,
|
|
780
|
+
image_uri=result.image_uri,
|
|
781
|
+
)
|
|
782
|
+
except Exception as error: # pragma: no cover - defensive UI boundary.
|
|
783
|
+
self.failed.emit(str(error))
|
|
784
|
+
finally:
|
|
785
|
+
self._imageScheduleRequested.emit()
|
|
786
|
+
|
|
787
|
+
@Slot()
|
|
788
|
+
def stop(self) -> None:
|
|
789
|
+
self._stop_requested = True
|
|
790
|
+
self._startup_loading = False
|
|
791
|
+
self._startup_snapshot = None
|
|
792
|
+
if self._stopped:
|
|
793
|
+
return
|
|
794
|
+
if self._timer is not None:
|
|
795
|
+
self._timer.stop()
|
|
796
|
+
if self._session is not None:
|
|
797
|
+
try:
|
|
798
|
+
self._session.stop()
|
|
799
|
+
except Exception as error: # pragma: no cover - defensive UI boundary.
|
|
800
|
+
self.failed.emit(str(error))
|
|
801
|
+
image_thread = self._image_thread
|
|
802
|
+
if image_thread is not None:
|
|
803
|
+
image_thread.quit()
|
|
804
|
+
image_thread.wait()
|
|
805
|
+
self._image_thread = None
|
|
806
|
+
self._image_worker = None
|
|
807
|
+
self._stopped = True
|
|
808
|
+
self.finished.emit()
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
class LiveSessionAdapter(SessionAdapter):
|
|
812
|
+
"""Run the production live session on one adapter-owned QThread.
|
|
813
|
+
Immutable snapshots return through queued Qt signals.
|
|
814
|
+
"""
|
|
815
|
+
|
|
816
|
+
_commandRequested = Signal(object)
|
|
817
|
+
|
|
818
|
+
def __init__(
|
|
819
|
+
self,
|
|
820
|
+
*,
|
|
821
|
+
session_factory: SessionFactory,
|
|
822
|
+
poll_interval_ms: int,
|
|
823
|
+
startup_scan: bool = True,
|
|
824
|
+
parent: QObject | None = None,
|
|
825
|
+
) -> None:
|
|
826
|
+
if poll_interval_ms <= 0:
|
|
827
|
+
raise ValueError("poll_interval_ms must be greater than zero.")
|
|
828
|
+
super().__init__(parent=parent)
|
|
829
|
+
self._session_factory = session_factory
|
|
830
|
+
self._poll_interval_ms = poll_interval_ms
|
|
831
|
+
self._startup_scan = startup_scan
|
|
832
|
+
self.thread: QThread | None = None
|
|
833
|
+
self._worker: _LiveSessionWorker | None = None
|
|
834
|
+
|
|
835
|
+
@Slot()
|
|
836
|
+
def start(self) -> None:
|
|
837
|
+
if self.thread is not None and self.thread.isRunning():
|
|
838
|
+
return
|
|
839
|
+
thread = QThread(parent=self)
|
|
840
|
+
worker = _LiveSessionWorker(
|
|
841
|
+
session_factory=self._session_factory,
|
|
842
|
+
poll_interval_ms=self._poll_interval_ms,
|
|
843
|
+
startup_scan=self._startup_scan,
|
|
844
|
+
)
|
|
845
|
+
worker.moveToThread(thread)
|
|
846
|
+
thread.started.connect(worker.start)
|
|
847
|
+
self._commandRequested.connect(worker.dispatch, Qt.ConnectionType.QueuedConnection)
|
|
848
|
+
worker.snapshotReady.connect(
|
|
849
|
+
self._apply_snapshot,
|
|
850
|
+
Qt.ConnectionType.QueuedConnection,
|
|
851
|
+
)
|
|
852
|
+
worker.failed.connect(self._apply_failure, Qt.ConnectionType.QueuedConnection)
|
|
853
|
+
thread.finished.connect(worker.deleteLater)
|
|
854
|
+
worker.finished.connect(
|
|
855
|
+
thread.quit,
|
|
856
|
+
Qt.ConnectionType.DirectConnection,
|
|
857
|
+
)
|
|
858
|
+
self.thread = thread
|
|
859
|
+
self._worker = worker
|
|
860
|
+
thread.start()
|
|
861
|
+
|
|
862
|
+
@Slot()
|
|
863
|
+
def shutdown(self) -> None:
|
|
864
|
+
thread = self.thread
|
|
865
|
+
worker = self._worker
|
|
866
|
+
if thread is None or worker is None or not thread.isRunning():
|
|
867
|
+
return
|
|
868
|
+
worker.request_stop()
|
|
869
|
+
thread.requestInterruption()
|
|
870
|
+
QMetaObject.invokeMethod(
|
|
871
|
+
worker,
|
|
872
|
+
"stop",
|
|
873
|
+
Qt.ConnectionType.QueuedConnection,
|
|
874
|
+
)
|
|
875
|
+
thread.wait(100)
|
|
876
|
+
|
|
877
|
+
def wait_for_shutdown(self) -> None:
|
|
878
|
+
"""Wait for the owned worker thread before the adapter is destroyed."""
|
|
879
|
+
thread = self.thread
|
|
880
|
+
if thread is not None and thread.isRunning():
|
|
881
|
+
thread.wait()
|
|
882
|
+
|
|
883
|
+
def _dispatch(self, *, command: LiveSessionCommand) -> None:
|
|
884
|
+
self._commandRequested.emit(command)
|