ankigammon 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ankigammon might be problematic. Click here for more details.

Files changed (56) 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 +373 -0
  5. ankigammon/anki/__init__.py +6 -0
  6. ankigammon/anki/ankiconnect.py +224 -0
  7. ankigammon/anki/apkg_exporter.py +123 -0
  8. ankigammon/anki/card_generator.py +1307 -0
  9. ankigammon/anki/card_styles.py +1034 -0
  10. ankigammon/gui/__init__.py +8 -0
  11. ankigammon/gui/app.py +209 -0
  12. ankigammon/gui/dialogs/__init__.py +10 -0
  13. ankigammon/gui/dialogs/export_dialog.py +597 -0
  14. ankigammon/gui/dialogs/import_options_dialog.py +163 -0
  15. ankigammon/gui/dialogs/input_dialog.py +776 -0
  16. ankigammon/gui/dialogs/note_dialog.py +93 -0
  17. ankigammon/gui/dialogs/settings_dialog.py +384 -0
  18. ankigammon/gui/format_detector.py +292 -0
  19. ankigammon/gui/main_window.py +1071 -0
  20. ankigammon/gui/resources/icon.icns +0 -0
  21. ankigammon/gui/resources/icon.ico +0 -0
  22. ankigammon/gui/resources/icon.png +0 -0
  23. ankigammon/gui/resources/style.qss +394 -0
  24. ankigammon/gui/resources.py +26 -0
  25. ankigammon/gui/widgets/__init__.py +8 -0
  26. ankigammon/gui/widgets/position_list.py +193 -0
  27. ankigammon/gui/widgets/smart_input.py +268 -0
  28. ankigammon/models.py +322 -0
  29. ankigammon/parsers/__init__.py +7 -0
  30. ankigammon/parsers/gnubg_parser.py +454 -0
  31. ankigammon/parsers/xg_binary_parser.py +870 -0
  32. ankigammon/parsers/xg_text_parser.py +729 -0
  33. ankigammon/renderer/__init__.py +5 -0
  34. ankigammon/renderer/animation_controller.py +406 -0
  35. ankigammon/renderer/animation_helper.py +221 -0
  36. ankigammon/renderer/color_schemes.py +145 -0
  37. ankigammon/renderer/svg_board_renderer.py +824 -0
  38. ankigammon/settings.py +239 -0
  39. ankigammon/thirdparty/__init__.py +7 -0
  40. ankigammon/thirdparty/xgdatatools/__init__.py +17 -0
  41. ankigammon/thirdparty/xgdatatools/xgimport.py +160 -0
  42. ankigammon/thirdparty/xgdatatools/xgstruct.py +1032 -0
  43. ankigammon/thirdparty/xgdatatools/xgutils.py +118 -0
  44. ankigammon/thirdparty/xgdatatools/xgzarc.py +260 -0
  45. ankigammon/utils/__init__.py +13 -0
  46. ankigammon/utils/gnubg_analyzer.py +431 -0
  47. ankigammon/utils/gnuid.py +622 -0
  48. ankigammon/utils/move_parser.py +239 -0
  49. ankigammon/utils/ogid.py +335 -0
  50. ankigammon/utils/xgid.py +419 -0
  51. ankigammon-1.0.0.dist-info/METADATA +370 -0
  52. ankigammon-1.0.0.dist-info/RECORD +56 -0
  53. ankigammon-1.0.0.dist-info/WHEEL +5 -0
  54. ankigammon-1.0.0.dist-info/entry_points.txt +2 -0
  55. ankigammon-1.0.0.dist-info/licenses/LICENSE +21 -0
  56. ankigammon-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1071 @@
