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