ankigammon 1.0.6__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.
- ankigammon/__init__.py +7 -0
- ankigammon/__main__.py +6 -0
- ankigammon/analysis/__init__.py +13 -0
- ankigammon/analysis/score_matrix.py +391 -0
- ankigammon/anki/__init__.py +6 -0
- ankigammon/anki/ankiconnect.py +216 -0
- ankigammon/anki/apkg_exporter.py +111 -0
- ankigammon/anki/card_generator.py +1325 -0
- ankigammon/anki/card_styles.py +1054 -0
- ankigammon/gui/__init__.py +8 -0
- ankigammon/gui/app.py +192 -0
- ankigammon/gui/dialogs/__init__.py +10 -0
- ankigammon/gui/dialogs/export_dialog.py +594 -0
- ankigammon/gui/dialogs/import_options_dialog.py +201 -0
- ankigammon/gui/dialogs/input_dialog.py +762 -0
- ankigammon/gui/dialogs/note_dialog.py +93 -0
- ankigammon/gui/dialogs/settings_dialog.py +420 -0
- ankigammon/gui/dialogs/update_dialog.py +373 -0
- ankigammon/gui/format_detector.py +377 -0
- ankigammon/gui/main_window.py +1611 -0
- ankigammon/gui/resources/down-arrow.svg +3 -0
- ankigammon/gui/resources/icon.icns +0 -0
- ankigammon/gui/resources/icon.ico +0 -0
- ankigammon/gui/resources/icon.png +0 -0
- ankigammon/gui/resources/style.qss +402 -0
- ankigammon/gui/resources.py +26 -0
- ankigammon/gui/update_checker.py +259 -0
- ankigammon/gui/widgets/__init__.py +8 -0
- ankigammon/gui/widgets/position_list.py +166 -0
- ankigammon/gui/widgets/smart_input.py +268 -0
- ankigammon/models.py +356 -0
- ankigammon/parsers/__init__.py +7 -0
- ankigammon/parsers/gnubg_match_parser.py +1094 -0
- ankigammon/parsers/gnubg_parser.py +468 -0
- ankigammon/parsers/sgf_parser.py +290 -0
- ankigammon/parsers/xg_binary_parser.py +1097 -0
- ankigammon/parsers/xg_text_parser.py +688 -0
- ankigammon/renderer/__init__.py +5 -0
- ankigammon/renderer/animation_controller.py +391 -0
- ankigammon/renderer/animation_helper.py +191 -0
- ankigammon/renderer/color_schemes.py +145 -0
- ankigammon/renderer/svg_board_renderer.py +791 -0
- ankigammon/settings.py +315 -0
- ankigammon/thirdparty/__init__.py +7 -0
- ankigammon/thirdparty/xgdatatools/__init__.py +17 -0
- ankigammon/thirdparty/xgdatatools/xgimport.py +160 -0
- ankigammon/thirdparty/xgdatatools/xgstruct.py +1032 -0
- ankigammon/thirdparty/xgdatatools/xgutils.py +118 -0
- ankigammon/thirdparty/xgdatatools/xgzarc.py +260 -0
- ankigammon/utils/__init__.py +13 -0
- ankigammon/utils/gnubg_analyzer.py +590 -0
- ankigammon/utils/gnuid.py +577 -0
- ankigammon/utils/move_parser.py +204 -0
- ankigammon/utils/ogid.py +326 -0
- ankigammon/utils/xgid.py +387 -0
- ankigammon-1.0.6.dist-info/METADATA +352 -0
- ankigammon-1.0.6.dist-info/RECORD +61 -0
- ankigammon-1.0.6.dist-info/WHEEL +5 -0
- ankigammon-1.0.6.dist-info/entry_points.txt +2 -0
- ankigammon-1.0.6.dist-info/licenses/LICENSE +21 -0
- ankigammon-1.0.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1611 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main application window.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from PySide6.QtWidgets import (
|
|
6
|
+
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
|
7
|
+
QPushButton, QLabel, QMessageBox, QInputDialog, QApplication
|
|
8
|
+
)
|
|
9
|
+
from PySide6.QtCore import Qt, Signal, Slot, QUrl, QSettings, QSize, QThread, QTimer
|
|
10
|
+
from PySide6.QtGui import QAction, QKeySequence, QDesktopServices
|
|
11
|
+
from PySide6.QtWebEngineWidgets import QWebEngineView
|
|
12
|
+
import qtawesome as qta
|
|
13
|
+
import base64
|
|
14
|
+
import subprocess
|
|
15
|
+
from typing import List, Tuple
|
|
16
|
+
|
|
17
|
+
from ankigammon import __version__
|
|
18
|
+
from ankigammon.settings import Settings
|
|
19
|
+
from ankigammon.renderer.svg_board_renderer import SVGBoardRenderer
|
|
20
|
+
from ankigammon.renderer.color_schemes import get_scheme
|
|
21
|
+
from ankigammon.models import Decision, Move
|
|
22
|
+
from ankigammon.gui.widgets import PositionListWidget
|
|
23
|
+
from ankigammon.gui.dialogs import SettingsDialog, ExportDialog, InputDialog, ImportOptionsDialog
|
|
24
|
+
from ankigammon.gui.dialogs.update_dialog import UpdateDialog, CheckingUpdateDialog, NoUpdateDialog, UpdateCheckFailedDialog
|
|
25
|
+
from ankigammon.gui.update_checker import VersionCheckerThread
|
|
26
|
+
from ankigammon.gui.resources import get_resource_path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MatchAnalysisWorker(QThread):
|
|
30
|
+
"""
|
|
31
|
+
Background thread for GnuBG match file analysis.
|
|
32
|
+
|
|
33
|
+
Signals:
|
|
34
|
+
status_message(str): status update message
|
|
35
|
+
finished(bool, str, list, int): success, message, decisions, total_count
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
status_message = Signal(str)
|
|
39
|
+
finished = Signal(bool, str, list, int)
|
|
40
|
+
|
|
41
|
+
def __init__(self, file_path: str, settings: Settings, threshold: float,
|
|
42
|
+
include_player_x: bool, include_player_o: bool,
|
|
43
|
+
filter_func, max_mcq_options: int):
|
|
44
|
+
super().__init__()
|
|
45
|
+
self.file_path = file_path
|
|
46
|
+
self.settings = settings
|
|
47
|
+
self.threshold = threshold
|
|
48
|
+
self.include_player_x = include_player_x
|
|
49
|
+
self.include_player_o = include_player_o
|
|
50
|
+
self.filter_func = filter_func
|
|
51
|
+
self.max_mcq_options = max_mcq_options
|
|
52
|
+
self._cancelled = False
|
|
53
|
+
self._analyzer = None
|
|
54
|
+
|
|
55
|
+
def cancel(self):
|
|
56
|
+
"""Request cancellation of analysis."""
|
|
57
|
+
self._cancelled = True
|
|
58
|
+
# Terminate GnuBG process if analyzer is running
|
|
59
|
+
if self._analyzer is not None:
|
|
60
|
+
self._analyzer.terminate()
|
|
61
|
+
|
|
62
|
+
def run(self):
|
|
63
|
+
"""Analyze match file in background thread."""
|
|
64
|
+
from ankigammon.utils.gnubg_analyzer import GNUBGAnalyzer
|
|
65
|
+
from ankigammon.parsers.gnubg_match_parser import parse_gnubg_match_files
|
|
66
|
+
import logging
|
|
67
|
+
import shutil
|
|
68
|
+
from pathlib import Path
|
|
69
|
+
import subprocess
|
|
70
|
+
|
|
71
|
+
logger = logging.getLogger(__name__)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
# Check for cancellation before starting
|
|
75
|
+
if self._cancelled:
|
|
76
|
+
self.finished.emit(False, "Cancelled", [], 0)
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
# Create analyzer
|
|
80
|
+
self.status_message.emit(f"Analyzing match with GnuBG ({self.settings.gnubg_analysis_ply}-ply)...")
|
|
81
|
+
|
|
82
|
+
self._analyzer = GNUBGAnalyzer(
|
|
83
|
+
self.settings.gnubg_path,
|
|
84
|
+
self.settings.gnubg_analysis_ply
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Analyze match
|
|
88
|
+
def progress_callback(status: str):
|
|
89
|
+
if self._cancelled:
|
|
90
|
+
return
|
|
91
|
+
self.status_message.emit(status)
|
|
92
|
+
|
|
93
|
+
exported_files = self._analyzer.analyze_match_file(
|
|
94
|
+
self.file_path,
|
|
95
|
+
max_moves=self.max_mcq_options,
|
|
96
|
+
progress_callback=progress_callback
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Check for cancellation after analysis
|
|
100
|
+
if self._cancelled:
|
|
101
|
+
# Cleanup temp files before returning
|
|
102
|
+
for temp_file in exported_files:
|
|
103
|
+
try:
|
|
104
|
+
temp_dir = Path(temp_file).parent
|
|
105
|
+
shutil.rmtree(temp_dir)
|
|
106
|
+
break
|
|
107
|
+
except:
|
|
108
|
+
pass
|
|
109
|
+
self.finished.emit(False, "Cancelled", [], 0)
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
logger.info(f"GnuBG exported {len(exported_files)} file(s)")
|
|
113
|
+
|
|
114
|
+
# Parse exported files
|
|
115
|
+
self.status_message.emit(f"Parsing analysis from {len(exported_files)} game(s)...")
|
|
116
|
+
|
|
117
|
+
# Detect if source was SGF file (need to swap scores)
|
|
118
|
+
is_sgf_source = self.file_path.endswith('.sgf')
|
|
119
|
+
|
|
120
|
+
all_decisions = parse_gnubg_match_files(exported_files, is_sgf_source=is_sgf_source)
|
|
121
|
+
total_count = len(all_decisions)
|
|
122
|
+
|
|
123
|
+
# Check for cancellation after parsing
|
|
124
|
+
if self._cancelled:
|
|
125
|
+
# Cleanup temp files before returning
|
|
126
|
+
for temp_file in exported_files:
|
|
127
|
+
try:
|
|
128
|
+
temp_dir = Path(temp_file).parent
|
|
129
|
+
shutil.rmtree(temp_dir)
|
|
130
|
+
break
|
|
131
|
+
except:
|
|
132
|
+
pass
|
|
133
|
+
self.finished.emit(False, "Cancelled", [], 0)
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
logger.info(f"Parsed {total_count} positions from match")
|
|
137
|
+
|
|
138
|
+
# Filter based on user options
|
|
139
|
+
self.status_message.emit("Filtering positions by error threshold...")
|
|
140
|
+
|
|
141
|
+
decisions = self.filter_func(
|
|
142
|
+
all_decisions,
|
|
143
|
+
self.threshold,
|
|
144
|
+
self.include_player_x,
|
|
145
|
+
self.include_player_o
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
logger.info(f"Filtered to {len(decisions)} positions (threshold: {self.threshold})")
|
|
149
|
+
|
|
150
|
+
# Cleanup temp files
|
|
151
|
+
self.status_message.emit("Cleaning up temporary files...")
|
|
152
|
+
for temp_file in exported_files:
|
|
153
|
+
try:
|
|
154
|
+
temp_dir = Path(temp_file).parent
|
|
155
|
+
shutil.rmtree(temp_dir)
|
|
156
|
+
break # Only need to remove directory once
|
|
157
|
+
except Exception as e:
|
|
158
|
+
logger.warning(f"Failed to cleanup temp files: {e}")
|
|
159
|
+
|
|
160
|
+
# Final cancellation check
|
|
161
|
+
if self._cancelled:
|
|
162
|
+
self.finished.emit(False, "Cancelled", [], 0)
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
self.finished.emit(True, "Success", decisions, total_count)
|
|
166
|
+
|
|
167
|
+
except subprocess.CalledProcessError as e:
|
|
168
|
+
if self._cancelled:
|
|
169
|
+
self.finished.emit(False, "Cancelled", [], 0)
|
|
170
|
+
else:
|
|
171
|
+
logger.error(f"GnuBG analysis failed: {e}")
|
|
172
|
+
error_msg = f"GnuBG analysis failed:\n\n{e.stderr if e.stderr else str(e)}"
|
|
173
|
+
self.finished.emit(False, error_msg, [], 0)
|
|
174
|
+
|
|
175
|
+
except Exception as e:
|
|
176
|
+
if self._cancelled:
|
|
177
|
+
self.finished.emit(False, "Cancelled", [], 0)
|
|
178
|
+
else:
|
|
179
|
+
logger.error(f"Match import failed: {e}", exc_info=True)
|
|
180
|
+
error_msg = f"Failed to import match file:\n\n{str(e)}"
|
|
181
|
+
self.finished.emit(False, error_msg, [], 0)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class MainWindow(QMainWindow):
|
|
185
|
+
"""Main application window for AnkiGammon."""
|
|
186
|
+
|
|
187
|
+
# Signals
|
|
188
|
+
decisions_parsed = Signal(list) # List[Decision]
|
|
189
|
+
|
|
190
|
+
def __init__(self, settings: Settings):
|
|
191
|
+
super().__init__()
|
|
192
|
+
self.settings = settings
|
|
193
|
+
self.current_decisions = []
|
|
194
|
+
self.renderer = SVGBoardRenderer(
|
|
195
|
+
color_scheme=get_scheme(settings.color_scheme),
|
|
196
|
+
orientation=settings.board_orientation
|
|
197
|
+
)
|
|
198
|
+
self.color_scheme_actions = {} # Store references to color scheme menu actions
|
|
199
|
+
self._gnubg_check_shown = False # Track if we've shown GnuBG config dialog in current import batch
|
|
200
|
+
self._import_queue = [] # Queue for sequential file imports
|
|
201
|
+
self._import_in_progress = False # Track if an import is currently being processed
|
|
202
|
+
self._version_checker_thread = None # Version checker thread
|
|
203
|
+
|
|
204
|
+
# Enable drag and drop
|
|
205
|
+
self.setAcceptDrops(True)
|
|
206
|
+
|
|
207
|
+
self._setup_ui()
|
|
208
|
+
self._setup_menu_bar()
|
|
209
|
+
self._setup_connections()
|
|
210
|
+
self._restore_window_state()
|
|
211
|
+
|
|
212
|
+
# Create drop overlay (will be shown during drag operations)
|
|
213
|
+
self._create_drop_overlay()
|
|
214
|
+
|
|
215
|
+
# Start background version check if enabled
|
|
216
|
+
if self.settings.check_for_updates:
|
|
217
|
+
QTimer.singleShot(2000, self._check_for_updates_background)
|
|
218
|
+
|
|
219
|
+
def _setup_ui(self):
|
|
220
|
+
"""Initialize the user interface."""
|
|
221
|
+
self.setWindowTitle("AnkiGammon - Backgammon Analysis to Anki")
|
|
222
|
+
self.setMinimumSize(1000, 700)
|
|
223
|
+
self.resize(1300, 720) # Optimal default size for board display
|
|
224
|
+
|
|
225
|
+
# Hide the status bar for a cleaner, modern look
|
|
226
|
+
self.statusBar().hide()
|
|
227
|
+
|
|
228
|
+
# Central widget with horizontal layout
|
|
229
|
+
central = QWidget()
|
|
230
|
+
central.setAcceptDrops(False) # Let drag events propagate to main window
|
|
231
|
+
self.setCentralWidget(central)
|
|
232
|
+
layout = QHBoxLayout(central)
|
|
233
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
234
|
+
layout.setSpacing(0)
|
|
235
|
+
|
|
236
|
+
# Left panel: Controls
|
|
237
|
+
left_panel = self._create_left_panel()
|
|
238
|
+
left_panel.setAcceptDrops(False) # Let drag events propagate to main window
|
|
239
|
+
layout.addWidget(left_panel, stretch=1)
|
|
240
|
+
|
|
241
|
+
# Right panel: Preview
|
|
242
|
+
self.preview = QWebEngineView()
|
|
243
|
+
self.preview.setContextMenuPolicy(Qt.NoContextMenu) # Disable browser context menu
|
|
244
|
+
self.preview.setAcceptDrops(False) # Let drag events propagate to main window
|
|
245
|
+
|
|
246
|
+
# Load icon and convert to base64 for embedding in HTML
|
|
247
|
+
icon_path = get_resource_path("ankigammon/gui/resources/icon.png")
|
|
248
|
+
icon_data_url = ""
|
|
249
|
+
if icon_path.exists():
|
|
250
|
+
with open(icon_path, "rb") as f:
|
|
251
|
+
icon_bytes = f.read()
|
|
252
|
+
icon_b64 = base64.b64encode(icon_bytes).decode('utf-8')
|
|
253
|
+
icon_data_url = f"data:image/png;base64,{icon_b64}"
|
|
254
|
+
|
|
255
|
+
welcome_html = f"""
|
|
256
|
+
<!DOCTYPE html>
|
|
257
|
+
<html>
|
|
258
|
+
<head>
|
|
259
|
+
<style>
|
|
260
|
+
body {{
|
|
261
|
+
margin: 0;
|
|
262
|
+
padding: 0;
|
|
263
|
+
display: flex;
|
|
264
|
+
flex-direction: column;
|
|
265
|
+
justify-content: center;
|
|
266
|
+
align-items: center;
|
|
267
|
+
min-height: 100vh;
|
|
268
|
+
background: linear-gradient(135deg, #1e1e2e 0%, #181825 100%);
|
|
269
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
270
|
+
color: #cdd6f4;
|
|
271
|
+
}}
|
|
272
|
+
.welcome {{
|
|
273
|
+
text-align: center;
|
|
274
|
+
padding: 40px;
|
|
275
|
+
}}
|
|
276
|
+
h1 {{
|
|
277
|
+
color: #f5e0dc;
|
|
278
|
+
font-size: 32px;
|
|
279
|
+
margin-bottom: 16px;
|
|
280
|
+
font-weight: 700;
|
|
281
|
+
}}
|
|
282
|
+
p {{
|
|
283
|
+
color: #a6adc8;
|
|
284
|
+
font-size: 16px;
|
|
285
|
+
margin: 8px 0;
|
|
286
|
+
}}
|
|
287
|
+
.icon {{
|
|
288
|
+
margin-bottom: 24px;
|
|
289
|
+
opacity: 0.6;
|
|
290
|
+
}}
|
|
291
|
+
.icon img {{
|
|
292
|
+
width: 140px;
|
|
293
|
+
height: auto;
|
|
294
|
+
}}
|
|
295
|
+
</style>
|
|
296
|
+
</head>
|
|
297
|
+
<body>
|
|
298
|
+
<div class="welcome">
|
|
299
|
+
<div class="icon">
|
|
300
|
+
<img src="{icon_data_url}" alt="AnkiGammon Icon" />
|
|
301
|
+
</div>
|
|
302
|
+
<h1>No Position Loaded</h1>
|
|
303
|
+
<p>Add positions to get started</p>
|
|
304
|
+
</div>
|
|
305
|
+
</body>
|
|
306
|
+
</html>
|
|
307
|
+
"""
|
|
308
|
+
self.preview.setHtml(welcome_html)
|
|
309
|
+
layout.addWidget(self.preview, stretch=2)
|
|
310
|
+
|
|
311
|
+
# Status bar
|
|
312
|
+
self.statusBar().showMessage("Ready")
|
|
313
|
+
|
|
314
|
+
def _create_left_panel(self) -> QWidget:
|
|
315
|
+
"""Create the left control panel."""
|
|
316
|
+
panel = QWidget()
|
|
317
|
+
layout = QVBoxLayout(panel)
|
|
318
|
+
layout.setContentsMargins(12, 12, 12, 12)
|
|
319
|
+
layout.setSpacing(12)
|
|
320
|
+
|
|
321
|
+
# Title
|
|
322
|
+
title = QLabel("<h2>AnkiGammon</h2>")
|
|
323
|
+
title.setAlignment(Qt.AlignCenter)
|
|
324
|
+
layout.addWidget(title)
|
|
325
|
+
|
|
326
|
+
# Button row: Import File and Add Positions
|
|
327
|
+
btn_row = QWidget()
|
|
328
|
+
btn_row_layout = QHBoxLayout(btn_row)
|
|
329
|
+
btn_row_layout.setContentsMargins(0, 0, 0, 0)
|
|
330
|
+
btn_row_layout.setSpacing(8)
|
|
331
|
+
|
|
332
|
+
# Import File button (equal primary) - full-sized with text + icon
|
|
333
|
+
self.btn_import_file = QPushButton(" Import File...")
|
|
334
|
+
self.btn_import_file.setIcon(qta.icon('fa6s.file-import', color='#1e1e2e'))
|
|
335
|
+
self.btn_import_file.setIconSize(QSize(18, 18))
|
|
336
|
+
self.btn_import_file.clicked.connect(self.on_import_file_clicked)
|
|
337
|
+
self.btn_import_file.setToolTip("Import .xg, .mat, or .txt match file")
|
|
338
|
+
self.btn_import_file.setCursor(Qt.PointingHandCursor)
|
|
339
|
+
btn_row_layout.addWidget(self.btn_import_file, stretch=1)
|
|
340
|
+
|
|
341
|
+
# Add Positions button (primary) - blue background needs dark icons
|
|
342
|
+
self.btn_add_positions = QPushButton(" Add Positions...")
|
|
343
|
+
self.btn_add_positions.setIcon(qta.icon('fa6s.clipboard-list', color='#1e1e2e'))
|
|
344
|
+
self.btn_add_positions.setIconSize(QSize(18, 18))
|
|
345
|
+
self.btn_add_positions.clicked.connect(self.on_add_positions_clicked)
|
|
346
|
+
self.btn_add_positions.setToolTip("Paste position IDs or full XG analysis")
|
|
347
|
+
self.btn_add_positions.setCursor(Qt.PointingHandCursor)
|
|
348
|
+
btn_row_layout.addWidget(self.btn_add_positions, stretch=1)
|
|
349
|
+
|
|
350
|
+
layout.addWidget(btn_row)
|
|
351
|
+
|
|
352
|
+
# Position list with integrated Clear All button
|
|
353
|
+
list_container = QWidget()
|
|
354
|
+
list_container_layout = QVBoxLayout(list_container)
|
|
355
|
+
list_container_layout.setContentsMargins(0, 0, 0, 0)
|
|
356
|
+
list_container_layout.setSpacing(0)
|
|
357
|
+
|
|
358
|
+
# Clear All button positioned at top-right
|
|
359
|
+
self.btn_clear_all = QPushButton(" Clear All")
|
|
360
|
+
self.btn_clear_all.setIcon(qta.icon('fa6s.trash-can', color='#a6adc8'))
|
|
361
|
+
self.btn_clear_all.setIconSize(QSize(11, 11))
|
|
362
|
+
self.btn_clear_all.setCursor(Qt.PointingHandCursor)
|
|
363
|
+
self.btn_clear_all.clicked.connect(self.on_clear_all_clicked)
|
|
364
|
+
self.btn_clear_all.setToolTip("Clear all positions")
|
|
365
|
+
self.btn_clear_all.setStyleSheet("""
|
|
366
|
+
QPushButton {
|
|
367
|
+
background-color: transparent;
|
|
368
|
+
color: #6c7086;
|
|
369
|
+
border: none;
|
|
370
|
+
padding: 6px 10px;
|
|
371
|
+
font-size: 11px;
|
|
372
|
+
font-weight: 500;
|
|
373
|
+
border-radius: 4px;
|
|
374
|
+
}
|
|
375
|
+
QPushButton:hover:enabled {
|
|
376
|
+
background-color: rgba(243, 139, 168, 0.15);
|
|
377
|
+
color: #f38ba8;
|
|
378
|
+
}
|
|
379
|
+
QPushButton:pressed:enabled {
|
|
380
|
+
background-color: rgba(243, 139, 168, 0.25);
|
|
381
|
+
}
|
|
382
|
+
""")
|
|
383
|
+
|
|
384
|
+
# Create header row with clear button aligned right (initially hidden)
|
|
385
|
+
self.list_header_row = QWidget()
|
|
386
|
+
header_layout = QHBoxLayout(self.list_header_row)
|
|
387
|
+
header_layout.setContentsMargins(0, 0, 0, 4)
|
|
388
|
+
header_layout.setSpacing(0)
|
|
389
|
+
header_layout.addStretch()
|
|
390
|
+
header_layout.addWidget(self.btn_clear_all)
|
|
391
|
+
self.list_header_row.hide() # Hidden until positions are added
|
|
392
|
+
list_container_layout.addWidget(self.list_header_row)
|
|
393
|
+
|
|
394
|
+
# Position list widget
|
|
395
|
+
self.position_list = PositionListWidget()
|
|
396
|
+
self.position_list.position_selected.connect(self.show_decision)
|
|
397
|
+
self.position_list.positions_deleted.connect(self.on_positions_deleted)
|
|
398
|
+
list_container_layout.addWidget(self.position_list, stretch=1)
|
|
399
|
+
|
|
400
|
+
layout.addWidget(list_container, stretch=1)
|
|
401
|
+
|
|
402
|
+
# Spacer
|
|
403
|
+
layout.addSpacing(12)
|
|
404
|
+
|
|
405
|
+
# Deck name indicator with edit button
|
|
406
|
+
deck_container = QWidget()
|
|
407
|
+
deck_layout = QHBoxLayout(deck_container)
|
|
408
|
+
deck_layout.setContentsMargins(18, 16, 18, 16)
|
|
409
|
+
deck_layout.setSpacing(14)
|
|
410
|
+
deck_container.setStyleSheet("""
|
|
411
|
+
QWidget {
|
|
412
|
+
background-color: rgba(137, 180, 250, 0.08);
|
|
413
|
+
border-radius: 12px;
|
|
414
|
+
}
|
|
415
|
+
""")
|
|
416
|
+
|
|
417
|
+
self.lbl_deck_name = QLabel()
|
|
418
|
+
self.lbl_deck_name.setWordWrap(True)
|
|
419
|
+
self.lbl_deck_name.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
|
420
|
+
self.lbl_deck_name.setTextFormat(Qt.RichText)
|
|
421
|
+
self.lbl_deck_name.setStyleSheet("""
|
|
422
|
+
QLabel {
|
|
423
|
+
color: #cdd6f4;
|
|
424
|
+
padding: 2px 0px;
|
|
425
|
+
background: transparent;
|
|
426
|
+
}
|
|
427
|
+
""")
|
|
428
|
+
self._update_deck_label()
|
|
429
|
+
deck_layout.addWidget(self.lbl_deck_name, stretch=1)
|
|
430
|
+
|
|
431
|
+
# Edit button for deck name
|
|
432
|
+
self.btn_edit_deck = QPushButton()
|
|
433
|
+
self.btn_edit_deck.setIcon(qta.icon('fa6s.pencil', color='#a6adc8'))
|
|
434
|
+
self.btn_edit_deck.setIconSize(QSize(16, 16))
|
|
435
|
+
self.btn_edit_deck.setFixedSize(32, 32)
|
|
436
|
+
self.btn_edit_deck.setToolTip("Edit deck name")
|
|
437
|
+
self.btn_edit_deck.setStyleSheet("""
|
|
438
|
+
QPushButton {
|
|
439
|
+
background-color: rgba(205, 214, 244, 0.05);
|
|
440
|
+
border: none;
|
|
441
|
+
border-radius: 8px;
|
|
442
|
+
padding: 0px;
|
|
443
|
+
}
|
|
444
|
+
QPushButton:hover {
|
|
445
|
+
background-color: rgba(205, 214, 244, 0.12);
|
|
446
|
+
}
|
|
447
|
+
QPushButton:pressed {
|
|
448
|
+
background-color: rgba(205, 214, 244, 0.18);
|
|
449
|
+
}
|
|
450
|
+
""")
|
|
451
|
+
self.btn_edit_deck.setCursor(Qt.PointingHandCursor)
|
|
452
|
+
self.btn_edit_deck.clicked.connect(self.on_edit_deck_name)
|
|
453
|
+
deck_layout.addWidget(self.btn_edit_deck, alignment=Qt.AlignVCenter)
|
|
454
|
+
|
|
455
|
+
layout.addWidget(deck_container)
|
|
456
|
+
|
|
457
|
+
layout.addSpacing(12)
|
|
458
|
+
|
|
459
|
+
# Settings button
|
|
460
|
+
self.btn_settings = QPushButton(" Settings")
|
|
461
|
+
self.btn_settings.setIcon(qta.icon('fa6s.gear', color='#cdd6f4'))
|
|
462
|
+
self.btn_settings.setIconSize(QSize(18, 18))
|
|
463
|
+
self.btn_settings.setObjectName("btn_settings")
|
|
464
|
+
self.btn_settings.setCursor(Qt.PointingHandCursor)
|
|
465
|
+
self.btn_settings.clicked.connect(self.on_settings_clicked)
|
|
466
|
+
layout.addWidget(self.btn_settings)
|
|
467
|
+
|
|
468
|
+
# Export button - blue background needs dark icons
|
|
469
|
+
self.btn_export = QPushButton(" Export to Anki")
|
|
470
|
+
self.btn_export.setIcon(qta.icon('fa6s.file-export', color='#1e1e2e'))
|
|
471
|
+
self.btn_export.setIconSize(QSize(18, 18))
|
|
472
|
+
self.btn_export.setEnabled(False)
|
|
473
|
+
self.btn_export.setCursor(Qt.PointingHandCursor)
|
|
474
|
+
self.btn_export.clicked.connect(self.on_export_clicked)
|
|
475
|
+
layout.addWidget(self.btn_export)
|
|
476
|
+
|
|
477
|
+
return panel
|
|
478
|
+
|
|
479
|
+
def _setup_menu_bar(self):
|
|
480
|
+
"""Create application menu bar."""
|
|
481
|
+
menubar = self.menuBar()
|
|
482
|
+
|
|
483
|
+
# File menu
|
|
484
|
+
file_menu = menubar.addMenu("&File")
|
|
485
|
+
|
|
486
|
+
act_add_positions = QAction("&Add Positions...", self)
|
|
487
|
+
act_add_positions.setShortcut("Ctrl+N")
|
|
488
|
+
act_add_positions.triggered.connect(self.on_add_positions_clicked)
|
|
489
|
+
file_menu.addAction(act_add_positions)
|
|
490
|
+
|
|
491
|
+
act_import_file = QAction("&Import File...", self)
|
|
492
|
+
act_import_file.setShortcut("Ctrl+O")
|
|
493
|
+
act_import_file.triggered.connect(self.on_import_file_clicked)
|
|
494
|
+
file_menu.addAction(act_import_file)
|
|
495
|
+
|
|
496
|
+
file_menu.addSeparator()
|
|
497
|
+
|
|
498
|
+
act_export = QAction("&Export to Anki...", self)
|
|
499
|
+
act_export.setShortcut("Ctrl+E")
|
|
500
|
+
act_export.triggered.connect(self.on_export_clicked)
|
|
501
|
+
file_menu.addAction(act_export)
|
|
502
|
+
|
|
503
|
+
file_menu.addSeparator()
|
|
504
|
+
|
|
505
|
+
act_quit = QAction("&Quit", self)
|
|
506
|
+
act_quit.setShortcut(QKeySequence.Quit)
|
|
507
|
+
act_quit.triggered.connect(self.close)
|
|
508
|
+
file_menu.addAction(act_quit)
|
|
509
|
+
|
|
510
|
+
# Edit menu
|
|
511
|
+
edit_menu = menubar.addMenu("&Edit")
|
|
512
|
+
|
|
513
|
+
act_settings = QAction("&Settings...", self)
|
|
514
|
+
act_settings.setShortcut("Ctrl+,")
|
|
515
|
+
act_settings.triggered.connect(self.on_settings_clicked)
|
|
516
|
+
edit_menu.addAction(act_settings)
|
|
517
|
+
|
|
518
|
+
# Board Theme menu
|
|
519
|
+
board_theme_menu = menubar.addMenu("&Board Theme")
|
|
520
|
+
|
|
521
|
+
# Add theme options directly (no submenu)
|
|
522
|
+
from ankigammon.renderer.color_schemes import list_schemes
|
|
523
|
+
for scheme in list_schemes():
|
|
524
|
+
act_scheme = QAction(scheme.title(), self)
|
|
525
|
+
act_scheme.setCheckable(True)
|
|
526
|
+
act_scheme.setChecked(scheme == self.settings.color_scheme)
|
|
527
|
+
act_scheme.triggered.connect(
|
|
528
|
+
lambda checked, s=scheme: self.change_color_scheme(s)
|
|
529
|
+
)
|
|
530
|
+
board_theme_menu.addAction(act_scheme)
|
|
531
|
+
self.color_scheme_actions[scheme] = act_scheme # Store reference
|
|
532
|
+
|
|
533
|
+
# Help menu
|
|
534
|
+
help_menu = menubar.addMenu("&Help")
|
|
535
|
+
|
|
536
|
+
act_check_updates = QAction("&Check for Updates...", self)
|
|
537
|
+
act_check_updates.triggered.connect(self.check_for_updates_manual)
|
|
538
|
+
help_menu.addAction(act_check_updates)
|
|
539
|
+
|
|
540
|
+
help_menu.addSeparator()
|
|
541
|
+
|
|
542
|
+
act_website = QAction("&Visit Website", self)
|
|
543
|
+
act_website.triggered.connect(self.show_website)
|
|
544
|
+
help_menu.addAction(act_website)
|
|
545
|
+
|
|
546
|
+
act_about = QAction("&About AnkiGammon", self)
|
|
547
|
+
act_about.triggered.connect(self.show_about_dialog)
|
|
548
|
+
help_menu.addAction(act_about)
|
|
549
|
+
|
|
550
|
+
def _setup_connections(self):
|
|
551
|
+
"""Connect signals and slots."""
|
|
552
|
+
self.decisions_parsed.connect(self.on_decisions_loaded)
|
|
553
|
+
|
|
554
|
+
def _update_deck_label(self):
|
|
555
|
+
"""Update the deck name label with current settings."""
|
|
556
|
+
export_method = "AnkiConnect" if self.settings.export_method == "ankiconnect" else "APKG"
|
|
557
|
+
self.lbl_deck_name.setText(
|
|
558
|
+
f"<div style='line-height: 1.5;'>"
|
|
559
|
+
f"<div style='color: #a6adc8; font-size: 12px; font-weight: 500; margin-bottom: 6px;'>Exporting to</div>"
|
|
560
|
+
f"<div style='font-size: 18px; font-weight: 600; color: #cdd6f4;'>{self.settings.deck_name} <span style='color: #6c7086; font-size: 13px; font-weight: 400;'>· {export_method}</span></div>"
|
|
561
|
+
f"</div>"
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
def _restore_window_state(self):
|
|
565
|
+
"""Restore window size and position from QSettings."""
|
|
566
|
+
settings = QSettings()
|
|
567
|
+
|
|
568
|
+
# Window geometry
|
|
569
|
+
geometry = settings.value("window/geometry")
|
|
570
|
+
if geometry:
|
|
571
|
+
self.restoreGeometry(geometry)
|
|
572
|
+
|
|
573
|
+
# Window state (splitter positions, etc.)
|
|
574
|
+
state = settings.value("window/state")
|
|
575
|
+
if state:
|
|
576
|
+
self.restoreState(state)
|
|
577
|
+
|
|
578
|
+
def _create_drop_overlay(self):
|
|
579
|
+
"""Create a visual overlay for drag-and-drop feedback."""
|
|
580
|
+
# Make overlay a child of central widget for proper positioning
|
|
581
|
+
self.drop_overlay = QWidget(self.centralWidget())
|
|
582
|
+
self.drop_overlay.setStyleSheet("""
|
|
583
|
+
QWidget {
|
|
584
|
+
background-color: rgba(137, 180, 250, 0.15);
|
|
585
|
+
border: 3px dashed #89b4fa;
|
|
586
|
+
border-radius: 12px;
|
|
587
|
+
}
|
|
588
|
+
""")
|
|
589
|
+
|
|
590
|
+
# Create layout for overlay content
|
|
591
|
+
overlay_layout = QVBoxLayout(self.drop_overlay)
|
|
592
|
+
overlay_layout.setAlignment(Qt.AlignCenter)
|
|
593
|
+
|
|
594
|
+
# Icon
|
|
595
|
+
icon_label = QLabel()
|
|
596
|
+
icon_label.setPixmap(qta.icon('fa6s.file-import', color='#89b4fa').pixmap(64, 64))
|
|
597
|
+
icon_label.setAlignment(Qt.AlignCenter)
|
|
598
|
+
overlay_layout.addWidget(icon_label)
|
|
599
|
+
|
|
600
|
+
# Text
|
|
601
|
+
text_label = QLabel("Drop file to import")
|
|
602
|
+
text_label.setStyleSheet("""
|
|
603
|
+
QLabel {
|
|
604
|
+
color: #89b4fa;
|
|
605
|
+
font-size: 18px;
|
|
606
|
+
font-weight: 600;
|
|
607
|
+
background: transparent;
|
|
608
|
+
border: none;
|
|
609
|
+
padding: 12px;
|
|
610
|
+
}
|
|
611
|
+
""")
|
|
612
|
+
text_label.setAlignment(Qt.AlignCenter)
|
|
613
|
+
overlay_layout.addWidget(text_label)
|
|
614
|
+
|
|
615
|
+
# Initially hidden
|
|
616
|
+
self.drop_overlay.hide()
|
|
617
|
+
self.drop_overlay.setAttribute(Qt.WA_TransparentForMouseEvents) # Don't block mouse events
|
|
618
|
+
|
|
619
|
+
@Slot()
|
|
620
|
+
def on_add_positions_clicked(self):
|
|
621
|
+
"""Handle add positions button click."""
|
|
622
|
+
dialog = InputDialog(self.settings, self)
|
|
623
|
+
dialog.positions_added.connect(self._on_positions_added)
|
|
624
|
+
|
|
625
|
+
dialog.exec()
|
|
626
|
+
|
|
627
|
+
@Slot(list)
|
|
628
|
+
def _on_positions_added(self, decisions):
|
|
629
|
+
"""Handle positions added from input dialog."""
|
|
630
|
+
if not decisions:
|
|
631
|
+
return
|
|
632
|
+
|
|
633
|
+
# Append to current decisions
|
|
634
|
+
self.current_decisions.extend(decisions)
|
|
635
|
+
self.btn_export.setEnabled(True)
|
|
636
|
+
self.list_header_row.show()
|
|
637
|
+
|
|
638
|
+
# Update position list
|
|
639
|
+
self.position_list.set_decisions(self.current_decisions)
|
|
640
|
+
|
|
641
|
+
@Slot(list)
|
|
642
|
+
def on_positions_deleted(self, indices: list):
|
|
643
|
+
"""Handle deletion of multiple positions."""
|
|
644
|
+
# Sort indices in descending order and delete
|
|
645
|
+
for index in sorted(indices, reverse=True):
|
|
646
|
+
if 0 <= index < len(self.current_decisions):
|
|
647
|
+
self.current_decisions.pop(index)
|
|
648
|
+
|
|
649
|
+
# Refresh the position list
|
|
650
|
+
self.position_list.set_decisions(self.current_decisions)
|
|
651
|
+
|
|
652
|
+
# Disable export and hide clear all if no positions remain
|
|
653
|
+
if not self.current_decisions:
|
|
654
|
+
self.btn_export.setEnabled(False)
|
|
655
|
+
self.list_header_row.hide()
|
|
656
|
+
# Show welcome screen
|
|
657
|
+
welcome_html = """
|
|
658
|
+
<!DOCTYPE html>
|
|
659
|
+
<html>
|
|
660
|
+
<head>
|
|
661
|
+
<style>
|
|
662
|
+
body {
|
|
663
|
+
margin: 0;
|
|
664
|
+
padding: 0;
|
|
665
|
+
display: flex;
|
|
666
|
+
flex-direction: column;
|
|
667
|
+
justify-content: center;
|
|
668
|
+
align-items: center;
|
|
669
|
+
min-height: 100vh;
|
|
670
|
+
background: linear-gradient(135deg, #1e1e2e 0%, #181825 100%);
|
|
671
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
672
|
+
color: #cdd6f4;
|
|
673
|
+
}
|
|
674
|
+
.welcome {
|
|
675
|
+
text-align: center;
|
|
676
|
+
padding: 40px;
|
|
677
|
+
}
|
|
678
|
+
h1 {
|
|
679
|
+
color: #f5e0dc;
|
|
680
|
+
font-size: 32px;
|
|
681
|
+
margin-bottom: 16px;
|
|
682
|
+
font-weight: 700;
|
|
683
|
+
}
|
|
684
|
+
p {
|
|
685
|
+
color: #a6adc8;
|
|
686
|
+
font-size: 16px;
|
|
687
|
+
margin: 8px 0;
|
|
688
|
+
}
|
|
689
|
+
.icon {
|
|
690
|
+
margin-bottom: 24px;
|
|
691
|
+
opacity: 0.6;
|
|
692
|
+
}
|
|
693
|
+
</style>
|
|
694
|
+
</head>
|
|
695
|
+
<body>
|
|
696
|
+
<div class="welcome">
|
|
697
|
+
<div class="icon">
|
|
698
|
+
<svg width="140" height="90" viewBox="-5 0 90 45" xmlns="http://www.w3.org/2000/svg">
|
|
699
|
+
<!-- First die -->
|
|
700
|
+
<g transform="translate(0, 10)">
|
|
701
|
+
<rect x="2" y="2" width="32" height="32" rx="4"
|
|
702
|
+
fill="#f5e0dc" stroke="#45475a" stroke-width="1.5"
|
|
703
|
+
transform="rotate(-15 18 18)"/>
|
|
704
|
+
<!-- Pips for 5 -->
|
|
705
|
+
<circle cx="10" cy="10" r="2.5" fill="#1e1e2e" transform="rotate(-15 18 18)"/>
|
|
706
|
+
<circle cx="26" cy="10" r="2.5" fill="#1e1e2e" transform="rotate(-15 18 18)"/>
|
|
707
|
+
<circle cx="18" cy="18" r="2.5" fill="#1e1e2e" transform="rotate(-15 18 18)"/>
|
|
708
|
+
<circle cx="10" cy="26" r="2.5" fill="#1e1e2e" transform="rotate(-15 18 18)"/>
|
|
709
|
+
<circle cx="26" cy="26" r="2.5" fill="#1e1e2e" transform="rotate(-15 18 18)"/>
|
|
710
|
+
</g>
|
|
711
|
+
|
|
712
|
+
<!-- Second die -->
|
|
713
|
+
<g transform="translate(36, 0)">
|
|
714
|
+
<rect x="2" y="2" width="32" height="32" rx="4"
|
|
715
|
+
fill="#f5e0dc" stroke="#45475a" stroke-width="1.5"
|
|
716
|
+
transform="rotate(12 18 18)"/>
|
|
717
|
+
<!-- Pips for 3 -->
|
|
718
|
+
<circle cx="10" cy="10" r="2.5" fill="#1e1e2e" transform="rotate(12 18 18)"/>
|
|
719
|
+
<circle cx="18" cy="18" r="2.5" fill="#1e1e2e" transform="rotate(12 18 18)"/>
|
|
720
|
+
<circle cx="26" cy="26" r="2.5" fill="#1e1e2e" transform="rotate(12 18 18)"/>
|
|
721
|
+
</g>
|
|
722
|
+
</svg>
|
|
723
|
+
</div>
|
|
724
|
+
<h1>No Position Loaded</h1>
|
|
725
|
+
<p>Add positions to get started</p>
|
|
726
|
+
</div>
|
|
727
|
+
</body>
|
|
728
|
+
</html>
|
|
729
|
+
"""
|
|
730
|
+
self.preview.setHtml(welcome_html)
|
|
731
|
+
self.preview.update() # Force repaint to avoid black screen issue
|
|
732
|
+
|
|
733
|
+
@Slot()
|
|
734
|
+
def on_clear_all_clicked(self):
|
|
735
|
+
"""Handle clear all button click."""
|
|
736
|
+
if not self.current_decisions:
|
|
737
|
+
return
|
|
738
|
+
|
|
739
|
+
# Show confirmation dialog
|
|
740
|
+
reply = QMessageBox.question(
|
|
741
|
+
self,
|
|
742
|
+
"Clear All Positions",
|
|
743
|
+
f"Are you sure you want to clear all {len(self.current_decisions)} position(s)?",
|
|
744
|
+
QMessageBox.Yes | QMessageBox.No,
|
|
745
|
+
QMessageBox.No
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
if reply == QMessageBox.Yes:
|
|
749
|
+
# Clear all decisions
|
|
750
|
+
self.current_decisions.clear()
|
|
751
|
+
self.position_list.set_decisions(self.current_decisions)
|
|
752
|
+
self.btn_export.setEnabled(False)
|
|
753
|
+
self.list_header_row.hide()
|
|
754
|
+
|
|
755
|
+
# Show welcome screen
|
|
756
|
+
welcome_html = """
|
|
757
|
+
<!DOCTYPE html>
|
|
758
|
+
<html>
|
|
759
|
+
<head>
|
|
760
|
+
<style>
|
|
761
|
+
body {
|
|
762
|
+
margin: 0;
|
|
763
|
+
padding: 0;
|
|
764
|
+
display: flex;
|
|
765
|
+
flex-direction: column;
|
|
766
|
+
justify-content: center;
|
|
767
|
+
align-items: center;
|
|
768
|
+
min-height: 100vh;
|
|
769
|
+
background: linear-gradient(135deg, #1e1e2e 0%, #181825 100%);
|
|
770
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
771
|
+
color: #cdd6f4;
|
|
772
|
+
}
|
|
773
|
+
.welcome {
|
|
774
|
+
text-align: center;
|
|
775
|
+
padding: 40px;
|
|
776
|
+
}
|
|
777
|
+
h1 {
|
|
778
|
+
color: #f5e0dc;
|
|
779
|
+
font-size: 32px;
|
|
780
|
+
margin-bottom: 16px;
|
|
781
|
+
font-weight: 700;
|
|
782
|
+
}
|
|
783
|
+
p {
|
|
784
|
+
color: #a6adc8;
|
|
785
|
+
font-size: 16px;
|
|
786
|
+
margin: 8px 0;
|
|
787
|
+
}
|
|
788
|
+
.icon {
|
|
789
|
+
margin-bottom: 24px;
|
|
790
|
+
opacity: 0.6;
|
|
791
|
+
}
|
|
792
|
+
</style>
|
|
793
|
+
</head>
|
|
794
|
+
<body>
|
|
795
|
+
<div class="welcome">
|
|
796
|
+
<div class="icon">
|
|
797
|
+
<svg width="140" height="90" viewBox="-5 0 90 45" xmlns="http://www.w3.org/2000/svg">
|
|
798
|
+
<!-- First die -->
|
|
799
|
+
<g transform="translate(0, 10)">
|
|
800
|
+
<rect x="2" y="2" width="32" height="32" rx="4"
|
|
801
|
+
fill="#f5e0dc" stroke="#45475a" stroke-width="1.5"
|
|
802
|
+
transform="rotate(-15 18 18)"/>
|
|
803
|
+
<!-- Pips for 5 -->
|
|
804
|
+
<circle cx="10" cy="10" r="2.5" fill="#1e1e2e" transform="rotate(-15 18 18)"/>
|
|
805
|
+
<circle cx="26" cy="10" r="2.5" fill="#1e1e2e" transform="rotate(-15 18 18)"/>
|
|
806
|
+
<circle cx="18" cy="18" r="2.5" fill="#1e1e2e" transform="rotate(-15 18 18)"/>
|
|
807
|
+
<circle cx="10" cy="26" r="2.5" fill="#1e1e2e" transform="rotate(-15 18 18)"/>
|
|
808
|
+
<circle cx="26" cy="26" r="2.5" fill="#1e1e2e" transform="rotate(-15 18 18)"/>
|
|
809
|
+
</g>
|
|
810
|
+
|
|
811
|
+
<!-- Second die -->
|
|
812
|
+
<g transform="translate(36, 0)">
|
|
813
|
+
<rect x="2" y="2" width="32" height="32" rx="4"
|
|
814
|
+
fill="#f5e0dc" stroke="#45475a" stroke-width="1.5"
|
|
815
|
+
transform="rotate(12 18 18)"/>
|
|
816
|
+
<!-- Pips for 3 -->
|
|
817
|
+
<circle cx="10" cy="10" r="2.5" fill="#1e1e2e" transform="rotate(12 18 18)"/>
|
|
818
|
+
<circle cx="18" cy="18" r="2.5" fill="#1e1e2e" transform="rotate(12 18 18)"/>
|
|
819
|
+
<circle cx="26" cy="26" r="2.5" fill="#1e1e2e" transform="rotate(12 18 18)"/>
|
|
820
|
+
</g>
|
|
821
|
+
</svg>
|
|
822
|
+
</div>
|
|
823
|
+
<h1>No Position Loaded</h1>
|
|
824
|
+
<p>Add positions to get started</p>
|
|
825
|
+
</div>
|
|
826
|
+
</body>
|
|
827
|
+
</html>
|
|
828
|
+
"""
|
|
829
|
+
self.preview.setHtml(welcome_html)
|
|
830
|
+
self.preview.update() # Force repaint to avoid black screen issue
|
|
831
|
+
|
|
832
|
+
@Slot(list)
|
|
833
|
+
def on_decisions_loaded(self, decisions):
|
|
834
|
+
"""Handle newly loaded decisions."""
|
|
835
|
+
self.current_decisions = decisions
|
|
836
|
+
self.btn_export.setEnabled(True)
|
|
837
|
+
self.list_header_row.show()
|
|
838
|
+
|
|
839
|
+
# Update position list
|
|
840
|
+
self.position_list.set_decisions(decisions)
|
|
841
|
+
|
|
842
|
+
def show_decision(self, decision: Decision):
|
|
843
|
+
"""Display a decision in the preview pane."""
|
|
844
|
+
# Generate SVG for the position
|
|
845
|
+
svg = self.renderer.render_svg(
|
|
846
|
+
decision.position,
|
|
847
|
+
dice=decision.dice,
|
|
848
|
+
on_roll=decision.on_roll,
|
|
849
|
+
cube_value=decision.cube_value,
|
|
850
|
+
cube_owner=decision.cube_owner,
|
|
851
|
+
score_x=decision.score_x,
|
|
852
|
+
score_o=decision.score_o,
|
|
853
|
+
match_length=decision.match_length,
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
# Wrap SVG in minimal HTML with dark theme
|
|
857
|
+
html = f"""
|
|
858
|
+
<!DOCTYPE html>
|
|
859
|
+
<html>
|
|
860
|
+
<head>
|
|
861
|
+
<style>
|
|
862
|
+
html, body {{
|
|
863
|
+
margin: 0;
|
|
864
|
+
padding: 0;
|
|
865
|
+
height: 100%;
|
|
866
|
+
overflow: hidden;
|
|
867
|
+
}}
|
|
868
|
+
body {{
|
|
869
|
+
padding: 20px;
|
|
870
|
+
display: flex;
|
|
871
|
+
justify-content: center;
|
|
872
|
+
align-items: center;
|
|
873
|
+
background: linear-gradient(135deg, #1e1e2e 0%, #181825 100%);
|
|
874
|
+
box-sizing: border-box;
|
|
875
|
+
}}
|
|
876
|
+
svg {{
|
|
877
|
+
max-width: 100%;
|
|
878
|
+
max-height: 100%;
|
|
879
|
+
height: auto;
|
|
880
|
+
filter: drop-shadow(0 10px 30px rgba(0, 0, 0, 0.5));
|
|
881
|
+
border-radius: 12px;
|
|
882
|
+
}}
|
|
883
|
+
</style>
|
|
884
|
+
</head>
|
|
885
|
+
<body>
|
|
886
|
+
{svg}
|
|
887
|
+
</body>
|
|
888
|
+
</html>
|
|
889
|
+
"""
|
|
890
|
+
|
|
891
|
+
self.preview.setHtml(html)
|
|
892
|
+
self.preview.update() # Force repaint to avoid black screen issue
|
|
893
|
+
|
|
894
|
+
@Slot()
|
|
895
|
+
def on_edit_deck_name(self):
|
|
896
|
+
"""Handle deck name edit button click."""
|
|
897
|
+
# Create input dialog
|
|
898
|
+
dialog = QInputDialog(self)
|
|
899
|
+
dialog.setWindowTitle("Edit Deck Name")
|
|
900
|
+
dialog.setLabelText("Enter deck name:")
|
|
901
|
+
dialog.setTextValue(self.settings.deck_name)
|
|
902
|
+
|
|
903
|
+
# Use a timer to set cursor pointers after dialog widgets are created
|
|
904
|
+
from PySide6.QtCore import QTimer
|
|
905
|
+
from PySide6.QtWidgets import QDialogButtonBox
|
|
906
|
+
|
|
907
|
+
def set_button_cursors():
|
|
908
|
+
button_box = dialog.findChild(QDialogButtonBox)
|
|
909
|
+
if button_box:
|
|
910
|
+
for button in button_box.buttons():
|
|
911
|
+
button.setCursor(Qt.PointingHandCursor)
|
|
912
|
+
|
|
913
|
+
QTimer.singleShot(0, set_button_cursors)
|
|
914
|
+
|
|
915
|
+
# Show dialog and get result
|
|
916
|
+
ok = dialog.exec()
|
|
917
|
+
new_name = dialog.textValue()
|
|
918
|
+
|
|
919
|
+
if ok and new_name.strip():
|
|
920
|
+
self.settings.deck_name = new_name.strip()
|
|
921
|
+
self._update_deck_label()
|
|
922
|
+
|
|
923
|
+
@Slot()
|
|
924
|
+
def on_settings_clicked(self):
|
|
925
|
+
"""Handle settings button click."""
|
|
926
|
+
dialog = SettingsDialog(self.settings, self)
|
|
927
|
+
dialog.settings_changed.connect(self.on_settings_changed)
|
|
928
|
+
dialog.exec()
|
|
929
|
+
|
|
930
|
+
@Slot(Settings)
|
|
931
|
+
def on_settings_changed(self, settings: Settings):
|
|
932
|
+
"""Handle settings changes."""
|
|
933
|
+
# Update renderer with new color scheme and orientation
|
|
934
|
+
self.renderer = SVGBoardRenderer(
|
|
935
|
+
color_scheme=get_scheme(settings.color_scheme),
|
|
936
|
+
orientation=settings.board_orientation
|
|
937
|
+
)
|
|
938
|
+
|
|
939
|
+
# Update menu checkmarks if color scheme changed
|
|
940
|
+
for scheme_name, action in self.color_scheme_actions.items():
|
|
941
|
+
action.setChecked(scheme_name == settings.color_scheme)
|
|
942
|
+
|
|
943
|
+
# Update deck name label
|
|
944
|
+
self._update_deck_label()
|
|
945
|
+
|
|
946
|
+
# Refresh current preview if a decision is displayed
|
|
947
|
+
if self.current_decisions:
|
|
948
|
+
selected = self.position_list.get_selected_decision()
|
|
949
|
+
if selected:
|
|
950
|
+
self.show_decision(selected)
|
|
951
|
+
|
|
952
|
+
@Slot()
|
|
953
|
+
def on_export_clicked(self):
|
|
954
|
+
"""Handle export button click."""
|
|
955
|
+
if not self.current_decisions:
|
|
956
|
+
QMessageBox.warning(
|
|
957
|
+
self,
|
|
958
|
+
"No Positions",
|
|
959
|
+
"Please add positions first"
|
|
960
|
+
)
|
|
961
|
+
return
|
|
962
|
+
|
|
963
|
+
dialog = ExportDialog(self.current_decisions, self.settings, self)
|
|
964
|
+
dialog.exec()
|
|
965
|
+
|
|
966
|
+
@Slot(str)
|
|
967
|
+
def change_color_scheme(self, scheme: str):
|
|
968
|
+
"""Change the color scheme."""
|
|
969
|
+
self.settings.color_scheme = scheme
|
|
970
|
+
|
|
971
|
+
# Update checkmarks: uncheck all, then check the selected one
|
|
972
|
+
for scheme_name, action in self.color_scheme_actions.items():
|
|
973
|
+
action.setChecked(scheme_name == scheme)
|
|
974
|
+
|
|
975
|
+
self.on_settings_changed(self.settings)
|
|
976
|
+
|
|
977
|
+
@Slot()
|
|
978
|
+
def show_website(self):
|
|
979
|
+
"""Open the project website."""
|
|
980
|
+
QDesktopServices.openUrl(QUrl("https://ankigammon.com/"))
|
|
981
|
+
|
|
982
|
+
@Slot()
|
|
983
|
+
def show_about_dialog(self):
|
|
984
|
+
"""Show about dialog."""
|
|
985
|
+
QMessageBox.about(
|
|
986
|
+
self,
|
|
987
|
+
"About AnkiGammon",
|
|
988
|
+
f"""<style>
|
|
989
|
+
a {{ color: #3daee9; text-decoration: none; font-weight: bold; }}
|
|
990
|
+
a:hover {{ text-decoration: underline; }}
|
|
991
|
+
</style>
|
|
992
|
+
<h2>AnkiGammon</h2>
|
|
993
|
+
<p>Version {__version__}</p>
|
|
994
|
+
<p>Convert backgammon position analysis into interactive Anki flashcards.</p>
|
|
995
|
+
<p>Built with PySide6 and Qt.</p>
|
|
996
|
+
|
|
997
|
+
<h3>Special Thanks</h3>
|
|
998
|
+
<p>OilSpillDuckling<br>Eran & OpenGammon<br>Orad & Backgammon101</p>
|
|
999
|
+
|
|
1000
|
+
<p><a href="https://github.com/Deinonychus999/AnkiGammon">GitHub Repository</a> | <a href="https://ko-fi.com/ankigammon">Donate</a></p>
|
|
1001
|
+
"""
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
def _ensure_played_move_in_candidates(self, decision: Decision, played_move: Move) -> None:
|
|
1005
|
+
"""
|
|
1006
|
+
Ensure the played move is in the top N candidates for MCQ display.
|
|
1007
|
+
|
|
1008
|
+
If the played move is not in the top N analyzed moves (where N is max_mcq_options),
|
|
1009
|
+
insert it at position N-1 (last slot) to ensure it appears as an option.
|
|
1010
|
+
|
|
1011
|
+
Args:
|
|
1012
|
+
decision: The decision object to modify
|
|
1013
|
+
played_move: The move that was actually played
|
|
1014
|
+
"""
|
|
1015
|
+
# Get the number of MCQ options from settings
|
|
1016
|
+
max_options = self.settings.max_mcq_options
|
|
1017
|
+
|
|
1018
|
+
# Check if played move is already in the top N candidates
|
|
1019
|
+
top_n = decision.candidate_moves[:max_options]
|
|
1020
|
+
|
|
1021
|
+
# If played move is already in top N, nothing to do
|
|
1022
|
+
if played_move in top_n:
|
|
1023
|
+
return
|
|
1024
|
+
|
|
1025
|
+
# Move is not in top N - insert it at position N-1 (last slot)
|
|
1026
|
+
decision.candidate_moves.remove(played_move)
|
|
1027
|
+
decision.candidate_moves.insert(max_options - 1, played_move)
|
|
1028
|
+
|
|
1029
|
+
def _filter_decisions_by_import_options(
|
|
1030
|
+
self,
|
|
1031
|
+
decisions: list[Decision],
|
|
1032
|
+
threshold: float,
|
|
1033
|
+
include_player_x: bool,
|
|
1034
|
+
include_player_o: bool
|
|
1035
|
+
) -> list[Decision]:
|
|
1036
|
+
"""
|
|
1037
|
+
Filter decisions based on import options.
|
|
1038
|
+
|
|
1039
|
+
Args:
|
|
1040
|
+
decisions: All parsed decisions
|
|
1041
|
+
threshold: Error threshold (positive value, e.g., 0.080)
|
|
1042
|
+
include_player_x: Include Player.X mistakes
|
|
1043
|
+
include_player_o: Include Player.O mistakes
|
|
1044
|
+
|
|
1045
|
+
Returns:
|
|
1046
|
+
Filtered list of decisions
|
|
1047
|
+
"""
|
|
1048
|
+
from ankigammon.models import Player, DecisionType
|
|
1049
|
+
import logging
|
|
1050
|
+
logger = logging.getLogger(__name__)
|
|
1051
|
+
|
|
1052
|
+
filtered = []
|
|
1053
|
+
|
|
1054
|
+
cube_decisions_found = sum(1 for d in decisions if d.decision_type == DecisionType.CUBE_ACTION)
|
|
1055
|
+
logger.info(f"DEBUG: Filtering {len(decisions)} total decisions ({cube_decisions_found} cube decisions)")
|
|
1056
|
+
|
|
1057
|
+
for decision in decisions:
|
|
1058
|
+
# Skip decisions with no moves
|
|
1059
|
+
if not decision.candidate_moves:
|
|
1060
|
+
continue
|
|
1061
|
+
|
|
1062
|
+
# Find the move that was actually played in the game
|
|
1063
|
+
played_move = next((m for m in decision.candidate_moves if m.was_played), None)
|
|
1064
|
+
|
|
1065
|
+
# Skip if no move is marked as played
|
|
1066
|
+
if not played_move:
|
|
1067
|
+
continue
|
|
1068
|
+
|
|
1069
|
+
# Handle cube and checker play decisions differently
|
|
1070
|
+
if decision.decision_type == DecisionType.CUBE_ACTION:
|
|
1071
|
+
# Check which player made the error
|
|
1072
|
+
attr = decision.get_cube_error_attribution()
|
|
1073
|
+
doubler = attr['doubler']
|
|
1074
|
+
responder = attr['responder']
|
|
1075
|
+
doubler_error = attr['doubler_error']
|
|
1076
|
+
responder_error = attr['responder_error']
|
|
1077
|
+
|
|
1078
|
+
logger.info(f"DEBUG: Cube decision - doubler={doubler}, doubler_error={doubler_error}, responder={responder}, responder_error={responder_error}, threshold={threshold}")
|
|
1079
|
+
|
|
1080
|
+
# Determine which player(s) made errors above threshold
|
|
1081
|
+
doubler_made_error = doubler_error is not None and abs(doubler_error) >= threshold
|
|
1082
|
+
responder_made_error = responder_error is not None and abs(responder_error) >= threshold
|
|
1083
|
+
|
|
1084
|
+
logger.info(f"DEBUG: doubler_made_error={doubler_made_error}, responder_made_error={responder_made_error}")
|
|
1085
|
+
|
|
1086
|
+
# Skip if no errors above threshold
|
|
1087
|
+
if not doubler_made_error and not responder_made_error:
|
|
1088
|
+
logger.info(f"DEBUG: Skipping cube decision - no errors above threshold")
|
|
1089
|
+
continue
|
|
1090
|
+
|
|
1091
|
+
# Check if we should include this decision based on player filter
|
|
1092
|
+
include_decision = False
|
|
1093
|
+
|
|
1094
|
+
if doubler == Player.X and doubler_made_error and include_player_x:
|
|
1095
|
+
include_decision = True
|
|
1096
|
+
if doubler == Player.O and doubler_made_error and include_player_o:
|
|
1097
|
+
include_decision = True
|
|
1098
|
+
if responder == Player.X and responder_made_error and include_player_x:
|
|
1099
|
+
include_decision = True
|
|
1100
|
+
if responder == Player.O and responder_made_error and include_player_o:
|
|
1101
|
+
include_decision = True
|
|
1102
|
+
|
|
1103
|
+
logger.info(f"DEBUG: include_decision={include_decision} (include_player_x={include_player_x}, include_player_o={include_player_o})")
|
|
1104
|
+
|
|
1105
|
+
if include_decision:
|
|
1106
|
+
# Include the played move in MCQ candidates
|
|
1107
|
+
self._ensure_played_move_in_candidates(decision, played_move)
|
|
1108
|
+
filtered.append(decision)
|
|
1109
|
+
logger.info(f"DEBUG: Added cube decision to filtered list")
|
|
1110
|
+
else:
|
|
1111
|
+
# For checker play from XG binary files, use XG's authoritative ErrMove field
|
|
1112
|
+
# Otherwise fall back to recalculated error
|
|
1113
|
+
if decision.xg_error_move is not None:
|
|
1114
|
+
# Use XG's ErrMove field (already absolute value)
|
|
1115
|
+
error_magnitude = decision.xg_error_move
|
|
1116
|
+
elif played_move.xg_error is not None:
|
|
1117
|
+
# Use XG text parser's calculated error
|
|
1118
|
+
error_magnitude = abs(played_move.xg_error)
|
|
1119
|
+
else:
|
|
1120
|
+
# Use recalculated error (for other sources)
|
|
1121
|
+
error_magnitude = played_move.error
|
|
1122
|
+
|
|
1123
|
+
# Only include if error is at or above threshold
|
|
1124
|
+
if error_magnitude < threshold:
|
|
1125
|
+
continue
|
|
1126
|
+
|
|
1127
|
+
# Check player filter - error belongs to the player on roll
|
|
1128
|
+
if decision.on_roll == Player.X and not include_player_x:
|
|
1129
|
+
continue
|
|
1130
|
+
if decision.on_roll == Player.O and not include_player_o:
|
|
1131
|
+
continue
|
|
1132
|
+
|
|
1133
|
+
# Include the played move in MCQ candidates
|
|
1134
|
+
self._ensure_played_move_in_candidates(decision, played_move)
|
|
1135
|
+
filtered.append(decision)
|
|
1136
|
+
|
|
1137
|
+
cube_decisions_filtered = sum(1 for d in filtered if d.decision_type == DecisionType.CUBE_ACTION)
|
|
1138
|
+
logger.info(f"DEBUG: After filtering: {len(filtered)} decisions ({cube_decisions_filtered} cube decisions)")
|
|
1139
|
+
|
|
1140
|
+
return filtered
|
|
1141
|
+
|
|
1142
|
+
def _import_match_file(self, file_path: str) -> Tuple[List[Decision], int]:
|
|
1143
|
+
"""
|
|
1144
|
+
Import match file with analysis via GnuBG.
|
|
1145
|
+
|
|
1146
|
+
Supports both .mat (Jellyfish) and .sgf (Smart Game Format) files.
|
|
1147
|
+
|
|
1148
|
+
Args:
|
|
1149
|
+
file_path: Path to match file (.mat or .sgf)
|
|
1150
|
+
|
|
1151
|
+
Returns:
|
|
1152
|
+
Tuple of (filtered_decisions, total_count) or (None, None) if cancelled/failed
|
|
1153
|
+
"""
|
|
1154
|
+
from PySide6.QtWidgets import QMessageBox, QProgressDialog
|
|
1155
|
+
from PySide6.QtCore import Qt
|
|
1156
|
+
from ankigammon.gui.dialogs.import_options_dialog import ImportOptionsDialog
|
|
1157
|
+
import logging
|
|
1158
|
+
|
|
1159
|
+
logger = logging.getLogger(__name__)
|
|
1160
|
+
|
|
1161
|
+
# Check if GnuBG is configured
|
|
1162
|
+
if not self.settings.is_gnubg_available():
|
|
1163
|
+
# Only show the dialog once per import batch
|
|
1164
|
+
if not self._gnubg_check_shown:
|
|
1165
|
+
self._gnubg_check_shown = True
|
|
1166
|
+
result = QMessageBox.question(
|
|
1167
|
+
self,
|
|
1168
|
+
"GnuBG Required",
|
|
1169
|
+
"Match file analysis requires GNU Backgammon.\n\n"
|
|
1170
|
+
"Would you like to configure it in Settings?",
|
|
1171
|
+
QMessageBox.Yes | QMessageBox.No
|
|
1172
|
+
)
|
|
1173
|
+
if result == QMessageBox.Yes:
|
|
1174
|
+
self.on_settings_clicked()
|
|
1175
|
+
return None, None
|
|
1176
|
+
|
|
1177
|
+
# Extract player names based on file type
|
|
1178
|
+
from ankigammon.parsers.gnubg_match_parser import GNUBGMatchParser
|
|
1179
|
+
from pathlib import Path
|
|
1180
|
+
|
|
1181
|
+
file_ext = Path(file_path).suffix.lower()
|
|
1182
|
+
if file_ext == '.sgf':
|
|
1183
|
+
# Extract from SGF file
|
|
1184
|
+
from ankigammon.parsers.sgf_parser import extract_player_names_from_sgf
|
|
1185
|
+
player1_name, player2_name = extract_player_names_from_sgf(file_path)
|
|
1186
|
+
else:
|
|
1187
|
+
# Extract from .mat file
|
|
1188
|
+
player1_name, player2_name = GNUBGMatchParser.extract_player_names_from_mat(file_path)
|
|
1189
|
+
|
|
1190
|
+
# Show import options dialog with actual player names
|
|
1191
|
+
import_dialog = ImportOptionsDialog(
|
|
1192
|
+
self.settings,
|
|
1193
|
+
player1_name=player1_name,
|
|
1194
|
+
player2_name=player2_name,
|
|
1195
|
+
parent=self
|
|
1196
|
+
)
|
|
1197
|
+
|
|
1198
|
+
if not import_dialog.exec():
|
|
1199
|
+
# User cancelled
|
|
1200
|
+
return None, None
|
|
1201
|
+
|
|
1202
|
+
# Get filter options
|
|
1203
|
+
threshold, include_player_x, include_player_o = import_dialog.get_options()
|
|
1204
|
+
|
|
1205
|
+
# Create progress dialog with spinner
|
|
1206
|
+
progress = QProgressDialog(
|
|
1207
|
+
f"Analyzing match with GnuBG ({self.settings.gnubg_analysis_ply}-ply)...",
|
|
1208
|
+
"Cancel",
|
|
1209
|
+
0,
|
|
1210
|
+
0,
|
|
1211
|
+
self
|
|
1212
|
+
)
|
|
1213
|
+
progress.setWindowTitle("Analyzing Match")
|
|
1214
|
+
progress.setWindowModality(Qt.WindowModal)
|
|
1215
|
+
progress.setMinimumDuration(0) # Show immediately
|
|
1216
|
+
progress.setMinimumWidth(500)
|
|
1217
|
+
|
|
1218
|
+
# Store results
|
|
1219
|
+
self._analysis_results = None
|
|
1220
|
+
|
|
1221
|
+
# Create and configure worker thread
|
|
1222
|
+
self._analysis_worker = MatchAnalysisWorker(
|
|
1223
|
+
file_path=file_path,
|
|
1224
|
+
settings=self.settings,
|
|
1225
|
+
threshold=threshold,
|
|
1226
|
+
include_player_x=include_player_x,
|
|
1227
|
+
include_player_o=include_player_o,
|
|
1228
|
+
filter_func=self._filter_decisions_by_import_options,
|
|
1229
|
+
max_mcq_options=self.settings.max_mcq_options
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
# Connect signals
|
|
1233
|
+
self._analysis_worker.status_message.connect(
|
|
1234
|
+
lambda msg: progress.setLabelText(msg)
|
|
1235
|
+
)
|
|
1236
|
+
self._analysis_worker.finished.connect(
|
|
1237
|
+
lambda success, message, decisions, total: self._on_analysis_finished(
|
|
1238
|
+
success, message, decisions, total, progress
|
|
1239
|
+
)
|
|
1240
|
+
)
|
|
1241
|
+
progress.canceled.connect(self._analysis_worker.cancel)
|
|
1242
|
+
|
|
1243
|
+
# Start worker
|
|
1244
|
+
self._analysis_worker.start()
|
|
1245
|
+
|
|
1246
|
+
# Show progress dialog (blocks until worker emits finished signal or user cancels)
|
|
1247
|
+
result = progress.exec()
|
|
1248
|
+
|
|
1249
|
+
# Check if user cancelled
|
|
1250
|
+
if progress.wasCanceled() or self._analysis_results is None:
|
|
1251
|
+
logger.info("Analysis cancelled by user")
|
|
1252
|
+
# Wait for worker to finish cleanup
|
|
1253
|
+
if hasattr(self, '_analysis_worker'):
|
|
1254
|
+
self._analysis_worker.wait(2000)
|
|
1255
|
+
return None, None
|
|
1256
|
+
|
|
1257
|
+
# Return results
|
|
1258
|
+
return self._analysis_results
|
|
1259
|
+
|
|
1260
|
+
@Slot(bool, str, list, int, object)
|
|
1261
|
+
def _on_analysis_finished(self, success: bool, message: str,
|
|
1262
|
+
decisions: List[Decision], total_count: int,
|
|
1263
|
+
progress_dialog):
|
|
1264
|
+
"""Handle completion of match analysis worker."""
|
|
1265
|
+
from PySide6.QtWidgets import QMessageBox
|
|
1266
|
+
import logging
|
|
1267
|
+
|
|
1268
|
+
logger = logging.getLogger(__name__)
|
|
1269
|
+
|
|
1270
|
+
if success:
|
|
1271
|
+
logger.info(f"Analysis completed: {len(decisions)} positions filtered from {total_count} total")
|
|
1272
|
+
self._analysis_results = (decisions, total_count)
|
|
1273
|
+
progress_dialog.accept()
|
|
1274
|
+
else:
|
|
1275
|
+
# Show error message unless user cancelled
|
|
1276
|
+
if message != "Cancelled":
|
|
1277
|
+
QMessageBox.critical(
|
|
1278
|
+
self,
|
|
1279
|
+
"Analysis Failed",
|
|
1280
|
+
message
|
|
1281
|
+
)
|
|
1282
|
+
self._analysis_results = (None, None)
|
|
1283
|
+
progress_dialog.close()
|
|
1284
|
+
|
|
1285
|
+
# Cleanup worker
|
|
1286
|
+
if hasattr(self, '_analysis_worker'):
|
|
1287
|
+
self._analysis_worker.deleteLater()
|
|
1288
|
+
del self._analysis_worker
|
|
1289
|
+
|
|
1290
|
+
@Slot()
|
|
1291
|
+
def on_import_file_clicked(self):
|
|
1292
|
+
"""Handle import file menu action."""
|
|
1293
|
+
from PySide6.QtWidgets import QFileDialog
|
|
1294
|
+
|
|
1295
|
+
# Show file dialog
|
|
1296
|
+
file_path, _ = QFileDialog.getOpenFileName(
|
|
1297
|
+
self,
|
|
1298
|
+
"Import Backgammon File",
|
|
1299
|
+
"",
|
|
1300
|
+
"All Supported Files (*.xg *.mat *.txt *.sgf);;XG Binary (*.xg);;Match Files (*.mat *.txt *.sgf);;All Files (*)"
|
|
1301
|
+
)
|
|
1302
|
+
|
|
1303
|
+
if not file_path:
|
|
1304
|
+
return
|
|
1305
|
+
|
|
1306
|
+
# Reset GnuBG check flag for this import
|
|
1307
|
+
self._gnubg_check_shown = False
|
|
1308
|
+
|
|
1309
|
+
# Add to import queue and start processing
|
|
1310
|
+
self._import_queue.append(file_path)
|
|
1311
|
+
self._process_import_queue()
|
|
1312
|
+
|
|
1313
|
+
def dragEnterEvent(self, event):
|
|
1314
|
+
"""Handle drag enter event - accept if it contains valid files."""
|
|
1315
|
+
if event.mimeData().hasUrls():
|
|
1316
|
+
# Accept any file - format detector will validate
|
|
1317
|
+
urls = event.mimeData().urls()
|
|
1318
|
+
for url in urls:
|
|
1319
|
+
if url.isLocalFile():
|
|
1320
|
+
# Show visual overlay
|
|
1321
|
+
self._show_drop_overlay()
|
|
1322
|
+
event.acceptProposedAction()
|
|
1323
|
+
return
|
|
1324
|
+
event.ignore()
|
|
1325
|
+
|
|
1326
|
+
def dragLeaveEvent(self, event):
|
|
1327
|
+
"""Handle drag leave event - hide overlay when drag leaves the window."""
|
|
1328
|
+
self._hide_drop_overlay()
|
|
1329
|
+
event.accept()
|
|
1330
|
+
|
|
1331
|
+
def dropEvent(self, event):
|
|
1332
|
+
"""Handle drop event - import the dropped backgammon files."""
|
|
1333
|
+
# Hide overlay immediately
|
|
1334
|
+
self._hide_drop_overlay()
|
|
1335
|
+
|
|
1336
|
+
if not event.mimeData().hasUrls():
|
|
1337
|
+
event.ignore()
|
|
1338
|
+
return
|
|
1339
|
+
|
|
1340
|
+
# Collect file paths to import
|
|
1341
|
+
file_paths = []
|
|
1342
|
+
urls = event.mimeData().urls()
|
|
1343
|
+
for url in urls:
|
|
1344
|
+
if url.isLocalFile():
|
|
1345
|
+
file_paths.append(url.toLocalFile())
|
|
1346
|
+
|
|
1347
|
+
# Accept the drop event immediately
|
|
1348
|
+
event.acceptProposedAction()
|
|
1349
|
+
|
|
1350
|
+
# Reset GnuBG check flag for this batch of imports
|
|
1351
|
+
self._gnubg_check_shown = False
|
|
1352
|
+
|
|
1353
|
+
# Add files to import queue
|
|
1354
|
+
self._import_queue.extend(file_paths)
|
|
1355
|
+
|
|
1356
|
+
# Start processing the queue
|
|
1357
|
+
self._process_import_queue()
|
|
1358
|
+
|
|
1359
|
+
def _show_drop_overlay(self):
|
|
1360
|
+
"""Show the drop overlay with proper sizing."""
|
|
1361
|
+
# Resize overlay to cover the entire parent (central widget)
|
|
1362
|
+
self.drop_overlay.setGeometry(self.drop_overlay.parentWidget().rect())
|
|
1363
|
+
self.drop_overlay.raise_() # Bring to front
|
|
1364
|
+
self.drop_overlay.show()
|
|
1365
|
+
|
|
1366
|
+
def _hide_drop_overlay(self):
|
|
1367
|
+
"""Hide the drop overlay."""
|
|
1368
|
+
self.drop_overlay.hide()
|
|
1369
|
+
|
|
1370
|
+
def _process_import_queue(self):
|
|
1371
|
+
"""Process files from the import queue sequentially."""
|
|
1372
|
+
# If already processing or queue is empty, do nothing
|
|
1373
|
+
if self._import_in_progress or not self._import_queue:
|
|
1374
|
+
return
|
|
1375
|
+
|
|
1376
|
+
# Mark as in progress
|
|
1377
|
+
self._import_in_progress = True
|
|
1378
|
+
|
|
1379
|
+
# Get next file from queue
|
|
1380
|
+
file_path = self._import_queue.pop(0)
|
|
1381
|
+
|
|
1382
|
+
# Use QTimer to defer processing to avoid blocking the UI
|
|
1383
|
+
# This also ensures the dialog from the previous import has fully closed
|
|
1384
|
+
def process_file():
|
|
1385
|
+
try:
|
|
1386
|
+
self._import_file(file_path)
|
|
1387
|
+
finally:
|
|
1388
|
+
# Mark as not in progress and process next file
|
|
1389
|
+
self._import_in_progress = False
|
|
1390
|
+
# Use QTimer to ensure UI updates properly between imports
|
|
1391
|
+
QTimer.singleShot(100, self._process_import_queue)
|
|
1392
|
+
|
|
1393
|
+
QTimer.singleShot(0, process_file)
|
|
1394
|
+
|
|
1395
|
+
def _import_file(self, file_path: str):
|
|
1396
|
+
"""
|
|
1397
|
+
Import a file at the given path.
|
|
1398
|
+
This is a helper method that can be called from both the menu action
|
|
1399
|
+
and the drag-and-drop handler.
|
|
1400
|
+
"""
|
|
1401
|
+
from ankigammon.gui.format_detector import FormatDetector, InputFormat
|
|
1402
|
+
from ankigammon.parsers.xg_binary_parser import XGBinaryParser
|
|
1403
|
+
import logging
|
|
1404
|
+
|
|
1405
|
+
logger = logging.getLogger(__name__)
|
|
1406
|
+
|
|
1407
|
+
try:
|
|
1408
|
+
# Read file
|
|
1409
|
+
with open(file_path, 'rb') as f:
|
|
1410
|
+
data = f.read()
|
|
1411
|
+
|
|
1412
|
+
# Detect format
|
|
1413
|
+
detector = FormatDetector(self.settings)
|
|
1414
|
+
result = detector.detect_binary(data)
|
|
1415
|
+
|
|
1416
|
+
logger.info(f"Detected format: {result.format}, count: {result.count}")
|
|
1417
|
+
|
|
1418
|
+
# Parse based on format
|
|
1419
|
+
decisions = []
|
|
1420
|
+
total_count = 0 # Track total before filtering (for XG binary)
|
|
1421
|
+
|
|
1422
|
+
if result.format == InputFormat.XG_BINARY:
|
|
1423
|
+
# Extract player names from XG file
|
|
1424
|
+
player1_name, player2_name = XGBinaryParser.extract_player_names(file_path)
|
|
1425
|
+
|
|
1426
|
+
# Show import options dialog for XG binary files
|
|
1427
|
+
import_dialog = ImportOptionsDialog(
|
|
1428
|
+
self.settings,
|
|
1429
|
+
player1_name=player1_name,
|
|
1430
|
+
player2_name=player2_name,
|
|
1431
|
+
parent=self
|
|
1432
|
+
)
|
|
1433
|
+
if import_dialog.exec():
|
|
1434
|
+
# User accepted - get options
|
|
1435
|
+
threshold, include_player_x, include_player_o = import_dialog.get_options()
|
|
1436
|
+
|
|
1437
|
+
# Parse all decisions
|
|
1438
|
+
all_decisions = XGBinaryParser.parse_file(file_path)
|
|
1439
|
+
total_count = len(all_decisions)
|
|
1440
|
+
|
|
1441
|
+
# Filter based on user options
|
|
1442
|
+
decisions = self._filter_decisions_by_import_options(
|
|
1443
|
+
all_decisions,
|
|
1444
|
+
threshold,
|
|
1445
|
+
include_player_x,
|
|
1446
|
+
include_player_o
|
|
1447
|
+
)
|
|
1448
|
+
|
|
1449
|
+
logger.info(f"Filtered {len(decisions)} positions from {total_count} total")
|
|
1450
|
+
else:
|
|
1451
|
+
# User cancelled
|
|
1452
|
+
return
|
|
1453
|
+
|
|
1454
|
+
elif result.format == InputFormat.MATCH_FILE or result.format == InputFormat.SGF_FILE:
|
|
1455
|
+
# Import match file with analysis
|
|
1456
|
+
decisions, total_count = self._import_match_file(file_path)
|
|
1457
|
+
if decisions is None:
|
|
1458
|
+
# User cancelled or error occurred
|
|
1459
|
+
return
|
|
1460
|
+
|
|
1461
|
+
else:
|
|
1462
|
+
QMessageBox.warning(
|
|
1463
|
+
self,
|
|
1464
|
+
"Unknown Format",
|
|
1465
|
+
f"Could not detect file format.\n\nSupported formats:\n- XG binary files (.xg)\n- Match files (.mat, .sgf)\n\n{result.details}"
|
|
1466
|
+
)
|
|
1467
|
+
return
|
|
1468
|
+
|
|
1469
|
+
# Add to current decisions
|
|
1470
|
+
self.current_decisions.extend(decisions)
|
|
1471
|
+
self.position_list.set_decisions(self.current_decisions)
|
|
1472
|
+
self.btn_export.setEnabled(True)
|
|
1473
|
+
self.list_header_row.show()
|
|
1474
|
+
|
|
1475
|
+
# Show success message
|
|
1476
|
+
from pathlib import Path
|
|
1477
|
+
filename = Path(file_path).name
|
|
1478
|
+
|
|
1479
|
+
# Show filtering info
|
|
1480
|
+
filtered_count = len(decisions)
|
|
1481
|
+
message = f"Imported {filtered_count} position(s) from {filename}"
|
|
1482
|
+
if total_count > filtered_count:
|
|
1483
|
+
message += f"\n(filtered from {total_count} total positions)"
|
|
1484
|
+
|
|
1485
|
+
QMessageBox.information(
|
|
1486
|
+
self,
|
|
1487
|
+
"Import Successful",
|
|
1488
|
+
message
|
|
1489
|
+
)
|
|
1490
|
+
|
|
1491
|
+
logger.info(f"Successfully imported {len(decisions)} positions from {file_path}")
|
|
1492
|
+
|
|
1493
|
+
except FileNotFoundError:
|
|
1494
|
+
QMessageBox.critical(
|
|
1495
|
+
self,
|
|
1496
|
+
"File Not Found",
|
|
1497
|
+
f"Could not find file: {file_path}"
|
|
1498
|
+
)
|
|
1499
|
+
except ValueError as e:
|
|
1500
|
+
QMessageBox.critical(
|
|
1501
|
+
self,
|
|
1502
|
+
"Invalid Format",
|
|
1503
|
+
f"Invalid file format:\n{str(e)}"
|
|
1504
|
+
)
|
|
1505
|
+
except Exception as e:
|
|
1506
|
+
logger.error(f"Failed to import file {file_path}: {e}", exc_info=True)
|
|
1507
|
+
QMessageBox.critical(
|
|
1508
|
+
self,
|
|
1509
|
+
"Import Failed",
|
|
1510
|
+
f"Failed to import file:\n{str(e)}"
|
|
1511
|
+
)
|
|
1512
|
+
|
|
1513
|
+
def _check_for_updates_background(self):
|
|
1514
|
+
"""Check for updates in the background (non-blocking)."""
|
|
1515
|
+
from datetime import datetime
|
|
1516
|
+
|
|
1517
|
+
# Check if snoozed
|
|
1518
|
+
snooze_until = self.settings.snooze_update_until
|
|
1519
|
+
if snooze_until:
|
|
1520
|
+
try:
|
|
1521
|
+
snooze_time = datetime.fromisoformat(snooze_until)
|
|
1522
|
+
if datetime.now() < snooze_time:
|
|
1523
|
+
return # Still snoozed
|
|
1524
|
+
except (ValueError, AttributeError):
|
|
1525
|
+
pass
|
|
1526
|
+
|
|
1527
|
+
# Start background check
|
|
1528
|
+
self._version_checker_thread = VersionCheckerThread(
|
|
1529
|
+
current_version=__version__,
|
|
1530
|
+
force_check=False
|
|
1531
|
+
)
|
|
1532
|
+
self._version_checker_thread.update_available.connect(self._on_update_available)
|
|
1533
|
+
self._version_checker_thread.start()
|
|
1534
|
+
|
|
1535
|
+
@Slot()
|
|
1536
|
+
def check_for_updates_manual(self):
|
|
1537
|
+
"""Manually check for updates (triggered by menu item)."""
|
|
1538
|
+
# Show checking dialog
|
|
1539
|
+
checking_dialog = CheckingUpdateDialog(self)
|
|
1540
|
+
checking_dialog.show()
|
|
1541
|
+
QApplication.processEvents()
|
|
1542
|
+
|
|
1543
|
+
# Start version check
|
|
1544
|
+
self._version_checker_thread = VersionCheckerThread(
|
|
1545
|
+
current_version=__version__,
|
|
1546
|
+
force_check=True # Force check even if recently checked
|
|
1547
|
+
)
|
|
1548
|
+
|
|
1549
|
+
def on_check_complete():
|
|
1550
|
+
checking_dialog.close()
|
|
1551
|
+
|
|
1552
|
+
def on_check_failed():
|
|
1553
|
+
checking_dialog.close()
|
|
1554
|
+
failed_dialog = UpdateCheckFailedDialog(self, __version__)
|
|
1555
|
+
failed_dialog.exec()
|
|
1556
|
+
|
|
1557
|
+
self._version_checker_thread.update_available.connect(self._on_update_available)
|
|
1558
|
+
self._version_checker_thread.check_failed.connect(on_check_failed)
|
|
1559
|
+
self._version_checker_thread.check_complete.connect(on_check_complete)
|
|
1560
|
+
self._version_checker_thread.finished.connect(lambda: self._on_manual_check_no_update(checking_dialog))
|
|
1561
|
+
self._version_checker_thread.start()
|
|
1562
|
+
|
|
1563
|
+
def _on_manual_check_no_update(self, checking_dialog):
|
|
1564
|
+
"""Handle manual check when no update is found."""
|
|
1565
|
+
# Only show "no update" dialog if update_available or check_failed wasn't emitted
|
|
1566
|
+
if not hasattr(self._version_checker_thread, '_update_emitted') and not hasattr(self._version_checker_thread, '_check_failed'):
|
|
1567
|
+
checking_dialog.close()
|
|
1568
|
+
no_update = NoUpdateDialog(self, __version__)
|
|
1569
|
+
no_update.exec()
|
|
1570
|
+
|
|
1571
|
+
@Slot(dict)
|
|
1572
|
+
def _on_update_available(self, release_info: dict):
|
|
1573
|
+
"""Handle update availability notification.
|
|
1574
|
+
|
|
1575
|
+
Args:
|
|
1576
|
+
release_info: Release information from GitHub API
|
|
1577
|
+
"""
|
|
1578
|
+
from datetime import datetime
|
|
1579
|
+
|
|
1580
|
+
# Mark that update was emitted (for manual check)
|
|
1581
|
+
if self._version_checker_thread:
|
|
1582
|
+
self._version_checker_thread._update_emitted = True
|
|
1583
|
+
|
|
1584
|
+
# Show update dialog
|
|
1585
|
+
dialog = UpdateDialog(self, release_info, __version__)
|
|
1586
|
+
result = dialog.exec()
|
|
1587
|
+
|
|
1588
|
+
# Handle user action
|
|
1589
|
+
if dialog.user_action == 'snooze':
|
|
1590
|
+
# Snooze for 24 hours
|
|
1591
|
+
self.settings.snooze_update_until = dialog.get_snooze_until()
|
|
1592
|
+
elif dialog.user_action == 'skip':
|
|
1593
|
+
# Skip this version entirely (set snooze to far future)
|
|
1594
|
+
self.settings.snooze_update_until = (
|
|
1595
|
+
datetime(2099, 1, 1).isoformat()
|
|
1596
|
+
)
|
|
1597
|
+
|
|
1598
|
+
def resizeEvent(self, event):
|
|
1599
|
+
"""Handle window resize - update overlay size."""
|
|
1600
|
+
super().resizeEvent(event)
|
|
1601
|
+
if hasattr(self, 'drop_overlay') and hasattr(self, 'centralWidget'):
|
|
1602
|
+
# Update overlay to match central widget size
|
|
1603
|
+
self.drop_overlay.setGeometry(self.drop_overlay.parentWidget().rect())
|
|
1604
|
+
|
|
1605
|
+
def closeEvent(self, event):
|
|
1606
|
+
"""Save window state on close."""
|
|
1607
|
+
settings = QSettings()
|
|
1608
|
+
settings.setValue("window/geometry", self.saveGeometry())
|
|
1609
|
+
settings.setValue("window/state", self.saveState())
|
|
1610
|
+
|
|
1611
|
+
event.accept()
|