ankigammon 1.0.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ankigammon/__init__.py +7 -0
- ankigammon/__main__.py +6 -0
- ankigammon/analysis/__init__.py +13 -0
- ankigammon/analysis/score_matrix.py +391 -0
- ankigammon/anki/__init__.py +6 -0
- ankigammon/anki/ankiconnect.py +216 -0
- ankigammon/anki/apkg_exporter.py +111 -0
- ankigammon/anki/card_generator.py +1325 -0
- ankigammon/anki/card_styles.py +1054 -0
- ankigammon/gui/__init__.py +8 -0
- ankigammon/gui/app.py +192 -0
- ankigammon/gui/dialogs/__init__.py +10 -0
- ankigammon/gui/dialogs/export_dialog.py +594 -0
- ankigammon/gui/dialogs/import_options_dialog.py +201 -0
- ankigammon/gui/dialogs/input_dialog.py +762 -0
- ankigammon/gui/dialogs/note_dialog.py +93 -0
- ankigammon/gui/dialogs/settings_dialog.py +420 -0
- ankigammon/gui/dialogs/update_dialog.py +373 -0
- ankigammon/gui/format_detector.py +377 -0
- ankigammon/gui/main_window.py +1611 -0
- ankigammon/gui/resources/down-arrow.svg +3 -0
- ankigammon/gui/resources/icon.icns +0 -0
- ankigammon/gui/resources/icon.ico +0 -0
- ankigammon/gui/resources/icon.png +0 -0
- ankigammon/gui/resources/style.qss +402 -0
- ankigammon/gui/resources.py +26 -0
- ankigammon/gui/update_checker.py +259 -0
- ankigammon/gui/widgets/__init__.py +8 -0
- ankigammon/gui/widgets/position_list.py +166 -0
- ankigammon/gui/widgets/smart_input.py +268 -0
- ankigammon/models.py +356 -0
- ankigammon/parsers/__init__.py +7 -0
- ankigammon/parsers/gnubg_match_parser.py +1094 -0
- ankigammon/parsers/gnubg_parser.py +468 -0
- ankigammon/parsers/sgf_parser.py +290 -0
- ankigammon/parsers/xg_binary_parser.py +1097 -0
- ankigammon/parsers/xg_text_parser.py +688 -0
- ankigammon/renderer/__init__.py +5 -0
- ankigammon/renderer/animation_controller.py +391 -0
- ankigammon/renderer/animation_helper.py +191 -0
- ankigammon/renderer/color_schemes.py +145 -0
- ankigammon/renderer/svg_board_renderer.py +791 -0
- ankigammon/settings.py +315 -0
- ankigammon/thirdparty/__init__.py +7 -0
- ankigammon/thirdparty/xgdatatools/__init__.py +17 -0
- ankigammon/thirdparty/xgdatatools/xgimport.py +160 -0
- ankigammon/thirdparty/xgdatatools/xgstruct.py +1032 -0
- ankigammon/thirdparty/xgdatatools/xgutils.py +118 -0
- ankigammon/thirdparty/xgdatatools/xgzarc.py +260 -0
- ankigammon/utils/__init__.py +13 -0
- ankigammon/utils/gnubg_analyzer.py +590 -0
- ankigammon/utils/gnuid.py +577 -0
- ankigammon/utils/move_parser.py +204 -0
- ankigammon/utils/ogid.py +326 -0
- ankigammon/utils/xgid.py +387 -0
- ankigammon-1.0.6.dist-info/METADATA +352 -0
- ankigammon-1.0.6.dist-info/RECORD +61 -0
- ankigammon-1.0.6.dist-info/WHEEL +5 -0
- ankigammon-1.0.6.dist-info/entry_points.txt +2 -0
- ankigammon-1.0.6.dist-info/licenses/LICENSE +21 -0
- ankigammon-1.0.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|