1
+ """
2
+ Main application window.
3
+ """
4
+
5
+ from PySide6.QtWidgets import (
6
+ QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
7
+ QPushButton, QLabel, QMessageBox, QInputDialog
8
+ )
9
+ from PySide6.QtCore import Qt, Signal, Slot, QUrl, QSettings, QSize
10
+ from PySide6.QtGui import QAction, QKeySequence, QDesktopServices
11
+ from PySide6.QtWebEngineWidgets import QWebEngineView
12
+ import qtawesome as qta
13
+ import base64
14
+
15
+ from ankigammon.settings import Settings
16
+ from ankigammon.renderer.svg_board_renderer import SVGBoardRenderer
17
+ from ankigammon.renderer.color_schemes import get_scheme
18
+ from ankigammon.models import Decision
19
+ from ankigammon.gui.widgets import PositionListWidget
20
+ from ankigammon.gui.dialogs import SettingsDialog, ExportDialog, InputDialog, ImportOptionsDialog
21
+ from ankigammon.gui.resources import get_resource_path
22
+
23
+
24
+ class MainWindow(QMainWindow):
25
+ """Main application window for AnkiGammon."""
26
+
27
+ # Signals
28
+ decisions_parsed = Signal(list) # List[Decision]
29
+
30
+ def __init__(self, settings: Settings):
31
+ super().__init__()
32
+ self.settings = settings
33
+ self.current_decisions = []
34
+ self.renderer = SVGBoardRenderer(
35
+ color_scheme=get_scheme(settings.color_scheme),
36
+ orientation=settings.board_orientation
37
+ )
38
+ self.color_scheme_actions = {} # Store references to color scheme menu actions
39
+
40
+ # Enable drag and drop
41
+ self.setAcceptDrops(True)
42
+
43
+ self._setup_ui()
44
+ self._setup_menu_bar()
45
+ self._setup_connections()
46
+ self._restore_window_state()
47
+
48
+ # Create drop overlay (will be shown during drag operations)
49
+ self._create_drop_overlay()
50
+
51
+ def _setup_ui(self):
52
+ """Initialize the user interface."""
53
+ self.setWindowTitle("AnkiGammon - Backgammon Analysis to Anki")
54
+ self.setMinimumSize(1000, 700)
55
+ self.resize(1300, 720) # Optimal default size for board display
56
+
57
+ # Hide the status bar for a cleaner, modern look
58
+ self.statusBar().hide()
59
+
60
+ # Central widget with horizontal layout
61
+ central = QWidget()
62
+ central.setAcceptDrops(False) # Let drag events propagate to main window
63
+ self.setCentralWidget(central)
64
+ layout = QHBoxLayout(central)
65
+ layout.setContentsMargins(0, 0, 0, 0)
66
+ layout.setSpacing(0)
67
+
68
+ # Left panel: Controls
69
+ left_panel = self._create_left_panel()
70
+ left_panel.setAcceptDrops(False) # Let drag events propagate to main window
71
+ layout.addWidget(left_panel, stretch=1)
72
+
73
+ # Right panel: Preview
74
+ self.preview = QWebEngineView()
75
+ self.preview.setContextMenuPolicy(Qt.NoContextMenu) # Disable browser context menu
76
+ self.preview.setAcceptDrops(False) # Let drag events propagate to main window
77
+
78
+ # Load icon and convert to base64 for embedding in HTML
79
+ icon_path = get_resource_path("ankigammon/gui/resources/icon.png")
80
+ icon_data_url = ""
81
+ if icon_path.exists():
82
+ with open(icon_path, "rb") as f:
83
+ icon_bytes = f.read()
84
+ icon_b64 = base64.b64encode(icon_bytes).decode('utf-8')
85
+ icon_data_url = f"data:image/png;base64,{icon_b64}"
86
+
87
+ welcome_html = f"""
88
+ <!DOCTYPE html>
89
+ <html>
90
+ <head>
91
+ <style>
92
+ body {{
93
+ margin: 0;
94
+ padding: 0;
95
+ display: flex;
96
+ flex-direction: column;
97
+ justify-content: center;
98
+ align-items: center;
99
+ min-height: 100vh;
100
+ background: linear-gradient(135deg, #1e1e2e 0%, #181825 100%);
101
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
102
+ color: #cdd6f4;
103
+ }}
104
+ .welcome {{
105
+ text-align: center;
106
+ padding: 40px;
107
+ }}
108
+ h1 {{
109
+ color: #f5e0dc;
110
+ font-size: 32px;
111
+ margin-bottom: 16px;
112
+ font-weight: 700;
113
+ }}
114
+ p {{
115
+ color: #a6adc8;
116
+ font-size: 16px;
117
+ margin: 8px 0;
118
+ }}
119
+ .icon {{
120
+ margin-bottom: 24px;
121
+ opacity: 0.6;
122
+ }}
123
+ .icon img {{
124
+ width: 140px;
125
+ height: auto;
126
+ }}
127
+ </style>
128
+ </head>
129
+ <body>
130
+ <div class="welcome">
131
+ <div class="icon">
132
+ <img src="{icon_data_url}" alt="AnkiGammon Icon" />
133
+ </div>
134
+ <h1>No Position Loaded</h1>
135
+ <p>Add positions to get started</p>
136
+ </div>
137
+ </body>
138
+ </html>
139
+ """
140
+ self.preview.setHtml(welcome_html)
141
+ layout.addWidget(self.preview, stretch=2)
142
+
143
+ # Status bar
144
+ self.statusBar().showMessage("Ready")
145
+
146
+ def _create_left_panel(self) -> QWidget:
147
+ """Create the left control panel."""
148
+ panel = QWidget()
149
+ layout = QVBoxLayout(panel)
150
+ layout.setContentsMargins(12, 12, 12, 12)
151
+ layout.setSpacing(12)
152
+
153
+ # Title
154
+ title = QLabel("<h2>AnkiGammon</h2>")
155
+ title.setAlignment(Qt.AlignCenter)
156
+ layout.addWidget(title)
157
+
158
+ # Button row: Add Positions and Import File
159
+ btn_row = QWidget()
160
+ btn_row_layout = QHBoxLayout(btn_row)
161
+ btn_row_layout.setContentsMargins(0, 0, 0, 0)
162
+ btn_row_layout.setSpacing(8)
163
+
164
+ # Add Positions button (primary) - blue background needs dark icons
165
+ self.btn_add_positions = QPushButton(" Add Positions...")
166
+ self.btn_add_positions.setIcon(qta.icon('fa6s.clipboard-list', color='#1e1e2e'))
167
+ self.btn_add_positions.setIconSize(QSize(18, 18))
168
+ self.btn_add_positions.clicked.connect(self.on_add_positions_clicked)
169
+ self.btn_add_positions.setToolTip("Paste position IDs or full XG analysis")
170
+ self.btn_add_positions.setCursor(Qt.PointingHandCursor)
171
+ btn_row_layout.addWidget(self.btn_add_positions, stretch=1)
172
+
173
+ # Import File button (equal primary) - full-sized with text + icon
174
+ self.btn_import_file = QPushButton(" Import File...")
175
+ self.btn_import_file.setIcon(qta.icon('fa6s.file-import', color='#1e1e2e'))
176
+ self.btn_import_file.setIconSize(QSize(18, 18))
177
+ self.btn_import_file.clicked.connect(self.on_import_file_clicked)
178
+ self.btn_import_file.setToolTip("Import .xg file")
179
+ self.btn_import_file.setCursor(Qt.PointingHandCursor)
180
+ btn_row_layout.addWidget(self.btn_import_file, stretch=1)
181
+
182
+ layout.addWidget(btn_row)
183
+
184
+ # Position list with integrated Clear All button
185
+ list_container = QWidget()
186
+ list_container_layout = QVBoxLayout(list_container)
187
+ list_container_layout.setContentsMargins(0, 0, 0, 0)
188
+ list_container_layout.setSpacing(0)
189
+
190
+ # Clear All button positioned at top-right
191
+ self.btn_clear_all = QPushButton(" Clear All")
192
+ self.btn_clear_all.setIcon(qta.icon('fa6s.trash-can', color='#a6adc8'))
193
+ self.btn_clear_all.setIconSize(QSize(11, 11))
194
+ self.btn_clear_all.setCursor(Qt.PointingHandCursor)
195
+ self.btn_clear_all.clicked.connect(self.on_clear_all_clicked)
196
+ self.btn_clear_all.setToolTip("Clear all positions")
197
+ self.btn_clear_all.setStyleSheet("""
198
+ QPushButton {
199
+ background-color: transparent;
200
+ color: #6c7086;
201
+ border: none;
202
+ padding: 6px 10px;
203
+ font-size: 11px;
204
+ font-weight: 500;
205
+ border-radius: 4px;
206
+ }
207
+ QPushButton:hover:enabled {
208
+ background-color: rgba(243, 139, 168, 0.15);
209
+ color: #f38ba8;
210
+ }
211
+ QPushButton:pressed:enabled {
212
+ background-color: rgba(243, 139, 168, 0.25);
213
+ }
214
+ """)
215
+
216
+ # Create header row with clear button aligned right (initially hidden)
217
+ self.list_header_row = QWidget()
218
+ header_layout = QHBoxLayout(self.list_header_row)
219
+ header_layout.setContentsMargins(0, 0, 0, 4)
220
+ header_layout.setSpacing(0)
221
+ header_layout.addStretch()
222
+ header_layout.addWidget(self.btn_clear_all)
223
+ self.list_header_row.hide() # Hidden until positions are added
224
+ list_container_layout.addWidget(self.list_header_row)
225
+
226
+ # Position list widget
227
+ self.position_list = PositionListWidget()
228
+ self.position_list.position_selected.connect(self.show_decision)
229
+ self.position_list.positions_deleted.connect(self.on_positions_deleted)
230
+ list_container_layout.addWidget(self.position_list, stretch=1)
231
+
232
+ layout.addWidget(list_container, stretch=1)
233
+
234
+ # Spacer
235
+ layout.addSpacing(12)
236
+
237
+ # Deck name indicator with edit button
238
+ deck_container = QWidget()
239
+ deck_layout = QHBoxLayout(deck_container)
240
+ deck_layout.setContentsMargins(18, 16, 18, 16)
241
+ deck_layout.setSpacing(14)
242
+ deck_container.setStyleSheet("""
243
+ QWidget {
244
+ background-color: rgba(137, 180, 250, 0.08);
245
+ border-radius: 12px;
246
+ }
247
+ """)
248
+
249
+ self.lbl_deck_name = QLabel()
250
+ self.lbl_deck_name.setWordWrap(True)
251
+ self.lbl_deck_name.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
252
+ self.lbl_deck_name.setTextFormat(Qt.RichText)
253
+ self.lbl_deck_name.setStyleSheet("""
254
+ QLabel {
255
+ color: #cdd6f4;
256
+ padding: 2px 0px;
257
+ background: transparent;
258
+ }
259
+ """)
260
+ self._update_deck_label()
261
+ deck_layout.addWidget(self.lbl_deck_name, stretch=1)
262
+
263
+ # Edit button for deck name
264
+ self.btn_edit_deck = QPushButton()
265
+ self.btn_edit_deck.setIcon(qta.icon('fa6s.pencil', color='#a6adc8'))
266
+ self.btn_edit_deck.setIconSize(QSize(16, 16))
267
+ self.btn_edit_deck.setFixedSize(32, 32)
268
+ self.btn_edit_deck.setToolTip("Edit deck name")
269
+ self.btn_edit_deck.setStyleSheet("""
270
+ QPushButton {
271
+ background-color: rgba(205, 214, 244, 0.05);
272
+ border: none;
273
+ border-radius: 8px;
274
+ padding: 0px;
275
+ }
276
+ QPushButton:hover {
277
+ background-color: rgba(205, 214, 244, 0.12);
278
+ }
279
+ QPushButton:pressed {
280
+ background-color: rgba(205, 214, 244, 0.18);
281
+ }
282
+ """)
283
+ self.btn_edit_deck.setCursor(Qt.PointingHandCursor)
284
+ self.btn_edit_deck.clicked.connect(self.on_edit_deck_name)
285
+ deck_layout.addWidget(self.btn_edit_deck, alignment=Qt.AlignVCenter)
286
+
287
+ layout.addWidget(deck_container)
288
+
289
+ layout.addSpacing(12)
290
+
291
+ # Settings button
292
+ self.btn_settings = QPushButton(" Settings")
293
+ self.btn_settings.setIcon(qta.icon('fa6s.gear', color='#cdd6f4'))
294
+ self.btn_settings.setIconSize(QSize(18, 18))
295
+ self.btn_settings.setObjectName("btn_settings")
296
+ self.btn_settings.setCursor(Qt.PointingHandCursor)
297
+ self.btn_settings.clicked.connect(self.on_settings_clicked)
298
+ layout.addWidget(self.btn_settings)
299
+
300
+ # Export button - blue background needs dark icons
301
+ self.btn_export = QPushButton(" Export to Anki")
302
+ self.btn_export.setIcon(qta.icon('fa6s.file-export', color='#1e1e2e'))
303
+ self.btn_export.setIconSize(QSize(18, 18))
304
+ self.btn_export.setEnabled(False)
305
+ self.btn_export.setCursor(Qt.PointingHandCursor)
306
+ self.btn_export.clicked.connect(self.on_export_clicked)
307
+ layout.addWidget(self.btn_export)
308
+
309
+ return panel
310
+
311
+ def _setup_menu_bar(self):
312
+ """Create application menu bar."""
313
+ menubar = self.menuBar()
314
+
315
+ # File menu
316
+ file_menu = menubar.addMenu("&File")
317
+
318
+ act_add_positions = QAction("&Add Positions...", self)
319
+ act_add_positions.setShortcut("Ctrl+N")
320
+ act_add_positions.triggered.connect(self.on_add_positions_clicked)
321
+ file_menu.addAction(act_add_positions)
322
+
323
+ act_import_file = QAction("&Import File...", self)
324
+ act_import_file.setShortcut("Ctrl+O")
325
+ act_import_file.triggered.connect(self.on_import_file_clicked)
326
+ file_menu.addAction(act_import_file)
327
+
328
+ file_menu.addSeparator()
329
+
330
+ act_export = QAction("&Export to Anki...", self)
331
+ act_export.setShortcut("Ctrl+E")
332
+ act_export.triggered.connect(self.on_export_clicked)
333
+ file_menu.addAction(act_export)
334
+
335
+ file_menu.addSeparator()
336
+
337
+ act_quit = QAction("&Quit", self)
338
+ act_quit.setShortcut(QKeySequence.Quit)
339
+ act_quit.triggered.connect(self.close)
340
+ file_menu.addAction(act_quit)
341
+
342
+ # Edit menu
343
+ edit_menu = menubar.addMenu("&Edit")
344
+
345
+ act_settings = QAction("&Settings...", self)
346
+ act_settings.setShortcut("Ctrl+,")
347
+ act_settings.triggered.connect(self.on_settings_clicked)
348
+ edit_menu.addAction(act_settings)
349
+
350
+ # Board Theme menu
351
+ board_theme_menu = menubar.addMenu("&Board Theme")
352
+
353
+ # Add theme options directly (no submenu)
354
+ from ankigammon.renderer.color_schemes import list_schemes
355
+ for scheme in list_schemes():
356
+ act_scheme = QAction(scheme.title(), self)
357
+ act_scheme.setCheckable(True)
358
+ act_scheme.setChecked(scheme == self.settings.color_scheme)
359
+ act_scheme.triggered.connect(
360
+ lambda checked, s=scheme: self.change_color_scheme(s)
361
+ )
362
+ board_theme_menu.addAction(act_scheme)
363
+ self.color_scheme_actions[scheme] = act_scheme # Store reference
364
+
365
+ # Help menu
366
+ help_menu = menubar.addMenu("&Help")
367
+
368
+ act_docs = QAction("&Documentation", self)
369
+ act_docs.triggered.connect(self.show_documentation)
370
+ help_menu.addAction(act_docs)
371
+
372
+ act_about = QAction("&About AnkiGammon", self)
373
+ act_about.triggered.connect(self.show_about_dialog)
374
+ help_menu.addAction(act_about)
375
+
376
+ def _setup_connections(self):
377
+ """Connect signals and slots."""
378
+ self.decisions_parsed.connect(self.on_decisions_loaded)
379
+
380
+ def _update_deck_label(self):
381
+ """Update the deck name label with current settings."""
382
+ export_method = "AnkiConnect" if self.settings.export_method == "ankiconnect" else "APKG"
383
+ self.lbl_deck_name.setText(
384
+ f"<div style='line-height: 1.5;'>"
385
+ f"<div style='color: #a6adc8; font-size: 12px; font-weight: 500; margin-bottom: 6px;'>Exporting to</div>"
386
+ 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>"
387
+ f"</div>"
388
+ )
389
+
390
+ def _restore_window_state(self):
391
+ """Restore window size and position from QSettings."""
392
+ settings = QSettings()
393
+
394
+ # Window geometry
395
+ geometry = settings.value("window/geometry")
396
+ if geometry:
397
+ self.restoreGeometry(geometry)
398
+
399
+ # Window state (splitter positions, etc.)
400
+ state = settings.value("window/state")
401
+ if state:
402
+ self.restoreState(state)
403
+
404
+ def _create_drop_overlay(self):
405
+ """Create a visual overlay for drag-and-drop feedback."""
406
+ # Make overlay a child of central widget for proper positioning
407
+ self.drop_overlay = QWidget(self.centralWidget())
408
+ self.drop_overlay.setStyleSheet("""
409
+ QWidget {
410
+ background-color: rgba(137, 180, 250, 0.15);
411
+ border: 3px dashed #89b4fa;
412
+ border-radius: 12px;
413
+ }
414
+ """)
415
+
416
+ # Create layout for overlay content
417
+ overlay_layout = QVBoxLayout(self.drop_overlay)
418
+ overlay_layout.setAlignment(Qt.AlignCenter)
419
+
420
+ # Icon
421
+ icon_label = QLabel()
422
+ icon_label.setPixmap(qta.icon('fa6s.file-import', color='#89b4fa').pixmap(64, 64))
423
+ icon_label.setAlignment(Qt.AlignCenter)
424
+ overlay_layout.addWidget(icon_label)
425
+
426
+ # Text
427
+ text_label = QLabel("Drop .xg file to import")
428
+ text_label.setStyleSheet("""
429
+ QLabel {
430
+ color: #89b4fa;
431
+ font-size: 18px;
432
+ font-weight: 600;
433
+ background: transparent;
434
+ border: none;
435
+ padding: 12px;
436
+ }
437
+ """)
438
+ text_label.setAlignment(Qt.AlignCenter)
439
+ overlay_layout.addWidget(text_label)
440
+
441
+ # Initially hidden
442
+ self.drop_overlay.hide()
443
+ self.drop_overlay.setAttribute(Qt.WA_TransparentForMouseEvents) # Don't block mouse events
444
+
445
+ @Slot()
446
+ def on_add_positions_clicked(self):
447
+ """Handle add positions button click."""
448
+ dialog = InputDialog(self.settings, self)
449
+ dialog.positions_added.connect(self._on_positions_added)
450
+
451
+ dialog.exec()
452
+
453
+ @Slot(list)
454
+ def _on_positions_added(self, decisions):
455
+ """Handle positions added from input dialog."""
456
+ if not decisions:
457
+ return
458
+
459
+ # Append to current decisions
460
+ self.current_decisions.extend(decisions)
461
+ self.btn_export.setEnabled(True)
462
+ self.list_header_row.show()
463
+
464
+ # Update position list
465
+ self.position_list.set_decisions(self.current_decisions)
466
+
467
+ @Slot(list)
468
+ def on_positions_deleted(self, indices: list):
469
+ """Handle deletion of multiple positions."""
470
+ # Sort indices in descending order and delete
471
+ for index in sorted(indices, reverse=True):
472
+ if 0 <= index < len(self.current_decisions):
473
+ self.current_decisions.pop(index)
474
+
475
+ # Refresh list ONCE (more efficient)
476
+ self.position_list.set_decisions(self.current_decisions)
477
+
478
+ # Disable export and hide clear all if no positions remain
479
+ if not self.current_decisions:
480
+ self.btn_export.setEnabled(False)
481
+ self.list_header_row.hide()
482
+ # Show welcome screen
483
+ welcome_html = """
484
+ <!DOCTYPE html>
485
+ <html>
486
+ <head>
487
+ <style>
488
+ body {
489
+ margin: 0;
490
+ padding: 0;
491
+ display: flex;
492
+ flex-direction: column;
493
+ justify-content: center;
494
+ align-items: center;
495
+ min-height: 100vh;
496
+ background: linear-gradient(135deg, #1e1e2e 0%, #181825 100%);
497
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
498
+ color: #cdd6f4;
499
+ }
500
+ .welcome {
501
+ text-align: center;
502
+ padding: 40px;
503
+ }
504
+ h1 {
505
+ color: #f5e0dc;
506
+ font-size: 32px;
507
+ margin-bottom: 16px;
508
+ font-weight: 700;
509
+ }
510
+ p {
511
+ color: #a6adc8;
512
+ font-size: 16px;
513
+ margin: 8px 0;
514
+ }
515
+ .icon {
516
+ margin-bottom: 24px;
517
+ opacity: 0.6;
518
+ }
519
+ </style>
520
+ </head>
521
+ <body>
522
+ <div class="welcome">
523
+ <div class="icon">
524
+ <svg width="140" height="90" viewBox="-5 0 90 45" xmlns="http://www.w3.org/2000/svg">
525
+ <!-- First die -->
526
+ <g transform="translate(0, 10)">
527
+ <rect x="2" y="2" width="32" height="32" rx="4"
528
+ fill="#f5e0dc" stroke="#45475a" stroke-width="1.5"
529
+ transform="rotate(-15 18 18)"/>
530
+ <!-- Pips for 5 -->
531
+ <circle cx="10" cy="10" r="2.5" fill="#1e1e2e" transform="rotate(-15 18 18)"/>
532
+ <circle cx="26" cy="10" r="2.5" fill="#1e1e2e" transform="rotate(-15 18 18)"/>
533
+ <circle cx="18" cy="18" r="2.5" fill="#1e1e2e" transform="rotate(-15 18 18)"/>
534
+ <circle cx="10" cy="26" r="2.5" fill="#1e1e2e" transform="rotate(-15 18 18)"/>
535
+ <circle cx="26" cy="26" r="2.5" fill="#1e1e2e" transform="rotate(-15 18 18)"/>
536
+ </g>
537
+
538
+ <!-- Second die -->
539
+ <g transform="translate(36, 0)">
540
+ <rect x="2" y="2" width="32" height="32" rx="4"
541
+ fill="#f5e0dc" stroke="#45475a" stroke-width="1.5"
542
+ transform="rotate(12 18 18)"/>
543
+ <!-- Pips for 3 -->
544
+ <circle cx="10" cy="10" r="2.5" fill="#1e1e2e" transform="rotate(12 18 18)"/>
545
+ <circle cx="18" cy="18" r="2.5" fill="#1e1e2e" transform="rotate(12 18 18)"/>
546
+ <circle cx="26" cy="26" r="2.5" fill="#1e1e2e" transform="rotate(12 18 18)"/>
547
+ </g>
548
+ </svg>
549
+ </div>
550
+ <h1>No Position Loaded</h1>
551
+ <p>Add positions to get started</p>
552
+ </div>
553
+ </body>
554
+ </html>
555
+ """
556
+ self.preview.setHtml(welcome_html)
557
+ self.preview.update() # Force repaint to avoid black screen issue
558
+
559
+ @Slot()
560
+ def on_clear_all_clicked(self):
561
+ """Handle clear all button click."""
562
+ if not self.current_decisions:
563
+ return
564
+
565
+ # Show confirmation dialog
566
+ reply = QMessageBox.question(
567
+ self,
568
+ "Clear All Positions",
569
+ f"Are you sure you want to clear all {len(self.current_decisions)} position(s)?",
570
+ QMessageBox.Yes | QMessageBox.No,
571
+ QMessageBox.No
572
+ )
573
+
574
+ if reply == QMessageBox.Yes:
575
+ # Clear all decisions
576
+ self.current_decisions.clear()
577
+ self.position_list.set_decisions(self.current_decisions)
578
+ self.btn_export.setEnabled(False)
579
+ self.list_header_row.hide()
580
+
581
+ # Show welcome screen
582
+ welcome_html = """
583
+ <!DOCTYPE html>
584
+ <html>
585
+ <head>
586
+ <style>
587
+ body {
588
+ margin: 0;
589
+ padding: 0;
590
+ display: flex;
591
+ flex-direction: column;
592
+ justify-content: center;
593
+ align-items: center;
594
+ min-height: 100vh;
595
+ background: linear-gradient(135deg, #1e1e2e 0%, #181825 100%);
596
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
597
+ color: #cdd6f4;
598
+ }
599
+ .welcome {
600
+ text-align: center;
601
+ padding: 40px;
602
+ }
603
+ h1 {
604
+ color: #f5e0dc;
605
+ font-size: 32px;
606
+ margin-bottom: 16px;
607
+ font-weight: 700;
608
+ }
609
+ p {
610
+ color: #a6adc8;
611
+ font-size: 16px;
612
+ margin: 8px 0;
613
+ }
614
+ .icon {
615
+ margin-bottom: 24px;
616
+ opacity: 0.6;
617
+ }
618
+ </style>
619
+ </head>
620
+ <body>
621
+ <div class="welcome">
622
+ <div class="icon">
623
+ <svg width="140" height="90" viewBox="-5 0 90 45" xmlns="http://www.w3.org/2000/svg">
624
+ <!-- First die -->
625
+ <g transform="translate(0, 10)">
626
+ <rect x="2" y="2" width="32" height="32" rx="4"
627
+ fill="#f5e0dc" stroke="#45475a" stroke-width="1.5"
628
+ transform="rotate(-15 18 18)"/>
629
+ <!-- Pips for 5 -->
630
+ <circle cx="10" cy="10" r="2.5" fill="#1e1e2e" transform="rotate(-15 18 18)"/>
631
+ <circle cx="26" cy="10" r="2.5" fill="#1e1e2e" transform="rotate(-15 18 18)"/>
632
+ <circle cx="18" cy="18" r="2.5" fill="#1e1e2e" transform="rotate(-15 18 18)"/>
633
+ <circle cx="10" cy="26" r="2.5" fill="#1e1e2e" transform="rotate(-15 18 18)"/>
634
+ <circle cx="26" cy="26" r="2.5" fill="#1e1e2e" transform="rotate(-15 18 18)"/>
635
+ </g>
636
+
637
+ <!-- Second die -->
638
+ <g transform="translate(36, 0)">
639
+ <rect x="2" y="2" width="32" height="32" rx="4"
640
+ fill="#f5e0dc" stroke="#45475a" stroke-width="1.5"
641
+ transform="rotate(12 18 18)"/>
642
+ <!-- Pips for 3 -->
643
+ <circle cx="10" cy="10" r="2.5" fill="#1e1e2e" transform="rotate(12 18 18)"/>
644
+ <circle cx="18" cy="18" r="2.5" fill="#1e1e2e" transform="rotate(12 18 18)"/>
645
+ <circle cx="26" cy="26" r="2.5" fill="#1e1e2e" transform="rotate(12 18 18)"/>
646
+ </g>
647
+ </svg>
648
+ </div>
649
+ <h1>No Position Loaded</h1>
650
+ <p>Add positions to get started</p>
651
+ </div>
652
+ </body>
653
+ </html>
654
+ """
655
+ self.preview.setHtml(welcome_html)
656
+ self.preview.update() # Force repaint to avoid black screen issue
657
+
658
+ @Slot(list)
659
+ def on_decisions_loaded(self, decisions):
660
+ """Handle newly loaded decisions."""
661
+ self.current_decisions = decisions
662
+ self.btn_export.setEnabled(True)
663
+ self.list_header_row.show()
664
+
665
+ # Update position list
666
+ self.position_list.set_decisions(decisions)
667
+
668
+ def show_decision(self, decision: Decision):
669
+ """Display a decision in the preview pane."""
670
+ # Generate SVG using existing renderer (zero changes!)
671
+ svg = self.renderer.render_svg(
672
+ decision.position,
673
+ dice=decision.dice,
674
+ on_roll=decision.on_roll,
675
+ cube_value=decision.cube_value,
676
+ cube_owner=decision.cube_owner,
677
+ score_x=decision.score_x,
678
+ score_o=decision.score_o,
679
+ match_length=decision.match_length,
680
+ )
681
+
682
+ # Wrap SVG in minimal HTML with dark theme
683
+ html = f"""
684
+ <!DOCTYPE html>
685
+ <html>
686
+ <head>
687
+ <style>
688
+ html, body {{
689
+ margin: 0;
690
+ padding: 0;
691
+ height: 100%;
692
+ overflow: hidden;
693
+ }}
694
+ body {{
695
+ padding: 20px;
696
+ display: flex;
697
+ justify-content: center;
698
+ align-items: center;
699
+ background: linear-gradient(135deg, #1e1e2e 0%, #181825 100%);
700
+ box-sizing: border-box;
701
+ }}
702
+ svg {{
703
+ max-width: 100%;
704
+ max-height: 100%;
705
+ height: auto;
706
+ filter: drop-shadow(0 10px 30px rgba(0, 0, 0, 0.5));
707
+ border-radius: 12px;
708
+ }}
709
+ </style>
710
+ </head>
711
+ <body>
712
+ {svg}
713
+ </body>
714
+ </html>
715
+ """
716
+
717
+ self.preview.setHtml(html)
718
+ self.preview.update() # Force repaint to avoid black screen issue
719
+
720
+ @Slot()
721
+ def on_edit_deck_name(self):
722
+ """Handle deck name edit button click."""
723
+ # Create input dialog
724
+ dialog = QInputDialog(self)
725
+ dialog.setWindowTitle("Edit Deck Name")
726
+ dialog.setLabelText("Enter deck name:")
727
+ dialog.setTextValue(self.settings.deck_name)
728
+
729
+ # Use a timer to set cursor pointers after dialog widgets are created
730
+ from PySide6.QtCore import QTimer
731
+ from PySide6.QtWidgets import QDialogButtonBox
732
+
733
+ def set_button_cursors():
734
+ button_box = dialog.findChild(QDialogButtonBox)
735
+ if button_box:
736
+ for button in button_box.buttons():
737
+ button.setCursor(Qt.PointingHandCursor)
738
+
739
+ QTimer.singleShot(0, set_button_cursors)
740
+
741
+ # Show dialog and get result
742
+ ok = dialog.exec()
743
+ new_name = dialog.textValue()
744
+
745
+ if ok and new_name.strip():
746
+ self.settings.deck_name = new_name.strip()
747
+ self._update_deck_label()
748
+
749
+ @Slot()
750
+ def on_settings_clicked(self):
751
+ """Handle settings button click."""
752
+ dialog = SettingsDialog(self.settings, self)
753
+ dialog.settings_changed.connect(self.on_settings_changed)
754
+ dialog.exec()
755
+
756
+ @Slot(Settings)
757
+ def on_settings_changed(self, settings: Settings):
758
+ """Handle settings changes."""
759
+ # Update renderer with new color scheme and orientation
760
+ self.renderer = SVGBoardRenderer(
761
+ color_scheme=get_scheme(settings.color_scheme),
762
+ orientation=settings.board_orientation
763
+ )
764
+
765
+ # Update menu checkmarks if color scheme changed
766
+ for scheme_name, action in self.color_scheme_actions.items():
767
+ action.setChecked(scheme_name == settings.color_scheme)
768
+
769
+ # Update deck name label
770
+ self._update_deck_label()
771
+
772
+ # Refresh current preview if a decision is displayed
773
+ if self.current_decisions:
774
+ selected = self.position_list.get_selected_decision()
775
+ if selected:
776
+ self.show_decision(selected)
777
+
778
+ @Slot()
779
+ def on_export_clicked(self):
780
+ """Handle export button click."""
781
+ if not self.current_decisions:
782
+ QMessageBox.warning(
783
+ self,
784
+ "No Positions",
785
+ "Please add positions first"
786
+ )
787
+ return
788
+
789
+ dialog = ExportDialog(self.current_decisions, self.settings, self)
790
+ dialog.exec()
791
+
792
+ @Slot(str)
793
+ def change_color_scheme(self, scheme: str):
794
+ """Change the color scheme."""
795
+ self.settings.color_scheme = scheme
796
+
797
+ # Update checkmarks: uncheck all, then check the selected one
798
+ for scheme_name, action in self.color_scheme_actions.items():
799
+ action.setChecked(scheme_name == scheme)
800
+
801
+ self.on_settings_changed(self.settings)
802
+
803
+ @Slot()
804
+ def show_documentation(self):
805
+ """Show online documentation."""
806
+ QDesktopServices.openUrl(QUrl("https://github.com/Deinonychus999/AnkiGammon"))
807
+
808
+ @Slot()
809
+ def show_about_dialog(self):
810
+ """Show about dialog."""
811
+ QMessageBox.about(
812
+ self,
813
+ "About AnkiGammon",
814
+ """<h2>AnkiGammon</h2>
815
+ <p>Version 1.0.0</p>
816
+ <p>Convert backgammon position analysis into interactive Anki flashcards.</p>
817
+ <p>Built with PySide6 and Qt.</p>
818
+
819
+ <h3>Special Thanks</h3>
820
+ <p>OilSpillDuckling<br>Eran & OpenGammon</p>
821
+
822
+ <p><a href="https://github.com/Deinonychus999/AnkiGammon">GitHub Repository</a></p>
823
+ """
824
+ )
825
+
826
+ def _filter_decisions_by_import_options(
827
+ self,
828
+ decisions: list[Decision],
829
+ threshold: float,
830
+ include_player_x: bool,
831
+ include_player_o: bool
832
+ ) -> list[Decision]:
833
+ """
834
+ Filter decisions based on import options.
835
+
836
+ Args:
837
+ decisions: All parsed decisions
838
+ threshold: Error threshold (positive value, e.g., 0.080)
839
+ include_player_x: Include Player.X mistakes
840
+ include_player_o: Include Player.O mistakes
841
+
842
+ Returns:
843
+ Filtered list of decisions
844
+ """
845
+ from ankigammon.models import Player
846
+
847
+ filtered = []
848
+
849
+ for decision in decisions:
850
+ # Check player filter
851
+ if decision.on_roll == Player.X and not include_player_x:
852
+ continue
853
+ if decision.on_roll == Player.O and not include_player_o:
854
+ continue
855
+
856
+ # Skip decisions with no moves
857
+ if not decision.candidate_moves:
858
+ continue
859
+
860
+ # Find the move that was actually played in the game
861
+ played_move = next((m for m in decision.candidate_moves if m.was_played), None)
862
+
863
+ # Fallback: if no move is marked as played, skip this decision
864
+ # (This should not happen with proper XG files, but handles edge cases)
865
+ if not played_move:
866
+ continue
867
+
868
+ # Use xg_error if available (convert to absolute value),
869
+ # otherwise use error (already positive)
870
+ error_magnitude = abs(played_move.xg_error) if played_move.xg_error is not None else played_move.error
871
+
872
+ # Only include if error is at or above threshold
873
+ if error_magnitude >= threshold:
874
+ filtered.append(decision)
875
+
876
+ return filtered
877
+
878
+ @Slot()
879
+ def on_import_file_clicked(self):
880
+ """Handle import file menu action."""
881
+ from PySide6.QtWidgets import QFileDialog
882
+
883
+ # Show file dialog
884
+ file_path, _ = QFileDialog.getOpenFileName(
885
+ self,
886
+ "Import Backgammon File",
887
+ "",
888
+ "XG Binary (*.xg);;All Files (*.*)"
889
+ )
890
+
891
+ if not file_path:
892
+ return
893
+
894
+ # Use the shared import logic
895
+ self._import_file(file_path)
896
+
897
+ def dragEnterEvent(self, event):
898
+ """Handle drag enter event - accept if it contains valid files."""
899
+ if event.mimeData().hasUrls():
900
+ # Check if any of the URLs are .xg files
901
+ urls = event.mimeData().urls()
902
+ for url in urls:
903
+ if url.isLocalFile():
904
+ file_path = url.toLocalFile()
905
+ if file_path.endswith('.xg'):
906
+ # Show visual overlay
907
+ self._show_drop_overlay()
908
+ event.acceptProposedAction()
909
+ return
910
+ event.ignore()
911
+
912
+ def dragLeaveEvent(self, event):
913
+ """Handle drag leave event - hide overlay when drag leaves the window."""
914
+ self._hide_drop_overlay()
915
+ event.accept()
916
+
917
+ def dropEvent(self, event):
918
+ """Handle drop event - import the dropped .xg files."""
919
+ # Hide overlay immediately
920
+ self._hide_drop_overlay()
921
+
922
+ if not event.mimeData().hasUrls():
923
+ event.ignore()
924
+ return
925
+
926
+ # Process each dropped file
927
+ urls = event.mimeData().urls()
928
+ for url in urls:
929
+ if url.isLocalFile():
930
+ file_path = url.toLocalFile()
931
+ if file_path.endswith('.xg'):
932
+ # Import the file using the existing import logic
933
+ self._import_file(file_path)
934
+
935
+ event.acceptProposedAction()
936
+
937
+ def _show_drop_overlay(self):
938
+ """Show the drop overlay with proper sizing."""
939
+ # Resize overlay to cover the entire parent (central widget)
940
+ self.drop_overlay.setGeometry(self.drop_overlay.parentWidget().rect())
941
+ self.drop_overlay.raise_() # Bring to front
942
+ self.drop_overlay.show()
943
+
944
+ def _hide_drop_overlay(self):
945
+ """Hide the drop overlay."""
946
+ self.drop_overlay.hide()
947
+
948
+ def _import_file(self, file_path: str):
949
+ """
950
+ Import a file at the given path.
951
+ This is a helper method that can be called from both the menu action
952
+ and the drag-and-drop handler.
953
+ """
954
+ from ankigammon.gui.format_detector import FormatDetector, InputFormat
955
+ from ankigammon.parsers.xg_binary_parser import XGBinaryParser
956
+ import logging
957
+
958
+ logger = logging.getLogger(__name__)
959
+
960
+ try:
961
+ # Read file
962
+ with open(file_path, 'rb') as f:
963
+ data = f.read()
964
+
965
+ # Detect format
966
+ detector = FormatDetector(self.settings)
967
+ result = detector.detect_binary(data)
968
+
969
+ logger.info(f"Detected format: {result.format}, count: {result.count}")
970
+
971
+ # Parse based on format
972
+ decisions = []
973
+ total_count = 0 # Track total before filtering (for XG binary)
974
+
975
+ if result.format == InputFormat.XG_BINARY:
976
+ # Extract player names from XG file
977
+ player1_name, player2_name = XGBinaryParser.extract_player_names(file_path)
978
+
979
+ # Show import options dialog for XG binary files
980
+ import_dialog = ImportOptionsDialog(
981
+ self.settings,
982
+ player1_name=player1_name,
983
+ player2_name=player2_name,
984
+ parent=self
985
+ )
986
+ if import_dialog.exec():
987
+ # User accepted - get options
988
+ threshold, include_player_x, include_player_o = import_dialog.get_options()
989
+
990
+ # Parse all decisions
991
+ all_decisions = XGBinaryParser.parse_file(file_path)
992
+ total_count = len(all_decisions)
993
+
994
+ # Filter based on user options
995
+ decisions = self._filter_decisions_by_import_options(
996
+ all_decisions,
997
+ threshold,
998
+ include_player_x,
999
+ include_player_o
1000
+ )
1001
+
1002
+ logger.info(f"Filtered {len(decisions)} positions from {total_count} total")
1003
+ else:
1004
+ # User cancelled
1005
+ return
1006
+ else:
1007
+ QMessageBox.warning(
1008
+ self,
1009
+ "Unknown Format",
1010
+ f"Could not detect file format.\n\nOnly XG binary files (.xg) are supported for file import.\n\n{result.details}"
1011
+ )
1012
+ return
1013
+
1014
+ # Add to current decisions
1015
+ self.current_decisions.extend(decisions)
1016
+ self.position_list.set_decisions(self.current_decisions)
1017
+ self.btn_export.setEnabled(True)
1018
+ self.list_header_row.show()
1019
+
1020
+ # Show success message
1021
+ from pathlib import Path
1022
+ filename = Path(file_path).name
1023
+
1024
+ # Show filtering info
1025
+ filtered_count = len(decisions)
1026
+ message = f"Imported {filtered_count} position(s) from {filename}"
1027
+ if total_count > filtered_count:
1028
+ message += f"\n(filtered from {total_count} total positions)"
1029
+
1030
+ QMessageBox.information(
1031
+ self,
1032
+ "Import Successful",
1033
+ message
1034
+ )
1035
+
1036
+ logger.info(f"Successfully imported {len(decisions)} positions from {file_path}")
1037
+
1038
+ except FileNotFoundError:
1039
+ QMessageBox.critical(
1040
+ self,
1041
+ "File Not Found",
1042
+ f"Could not find file: {file_path}"
1043
+ )
1044
+ except ValueError as e:
1045
+ QMessageBox.critical(
1046
+ self,
1047
+ "Invalid Format",
1048
+ f"Invalid file format:\n{str(e)}"
1049
+ )
1050
+ except Exception as e:
1051
+ logger.error(f"Failed to import file {file_path}: {e}", exc_info=True)
1052
+ QMessageBox.critical(
1053
+ self,
1054
+ "Import Failed",
1055
+ f"Failed to import file:\n{str(e)}"
1056
+ )
1057
+
1058
+ def resizeEvent(self, event):
1059
+ """Handle window resize - update overlay size."""
1060
+ super().resizeEvent(event)
1061
+ if hasattr(self, 'drop_overlay') and hasattr(self, 'centralWidget'):
1062
+ # Update overlay to match central widget size
1063
+ self.drop_overlay.setGeometry(self.drop_overlay.parentWidget().rect())
1064
+
1065
+ def closeEvent(self, event):
1066
+ """Save window state on close."""
1067
+ settings = QSettings()
1068
+ settings.setValue("window/geometry", self.saveGeometry())
1069
+ settings.setValue("window/state", self.saveState())
1070
+
1071
+ event.accept()