ankigammon 1.0.0__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (56) hide show
  1. ankigammon/__init__.py +7 -0
  2. ankigammon/__main__.py +6 -0
  3. ankigammon/analysis/__init__.py +13 -0
  4. ankigammon/analysis/score_matrix.py +373 -0
  5. ankigammon/anki/__init__.py +6 -0
  6. ankigammon/anki/ankiconnect.py +224 -0
  7. ankigammon/anki/apkg_exporter.py +123 -0
  8. ankigammon/anki/card_generator.py +1307 -0
  9. ankigammon/anki/card_styles.py +1034 -0
  10. ankigammon/gui/__init__.py +8 -0
  11. ankigammon/gui/app.py +209 -0
  12. ankigammon/gui/dialogs/__init__.py +10 -0
  13. ankigammon/gui/dialogs/export_dialog.py +597 -0
  14. ankigammon/gui/dialogs/import_options_dialog.py +163 -0
  15. ankigammon/gui/dialogs/input_dialog.py +776 -0
  16. ankigammon/gui/dialogs/note_dialog.py +93 -0
  17. ankigammon/gui/dialogs/settings_dialog.py +384 -0
  18. ankigammon/gui/format_detector.py +292 -0
  19. ankigammon/gui/main_window.py +1071 -0
  20. ankigammon/gui/resources/icon.icns +0 -0
  21. ankigammon/gui/resources/icon.ico +0 -0
  22. ankigammon/gui/resources/icon.png +0 -0
  23. ankigammon/gui/resources/style.qss +394 -0
  24. ankigammon/gui/resources.py +26 -0
  25. ankigammon/gui/widgets/__init__.py +8 -0
  26. ankigammon/gui/widgets/position_list.py +193 -0
  27. ankigammon/gui/widgets/smart_input.py +268 -0
  28. ankigammon/models.py +322 -0
  29. ankigammon/parsers/__init__.py +7 -0
  30. ankigammon/parsers/gnubg_parser.py +454 -0
  31. ankigammon/parsers/xg_binary_parser.py +870 -0
  32. ankigammon/parsers/xg_text_parser.py +729 -0
  33. ankigammon/renderer/__init__.py +5 -0
  34. ankigammon/renderer/animation_controller.py +406 -0
  35. ankigammon/renderer/animation_helper.py +221 -0
  36. ankigammon/renderer/color_schemes.py +145 -0
  37. ankigammon/renderer/svg_board_renderer.py +824 -0
  38. ankigammon/settings.py +239 -0
  39. ankigammon/thirdparty/__init__.py +7 -0
  40. ankigammon/thirdparty/xgdatatools/__init__.py +17 -0
  41. ankigammon/thirdparty/xgdatatools/xgimport.py +160 -0
  42. ankigammon/thirdparty/xgdatatools/xgstruct.py +1032 -0
  43. ankigammon/thirdparty/xgdatatools/xgutils.py +118 -0
  44. ankigammon/thirdparty/xgdatatools/xgzarc.py +260 -0
  45. ankigammon/utils/__init__.py +13 -0
  46. ankigammon/utils/gnubg_analyzer.py +431 -0
  47. ankigammon/utils/gnuid.py +622 -0
  48. ankigammon/utils/move_parser.py +239 -0
  49. ankigammon/utils/ogid.py +335 -0
  50. ankigammon/utils/xgid.py +419 -0
  51. ankigammon-1.0.0.dist-info/METADATA +370 -0
  52. ankigammon-1.0.0.dist-info/RECORD +56 -0
  53. ankigammon-1.0.0.dist-info/WHEEL +5 -0
  54. ankigammon-1.0.0.dist-info/entry_points.txt +2 -0
  55. ankigammon-1.0.0.dist-info/licenses/LICENSE +21 -0
  56. ankigammon-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,93 @@
