ankigammon 1.0.5__tar.gz → 1.0.10__tar.gz

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 (73) hide show
  1. {ankigammon-1.0.5 → ankigammon-1.0.10}/PKG-INFO +1 -1
  2. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/__init__.py +1 -1
  3. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/gui/app.py +6 -0
  4. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/gui/dialogs/__init__.py +2 -1
  5. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/gui/dialogs/import_options_dialog.py +34 -20
  6. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/gui/dialogs/input_dialog.py +14 -13
  7. ankigammon-1.0.10/ankigammon/gui/dialogs/shortcuts_dialog.py +192 -0
  8. ankigammon-1.0.10/ankigammon/gui/dialogs/update_dialog.py +374 -0
  9. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/gui/main_window.py +150 -32
  10. ankigammon-1.0.10/ankigammon/gui/silent_messagebox.py +117 -0
  11. ankigammon-1.0.10/ankigammon/gui/update_checker.py +301 -0
  12. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/gui/widgets/position_list.py +4 -3
  13. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/parsers/gnubg_parser.py +26 -25
  14. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/settings.py +52 -8
  15. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/utils/xgid.py +9 -10
  16. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon.egg-info/PKG-INFO +1 -1
  17. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon.egg-info/SOURCES.txt +4 -0
  18. {ankigammon-1.0.5 → ankigammon-1.0.10}/pyproject.toml +1 -1
  19. {ankigammon-1.0.5 → ankigammon-1.0.10}/tests/test_basic.py +3 -1
  20. {ankigammon-1.0.5 → ankigammon-1.0.10}/LICENSE +0 -0
  21. {ankigammon-1.0.5 → ankigammon-1.0.10}/README.md +0 -0
  22. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/__main__.py +0 -0
  23. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/analysis/__init__.py +0 -0
  24. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/analysis/score_matrix.py +0 -0
  25. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/anki/__init__.py +0 -0
  26. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/anki/ankiconnect.py +0 -0
  27. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/anki/apkg_exporter.py +0 -0
  28. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/anki/card_generator.py +0 -0
  29. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/anki/card_styles.py +0 -0
  30. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/gui/__init__.py +0 -0
  31. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/gui/dialogs/export_dialog.py +0 -0
  32. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/gui/dialogs/note_dialog.py +0 -0
  33. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/gui/dialogs/settings_dialog.py +0 -0
  34. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/gui/format_detector.py +0 -0
  35. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/gui/resources/down-arrow.svg +0 -0
  36. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/gui/resources/icon.icns +0 -0
  37. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/gui/resources/icon.ico +0 -0
  38. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/gui/resources/icon.png +0 -0
  39. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/gui/resources/style.qss +0 -0
  40. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/gui/resources.py +0 -0
  41. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/gui/widgets/__init__.py +0 -0
  42. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/gui/widgets/smart_input.py +0 -0
  43. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/models.py +0 -0
  44. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/parsers/__init__.py +0 -0
  45. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/parsers/gnubg_match_parser.py +0 -0
  46. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/parsers/sgf_parser.py +0 -0
  47. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/parsers/xg_binary_parser.py +0 -0
  48. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/parsers/xg_text_parser.py +0 -0
  49. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/renderer/__init__.py +0 -0
  50. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/renderer/animation_controller.py +0 -0
  51. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/renderer/animation_helper.py +0 -0
  52. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/renderer/color_schemes.py +0 -0
  53. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/renderer/svg_board_renderer.py +0 -0
  54. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/thirdparty/__init__.py +0 -0
  55. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/thirdparty/xgdatatools/__init__.py +0 -0
  56. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/thirdparty/xgdatatools/xgimport.py +0 -0
  57. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/thirdparty/xgdatatools/xgstruct.py +0 -0
  58. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/thirdparty/xgdatatools/xgutils.py +0 -0
  59. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/thirdparty/xgdatatools/xgzarc.py +0 -0
  60. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/utils/__init__.py +0 -0
  61. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/utils/gnubg_analyzer.py +0 -0
  62. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/utils/gnuid.py +0 -0
  63. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/utils/move_parser.py +0 -0
  64. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon/utils/ogid.py +0 -0
  65. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon.egg-info/dependency_links.txt +0 -0
  66. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon.egg-info/entry_points.txt +0 -0
  67. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon.egg-info/requires.txt +0 -0
  68. {ankigammon-1.0.5 → ankigammon-1.0.10}/ankigammon.egg-info/top_level.txt +0 -0
  69. {ankigammon-1.0.5 → ankigammon-1.0.10}/setup.cfg +0 -0
  70. {ankigammon-1.0.5 → ankigammon-1.0.10}/tests/test_format_detector.py +0 -0
  71. {ankigammon-1.0.5 → ankigammon-1.0.10}/tests/test_import_dialog_player_matching.py +0 -0
  72. {ankigammon-1.0.5 → ankigammon-1.0.10}/tests/test_played_move_injection.py +0 -0
  73. {ankigammon-1.0.5 → ankigammon-1.0.10}/tests/test_settings.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ankigammon
