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,762 @@
1
+ """
2
+ Smart input dialog for adding positions via paste.
3
+
4
+ Supports:
5
+ - Position IDs (XGID/OGID/GNUID) - analyzed with GnuBG
6
+ - Full XG analysis text - parsed directly
7
+ """
8
+
9
+ from typing import List
10
+ from pathlib import Path
11
+
12
+ import qtawesome as qta
13
+
14
+ from PySide6.QtWidgets import (
15
+ QDialog, QVBoxLayout, QHBoxLayout, QPushButton,
16
+ QLabel, QListWidget, QListWidgetItem, QMessageBox,
17
+ QFrame, QSplitter, QWidget, QProgressDialog, QMenu,
18
+ QAbstractItemView
19
+ )
20
+ from PySide6.QtCore import Qt, Signal, Slot
21
+ from PySide6.QtGui import QAction, QKeyEvent
22
+ from PySide6.QtWebEngineWidgets import QWebEngineView
23
+
24
+ from ankigammon.settings import Settings
25
+ from ankigammon.models import Decision, Position, Player, CubeState, DecisionType
26
+ from ankigammon.parsers.xg_text_parser import XGTextParser
27
+ from ankigammon.gui.dialogs.settings_dialog import SettingsDialog
28
+ from ankigammon.utils.gnuid import parse_gnuid
29
+ from ankigammon.utils.ogid import parse_ogid
30
+ from ankigammon.utils.xgid import parse_xgid
31
+ from ankigammon.renderer.svg_board_renderer import SVGBoardRenderer
32
+ from ankigammon.renderer.color_schemes import get_scheme
33
+ from ankigammon.gui.format_detector import InputFormat
34
+ from ankigammon.gui.dialogs.note_dialog import NoteEditDialog
35
+
36
+
37
+ class PendingPositionItem(QListWidgetItem):
38
+ """List item for a pending position."""
39
+
40
+ def __init__(self, decision: Decision, needs_analysis: bool = False):
41
+ super().__init__()
42
+ self.decision = decision
43
+ self.needs_analysis = needs_analysis
44
+
45
+ # Set display text
46
+ self._update_display()
47
+
48
+ def _update_display(self):
49
+ """Update display text based on decision."""
50
+ # Use consistent display format
51
+ self.setText(self.decision.get_short_display_text())
52
+
53
+ # Icon based on analysis status
54
+ if self.needs_analysis:
55
+ self.setIcon(qta.icon('fa6s.magnifying-glass', color='#89b4fa')) # Info blue
56
+ else:
57
+ self.setIcon(qta.icon('fa6s.circle-check', color='#a6e3a1')) # Success green
58
+
59
+ # Tooltip with metadata + analysis status
60
+ tooltip = self.decision.get_metadata_text()
61
+ if self.needs_analysis:
62
+ tooltip += "\n\nNeeds GnuBG analysis"
63
+ else:
64
+ tooltip += f"\n\n{len(self.decision.candidate_moves)} moves analyzed"
65
+
66
+ # Add note if present
67
+ if self.decision.note:
68
+ tooltip += f"\n\nNote: {self.decision.note}"
69
+
70
+ self.setToolTip(tooltip)
71
+
72
+
73
+ class PendingListWidget(QListWidget):
74
+ """Custom list widget for pending positions with deletion support."""
75
+
76
+ items_deleted = Signal(list) # Emits list of indices of deleted items
77
+
78
+ def __init__(self, parent=None):
79
+ super().__init__(parent)
80
+
81
+ # Enable smooth scrolling
82
+ self.setVerticalScrollMode(QListWidget.ScrollPerPixel)
83
+
84
+ # Enable multi-selection with Ctrl/Shift+Click
85
+ self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
86
+
87
+ # Enable context menu
88
+ self.setContextMenuPolicy(Qt.CustomContextMenu)
89
+ self.customContextMenuRequested.connect(self._show_context_menu)
90
+
91
+ # Set styling
92
+ self.setStyleSheet("""
93
+ QListWidget {
94
+ background-color: #1e1e2e;
95
+ border: 2px solid #313244;
96
+ border-radius: 8px;
97
+ padding: 8px;
98
+ }
99
+ QListWidget::item {
100
+ padding: 8px;
101
+ border-radius: 4px;
102
+ color: #cdd6f4;
103
+ }
104
+ QListWidget::item:selected {
105
+ background-color: #45475a;
106
+ }
107
+ QListWidget::item:hover {
108
+ background-color: #313244;
109
+ }
110
+ """)
111
+
112
+ @Slot()
113
+ def _show_context_menu(self, pos):
114
+ """Show context menu for edit note and delete actions."""
115
+ # Get all selected items
116
+ selected_items = self.selectedItems()
117
+
118
+ if not selected_items:
119
+ return
120
+
121
+ # Create context menu
122
+ menu = QMenu(self)
123
+ menu.setCursor(Qt.PointingHandCursor)
124
+
125
+ # Edit Note action (single selection only)
126
+ if len(selected_items) == 1:
127
+ item = selected_items[0]
128
+ edit_note_action = QAction(
129
+ qta.icon('fa6s.note-sticky', color='#f9e2af'), # Yellow note icon
130
+ "Edit Note...",
131
+ self
132
+ )
133
+ edit_note_action.triggered.connect(lambda: self._edit_note(item))
134
+ menu.addAction(edit_note_action)
135
+
136
+ menu.addSeparator()
137
+
138
+ # Delete action (supports single or multiple selections)
139
+ delete_text = "Delete" if len(selected_items) == 1 else f"Delete {len(selected_items)} Items"
140
+ delete_action = QAction(
141
+ qta.icon('fa6s.trash', color='#f38ba8'), # Red delete icon
142
+ delete_text,
143
+ self
144
+ )
145
+ delete_action.triggered.connect(self._delete_selected_items)
146
+ menu.addAction(delete_action)
147
+
148
+ # Show menu at cursor position
149
+ menu.exec(self.mapToGlobal(pos))
150
+
151
+ def _edit_note(self, item: PendingPositionItem):
152
+ """Edit the note for a pending position."""
153
+ current_note = item.decision.note or ""
154
+
155
+ # Create custom note edit dialog
156
+ dialog = NoteEditDialog(current_note, "Note for pending position:", self)
157
+
158
+ # Show dialog and get result
159
+ if dialog.exec() == QDialog.Accepted:
160
+ new_note = dialog.get_text()
161
+
162
+ # Update the decision's note
163
+ item.decision.note = new_note.strip() if new_note.strip() else None
164
+
165
+ # Update tooltip to reflect the new note
166
+ tooltip = item.decision.get_short_display_text()
167
+ if item.needs_analysis:
168
+ tooltip += "\n\nNeeds GnuBG analysis"
169
+ else:
170
+ tooltip += f"\n\n{len(item.decision.candidate_moves)} moves analyzed"
171
+ if item.decision.note:
172
+ tooltip += f"\n\nNote: {item.decision.note}"
173
+ item.setToolTip(tooltip)
174
+
175
+ def _delete_selected_items(self):
176
+ """Delete all selected items from the list with confirmation."""
177
+ selected_items = self.selectedItems()
178
+
179
+ if not selected_items:
180
+ return
181
+
182
+ # Confirm deletion
183
+ if len(selected_items) == 1:
184
+ item = selected_items[0]
185
+ message = f"Delete pending position?\n\n{item.decision.get_short_display_text()}"
186
+ title = "Delete Position"
187
+ else:
188
+ message = f"Delete {len(selected_items)} selected pending position(s)?"
189
+ title = "Delete Positions"
190
+
191
+ # Show confirmation dialog
192
+ reply = QMessageBox.question(
193
+ self,
194
+ title,
195
+ message,
196
+ QMessageBox.Yes | QMessageBox.No
197
+ )
198
+
199
+ if reply == QMessageBox.Yes:
200
+ # Delete in descending order to avoid index shifting
201
+ rows_to_delete = sorted([self.row(item) for item in selected_items], reverse=True)
202
+ for row in rows_to_delete:
203
+ self.takeItem(row)
204
+
205
+ # Emit signal with deleted indices
206
+ self.items_deleted.emit(rows_to_delete)
207
+
208
+ def keyPressEvent(self, event: QKeyEvent):
209
+ """Handle keyboard events for deletion."""
210
+ if event.key() in (Qt.Key_Delete, Qt.Key_Backspace):
211
+ self._delete_selected_items()
212
+ else:
213
+ super().keyPressEvent(event)
214
+
215
+
216
+ class InputDialog(QDialog):
217
+ """
218
+ Dialog for smart position input.
219
+
220
+ Allows users to paste:
221
+ - Full XG analysis text (parsed directly)
222
+ - Position IDs (XGID/OGID/GNUID) - analyzed with GnuBG
223
+
224
+ Signals:
225
+ positions_added(List[Decision]): Emitted when positions are added
226
+ """
227
+
228
+ positions_added = Signal(list)
229
+
230
+ def __init__(self, settings: Settings, parent=None):
231
+ super().__init__(parent)
232
+ self.settings = settings
233
+ self.pending_decisions: List[Decision] = []
234
+ self.renderer = SVGBoardRenderer(
235
+ color_scheme=get_scheme(settings.color_scheme),
236
+ orientation=settings.board_orientation
237
+ )
238
+
239
+ self.setWindowTitle("Add Positions")
240
+ self.setModal(True)
241
+ self.setMinimumSize(1100, 850)
242
+ self.resize(1150, 900) # Default size - taller for better preview
243
+
244
+ self._setup_ui()
245
+ self._setup_connections()
246
+
247
+ def _setup_ui(self):
248
+ """Initialize the user interface."""
249
+ layout = QVBoxLayout(self)
250
+ layout.setContentsMargins(20, 20, 20, 20)
251
+ layout.setSpacing(16)
252
+
253
+ # Title
254
+ title = QLabel("<h2>Add Positions to Export List</h2>")
255
+ title.setStyleSheet("color: #f5e0dc; margin-bottom: 8px;")
256
+ layout.addWidget(title)
257
+
258
+ # Main content area (splitter)
259
+ splitter = QSplitter(Qt.Horizontal)
260
+
261
+ # Left side: Input area
262
+ left_widget = self._create_input_panel()
263
+ splitter.addWidget(left_widget)
264
+
265
+ # Right side: Pending list + preview
266
+ right_widget = self._create_pending_panel()
267
+ splitter.addWidget(right_widget)
268
+
269
+ # Set initial splitter ratio
270
+ splitter.setStretchFactor(0, 50)
271
+ splitter.setStretchFactor(1, 50)
272
+
273
+ layout.addWidget(splitter, stretch=1)
274
+
275
+ # Bottom buttons
276
+ button_layout = QHBoxLayout()
277
+ button_layout.addStretch()
278
+
279
+ self.btn_done = QPushButton("Done")
280
+ self.btn_done.setStyleSheet("""
281
+ QPushButton {
282
+ background-color: #89b4fa;
283
+ color: #1e1e2e;
284
+ border: none;
285
+ padding: 10px 24px;
286
+ border-radius: 6px;
287
+ font-weight: 600;
288
+ font-size: 13px;
289
+ }
290
+ QPushButton:hover {
291
+ background-color: #a0c8fc;
292
+ }
293
+ QPushButton:pressed {
294
+ background-color: #74c7ec;
295
+ }
296
+ """)
297
+ self.btn_done.setCursor(Qt.PointingHandCursor)
298
+ self.btn_done.clicked.connect(self.accept)
299
+ button_layout.addWidget(self.btn_done)
300
+
301
+ self.btn_cancel = QPushButton("Cancel")
302
+ self.btn_cancel.setStyleSheet("""
303
+ QPushButton {
304
+ background-color: #45475a;
305
+ color: #cdd6f4;
306
+ border: none;
307
+ padding: 10px 24px;
308
+ border-radius: 6px;
309
+ font-size: 13px;
310
+ }
311
+ QPushButton:hover {
312
+ background-color: #585b70;
313
+ }
314
+ """)
315
+ self.btn_cancel.setCursor(Qt.PointingHandCursor)
316
+ self.btn_cancel.clicked.connect(self.reject)
317
+ button_layout.addWidget(self.btn_cancel)
318
+
319
+ layout.addLayout(button_layout)
320
+
321
+ def _create_input_panel(self) -> QWidget:
322
+ """Create the input panel with smart input widget."""
323
+ # Local import to avoid circular dependency
324
+ from ankigammon.gui.widgets import SmartInputWidget
325
+
326
+ panel = QWidget()
327
+ layout = QVBoxLayout(panel)
328
+ layout.setContentsMargins(0, 0, 0, 0)
329
+ layout.setSpacing(12)
330
+
331
+ # Smart input widget
332
+ self.input_widget = SmartInputWidget(self.settings)
333
+ layout.addWidget(self.input_widget, stretch=1)
334
+
335
+ # Buttons
336
+ button_layout = QHBoxLayout()
337
+
338
+ self.btn_add = QPushButton("Add to List")
339
+ self.btn_add.setStyleSheet("""
340
+ QPushButton {
341
+ background-color: #a6e3a1;
342
+ color: #1e1e2e;
343
+ border: none;
344
+ padding: 8px 20px;
345
+ border-radius: 6px;
346
+ font-weight: 600;
347
+ }
348
+ QPushButton:hover {
349
+ background-color: #94e2d5;
350
+ }
351
+ QPushButton:disabled {
352
+ background-color: #45475a;
353
+ color: #6c7086;
354
+ }
355
+ """)
356
+ self.btn_add.setCursor(Qt.PointingHandCursor)
357
+ self.btn_add.clicked.connect(self._on_add_clicked)
358
+ button_layout.addWidget(self.btn_add)
359
+
360
+ self.btn_clear = QPushButton("Clear Input")
361
+ self.btn_clear.setStyleSheet("""
362
+ QPushButton {
363
+ background-color: #45475a;
364
+ color: #cdd6f4;
365
+ border: none;
366
+ padding: 8px 20px;
367
+ border-radius: 6px;
368
+ }
369
+ QPushButton:hover {
370
+ background-color: #585b70;
371
+ }
372
+ """)
373
+ self.btn_clear.setCursor(Qt.PointingHandCursor)
374
+ self.btn_clear.clicked.connect(self.input_widget.clear_text)
375
+ button_layout.addWidget(self.btn_clear)
376
+
377
+ button_layout.addStretch()
378
+ layout.addLayout(button_layout)
379
+
380
+ return panel
381
+
382
+ def _create_pending_panel(self) -> QWidget:
383
+ """Create the pending positions panel."""
384
+ panel = QWidget()
385
+ layout = QVBoxLayout(panel)
386
+ layout.setContentsMargins(0, 0, 0, 0)
387
+ layout.setSpacing(12)
388
+
389
+ # Label with count
390
+ header_layout = QHBoxLayout()
391
+
392
+ label = QLabel("Pending Export:")
393
+ label.setStyleSheet("font-weight: 600; color: #cdd6f4;")
394
+ header_layout.addWidget(label)
395
+
396
+ self.count_label = QLabel("0 positions")
397
+ self.count_label.setStyleSheet("color: #a6adc8; font-size: 11px;")
398
+ header_layout.addWidget(self.count_label)
399
+
400
+ header_layout.addStretch()
401
+
402
+ self.btn_clear_all = QPushButton("Clear All")
403
+ self.btn_clear_all.setStyleSheet("""
404
+ QPushButton {
405
+ background-color: #45475a;
406
+ color: #cdd6f4;
407
+ border: none;
408
+ padding: 4px 12px;
409
+ border-radius: 4px;
410
+ font-size: 11px;
411
+ }
412
+ QPushButton:hover {
413
+ background-color: #f38ba8;
414
+ color: #1e1e2e;
415
+ }
416
+ """)
417
+ self.btn_clear_all.setCursor(Qt.PointingHandCursor)
418
+ self.btn_clear_all.clicked.connect(self._on_clear_all_clicked)
419
+ header_layout.addWidget(self.btn_clear_all)
420
+
421
+ layout.addLayout(header_layout)
422
+
423
+ # Create vertical splitter for pending list and preview
424
+ splitter = QSplitter(Qt.Vertical)
425
+ splitter.setChildrenCollapsible(False)
426
+
427
+ # Pending list
428
+ self.pending_list = PendingListWidget()
429
+ self.pending_list.currentItemChanged.connect(self._on_selection_changed)
430
+ self.pending_list.items_deleted.connect(self._on_items_deleted)
431
+ splitter.addWidget(self.pending_list)
432
+
433
+ # Preview pane
434
+ preview_container = QWidget()
435
+ preview_layout = QVBoxLayout(preview_container)
436
+ preview_layout.setContentsMargins(0, 8, 0, 0)
437
+ preview_layout.setSpacing(8)
438
+
439
+ preview_label = QLabel("Preview:")
440
+ preview_label.setStyleSheet("font-weight: 600; color: #cdd6f4;")
441
+ preview_layout.addWidget(preview_label)
442
+
443
+ self.preview = QWebEngineView()
444
+ self.preview.setContextMenuPolicy(Qt.NoContextMenu)
445
+ self.preview.setMinimumHeight(400)
446
+ self.preview.setHtml(self._get_empty_preview_html())
447
+ preview_layout.addWidget(self.preview, stretch=1)
448
+
449
+ splitter.addWidget(preview_container)
450
+
451
+ # Set splitter proportions
452
+ splitter.setStretchFactor(0, 25)
453
+ splitter.setStretchFactor(1, 75)
454
+
455
+ layout.addWidget(splitter, stretch=1)
456
+
457
+ return panel
458
+
459
+ def _setup_connections(self):
460
+ """Setup signal connections."""
461
+ pass
462
+
463
+ @Slot()
464
+ def _on_add_clicked(self):
465
+ """Handle Add to List button click."""
466
+ text = self.input_widget.get_text()
467
+ result = self.input_widget.get_last_result()
468
+
469
+ if not text.strip():
470
+ QMessageBox.warning(
471
+ self,
472
+ "No Input",
473
+ "Please paste some text first"
474
+ )
475
+ return
476
+
477
+ if not result or result.format == InputFormat.UNKNOWN:
478
+ QMessageBox.warning(
479
+ self,
480
+ "Invalid Format",
481
+ "Could not detect valid position format.\n\n"
482
+ "Please paste XGID/OGID/GNUID or full XG analysis text."
483
+ )
484
+ return
485
+
486
+ # Check for GnuBG requirement
487
+ if result.format == InputFormat.POSITION_IDS and not self.settings.is_gnubg_available():
488
+ reply = QMessageBox.question(
489
+ self,
490
+ "GnuBG Required",
491
+ "Position IDs require GnuBG analysis, but GnuBG is not configured.\n\n"
492
+ "Would you like to configure GnuBG in Settings?",
493
+ QMessageBox.Yes | QMessageBox.No
494
+ )
495
+ if reply == QMessageBox.Yes:
496
+ # Open settings dialog to configure GnuBG
497
+ dialog = SettingsDialog(self.settings, self)
498
+ dialog.exec()
499
+ return
500
+
501
+ # Parse input
502
+ try:
503
+ decisions = self._parse_input(text, result.format)
504
+
505
+ if not decisions:
506
+ QMessageBox.warning(
507
+ self,
508
+ "Parse Failed",
509
+ "Could not parse any valid positions from input."
510
+ )
511
+ return
512
+
513
+ # Add to pending list
514
+ for decision in decisions:
515
+ # Check if decision has analysis data
516
+ needs_analysis = not bool(decision.candidate_moves)
517
+ self.pending_decisions.append(decision)
518
+
519
+ item = PendingPositionItem(decision, needs_analysis)
520
+ self.pending_list.addItem(item)
521
+
522
+ # Update count
523
+ self._update_count_label()
524
+
525
+ # Clear input
526
+ self.input_widget.clear_text()
527
+
528
+ # Select first new item
529
+ if decisions:
530
+ self.pending_list.setCurrentRow(len(self.pending_decisions) - len(decisions))
531
+
532
+ except Exception as e:
533
+ QMessageBox.critical(
534
+ self,
535
+ "Parse Error",
536
+ f"Failed to parse input:\n{str(e)}"
537
+ )
538
+
539
+ def _parse_input(self, text: str, format_type: InputFormat) -> List[Decision]:
540
+ """Parse input text into Decision objects."""
541
+ if format_type == InputFormat.FULL_ANALYSIS:
542
+ # Use XGTextParser for full analysis
543
+ decisions = XGTextParser.parse_string(text)
544
+ return decisions
545
+
546
+ elif format_type == InputFormat.XG_BINARY:
547
+ # Binary format should use file import
548
+ raise ValueError(
549
+ "XG binary format (.xg files) must be imported via File → Import.\n"
550
+ "Binary data cannot be pasted as text."
551
+ )
552
+
553
+ elif format_type == InputFormat.POSITION_IDS:
554
+ # Try parsing as position IDs (XGID, GNUID, or OGID)
555
+ decisions = []
556
+
557
+ # Split by lines
558
+ lines = [line.strip() for line in text.split('\n') if line.strip()]
559
+
560
+ for line in lines:
561
+ decision = self._parse_position_id(line)
562
+ if decision:
563
+ decisions.append(decision)
564
+
565
+ return decisions
566
+
567
+ return []
568
+
569
+ def _parse_position_id(self, position_id: str) -> Decision:
570
+ """Parse a single position ID (XGID, GNUID, or OGID) into a Decision."""
571
+ # Try XGID
572
+ if 'XGID=' in position_id or ':' in position_id:
573
+ try:
574
+ position, metadata = parse_xgid(position_id)
575
+ return self._create_decision_from_metadata(position, metadata)
576
+ except:
577
+ pass
578
+
579
+ # Try GNUID
580
+ if ':' in position_id:
581
+ parts = position_id.split(':')
582
+ if len(parts) >= 2 and len(parts[0]) == 14 and len(parts[1]) == 12:
583
+ try:
584
+ position, metadata = parse_gnuid(position_id)
585
+ return self._create_decision_from_metadata(position, metadata)
586
+ except:
587
+ pass
588
+
589
+ # Try OGID
590
+ if ':' in position_id:
591
+ try:
592
+ position, metadata = parse_ogid(position_id)
593
+ return self._create_decision_from_metadata(position, metadata)
594
+ except:
595
+ pass
596
+
597
+ return None
598
+
599
+ def _create_decision_from_metadata(self, position: Position, metadata: dict) -> Decision:
600
+ """Create a Decision object from position and metadata."""
601
+ from ankigammon.utils.xgid import encode_xgid
602
+
603
+ # Determine Crawford status (only applies to match play, not money games)
604
+ match_length = metadata.get('match_length', 0)
605
+ crawford = False
606
+
607
+ if match_length > 0:
608
+ if 'crawford' in metadata and metadata['crawford']:
609
+ crawford = True
610
+ elif 'crawford_jacoby' in metadata and metadata['crawford_jacoby'] > 0:
611
+ crawford = True
612
+ elif 'match_modifier' in metadata and metadata['match_modifier'] == 'C':
613
+ crawford = True
614
+
615
+ # Generate XGID for GnuBG analysis
616
+ xgid = encode_xgid(
617
+ position=position,
618
+ cube_value=metadata.get('cube_value', 1),
619
+ cube_owner=metadata.get('cube_owner', CubeState.CENTERED),
620
+ dice=metadata.get('dice'),
621
+ on_roll=metadata.get('on_roll', Player.X),
622
+ score_x=metadata.get('score_x', 0),
623
+ score_o=metadata.get('score_o', 0),
624
+ match_length=metadata.get('match_length', 0),
625
+ crawford_jacoby=metadata.get('crawford_jacoby', 1 if crawford else 0)
626
+ )
627
+
628
+ return Decision(
629
+ position=position,
630
+ xgid=xgid,
631
+ on_roll=metadata.get('on_roll', Player.X),
632
+ dice=metadata.get('dice'),
633
+ score_x=metadata.get('score_x', 0),
634
+ score_o=metadata.get('score_o', 0),
635
+ match_length=metadata.get('match_length', 0),
636
+ crawford=crawford,
637
+ cube_value=metadata.get('cube_value', 1),
638
+ cube_owner=metadata.get('cube_owner', CubeState.CENTERED),
639
+ decision_type=DecisionType.CUBE_ACTION if not metadata.get('dice') else DecisionType.CHECKER_PLAY,
640
+ candidate_moves=[] # Will be populated by GnuBG analysis
641
+ )
642
+
643
+ @Slot()
644
+ def _on_clear_all_clicked(self):
645
+ """Handle Clear All button click."""
646
+ if not self.pending_decisions:
647
+ return
648
+
649
+ reply = QMessageBox.question(
650
+ self,
651
+ "Clear All",
652
+ f"Remove all {len(self.pending_decisions)} pending position(s)?",
653
+ QMessageBox.Yes | QMessageBox.No
654
+ )
655
+
656
+ if reply == QMessageBox.Yes:
657
+ self.pending_decisions.clear()
658
+ self.pending_list.clear()
659
+ self._update_count_label()
660
+ self.preview.setHtml(self._get_empty_preview_html())
661
+
662
+ @Slot(list)
663
+ def _on_items_deleted(self, indices: list):
664
+ """Handle deletion of multiple pending items."""
665
+ # Delete in descending order
666
+ for index in sorted(indices, reverse=True):
667
+ if 0 <= index < len(self.pending_decisions):
668
+ self.pending_decisions.pop(index)
669
+
670
+ # Update count label
671
+ self._update_count_label()
672
+
673
+ # Clear preview if no items remain or no selection
674
+ if not self.pending_decisions:
675
+ self.preview.setHtml(self._get_empty_preview_html())
676
+
677
+ @Slot(QListWidgetItem, QListWidgetItem)
678
+ def _on_selection_changed(self, current, previous):
679
+ """Handle selection change in pending list."""
680
+ if not current:
681
+ self.preview.setHtml(self._get_empty_preview_html())
682
+ return
683
+
684
+ if isinstance(current, PendingPositionItem):
685
+ self._show_preview(current.decision)
686
+
687
+ def _show_preview(self, decision: Decision):
688
+ """Show preview of a decision."""
689
+ svg = self.renderer.render_svg(
690
+ decision.position,
691
+ dice=decision.dice,
692
+ on_roll=decision.on_roll,
693
+ cube_value=decision.cube_value,
694
+ cube_owner=decision.cube_owner,
695
+ score_x=decision.score_x,
696
+ score_o=decision.score_o,
697
+ match_length=decision.match_length,
698
+ )
699
+
700
+ html = f"""
701
+ <!DOCTYPE html>
702
+ <html>
703
+ <head>
704
+ <style>
705
+ body {{
706
+ margin: 0;
707
+ padding: 10px;
708
+ background: #1e1e2e;
709
+ display: flex;
710
+ justify-content: center;
711
+ }}
712
+ svg {{
713
+ max-width: 100%;
714
+ height: auto;
715
+ }}
716
+ </style>
717
+ </head>
718
+ <body>
719
+ {svg}
720
+ </body>
721
+ </html>
722
+ """
723
+
724
+ self.preview.setHtml(html)
725
+
726
+ def _update_count_label(self):
727
+ """Update the pending count label."""
728
+ count = len(self.pending_decisions)
729
+ self.count_label.setText(f"{count} position{'s' if count != 1 else ''}")
730
+
731
+ def _get_empty_preview_html(self) -> str:
732
+ """Get HTML for empty preview state."""
733
+ return """
734
+ <!DOCTYPE html>
735
+ <html>
736
+ <head>
737
+ <style>
738
+ body {
739
+ margin: 0;
740
+ padding: 20px;
741
+ background: #1e1e2e;
742
+ color: #6c7086;
743
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
744
+ text-align: center;
745
+ }
746
+ </style>
747
+ </head>
748
+ <body>
749
+ <p>Select a position to preview</p>
750
+ </body>
751
+ </html>
752
+ """
753
+
754
+ def accept(self):
755
+ """Handle dialog acceptance."""
756
+ if self.pending_decisions:
757
+ self.positions_added.emit(self.pending_decisions)
758
+ super().accept()
759
+
760
+ def get_pending_decisions(self) -> List[Decision]:
761
+ """Get list of pending decisions."""
762
+ return self.pending_decisions