ankigammon 1.0.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. ankigammon/__init__.py +7 -0
  2. ankigammon/__main__.py +6 -0
  3. ankigammon/analysis/__init__.py +13 -0
  4. ankigammon/analysis/score_matrix.py +391 -0
  5. ankigammon/anki/__init__.py +6 -0
  6. ankigammon/anki/ankiconnect.py +216 -0
  7. ankigammon/anki/apkg_exporter.py +111 -0
  8. ankigammon/anki/card_generator.py +1325 -0
  9. ankigammon/anki/card_styles.py +1054 -0
  10. ankigammon/gui/__init__.py +8 -0
  11. ankigammon/gui/app.py +192 -0
  12. ankigammon/gui/dialogs/__init__.py +10 -0
  13. ankigammon/gui/dialogs/export_dialog.py +594 -0
  14. ankigammon/gui/dialogs/import_options_dialog.py +201 -0
  15. ankigammon/gui/dialogs/input_dialog.py +762 -0
  16. ankigammon/gui/dialogs/note_dialog.py +93 -0
  17. ankigammon/gui/dialogs/settings_dialog.py +420 -0
  18. ankigammon/gui/dialogs/update_dialog.py +373 -0
  19. ankigammon/gui/format_detector.py +377 -0
  20. ankigammon/gui/main_window.py +1611 -0
  21. ankigammon/gui/resources/down-arrow.svg +3 -0
  22. ankigammon/gui/resources/icon.icns +0 -0
  23. ankigammon/gui/resources/icon.ico +0 -0
  24. ankigammon/gui/resources/icon.png +0 -0
  25. ankigammon/gui/resources/style.qss +402 -0
  26. ankigammon/gui/resources.py +26 -0
  27. ankigammon/gui/update_checker.py +259 -0
  28. ankigammon/gui/widgets/__init__.py +8 -0
  29. ankigammon/gui/widgets/position_list.py +166 -0
  30. ankigammon/gui/widgets/smart_input.py +268 -0
  31. ankigammon/models.py +356 -0
  32. ankigammon/parsers/__init__.py +7 -0
  33. ankigammon/parsers/gnubg_match_parser.py +1094 -0
  34. ankigammon/parsers/gnubg_parser.py +468 -0
  35. ankigammon/parsers/sgf_parser.py +290 -0
  36. ankigammon/parsers/xg_binary_parser.py +1097 -0
  37. ankigammon/parsers/xg_text_parser.py +688 -0
  38. ankigammon/renderer/__init__.py +5 -0
  39. ankigammon/renderer/animation_controller.py +391 -0
  40. ankigammon/renderer/animation_helper.py +191 -0
  41. ankigammon/renderer/color_schemes.py +145 -0
  42. ankigammon/renderer/svg_board_renderer.py +791 -0
  43. ankigammon/settings.py +315 -0
  44. ankigammon/thirdparty/__init__.py +7 -0
  45. ankigammon/thirdparty/xgdatatools/__init__.py +17 -0
  46. ankigammon/thirdparty/xgdatatools/xgimport.py +160 -0
  47. ankigammon/thirdparty/xgdatatools/xgstruct.py +1032 -0
  48. ankigammon/thirdparty/xgdatatools/xgutils.py +118 -0
  49. ankigammon/thirdparty/xgdatatools/xgzarc.py +260 -0
  50. ankigammon/utils/__init__.py +13 -0
  51. ankigammon/utils/gnubg_analyzer.py +590 -0
  52. ankigammon/utils/gnuid.py +577 -0
  53. ankigammon/utils/move_parser.py +204 -0
  54. ankigammon/utils/ogid.py +326 -0
  55. ankigammon/utils/xgid.py +387 -0
  56. ankigammon-1.0.6.dist-info/METADATA +352 -0
  57. ankigammon-1.0.6.dist-info/RECORD +61 -0
  58. ankigammon-1.0.6.dist-info/WHEEL +5 -0
  59. ankigammon-1.0.6.dist-info/entry_points.txt +2 -0
  60. ankigammon-1.0.6.dist-info/licenses/LICENSE +21 -0
  61. ankigammon-1.0.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,166 @@