3
- Version: 1.0.5
3
+ Version: 1.0.10
4
4
  Summary: Convert eXtreme Gammon backgammon analysis into Anki flashcards
5
5
  Author-email: AnkiGammon Contributors <admin@ankigammon.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  """AnkiGammon: Convert eXtreme Gammon analysis into Anki flashcards."""
2
2
 
3
- __version__ = "1.0.5"
3
+ __version__ = "1.0.10"
4
4
 
5
5
  from ankigammon.models import Decision, Move, Position, CubeState
6
6
 
@@ -127,7 +127,13 @@ def main():
127
127
  Returns:
128
128
  int: Application exit code
129
129
  """
130
+ import multiprocessing
130
131
  import logging
132
+
133
+ # CRITICAL: Required for PyInstaller + multiprocessing on Windows
134
+ # Without this, worker processes will spawn new GUI windows when using ProcessPoolExecutor
135
+ multiprocessing.freeze_support()
136
+
131
137
  logging.basicConfig(
132
138
  level=logging.INFO,
133
139
  format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
@@ -2,9 +2,10 @@
2
2
  GUI dialogs package.
3
3
  """
4
4
 
5
- __all__ = ['SettingsDialog', 'ExportDialog', 'InputDialog', 'ImportOptionsDialog']
5
+ __all__ = ['SettingsDialog', 'ExportDialog', 'InputDialog', 'ImportOptionsDialog', 'ShortcutsDialog']
6
6
 
7
7
  from .settings_dialog import SettingsDialog
8
8
  from .export_dialog import ExportDialog
9
9
  from .input_dialog import InputDialog
10
10
  from .import_options_dialog import ImportOptionsDialog
11
+ from .shortcuts_dialog import ShortcutsDialog
@@ -19,15 +19,15 @@ class ImportOptionsDialog(QDialog):
19
19
  Dialog for configuring XG import filtering options.
20
20
 
21
21
  Allows users to filter imported positions by:
22
- - Error threshold (only import mistakes above this threshold)
22
+ - Error thresholds (separate for checker play and cube decisions)
23
23
  - Player selection (import mistakes from X, O, or both)
24
24
 
25
25
  Signals:
