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.
Files changed (61) hide show
  1. ankigammon/__init__.py +7 -0
  2. ankigammon/__main__.py +6 -0
  3. ankigammon/analysis/__init__.py +13 -0
  4. ankigammon/analysis/score_matrix.py +391 -0
  5. ankigammon/anki/__init__.py +6 -0
  6. ankigammon/anki/ankiconnect.py +216 -0
  7. ankigammon/anki/apkg_exporter.py +111 -0
  8. ankigammon/anki/card_generator.py +1325 -0
  9. ankigammon/anki/card_styles.py +1054 -0
  10. ankigammon/gui/__init__.py +8 -0
  11. ankigammon/gui/app.py +192 -0
  12. ankigammon/gui/dialogs/__init__.py +10 -0
  13. ankigammon/gui/dialogs/export_dialog.py +594 -0
  14. ankigammon/gui/dialogs/import_options_dialog.py +201 -0
  15. ankigammon/gui/dialogs/input_dialog.py +762 -0
  16. ankigammon/gui/dialogs/note_dialog.py +93 -0
  17. ankigammon/gui/dialogs/settings_dialog.py +420 -0
  18. ankigammon/gui/dialogs/update_dialog.py +373 -0
  19. ankigammon/gui/format_detector.py +377 -0
  20. ankigammon/gui/main_window.py +1611 -0
  21. ankigammon/gui/resources/down-arrow.svg +3 -0
  22. ankigammon/gui/resources/icon.icns +0 -0
  23. ankigammon/gui/resources/icon.ico +0 -0
  24. ankigammon/gui/resources/icon.png +0 -0
  25. ankigammon/gui/resources/style.qss +402 -0
  26. ankigammon/gui/resources.py +26 -0
  27. ankigammon/gui/update_checker.py +259 -0
  28. ankigammon/gui/widgets/__init__.py +8 -0
  29. ankigammon/gui/widgets/position_list.py +166 -0
  30. ankigammon/gui/widgets/smart_input.py +268 -0
  31. ankigammon/models.py +356 -0
  32. ankigammon/parsers/__init__.py +7 -0
  33. ankigammon/parsers/gnubg_match_parser.py +1094 -0
  34. ankigammon/parsers/gnubg_parser.py +468 -0
  35. ankigammon/parsers/sgf_parser.py +290 -0
  36. ankigammon/parsers/xg_binary_parser.py +1097 -0
  37. ankigammon/parsers/xg_text_parser.py +688 -0
  38. ankigammon/renderer/__init__.py +5 -0
  39. ankigammon/renderer/animation_controller.py +391 -0
  40. ankigammon/renderer/animation_helper.py +191 -0
  41. ankigammon/renderer/color_schemes.py +145 -0
  42. ankigammon/renderer/svg_board_renderer.py +791 -0
  43. ankigammon/settings.py +315 -0
  44. ankigammon/thirdparty/__init__.py +7 -0
  45. ankigammon/thirdparty/xgdatatools/__init__.py +17 -0
  46. ankigammon/thirdparty/xgdatatools/xgimport.py +160 -0
  47. ankigammon/thirdparty/xgdatatools/xgstruct.py +1032 -0
  48. ankigammon/thirdparty/xgdatatools/xgutils.py +118 -0
  49. ankigammon/thirdparty/xgdatatools/xgzarc.py +260 -0
  50. ankigammon/utils/__init__.py +13 -0
  51. ankigammon/utils/gnubg_analyzer.py +590 -0
  52. ankigammon/utils/gnuid.py +577 -0
  53. ankigammon/utils/move_parser.py +204 -0
  54. ankigammon/utils/ogid.py +326 -0
  55. ankigammon/utils/xgid.py +387 -0
  56. ankigammon-1.0.6.dist-info/METADATA +352 -0
  57. ankigammon-1.0.6.dist-info/RECORD +61 -0
  58. ankigammon-1.0.6.dist-info/WHEEL +5 -0
  59. ankigammon-1.0.6.dist-info/entry_points.txt +2 -0
  60. ankigammon-1.0.6.dist-info/licenses/LICENSE +21 -0
  61. 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()