1
+ """
2
+ Custom dialog for editing notes with word wrapping.
3
+ """
4
+
5
+ from PySide6.QtWidgets import (
6
+ QDialog, QVBoxLayout, QHBoxLayout, QPushButton,
7
+ QLabel, QPlainTextEdit
8
+ )
9
+ from PySide6.QtCore import Qt
10
+
11
+
12
+ class NoteEditDialog(QDialog):
13
+ """Custom dialog for editing notes with word wrapping."""
14
+
15
+ def __init__(self, current_note: str = "", label_text: str = "Note:", parent=None):
16
+ super().__init__(parent)
17
+ self.setWindowTitle("Edit Note")
18
+ self.setModal(True)
19
+ self.setMinimumSize(500, 300)
20
+
21
+ layout = QVBoxLayout(self)
22
+ layout.setSpacing(12)
23
+
24
+ # Label
25
+ label = QLabel(label_text)
26
+ label.setStyleSheet("color: #cdd6f4; font-weight: 600;")
27
+ layout.addWidget(label)
28
+
29
+ # Text edit with word wrap
30
+ self.text_edit = QPlainTextEdit()
31
+ self.text_edit.setPlainText(current_note)
32
+ self.text_edit.setLineWrapMode(QPlainTextEdit.WidgetWidth)
33
+ self.text_edit.setStyleSheet("""
34
+ QPlainTextEdit {
35
+ background-color: #1e1e2e;
36
+ color: #cdd6f4;
37
+ border: 2px solid #313244;
38
+ border-radius: 6px;
39
+ padding: 8px;
40
+ }
41
+ QPlainTextEdit:focus {
42
+ border-color: #89b4fa;
43
+ }
44
+ """)
45
+ layout.addWidget(self.text_edit, stretch=1)
46
+
47
+ # Buttons
48
+ button_layout = QHBoxLayout()
49
+ button_layout.addStretch()
50
+
51
+ self.btn_ok = QPushButton("OK")
52
+ self.btn_ok.setStyleSheet("""
53
+ QPushButton {
54
+ background-color: #89b4fa;
55
+ color: #1e1e2e;
56
+ border: none;
57
+ padding: 8px 24px;
58
+ border-radius: 6px;
59
+ font-weight: 600;
60
+ }
61
+ QPushButton:hover {
62
+ background-color: #a0c8fc;
63
+ }
64
+ """)
65
+ self.btn_ok.setCursor(Qt.PointingHandCursor)
66
+ self.btn_ok.clicked.connect(self.accept)
67
+ button_layout.addWidget(self.btn_ok)
68
+
69
+ self.btn_cancel = QPushButton("Cancel")
70
+ self.btn_cancel.setStyleSheet("""
71
+ QPushButton {
72
+ background-color: #45475a;
73
+ color: #cdd6f4;
74
+ border: none;
75
+ padding: 8px 24px;
76
+ border-radius: 6px;
77
+ }
78
+ QPushButton:hover {
79
+ background-color: #585b70;
80
+ }
81
+ """)
82
+ self.btn_cancel.setCursor(Qt.PointingHandCursor)
83
+ self.btn_cancel.clicked.connect(self.reject)
84
+ button_layout.addWidget(self.btn_cancel)
85
+
86
+ layout.addLayout(button_layout)
87
+
88
+ # Focus the text edit
89
+ self.text_edit.setFocus()
90
+
91
+ def get_text(self) -> str:
92
+ """Get the edited text."""
93
+ return self.text_edit.toPlainText()
@@ -0,0 +1,384 @@
1
+ """
2
+ Settings configuration dialog.
3
+ """
4
+
5
+ import os
6
+ import sys
7
+ import subprocess
8
+ import tempfile
9
+ from pathlib import Path
10
+ from typing import Optional
11
+ import qtawesome as qta
12
+ from PySide6.QtWidgets import (
13
+ QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
14
+ QComboBox, QCheckBox, QLineEdit, QPushButton,
15
+ QGroupBox, QFileDialog, QLabel, QDialogButtonBox
16
+ )
17
+ from PySide6.QtCore import Qt, Signal, QThread
18
+
19
+ from ankigammon.settings import Settings
20
+ from ankigammon.renderer.color_schemes import list_schemes
21
+
22
+
23
+ class GnuBGValidationWorker(QThread):
24
+ """Worker thread for validating GnuBG executable without blocking UI."""
25
+
26
+ # Signals to communicate with main thread
27
+ validation_complete = Signal(str, str) # (status_text, status_type)
28
+
29
+ def __init__(self, gnubg_path: str):
30
+ super().__init__()
31
+ self.gnubg_path = gnubg_path
32
+
33
+ def run(self):
34
+ """Run validation in background thread."""
35
+ path_obj = Path(self.gnubg_path)
36
+
37
+ # Check if file exists
38
+ if not path_obj.exists():
39
+ self.validation_complete.emit("File not found", "error")
40
+ return
41
+
42
+ if not path_obj.is_file():
43
+ self.validation_complete.emit("Not a file", "error")
44
+ return
45
+
46
+ # Create a simple command file (same approach as gnubg_analyzer)
47
+ command_file = None
48
+ try:
49
+ # Create temp command file
50
+ fd, command_file = tempfile.mkstemp(suffix=".txt", prefix="gnubg_test_")
51
+ try:
52
+ with os.fdopen(fd, 'w') as f:
53
+ # Simple command that should work on any gnubg
54
+ f.write("quit\n")
55
+ except:
56
+ os.close(fd)
57
+ raise
58
+
59
+ # Try to run gnubg with -t (text mode) and -c (command file)
60
+ # On Windows, prevent console window from appearing
61
+ # Use longer timeout since first launch can be slow (loading neural networks)
62
+ kwargs = {
63
+ 'capture_output': True,
64
+ 'text': True,
65
+ 'timeout': 15
66
+ }
67
+ if sys.platform == 'win32':
68
+ kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW
69
+
70
+ result = subprocess.run(
71
+ [str(self.gnubg_path), "-t", "-c", command_file],
72
+ **kwargs
73
+ )
74
+
75
+ # Check if it's actually GNU Backgammon
76
+ output = result.stdout + result.stderr
77
+ if "GNU Backgammon" in output or result.returncode == 0:
78
+ # Check if it's the GUI version (which may not work properly on Windows)
79
+ # Use stem to get filename without extension (works cross-platform)
80
+ exe_name = path_obj.stem.lower()
81
+ if sys.platform == 'win32' and "cli" not in exe_name and exe_name == "gnubg":
82
+ self.validation_complete.emit(
83
+ "GUI version detected (use gnubg-cli.exe)",
84
+ "warning"
85
+ )
86
+ else:
87
+ self.validation_complete.emit(
88
+ "Valid GnuBG executable",
89
+ "valid"
90
+ )
91
+ else:
92
+ self.validation_complete.emit("Not GNU Backgammon", "warning")
93
+
94
+ except subprocess.TimeoutExpired:
95
+ self.validation_complete.emit("Validation timeout", "warning")
96
+ except Exception as e:
97
+ self.validation_complete.emit(
98
+ f"Cannot execute: {type(e).__name__}",
99
+ "warning"
100
+ )
101
+ finally:
102
+ # Clean up temp file
103
+ if command_file:
104
+ try:
105
+ os.unlink(command_file)
106
+ except OSError:
107
+ pass
108
+
109
+
110
+ class SettingsDialog(QDialog):
111
+ """
112
+ Dialog for configuring application settings.
113
+
114
+ Signals:
115
+ settings_changed(Settings): Emitted when user saves changes
116
+ """
117
+
118
+ settings_changed = Signal(Settings)
119
+
120
+ def __init__(self, settings: Settings, parent: Optional[QDialog] = None):
121
+ super().__init__(parent)
122
+ self.settings = settings
123
+ self.original_settings = Settings()
124
+ self.original_settings.color_scheme = settings.color_scheme
125
+ self.original_settings.deck_name = settings.deck_name
126
+ self.original_settings.show_options = settings.show_options
127
+ self.original_settings.interactive_moves = settings.interactive_moves
128
+ self.original_settings.export_method = settings.export_method
129
+ self.original_settings.board_orientation = settings.board_orientation
130
+ self.original_settings.gnubg_path = settings.gnubg_path
131
+ self.original_settings.gnubg_analysis_ply = settings.gnubg_analysis_ply
132
+ self.original_settings.generate_score_matrix = settings.generate_score_matrix
133
+
134
+ # Validation worker
135
+ self.validation_worker: Optional[GnuBGValidationWorker] = None
136
+
137
+ self.setWindowTitle("Settings")
138
+ self.setModal(True)
139
+ self.setMinimumWidth(500)
140
+
141
+ self._setup_ui()
142
+ self._load_settings()
143
+
144
+ def _setup_ui(self):
145
+ """Initialize the user interface."""
146
+ layout = QVBoxLayout(self)
147
+
148
+ # Anki settings group
149
+ anki_group = self._create_anki_group()
150
+ layout.addWidget(anki_group)
151
+
152
+ # Card settings group
153
+ card_group = self._create_card_group()
154
+ layout.addWidget(card_group)
155
+
156
+ # GnuBG settings group
157
+ gnubg_group = self._create_gnubg_group()
158
+ layout.addWidget(gnubg_group)
159
+
160
+ # Dialog buttons
161
+ button_box = QDialogButtonBox(
162
+ QDialogButtonBox.Ok | QDialogButtonBox.Cancel
163
+ )
164
+ button_box.accepted.connect(self.accept)
165
+ button_box.rejected.connect(self.reject)
166
+
167
+ # Add cursor pointers to OK and Cancel buttons
168
+ for button in button_box.buttons():
169
+ button.setCursor(Qt.PointingHandCursor)
170
+
171
+ layout.addWidget(button_box)
172
+
173
+ def _create_anki_group(self) -> QGroupBox:
174
+ """Create Anki settings group."""
175
+ group = QGroupBox("Anki Export")
176
+ form = QFormLayout(group)
177
+
178
+ # Deck name
179
+ self.txt_deck_name = QLineEdit()
180
+ form.addRow("Default Deck Name:", self.txt_deck_name)
181
+
182
+ # Export method
183
+ self.cmb_export_method = QComboBox()
184
+ self.cmb_export_method.addItems(["AnkiConnect", "APKG File"])
185
+ self.cmb_export_method.setCursor(Qt.PointingHandCursor)
186
+ form.addRow("Default Export Method:", self.cmb_export_method)
187
+
188
+ return group
189
+
190
+ def _create_card_group(self) -> QGroupBox:
191
+ """Create card settings group."""
192
+ group = QGroupBox("Card Appearance")
193
+ form = QFormLayout(group)
194
+
195
+ # Board theme
196
+ self.cmb_color_scheme = QComboBox()
197
+ self.cmb_color_scheme.addItems(list_schemes())
198
+ self.cmb_color_scheme.setCursor(Qt.PointingHandCursor)
199
+ form.addRow("Board Theme:", self.cmb_color_scheme)
200
+
201
+ # Board orientation
202
+ self.cmb_board_orientation = QComboBox()
203
+ self.cmb_board_orientation.addItem("Counter-clockwise", "counter-clockwise")
204
+ self.cmb_board_orientation.addItem("Clockwise", "clockwise")
205
+ self.cmb_board_orientation.setCursor(Qt.PointingHandCursor)
206
+ form.addRow("Board Orientation:", self.cmb_board_orientation)
207
+
208
+ # Show options
209
+ self.chk_show_options = QCheckBox("Show multiple choice options on card front")
210
+ self.chk_show_options.setCursor(Qt.PointingHandCursor)
211
+ form.addRow(self.chk_show_options)
212
+
213
+ # Interactive moves
214
+ self.chk_interactive_moves = QCheckBox("Enable interactive move visualization")
215
+ self.chk_interactive_moves.setCursor(Qt.PointingHandCursor)
216
+ form.addRow(self.chk_interactive_moves)
217
+
218
+ return group
219
+
220
+ def _create_gnubg_group(self) -> QGroupBox:
221
+ """Create GnuBG settings group."""
222
+ group = QGroupBox("GnuBG Integration (Optional)")
223
+ form = QFormLayout(group)
224
+
225
+ # GnuBG path
226
+ path_layout = QHBoxLayout()
227
+ self.txt_gnubg_path = QLineEdit()
228
+ btn_browse = QPushButton("Browse...")
229
+ btn_browse.setCursor(Qt.PointingHandCursor)
230
+ btn_browse.clicked.connect(self._browse_gnubg)
231
+ path_layout.addWidget(self.txt_gnubg_path)
232
+ path_layout.addWidget(btn_browse)
233
+ form.addRow("GnuBG Path:", path_layout)
234
+
235
+ # Analysis depth
236
+ self.cmb_gnubg_ply = QComboBox()
237
+ self.cmb_gnubg_ply.addItems(["0", "1", "2", "3", "4"])
238
+ self.cmb_gnubg_ply.setCursor(Qt.PointingHandCursor)
239
+ form.addRow("Analysis Depth (ply):", self.cmb_gnubg_ply)
240
+
241
+ # Score matrix generation with inline warning
242
+ matrix_layout = QHBoxLayout()
243
+ self.chk_generate_score_matrix = QCheckBox("Generate score matrix for cube decisions")
244
+ self.chk_generate_score_matrix.setCursor(Qt.PointingHandCursor)
245
+ matrix_layout.addWidget(self.chk_generate_score_matrix)
246
+ matrix_warning = QLabel("(time-consuming)")
247
+ matrix_warning.setStyleSheet("font-size: 11px; color: #a6adc8; margin-left: 8px;")
248
+ matrix_layout.addWidget(matrix_warning)
249
+ matrix_layout.addStretch()
250
+ form.addRow(matrix_layout)
251
+
252
+ # Status display (icon + text in horizontal layout)
253
+ status_layout = QHBoxLayout()
254
+ self.lbl_gnubg_status_icon = QLabel()
255
+ self.lbl_gnubg_status_text = QLabel()
256
+ status_layout.addWidget(self.lbl_gnubg_status_icon)
257
+ status_layout.addWidget(self.lbl_gnubg_status_text)
258
+ status_layout.addStretch()
259
+ form.addRow("Status:", status_layout)
260
+
261
+ return group
262
+
263
+ def _load_settings(self):
264
+ """Load current settings into widgets."""
265
+ self.txt_deck_name.setText(self.settings.deck_name)
266
+
267
+ # Export method
268
+ method_index = 0 if self.settings.export_method == "ankiconnect" else 1
269
+ self.cmb_export_method.setCurrentIndex(method_index)
270
+
271
+ # Color scheme
272
+ scheme_index = list_schemes().index(self.settings.color_scheme)
273
+ self.cmb_color_scheme.setCurrentIndex(scheme_index)
274
+
275
+ # Board orientation
276
+ orientation_index = 0 if self.settings.board_orientation == "counter-clockwise" else 1
277
+ self.cmb_board_orientation.setCurrentIndex(orientation_index)
278
+
279
+ self.chk_show_options.setChecked(self.settings.show_options)
280
+ self.chk_interactive_moves.setChecked(self.settings.interactive_moves)
281
+
282
+ # GnuBG
283
+ if self.settings.gnubg_path:
284
+ self.txt_gnubg_path.setText(self.settings.gnubg_path)
285
+ self.cmb_gnubg_ply.setCurrentIndex(self.settings.gnubg_analysis_ply)
286
+ self.chk_generate_score_matrix.setChecked(self.settings.generate_score_matrix)
287
+ self._update_gnubg_status()
288
+
289
+ def _browse_gnubg(self):
290
+ """Browse for GnuBG executable."""
291
+ # Platform-specific file filter
292
+ if sys.platform == 'win32':
293
+ file_filter = "Executables (*.exe);;All Files (*)"
294
+ else:
295
+ file_filter = "All Files (*)"
296
+
297
+ file_path, _ = QFileDialog.getOpenFileName(
298
+ self,
299
+ "Select GnuBG Executable",
300
+ "",
301
+ file_filter
302
+ )
303
+ if file_path:
304
+ self.txt_gnubg_path.setText(file_path)
305
+ self._update_gnubg_status()
306
+
307
+ def _update_gnubg_status(self):
308
+ """Update GnuBG status label asynchronously."""
309
+ # Cancel any running validation
310
+ if self.validation_worker and self.validation_worker.isRunning():
311
+ self.validation_worker.quit()
312
+ self.validation_worker.wait()
313
+
314
+ path = self.txt_gnubg_path.text()
315
+ if not path:
316
+ self.lbl_gnubg_status_icon.setPixmap(qta.icon('fa6s.circle', color='#6c7086').pixmap(18, 18))
317
+ self.lbl_gnubg_status_text.setText("Not configured")
318
+ self.lbl_gnubg_status_text.setStyleSheet("")
319
+ return
320
+
321
+ # Show loading state
322
+ self.lbl_gnubg_status_icon.setPixmap(qta.icon('fa6s.spinner', color='#6c7086').pixmap(18, 18))
323
+ self.lbl_gnubg_status_text.setText("Validating...")
324
+ self.lbl_gnubg_status_text.setStyleSheet("color: gray;")
325
+
326
+ # Start validation in background thread
327
+ self.validation_worker = GnuBGValidationWorker(path)
328
+ self.validation_worker.validation_complete.connect(self._on_validation_complete)
329
+ self.validation_worker.start()
330
+
331
+ def _on_validation_complete(self, status_text: str, status_type: str):
332
+ """Handle validation completion."""
333
+ # Determine icon based on status type
334
+ if status_type == "valid":
335
+ icon = qta.icon('fa6s.circle-check', color='#a6e3a1')
336
+ elif status_type == "warning":
337
+ icon = qta.icon('fa6s.triangle-exclamation', color='#fab387')
338
+ elif status_type == "error":
339
+ icon = qta.icon('fa6s.circle-xmark', color='#f38ba8')
340
+ else:
341
+ icon = None
342
+
343
+ # Set icon and text separately
344
+ if icon:
345
+ self.lbl_gnubg_status_icon.setPixmap(icon.pixmap(18, 18))
346
+ self.lbl_gnubg_status_text.setText(status_text)
347
+ self.lbl_gnubg_status_text.setStyleSheet("")
348
+
349
+ def accept(self):
350
+ """Save settings and close dialog."""
351
+ # Update settings object
352
+ self.settings.deck_name = self.txt_deck_name.text()
353
+ self.settings.export_method = (
354
+ "ankiconnect" if self.cmb_export_method.currentIndex() == 0 else "apkg"
355
+ )
356
+ self.settings.color_scheme = self.cmb_color_scheme.currentText()
357
+ self.settings.board_orientation = self.cmb_board_orientation.currentData()
358
+ self.settings.show_options = self.chk_show_options.isChecked()
359
+ self.settings.interactive_moves = self.chk_interactive_moves.isChecked()
360
+ self.settings.gnubg_path = self.txt_gnubg_path.text() or None
361
+ self.settings.gnubg_analysis_ply = self.cmb_gnubg_ply.currentIndex()
362
+ self.settings.generate_score_matrix = self.chk_generate_score_matrix.isChecked()
363
+
364
+ # Emit signal
365
+ self.settings_changed.emit(self.settings)
366
+
367
+ super().accept()
368
+
369
+ def reject(self):
370
+ """Restore original settings and close dialog."""
371
+ # Clean up validation worker
372
+ if self.validation_worker and self.validation_worker.isRunning():
373
+ self.validation_worker.quit()
374
+ self.validation_worker.wait()
375
+ # Don't modify settings object
376
+ super().reject()
377
+
378
+ def closeEvent(self, event):
379
+ """Clean up when dialog is closed."""
380
+ # Clean up validation worker
381
+ if self.validation_worker and self.validation_worker.isRunning():
382
+ self.validation_worker.quit()
383
+ self.validation_worker.wait()
384
+ super().closeEvent(event)