26
- options_accepted(float, bool, bool): Emitted when user accepts
27
- Args: (threshold, include_player_x, include_player_o)
26
+ options_accepted(float, float, bool, bool): Emitted when user accepts
27
+ Args: (checker_threshold, cube_threshold, include_player_x, include_player_o)
28
28
  """
29
29
 
30
- options_accepted = Signal(float, bool, bool)
30
+ options_accepted = Signal(float, float, bool, bool)
31
31
 
32
32
  def __init__(
33
33
  self,
@@ -76,18 +76,28 @@ class ImportOptionsDialog(QDialog):
76
76
 
77
77
  def _create_threshold_group(self) -> QGroupBox:
78
78
  """Create error threshold settings group."""
79
- group = QGroupBox("Error Threshold")
79
+ group = QGroupBox("Error Thresholds")
80
80
  form = QFormLayout(group)
81
81
 
82
- # Threshold spinbox
83
- self.spin_threshold = QDoubleSpinBox()
84
- self.spin_threshold.setMinimum(0.000)
85
- self.spin_threshold.setMaximum(1.000)
86
- self.spin_threshold.setSingleStep(0.001)
87
- self.spin_threshold.setDecimals(3)
88
- self.spin_threshold.setValue(0.080)
89
- self.spin_threshold.setCursor(Qt.PointingHandCursor)
90
- form.addRow("Minimum Error:", self.spin_threshold)
82
+ # Checker play threshold spinbox
83
+ self.spin_checker_threshold = QDoubleSpinBox()
84
+ self.spin_checker_threshold.setMinimum(0.000)
85
+ self.spin_checker_threshold.setMaximum(1.000)
86
+ self.spin_checker_threshold.setSingleStep(0.001)
87
+ self.spin_checker_threshold.setDecimals(3)
88
+ self.spin_checker_threshold.setValue(0.080)
89
+ self.spin_checker_threshold.setCursor(Qt.PointingHandCursor)
90
+ form.addRow("Checker Play:", self.spin_checker_threshold)
91
+
92
+ # Cube decision threshold spinbox
93
+ self.spin_cube_threshold = QDoubleSpinBox()
94
+ self.spin_cube_threshold.setMinimum(0.000)
95
+ self.spin_cube_threshold.setMaximum(1.000)
96
+ self.spin_cube_threshold.setSingleStep(0.001)
97
+ self.spin_cube_threshold.setDecimals(3)
98
+ self.spin_cube_threshold.setValue(0.080)
99
+ self.spin_cube_threshold.setCursor(Qt.PointingHandCursor)
100
+ form.addRow("Cube Decisions:", self.spin_cube_threshold)
91
101
 
92
102
  return group
93
103
 
@@ -126,7 +136,8 @@ class ImportOptionsDialog(QDialog):
126
136
 
127
137
  def _load_settings(self):
128
138
  """Load current settings into widgets, matching by player name."""
129
- self.spin_threshold.setValue(self.settings.import_error_threshold)
139
+ self.spin_checker_threshold.setValue(self.settings.import_checker_error_threshold)
140
+ self.spin_cube_threshold.setValue(self.settings.import_cube_error_threshold)
130
141
 
131
142
  selected_names = self.settings.import_selected_player_names
132
143
  selected_names_lower = [name.lower() for name in selected_names]
@@ -165,7 +176,8 @@ class ImportOptionsDialog(QDialog):
165
176
 
166
177
  def accept(self):
167
178
  """Save settings and emit options."""
168
- self.settings.import_error_threshold = self.spin_threshold.value()
179
+ self.settings.import_checker_error_threshold = self.spin_checker_threshold.value()
180
+ self.settings.import_cube_error_threshold = self.spin_cube_threshold.value()
169
181
 
170
182
  selected_names = []
171
183
  if self.chk_player_o.isChecked():
@@ -180,22 +192,24 @@ class ImportOptionsDialog(QDialog):
180
192
  self.settings.import_include_player_o = self.chk_player_o.isChecked()
181
193
 
182
194
  self.options_accepted.emit(
183
- self.spin_threshold.value(),
195
+ self.spin_checker_threshold.value(),
196
+ self.spin_cube_threshold.value(),
184
197
  self.chk_player_x.isChecked(),
185
198
  self.chk_player_o.isChecked()
186
199
  )
187
200
 
188
201
  super().accept()
189
202
 
190
- def get_options(self) -> tuple[float, bool, bool]:
203
+ def get_options(self) -> tuple[float, float, bool, bool]:
191
204
  """
192
205
  Get the selected import options.
193
206
 
194
207
  Returns:
195
- Tuple of (threshold, include_player_x, include_player_o)
208
+ Tuple of (checker_threshold, cube_threshold, include_player_x, include_player_o)
196
209
  """
197
210
  return (
198
- self.spin_threshold.value(),
211
+ self.spin_checker_threshold.value(),
212
+ self.spin_cube_threshold.value(),
199
213
  self.chk_player_x.isChecked(),
200
214
  self.chk_player_o.isChecked()
201
215
  )
@@ -25,6 +25,7 @@ from ankigammon.settings import Settings
25
25
  from ankigammon.models import Decision, Position, Player, CubeState, DecisionType
26
26
  from ankigammon.parsers.xg_text_parser import XGTextParser
27
27
  from ankigammon.gui.dialogs.settings_dialog import SettingsDialog
28
+ from ankigammon.gui import silent_messagebox
28
29
  from ankigammon.utils.gnuid import parse_gnuid
29
30
  from ankigammon.utils.ogid import parse_ogid
30
31
  from ankigammon.utils.xgid import parse_xgid
@@ -189,14 +190,14 @@ class PendingListWidget(QListWidget):
189
190
  title = "Delete Positions"
190
191
 
191
192
  # Show confirmation dialog
192
- reply = QMessageBox.question(
193
+ reply = silent_messagebox.question(
193
194
  self,
194
195
  title,
195
196
  message,
196
- QMessageBox.Yes | QMessageBox.No
197
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
197
198
  )
198
199
 
199
- if reply == QMessageBox.Yes:
200
+ if reply == QMessageBox.StandardButton.Yes:
200
201
  # Delete in descending order to avoid index shifting
201
202
  rows_to_delete = sorted([self.row(item) for item in selected_items], reverse=True)
202
203
  for row in rows_to_delete:
@@ -467,7 +468,7 @@ class InputDialog(QDialog):
467
468
  result = self.input_widget.get_last_result()
468
469
 
469
470
  if not text.strip():
470
- QMessageBox.warning(
471
+ silent_messagebox.warning(
471
472
  self,
472
473
  "No Input",
473
474
  "Please paste some text first"
@@ -475,7 +476,7 @@ class InputDialog(QDialog):
475
476
  return
476
477
 
477
478
  if not result or result.format == InputFormat.UNKNOWN:
478
- QMessageBox.warning(
479
+ silent_messagebox.warning(
479
480
  self,
480
481
  "Invalid Format",
481
482
  "Could not detect valid position format.\n\n"
@@ -485,14 +486,14 @@ class InputDialog(QDialog):
485
486
 
486
487
  # Check for GnuBG requirement
487
488
  if result.format == InputFormat.POSITION_IDS and not self.settings.is_gnubg_available():
488
- reply = QMessageBox.question(
489
+ reply = silent_messagebox.question(
489
490
  self,
490
491
  "GnuBG Required",
491
492
  "Position IDs require GnuBG analysis, but GnuBG is not configured.\n\n"
492
493
  "Would you like to configure GnuBG in Settings?",
493
- QMessageBox.Yes | QMessageBox.No
494
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
494
495
  )
495
- if reply == QMessageBox.Yes:
496
+ if reply == QMessageBox.StandardButton.Yes:
496
497
  # Open settings dialog to configure GnuBG
497
498
  dialog = SettingsDialog(self.settings, self)
498
499
  dialog.exec()
@@ -503,7 +504,7 @@ class InputDialog(QDialog):
503
504
  decisions = self._parse_input(text, result.format)
504
505
 
505
506
  if not decisions:
506
- QMessageBox.warning(
507
+ silent_messagebox.warning(
507
508
  self,
508
509
  "Parse Failed",
509
510
  "Could not parse any valid positions from input."
@@ -530,7 +531,7 @@ class InputDialog(QDialog):
530
531
  self.pending_list.setCurrentRow(len(self.pending_decisions) - len(decisions))
531
532
 
532
533
  except Exception as e:
533
- QMessageBox.critical(
534
+ silent_messagebox.critical(
534
535
  self,
535
536
  "Parse Error",
536
537
  f"Failed to parse input:\n{str(e)}"
@@ -646,14 +647,14 @@ class InputDialog(QDialog):
646
647
  if not self.pending_decisions:
647
648
  return
648
649
 
649
- reply = QMessageBox.question(
650
+ reply = silent_messagebox.question(
650
651
  self,
651
652
  "Clear All",
652
653
  f"Remove all {len(self.pending_decisions)} pending position(s)?",
653
- QMessageBox.Yes | QMessageBox.No
654
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
654
655
  )
655
656
 
656
- if reply == QMessageBox.Yes:
657
+ if reply == QMessageBox.StandardButton.Yes:
657
658
  self.pending_decisions.clear()
658
659
  self.pending_list.clear()
659
660
  self._update_count_label()
@@ -0,0 +1,192 @@
1
+ """
2
+ Tips and shortcuts reference dialog.
3
+ """
4
+
5
+ import sys
6
+ from PySide6.QtWidgets import (
7
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea, QWidget, QGridLayout
8
+ )
9
+ from PySide6.QtCore import Qt
10
+
11
+
12
+ class ShortcutsDialog(QDialog):
13
+ """Dialog displaying tips and keyboard shortcuts."""
14
+
15
+ def __init__(self, parent=None):
16
+ super().__init__(parent)
17
+ self.setWindowTitle("Tips & Shortcuts")
18
+ self.setMinimumSize(600, 500)
19
+ self.setModal(False) # Allow interaction with main window
20
+
21
+ self._setup_ui()
22
+
23
+ def _setup_ui(self):
24
+ """Initialize the user interface."""
25
+ layout = QVBoxLayout(self)
26
+ layout.setContentsMargins(0, 0, 0, 0)
27
+ layout.setSpacing(0)
28
+
29
+ # Scrollable content area
30
+ scroll = QScrollArea()
31
+ scroll.setWidgetResizable(True)
32
+ scroll.setStyleSheet("border: none; background-color: #181825;")
33
+
34
+ content = QWidget()
35
+ content_layout = QVBoxLayout(content)
36
+ content_layout.setContentsMargins(20, 16, 20, 16)
37
+ content_layout.setSpacing(12)
38
+
39
+ # Tips section (moved to top)
40
+ tips_title = QLabel("💡 Did you know?")
41
+ tips_title.setStyleSheet("font-size: 16px; font-weight: bold; color: #f5e0dc;")
42
+ content_layout.addWidget(tips_title)
43
+
44
+ tips = [
45
+ "Right-click any position to edit its note or delete it",
46
+ "Drag and drop .xg, .mat, or .sgf files anywhere to import",
47
+ "You can select multiple positions and delete them all at once",
48
+ ]
49
+
50
+ for tip in tips:
51
+ tip_card = QWidget()
52
+ tip_card.setStyleSheet("""
53
+ QWidget {
54
+ background-color: rgba(137, 180, 250, 0.08);
55
+ border-left: 3px solid #89b4fa;
56
+ border-radius: 4px;
57
+ padding: 10px;
58
+ }
59
+ """)
60
+ tip_layout = QHBoxLayout(tip_card)
61
+ tip_layout.setContentsMargins(10, 10, 10, 10)
62
+
63
+ tip_label = QLabel(tip)
64
+ tip_label.setStyleSheet("color: #cdd6f4; font-size: 13px; background: transparent; border: none;")
65
+ tip_label.setWordWrap(True)
66
+ tip_layout.addWidget(tip_label)
67
+
68
+ content_layout.addWidget(tip_card)
69
+
70
+ # Shortcuts section (moved below tips)
71
+ content_layout.addSpacing(8)
72
+
73
+ shortcuts_title = QLabel("⌨️ Keyboard Shortcuts")
74
+ shortcuts_title.setStyleSheet("font-size: 16px; font-weight: bold; color: #f5e0dc;")
75
+ content_layout.addWidget(shortcuts_title)
76
+
77
+ # Determine modifier key based on OS
78
+ modifier = "Cmd" if sys.platform == "darwin" else "Ctrl"
79
+
80
+ shortcuts = [
81
+ (f"{modifier}+N", "Add positions"),
82
+ (f"{modifier}+O", "Import file"),
83
+ (f"{modifier}+E", "Export to Anki"),
84
+ (f"{modifier}+,", "Open Settings"),
85
+ ("Delete", "Remove selected"),
86
+ ("Shift+Click", "Select range"),
87
+ (f"{modifier}+Click", "Select multiple"),
88
+ ]
89
+
90
+ grid = QGridLayout()
91
+ grid.setSpacing(8)
92
+ grid.setContentsMargins(0, 0, 0, 0)
93
+
94
+ for i, (key, desc) in enumerate(shortcuts):
95
+ row = i // 2
96
+ col = i % 2
97
+
98
+ card = self._create_shortcut_card(key, desc)
99
+ grid.addWidget(card, row, col)
100
+
101
+ content_layout.addLayout(grid)
102
+
103
+ content_layout.addStretch()
104
+
105
+ scroll.setWidget(content)
106
+ layout.addWidget(scroll)
107
+
108
+ # Footer with close button
109
+ footer = QWidget()
110
+ footer.setStyleSheet("background-color: #1e1e2e; padding: 16px;")
111
+ footer_layout = QHBoxLayout(footer)
112
+ footer_layout.setContentsMargins(20, 16, 20, 16)
113
+
114
+ close_hint = QLabel("Press ESC to close")
115
+ close_hint.setStyleSheet("color: #6c7086; font-size: 11px;")
116
+ footer_layout.addWidget(close_hint)
117
+
118
+ footer_layout.addStretch()
119
+
120
+ close_btn = QPushButton("Got it")
121
+ close_btn.setDefault(True)
122
+ close_btn.setStyleSheet("""
123
+ QPushButton {
124
+ background-color: #89b4fa;
125
+ color: #1e1e2e;
126
+ border: none;
127
+ padding: 8px 24px;
128
+ border-radius: 6px;
129
+ font-weight: 600;
130
+ }
131
+ QPushButton:hover {
132
+ background-color: #74c7ec;
133
+ }
134
+ """)
135
+ close_btn.setCursor(Qt.PointingHandCursor)
136
+ close_btn.clicked.connect(self.accept)
137
+ footer_layout.addWidget(close_btn)
138
+
139
+ layout.addWidget(footer)
140
+
141
+ def _create_shortcut_card(self, key: str, description: str) -> QWidget:
142
+ """Create a card widget for a keyboard shortcut."""
143
+ card = QWidget()
144
+ card.setStyleSheet("""
145
+ QWidget {
146
+ background-color: #262637;
147
+ border-radius: 6px;
148
+ padding: 10px;
149
+ }
150
+ """)
151
+
152
+ card_layout = QHBoxLayout(card)
153
+ card_layout.setContentsMargins(10, 10, 10, 10)
154
+ card_layout.setSpacing(10)
155
+
156
+ # Key badge
157
+ key_badge = QLabel(key)
158
+ key_badge.setStyleSheet("""
159
+ QLabel {
160
+ background-color: #45475a;
161
+ color: #f5e0dc;
162
+ padding: 4px 10px;
163
+ border-radius: 4px;
164
+ font-family: 'Consolas', monospace;
165
+ font-size: 11px;
166
+ font-weight: bold;
167
+ border: none;
168
+ }
169
+ """)
170
+ key_badge.setAlignment(Qt.AlignCenter)
171
+ card_layout.addWidget(key_badge)
172
+
173
+ # Description
174
+ desc_label = QLabel(description)
175
+ desc_label.setStyleSheet("""
176
+ QLabel {
177
+ color: #cdd6f4;
178
+ font-size: 13px;
179
+ background: transparent;
180
+ border: none;
181
+ }
182
+ """)
183
+ card_layout.addWidget(desc_label, stretch=1)
184
+
185
+ return card
186
+
187
+ def keyPressEvent(self, event):
188
+ """Close on ESC key."""
189
+ if event.key() == Qt.Key_Escape:
190
+ self.accept()
191
+ else:
192
+ super().keyPressEvent(event)