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,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()
|