1
+ """
2
+ Widget for displaying list of parsed positions.
3
+ """
4
+
5
+ from typing import List, Optional
6
+ from PySide6.QtWidgets import (
7
+ QListWidget, QListWidgetItem, QWidget, QVBoxLayout, QLabel, QMenu, QMessageBox,
8
+ QDialog, QAbstractItemView
9
+ )
10
+ from PySide6.QtCore import Qt, Signal, Slot
11
+ from PySide6.QtGui import QIcon, QAction, QKeyEvent
12
+ import qtawesome as qta
13
+
14
+ from ankigammon.models import Decision, DecisionType, Player
15
+ from ankigammon.gui.dialogs.note_dialog import NoteEditDialog
16
+
17
+
18
+ class PositionListItem(QListWidgetItem):
19
+ """Custom list item for a decision/position."""
20
+
21
+ def __init__(self, decision: Decision, index: int):
22
+ super().__init__()
23
+ self.decision = decision
24
+ self.index = index
25
+
26
+ self.setText(f"#{index + 1}: {decision.get_short_display_text()}")
27
+
28
+ tooltip = decision.get_metadata_text()
29
+ if decision.note:
30
+ tooltip += f"\n\nNote: {decision.note}"
31
+ self.setToolTip(tooltip)
32
+
33
+
34
+ class PositionListWidget(QListWidget):
35
+ """
36
+ List widget for displaying parsed positions.
37
+
38
+ Signals:
39
+ position_selected(Decision): Emitted when user selects a position
40
+ positions_deleted(list): Emitted when user deletes position(s) - List[int] of indices
41
+ """
42
+
43
+ position_selected = Signal(Decision)
44
+ positions_deleted = Signal(list)
45
+
46
+ def __init__(self, parent: Optional[QWidget] = None):
47
+ super().__init__(parent)
48
+ self.decisions: List[Decision] = []
49
+
50
+ self.setVerticalScrollMode(QListWidget.ScrollPerPixel)
51
+ self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
52
+ self.setContextMenuPolicy(Qt.CustomContextMenu)
53
+ self.customContextMenuRequested.connect(self._show_context_menu)
54
+ self.currentItemChanged.connect(self._on_selection_changed)
55
+
56
+ def set_decisions(self, decisions: List[Decision]):
57
+ """Load decisions into the list."""
58
+ self.clear()
59
+ self.decisions = decisions
60
+
61
+ for i, decision in enumerate(decisions):
62
+ item = PositionListItem(decision, i)
63
+ self.addItem(item)
64
+
65
+ if decisions:
66
+ self.setCurrentRow(0)
67
+
68
+ @Slot(QListWidgetItem, QListWidgetItem)
69
+ def _on_selection_changed(self, current, previous):
70
+ """Handle selection change."""
71
+ if current and isinstance(current, PositionListItem):
72
+ self.position_selected.emit(current.decision)
73
+
74
+ @Slot()
75
+ def _show_context_menu(self, pos):
76
+ """Show context menu for delete action."""
77
+ selected_items = self.selectedItems()
78
+
79
+ if not selected_items:
80
+ return
81
+
82
+ menu = QMenu(self)
83
+ menu.setCursor(Qt.PointingHandCursor)
84
+
85
+ if len(selected_items) == 1:
86
+ item = selected_items[0]
87
+ edit_note_action = QAction(
88
+ qta.icon('fa6s.note-sticky', color='#f9e2af'),
89
+ "Edit Note...",
90
+ self
91
+ )
92
+ edit_note_action.triggered.connect(lambda: self._edit_note(item))
93
+ menu.addAction(edit_note_action)
94
+
95
+ menu.addSeparator()
96
+
97
+ delete_text = "Delete" if len(selected_items) == 1 else f"Delete {len(selected_items)} Items"
98
+ delete_action = QAction(
99
+ qta.icon('fa6s.trash', color='#f38ba8'),
100
+ delete_text,
101
+ self
102
+ )
103
+ delete_action.triggered.connect(self._delete_selected_items)
104
+ menu.addAction(delete_action)
105
+
106
+ menu.exec(self.mapToGlobal(pos))
107
+
108
+ def _edit_note(self, item: PositionListItem):
109
+ """Edit the note for a position."""
110
+ current_note = item.decision.note or ""
111
+
112
+ dialog = NoteEditDialog(current_note, f"Note for position #{item.index + 1}:", self)
113
+
114
+ if dialog.exec() == QDialog.Accepted:
115
+ new_note = dialog.get_text()
116
+
117
+ item.decision.note = new_note.strip() if new_note.strip() else None
118
+
119
+ tooltip = item.decision.get_metadata_text()
120
+ if item.decision.note:
121
+ tooltip += f"\n\nNote: {item.decision.note}"
122
+ item.setToolTip(tooltip)
123
+
124
+ def _delete_selected_items(self):
125
+ """Delete selected items with confirmation."""
126
+ selected_items = self.selectedItems()
127
+
128
+ if not selected_items:
129
+ return
130
+
131
+ if len(selected_items) == 1:
132
+ item = selected_items[0]
133
+ message = f"Delete position #{item.index + 1}?\n\n{item.decision.get_short_display_text()}"
134
+ title = "Delete Position"
135
+ else:
136
+ message = f"Delete {len(selected_items)} selected position(s)?"
137
+ title = "Delete Positions"
138
+
139
+ reply = QMessageBox.question(
140
+ self,
141
+ title,
142
+ message,
143
+ QMessageBox.Yes | QMessageBox.No
144
+ )
145
+
146
+ if reply == QMessageBox.Yes:
147
+ indices_to_delete = sorted([item.index for item in selected_items], reverse=True)
148
+ rows_to_delete = sorted([self.row(item) for item in selected_items], reverse=True)
149
+ for row in rows_to_delete:
150
+ self.takeItem(row)
151
+
152
+ self.positions_deleted.emit(indices_to_delete)
153
+
154
+ def keyPressEvent(self, event: QKeyEvent):
155
+ """Handle keyboard shortcuts for deletion."""
156
+ if event.key() in (Qt.Key_Delete, Qt.Key_Backspace):
157
+ self._delete_selected_items()
158
+ else:
159
+ super().keyPressEvent(event)
160
+
161
+ def get_selected_decision(self) -> Optional[Decision]:
162
+ """Get currently selected decision."""
163
+ item = self.currentItem()
164
+ if isinstance(item, PositionListItem):
165
+ return item.decision
166
+ return None
@@ -0,0 +1,268 @@
1
+ """
2
+ Smart input widget with auto-detection and visual feedback.
3
+ """
4
+
5
+ from PySide6.QtWidgets import (
6
+ QWidget, QVBoxLayout, QHBoxLayout, QPlainTextEdit,
7
+ QLabel, QFrame, QPushButton
8
+ )
9
+ from PySide6.QtCore import Qt, Signal, QTimer
10
+ from PySide6.QtGui import QFont
11
+ import qtawesome as qta
12
+
13
+ from ankigammon.settings import Settings
14
+ from ankigammon.gui.format_detector import FormatDetector, DetectionResult, InputFormat
15
+
16
+
17
+ class SmartInputWidget(QWidget):
18
+ """
19
+ Input widget with intelligent format detection.
20
+
21
+ Signals:
22
+ format_detected(DetectionResult): Emitted when format is detected
23
+ """
24
+
25
+ format_detected = Signal(DetectionResult)
26
+
27
+ def __init__(self, settings: Settings, parent=None):
28
+ super().__init__(parent)
29
+ self.settings = settings
30
+ self.detector = FormatDetector(settings)
31
+ self.last_result = None
32
+
33
+ # Debounce timer for detection
34
+ self.detection_timer = QTimer()
35
+ self.detection_timer.setSingleShot(True)
36
+ self.detection_timer.timeout.connect(self._run_detection)
37
+
38
+ self._setup_ui()
39
+
40
+ def _setup_ui(self):
41
+ """Initialize the user interface."""
42
+ layout = QVBoxLayout(self)
43
+ layout.setContentsMargins(0, 0, 0, 0)
44
+ layout.setSpacing(12)
45
+
46
+ # Label
47
+ label = QLabel("Input Text:")
48
+ label.setStyleSheet("font-weight: 600; color: #cdd6f4;")
49
+ layout.addWidget(label)
50
+
51
+ # Text input area
52
+ self.text_area = QPlainTextEdit()
53
+ self.text_area.setPlaceholderText(
54
+ "Paste XG analysis or position IDs here...\n\n"
55
+ "Examples:\n"
56
+ "• Full XG analysis (Ctrl+C from eXtreme Gammon)\n"
57
+ "• XGID, OGID, or GNUID position IDs (one per line)\n"
58
+ "• Mixed formats supported - auto-detected"
59
+ )
60
+
61
+ # Use fixed-width font for better XGID readability
62
+ font = QFont("Consolas", 10)
63
+ if not font.exactMatch():
64
+ font = QFont("Courier New", 10)
65
+ self.text_area.setFont(font)
66
+
67
+ self.text_area.setLineWrapMode(QPlainTextEdit.NoWrap)
68
+ self.text_area.setTabChangesFocus(True)
69
+ self.text_area.setMinimumHeight(300)
70
+
71
+ # Dark theme styling
72
+ self.text_area.setStyleSheet("""
73
+ QPlainTextEdit {
74
+ background-color: #1e1e2e;
75
+ color: #cdd6f4;
76
+ border: 2px solid #313244;
77
+ border-radius: 8px;
78
+ padding: 12px;
79
+ selection-background-color: #585b70;
80
+ }
81
+ QPlainTextEdit:focus {
82
+ border-color: #89b4fa;
83
+ }
84
+ """)
85
+
86
+ self.text_area.textChanged.connect(self._on_text_changed)
87
+ layout.addWidget(self.text_area, stretch=1)
88
+
89
+ # Feedback container (outer wrapper with rounded corners)
90
+ self.feedback_container = QWidget()
91
+ self.feedback_container.setStyleSheet("""
92
+ QWidget {
93
+ background-color: #313244;
94
+ border-radius: 6px;
95
+ }
96
+ """)
97
+ self.feedback_container.setVisible(False)
98
+
99
+ container_layout = QHBoxLayout(self.feedback_container)
100
+ container_layout.setContentsMargins(0, 0, 0, 0)
101
+ container_layout.setSpacing(0)
102
+
103
+ # Left accent bar (separate widget - avoids Qt border-left + border-radius bug)
104
+ self.accent_bar = QWidget()
105
+ self.accent_bar.setFixedWidth(4)
106
+ self.accent_bar.setStyleSheet("background-color: #6c7086;")
107
+ container_layout.addWidget(self.accent_bar)
108
+
109
+ # Inner content panel
110
+ self.feedback_panel = QWidget()
111
+ self.feedback_panel.setStyleSheet("background-color: transparent;")
112
+ container_layout.addWidget(self.feedback_panel, stretch=1)
113
+
114
+ feedback_layout = QHBoxLayout(self.feedback_panel)
115
+ feedback_layout.setContentsMargins(12, 12, 12, 12)
116
+ feedback_layout.setSpacing(12) # Add spacing between icon and text
117
+
118
+ # Icon
119
+ self.feedback_icon = QLabel()
120
+ self.feedback_icon.setPixmap(qta.icon('fa6s.circle-info', color='#60a5fa').pixmap(20, 20))
121
+ self.feedback_icon.setMinimumSize(20, 20) # Minimum size instead of fixed
122
+ self.feedback_icon.setAlignment(Qt.AlignCenter)
123
+ self.feedback_icon.setScaledContents(False) # Prevent pixmap stretching/artifacts
124
+ feedback_layout.addWidget(self.feedback_icon, alignment=Qt.AlignTop)
125
+
126
+ # Text content
127
+ text_content = QVBoxLayout()
128
+ text_content.setSpacing(4)
129
+
130
+ self.feedback_title = QLabel()
131
+ self.feedback_title.setStyleSheet("font-weight: 600; font-size: 13px;")
132
+ text_content.addWidget(self.feedback_title)
133
+
134
+ self.feedback_detail = QLabel()
135
+ self.feedback_detail.setStyleSheet("font-size: 12px; color: #a6adc8;")
136
+ self.feedback_detail.setWordWrap(True)
137
+ text_content.addWidget(self.feedback_detail)
138
+
139
+ feedback_layout.addLayout(text_content, stretch=1)
140
+
141
+ # Override button
142
+ self.override_btn = QPushButton("Override...")
143
+ self.override_btn.setVisible(False)
144
+ self.override_btn.setStyleSheet("""
145
+ QPushButton {
146
+ background-color: #45475a;
147
+ color: #cdd6f4;
148
+ border: none;
149
+ padding: 6px 12px;
150
+ border-radius: 4px;
151
+ font-size: 11px;
152
+ }
153
+ QPushButton:hover {
154
+ background-color: #585b70;
155
+ }
156
+ """)
157
+ self.override_btn.setCursor(Qt.PointingHandCursor)
158
+ feedback_layout.addWidget(self.override_btn, alignment=Qt.AlignTop)
159
+
160
+ layout.addWidget(self.feedback_container)
161
+
162
+ def _on_text_changed(self):
163
+ """Handle text change (debounced)."""
164
+ # Cancel previous timer, start new one
165
+ self.detection_timer.stop()
166
+ self.detection_timer.start(500) # 500ms debounce
167
+
168
+ def _run_detection(self):
169
+ """Run format detection (after debounce)."""
170
+ text = self.text_area.toPlainText()
171
+
172
+ if not text.strip():
173
+ self.feedback_container.setVisible(False)
174
+ self.last_result = None
175
+ return
176
+
177
+ result = self.detector.detect(text)
178
+ self.last_result = result
179
+ self._update_feedback_ui(result)
180
+ self.format_detected.emit(result)
181
+
182
+ def _set_feedback_icon(self, icon_name: str, color: str):
183
+ """Helper to properly set feedback icon."""
184
+ self.feedback_icon.clear() # Clear old pixmap first
185
+ self.feedback_icon.setPixmap(qta.icon(icon_name, color=color).pixmap(20, 20))
186
+
187
+ def _set_feedback_style(self, bg_color: str, accent_color: str):
188
+ """Helper to properly set feedback panel style (avoids Qt border-left + border-radius bug)."""
189
+ self.feedback_container.setStyleSheet(f"""
190
+ QWidget {{
191
+ background-color: {bg_color};
192
+ border-radius: 6px;
193
+ }}
194
+ """)
195
+ self.accent_bar.setStyleSheet(f"background-color: {accent_color};")
196
+
197
+ def _update_feedback_ui(self, result: DetectionResult):
198
+ """Update feedback panel with detection result."""
199
+ self.feedback_container.setVisible(True)
200
+
201
+ # Update icon and styling based on result
202
+ if result.format == InputFormat.POSITION_IDS:
203
+ if result.warnings:
204
+ # Warning state (GnuBG not configured)
205
+ self._set_feedback_icon('fa6s.triangle-exclamation', '#fab387')
206
+ self._set_feedback_style('#2e2416', '#f9e2af')
207
+ self.feedback_title.setStyleSheet("font-weight: 600; font-size: 13px; color: #f9e2af;")
208
+ self.feedback_title.setText(f"{result.details}")
209
+ self.feedback_detail.setText(
210
+ result.warnings[0] + "\nConfigure GnuBG in Settings to analyze positions."
211
+ )
212
+ else:
213
+ # Success state
214
+ self._set_feedback_icon('fa6s.circle-check', '#a6e3a1')
215
+ self._set_feedback_style('#1e2d1f', '#a6e3a1')
216
+ self.feedback_title.setStyleSheet("font-weight: 600; font-size: 13px; color: #a6e3a1;")
217
+ self.feedback_title.setText(f"{result.details}")
218
+
219
+ # Calculate estimated time
220
+ est_seconds = result.count * 5 # ~5 seconds per position
221
+ self.feedback_detail.setText(
222
+ f"Will analyze with GnuBG ({self.settings.gnubg_analysis_ply}-ply)\n"
223
+ f"Estimated time: ~{est_seconds} seconds"
224
+ )
225
+
226
+ elif result.format == InputFormat.FULL_ANALYSIS:
227
+ # Success state (blue)
228
+ self._set_feedback_icon('fa6s.circle-check', '#89b4fa')
229
+ self._set_feedback_style('#1e2633', '#89b4fa')
230
+ self.feedback_title.setStyleSheet("font-weight: 600; font-size: 13px; color: #89b4fa;")
231
+ self.feedback_title.setText(f"{result.details}")
232
+
233
+ # Show preview of first position if available
234
+ preview_text = "Ready to add to export list"
235
+ if result.position_previews:
236
+ preview_text += f"\nFirst position: {result.position_previews[0]}"
237
+
238
+ if result.warnings:
239
+ preview_text += f"\n{result.warnings[0]}"
240
+
241
+ self.feedback_detail.setText(preview_text)
242
+
243
+ else:
244
+ # Unknown/error state
245
+ self._set_feedback_icon('fa6s.triangle-exclamation', '#fab387')
246
+ self._set_feedback_style('#2e2416', '#fab387')
247
+ self.feedback_title.setStyleSheet("font-weight: 600; font-size: 13px; color: #fab387;")
248
+ self.feedback_title.setText(f"{result.details}")
249
+
250
+ warning_text = "Paste XGID/OGID/GNUID or full XG analysis text"
251
+ if result.warnings:
252
+ warning_text = "\n".join(result.warnings)
253
+
254
+ self.feedback_detail.setText(warning_text)
255
+
256
+ def get_text(self) -> str:
257
+ """Get current input text."""
258
+ return self.text_area.toPlainText()
259
+
260
+ def clear_text(self):
261
+ """Clear input text."""
262
+ self.text_area.clear()
263
+ self.feedback_container.setVisible(False)
264
+ self.last_result = None
265
+
266
+ def get_last_result(self) -> DetectionResult:
267
+ """Get last detection result."""
268
+ return self.last_result