pysfi 0.1.7__py3-none-any.whl → 0.1.11__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 (55) hide show
  1. {pysfi-0.1.7.dist-info → pysfi-0.1.11.dist-info}/METADATA +11 -9
  2. pysfi-0.1.11.dist-info/RECORD +60 -0
  3. pysfi-0.1.11.dist-info/entry_points.txt +28 -0
  4. sfi/__init__.py +1 -1
  5. sfi/alarmclock/alarmclock.py +40 -40
  6. sfi/bumpversion/__init__.py +1 -1
  7. sfi/cleanbuild/cleanbuild.py +155 -0
  8. sfi/condasetup/condasetup.py +116 -0
  9. sfi/docscan/__init__.py +1 -1
  10. sfi/docscan/docscan.py +407 -103
  11. sfi/docscan/docscan_gui.py +1282 -596
  12. sfi/docscan/lang/eng.py +152 -0
  13. sfi/docscan/lang/zhcn.py +170 -0
  14. sfi/filedate/filedate.py +185 -112
  15. sfi/gittool/__init__.py +2 -0
  16. sfi/gittool/gittool.py +401 -0
  17. sfi/llmclient/llmclient.py +592 -0
  18. sfi/llmquantize/llmquantize.py +480 -0
  19. sfi/llmserver/llmserver.py +335 -0
  20. sfi/makepython/makepython.py +31 -30
  21. sfi/pdfsplit/pdfsplit.py +173 -173
  22. sfi/pyarchive/pyarchive.py +418 -0
  23. sfi/pyembedinstall/pyembedinstall.py +629 -0
  24. sfi/pylibpack/__init__.py +0 -0
  25. sfi/pylibpack/pylibpack.py +1457 -0
  26. sfi/pylibpack/rules/numpy.json +22 -0
  27. sfi/pylibpack/rules/pymupdf.json +10 -0
  28. sfi/pylibpack/rules/pyqt5.json +19 -0
  29. sfi/pylibpack/rules/pyside2.json +23 -0
  30. sfi/pylibpack/rules/scipy.json +23 -0
  31. sfi/pylibpack/rules/shiboken2.json +24 -0
  32. sfi/pyloadergen/pyloadergen.py +512 -227
  33. sfi/pypack/__init__.py +0 -0
  34. sfi/pypack/pypack.py +1142 -0
  35. sfi/pyprojectparse/__init__.py +0 -0
  36. sfi/pyprojectparse/pyprojectparse.py +500 -0
  37. sfi/pysourcepack/pysourcepack.py +308 -0
  38. sfi/quizbase/__init__.py +0 -0
  39. sfi/quizbase/quizbase.py +828 -0
  40. sfi/quizbase/quizbase_gui.py +987 -0
  41. sfi/regexvalidate/__init__.py +0 -0
  42. sfi/regexvalidate/regex_help.html +284 -0
  43. sfi/regexvalidate/regexvalidate.py +468 -0
  44. sfi/taskkill/taskkill.py +0 -2
  45. sfi/workflowengine/__init__.py +0 -0
  46. sfi/workflowengine/workflowengine.py +444 -0
  47. pysfi-0.1.7.dist-info/RECORD +0 -31
  48. pysfi-0.1.7.dist-info/entry_points.txt +0 -15
  49. sfi/embedinstall/embedinstall.py +0 -418
  50. sfi/projectparse/projectparse.py +0 -152
  51. sfi/pypacker/fspacker.py +0 -91
  52. {pysfi-0.1.7.dist-info → pysfi-0.1.11.dist-info}/WHEEL +0 -0
  53. /sfi/{embedinstall → docscan/lang}/__init__.py +0 -0
  54. /sfi/{projectparse → llmquantize}/__init__.py +0 -0
  55. /sfi/{pypacker → pyembedinstall}/__init__.py +0 -0
@@ -0,0 +1,987 @@
1
+ """PySide2 GUI version of quizbase application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, ClassVar
12
+
13
+ import PySide2
14
+ from PySide2.QtCore import Qt
15
+ from PySide2.QtWidgets import (
16
+ QAbstractItemView,
17
+ QApplication,
18
+ QButtonGroup,
19
+ QCheckBox,
20
+ QComboBox,
21
+ QDialog,
22
+ QDialogButtonBox,
23
+ QFileDialog,
24
+ QFormLayout,
25
+ QGroupBox,
26
+ QHBoxLayout,
27
+ QLabel,
28
+ QLineEdit,
29
+ QListWidget,
30
+ QMainWindow,
31
+ QMessageBox,
32
+ QPushButton,
33
+ QRadioButton,
34
+ QScrollArea,
35
+ QTabWidget,
36
+ QTextEdit,
37
+ QVBoxLayout,
38
+ QWidget,
39
+ )
40
+
41
+ try:
42
+ from sfi.quizbase.quizbase import (
43
+ AdaptiveQuizSession,
44
+ EssayQuestion,
45
+ FillBlankQuestion,
46
+ MultipleChoiceQuestion,
47
+ Question,
48
+ QuizResult,
49
+ QuizSession,
50
+ TrueFalseQuestion,
51
+ create_sample_quiz_data,
52
+ )
53
+ except ImportError:
54
+ try:
55
+ from quizbase import (
56
+ AdaptiveQuizSession,
57
+ EssayQuestion,
58
+ FillBlankQuestion,
59
+ MultipleChoiceQuestion,
60
+ Question,
61
+ QuizResult,
62
+ QuizSession,
63
+ TrueFalseQuestion,
64
+ create_sample_quiz_data,
65
+ )
66
+ except ImportError:
67
+ from sfi.quizbase.quizbase import (
68
+ AdaptiveQuizSession,
69
+ EssayQuestion,
70
+ FillBlankQuestion,
71
+ MultipleChoiceQuestion,
72
+ Question,
73
+ QuizResult,
74
+ QuizSession,
75
+ TrueFalseQuestion,
76
+ create_sample_quiz_data,
77
+ )
78
+
79
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
80
+ logger = logging.getLogger(__name__)
81
+
82
+ # Set Qt platform plugin path for Windows
83
+ qt_dir = Path(PySide2.__file__).parent
84
+ plugin_path = str(qt_dir / "plugins" / "platforms")
85
+ os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = plugin_path
86
+
87
+
88
+ _WRONG_ANSWERS_FILE = Path.home() / ".sfi" / "wrong_answers.json"
89
+
90
+
91
+ @dataclass
92
+ class QuizConfig:
93
+ """Quiz GUI configuration."""
94
+
95
+ window_width: int = 1000
96
+ window_height: int = 700
97
+ window_x: int = 100
98
+ window_y: int = 100
99
+ random_order: bool = False
100
+ wrong_answers_file: str = str(_WRONG_ANSWERS_FILE)
101
+ recent_files: list[str] | None = None
102
+
103
+
104
+ class ConfigManager:
105
+ """Manage GUI configuration persistence."""
106
+
107
+ DEFAULT_CONFIG: ClassVar[dict[str, Any]] = {
108
+ "window_width": 1000,
109
+ "window_height": 700,
110
+ "window_x": 100,
111
+ "window_y": 100,
112
+ "random_order": False,
113
+ "wrong_answers_file": str(_WRONG_ANSWERS_FILE),
114
+ "recent_files": [],
115
+ }
116
+
117
+ MAX_RECENT_ITEMS = 10
118
+
119
+ def __init__(self, config_file: Path | None = None):
120
+ """Initialize configuration manager."""
121
+ if config_file is None:
122
+ config_dir = Path.home() / ".sfi"
123
+ config_dir.mkdir(exist_ok=True)
124
+ config_file = config_dir / "quizbase_gui.json"
125
+ self.config_file = config_file
126
+ self.config = self._load_config()
127
+
128
+ def _load_config(self) -> dict[str, Any]:
129
+ """Load configuration from file."""
130
+ if self.config_file.exists():
131
+ try:
132
+ with open(self.config_file, encoding="utf-8") as f:
133
+ config = json.load(f)
134
+ return {**self.DEFAULT_CONFIG, **config}
135
+ except (OSError, json.JSONDecodeError) as e:
136
+ logger.warning(f"Failed to load config: {e}. Using defaults.")
137
+ return self.DEFAULT_CONFIG.copy()
138
+
139
+ def save_config(self) -> None:
140
+ """Save configuration to file."""
141
+ try:
142
+ with open(self.config_file, "w", encoding="utf-8") as f:
143
+ json.dump(self.config, f, indent=2, ensure_ascii=False)
144
+ except OSError as e:
145
+ logger.warning(f"Failed to save config: {e}")
146
+
147
+ def get(self, key: str, default: Any = None) -> Any:
148
+ """Get configuration value."""
149
+ return self.config.get(key, default)
150
+
151
+ def set(self, key: str, value: Any) -> None:
152
+ """Set configuration value."""
153
+ self.config[key] = value
154
+
155
+ def add_recent_file(self, file_path: str) -> None:
156
+ """Add file to recent files list."""
157
+ recent_files = self.config.get("recent_files", [])
158
+ recent_files = [f for f in recent_files if f != file_path]
159
+ recent_files.insert(0, file_path)
160
+ recent_files = recent_files[: self.MAX_RECENT_ITEMS]
161
+ self.config["recent_files"] = recent_files
162
+
163
+ def get_config(self) -> QuizConfig:
164
+ """Get configuration as QuizConfig dataclass."""
165
+ return QuizConfig(
166
+ window_width=self.get("window_width", 1000),
167
+ window_height=self.get("window_height", 700),
168
+ window_x=self.get("window_x", 100),
169
+ window_y=self.get("window_y", 100),
170
+ random_order=self.get("random_order", False),
171
+ wrong_answers_file=self.get("wrong_answers_file", str(_WRONG_ANSWERS_FILE)),
172
+ recent_files=self.get("recent_files", []),
173
+ )
174
+
175
+
176
+ class QuestionAnswerWidget(QWidget):
177
+ """Widget for answering questions based on question type."""
178
+
179
+ def __init__(self, parent: QWidget | None = None):
180
+ """Initialize question answer widget."""
181
+ super().__init__(parent)
182
+ self.question: Question | None = None
183
+ self.layout = QVBoxLayout(self)
184
+ self.current_answer_widget: QWidget | None = None
185
+
186
+ def set_question(self, question: Question) -> None:
187
+ """Set question and create appropriate answer widget."""
188
+ self.question = question
189
+
190
+ # Clear existing widget
191
+ if self.current_answer_widget:
192
+ self.layout.removeWidget(self.current_answer_widget) # type: ignore
193
+ self.current_answer_widget.deleteLater()
194
+ self.current_answer_widget = None
195
+
196
+ # Clean up old attributes that belong to the previous question type
197
+ if hasattr(self, "radio_buttons"):
198
+ delattr(self, "radio_buttons")
199
+ if hasattr(self, "checkboxes"):
200
+ delattr(self, "checkboxes")
201
+ if hasattr(self, "text_input"):
202
+ delattr(self, "text_input")
203
+ if hasattr(self, "text_edit"):
204
+ delattr(self, "text_edit")
205
+ if hasattr(self, "true_radio"):
206
+ delattr(self, "true_radio")
207
+ if hasattr(self, "false_radio"):
208
+ delattr(self, "false_radio")
209
+ if hasattr(self, "radio_button_group"):
210
+ delattr(self, "radio_button_group")
211
+
212
+ # Create appropriate answer widget based on question type
213
+ if isinstance(question, MultipleChoiceQuestion):
214
+ widget = self._create_multiple_choice_widget(question)
215
+ elif isinstance(question, FillBlankQuestion):
216
+ widget = self._create_fill_blank_widget(question)
217
+ elif isinstance(question, TrueFalseQuestion):
218
+ widget = self._create_true_false_widget(question)
219
+ elif isinstance(question, EssayQuestion):
220
+ widget = self._create_essay_widget(question)
221
+ else:
222
+ widget = QLabel("Unknown question type")
223
+
224
+ self.current_answer_widget = widget
225
+ self.layout.addWidget(widget) # type: ignore
226
+
227
+ def _create_multiple_choice_widget(
228
+ self, question: MultipleChoiceQuestion
229
+ ) -> QWidget:
230
+ """Create widget for multiple choice questions."""
231
+ widget = QWidget()
232
+ layout = QVBoxLayout(widget)
233
+
234
+ if question.allow_multiple:
235
+ # Checkboxes for multiple selections
236
+ self.checkboxes: list[QCheckBox] = []
237
+ for i, option in enumerate(question.options):
238
+ checkbox = QCheckBox(option)
239
+ checkbox.setProperty("option_index", i)
240
+ self.checkboxes.append(checkbox)
241
+ layout.addWidget(checkbox)
242
+ else:
243
+ # Radio buttons for single selection
244
+ self.radio_buttons: list[QRadioButton] = []
245
+ # Create a button group for radio buttons to ensure mutual exclusivity
246
+ self.radio_button_group = QButtonGroup(self)
247
+ for i, option in enumerate(question.options):
248
+ radio = QRadioButton(option)
249
+ radio.setProperty("option_index", i)
250
+ self.radio_buttons.append(radio)
251
+ self.radio_button_group.addButton(
252
+ radio, i
253
+ ) # Add to button group with ID
254
+ layout.addWidget(radio)
255
+
256
+ return widget
257
+
258
+ def _create_fill_blank_widget(self, question: FillBlankQuestion) -> QWidget:
259
+ """Create widget for fill in the blank questions."""
260
+ widget = QWidget()
261
+ layout = QHBoxLayout(widget)
262
+
263
+ label = QLabel("Answer:")
264
+ self.text_input = QLineEdit()
265
+ self.text_input.setPlaceholderText("Enter your answer here...")
266
+
267
+ layout.addWidget(label)
268
+ layout.addWidget(self.text_input)
269
+
270
+ return widget
271
+
272
+ def _create_true_false_widget(self, question: TrueFalseQuestion) -> QWidget:
273
+ """Create widget for true/false questions."""
274
+ widget = QWidget()
275
+ layout = QHBoxLayout(widget)
276
+
277
+ self.true_radio = QRadioButton("True")
278
+ self.false_radio = QRadioButton("False")
279
+ self.true_radio.setProperty("answer", True)
280
+ self.false_radio.setProperty("answer", False)
281
+
282
+ layout.addWidget(self.true_radio)
283
+ layout.addWidget(self.false_radio)
284
+
285
+ return widget
286
+
287
+ def _create_essay_widget(self, question: EssayQuestion) -> QWidget:
288
+ """Create widget for essay questions."""
289
+ widget = QWidget()
290
+ layout = QVBoxLayout(widget)
291
+
292
+ label = QLabel("Your Answer:")
293
+ label.setStyleSheet("font-weight: bold; margin-top: 10px;")
294
+
295
+ self.text_edit = QTextEdit()
296
+ self.text_edit.setPlaceholderText("Type your answer here...")
297
+ self.text_edit.setMinimumHeight(200)
298
+
299
+ if question.keywords:
300
+ hint = QLabel(f"Key points to cover: {', '.join(question.keywords)}")
301
+ hint.setStyleSheet("color: gray; font-style: italic; margin-bottom: 5px;")
302
+ layout.addWidget(hint)
303
+
304
+ layout.addWidget(label)
305
+ layout.addWidget(self.text_edit)
306
+
307
+ return widget
308
+
309
+ def get_answer(self) -> Any:
310
+ """Get user's answer."""
311
+ if isinstance(self.question, MultipleChoiceQuestion):
312
+ if self.question.allow_multiple:
313
+ return [
314
+ cb.property("option_index")
315
+ for cb in getattr(self, "checkboxes", [])
316
+ if cb.isChecked()
317
+ ]
318
+ else:
319
+ for radio in getattr(self, "radio_buttons", []):
320
+ if radio.isChecked():
321
+ return radio.property("option_index")
322
+ return None
323
+ elif isinstance(self.question, FillBlankQuestion):
324
+ return getattr(self, "text_input", QLineEdit()).text()
325
+ elif isinstance(self.question, TrueFalseQuestion):
326
+ if getattr(self, "true_radio", None) and self.true_radio.isChecked():
327
+ return True
328
+ elif getattr(self, "false_radio", None) and self.false_radio.isChecked():
329
+ return False
330
+ return None
331
+ elif isinstance(self.question, EssayQuestion):
332
+ return getattr(self, "text_edit", QTextEdit()).toPlainText()
333
+ return None
334
+
335
+ def clear_answer(self) -> None:
336
+ """Clear user's answer."""
337
+ if isinstance(self.question, MultipleChoiceQuestion):
338
+ if self.question.allow_multiple:
339
+ for cb in getattr(self, "checkboxes", []):
340
+ cb.setChecked(False)
341
+ else:
342
+ # Get radio buttons directly from self attribute
343
+ radio_buttons = getattr(self, "radio_buttons", None)
344
+ # Also try to access the button group if it exists
345
+ button_group = getattr(self, "radio_button_group", None)
346
+
347
+ if button_group:
348
+ # Temporarily disable the exclusive property of the button group
349
+ button_group.setExclusive(False)
350
+
351
+ if radio_buttons:
352
+ # For radio buttons, we need to temporarily block signals
353
+ # to clear all selections properly
354
+ for radio in radio_buttons:
355
+ radio.blockSignals(True)
356
+ radio.setChecked(False)
357
+ radio.blockSignals(False)
358
+
359
+ if button_group:
360
+ # Re-enable exclusive property after clearing
361
+ button_group.setExclusive(True)
362
+
363
+ # Force processing of events to ensure UI updates
364
+ from PySide2.QtWidgets import QApplication
365
+
366
+ app = QApplication.instance()
367
+ if app:
368
+ app.processEvents()
369
+ elif isinstance(self.question, FillBlankQuestion):
370
+ getattr(self, "text_input", QLineEdit()).clear()
371
+ elif isinstance(self.question, TrueFalseQuestion):
372
+ if getattr(self, "true_radio", None):
373
+ self.true_radio.blockSignals(True)
374
+ self.true_radio.setChecked(False)
375
+ self.true_radio.blockSignals(False)
376
+ if getattr(self, "false_radio", None):
377
+ self.false_radio.blockSignals(True)
378
+ self.false_radio.setChecked(False)
379
+ self.false_radio.blockSignals(False)
380
+ # Force processing of events to ensure UI updates
381
+ from PySide2.QtWidgets import QApplication
382
+
383
+ app = QApplication.instance()
384
+ if app:
385
+ app.processEvents()
386
+ elif isinstance(self.question, EssayQuestion):
387
+ getattr(self, "text_edit", QTextEdit()).clear()
388
+
389
+
390
+ class WrongAnswersDialog(QDialog):
391
+ """Dialog to display wrong answers."""
392
+
393
+ def __init__(self, results: list[QuizResult], parent: QWidget | None = None):
394
+ """Initialize wrong answers dialog."""
395
+ super().__init__(parent)
396
+ self.results = results
397
+ self.setWindowTitle("Wrong Answers Review")
398
+ self.setMinimumSize(800, 600)
399
+ self._setup_ui()
400
+
401
+ def _setup_ui(self) -> None:
402
+ """Setup dialog UI."""
403
+ layout = QVBoxLayout(self)
404
+
405
+ # Scroll area for results
406
+ scroll = QScrollArea()
407
+ scroll.setWidgetResizable(True)
408
+
409
+ content_widget = QWidget()
410
+ content_layout = QVBoxLayout(content_widget)
411
+
412
+ for i, result in enumerate(self.results, 1):
413
+ result_group = QGroupBox(f"Question {i}")
414
+ result_layout = QVBoxLayout()
415
+
416
+ # Question
417
+ question_label = QLabel(f"<b>Question:</b> {result.question.question_text}")
418
+ question_label.setWordWrap(True)
419
+ result_layout.addWidget(question_label)
420
+
421
+ # User answer
422
+ user_answer_label = QLabel(f"<b>Your Answer:</b> {result.user_answer}")
423
+ user_answer_label.setWordWrap(True)
424
+ result_layout.addWidget(user_answer_label)
425
+
426
+ # Explanation
427
+ explanation_label = QLabel(f"<b>Explanation:</b> {result.explanation}")
428
+ explanation_label.setWordWrap(True)
429
+ explanation_label.setStyleSheet("color: red;")
430
+ result_layout.addWidget(explanation_label)
431
+
432
+ result_group.setLayout(result_layout)
433
+ content_layout.addWidget(result_group)
434
+
435
+ scroll.setWidget(content_widget)
436
+ layout.addWidget(scroll)
437
+
438
+ # Close button
439
+ button_box = QDialogButtonBox(QDialogButtonBox.Close)
440
+ button_box.rejected.connect(self.accept)
441
+ layout.addWidget(button_box)
442
+
443
+
444
+ class QuizBaseMainWindow(QMainWindow):
445
+ """Main window for QuizBase GUI application."""
446
+
447
+ def __init__(self):
448
+ """Initialize main window."""
449
+ super().__init__()
450
+ self.config_manager = ConfigManager()
451
+ self.config = self.config_manager.get_config()
452
+ self.session: QuizSession | None = None
453
+ self.current_result: QuizResult | None = None
454
+ self._setup_ui()
455
+ self._load_settings()
456
+
457
+ def _setup_ui(self) -> None:
458
+ """Setup user interface."""
459
+ self.setWindowTitle("QuizBase - Universal Quiz System")
460
+ self.setMinimumSize(900, 700)
461
+
462
+ # Central widget
463
+ central_widget = QWidget()
464
+ self.setCentralWidget(central_widget)
465
+
466
+ # Main layout
467
+ main_layout = QVBoxLayout(central_widget)
468
+
469
+ # Top toolbar
470
+ toolbar_layout = QHBoxLayout()
471
+
472
+ # Open file button
473
+ self.open_button = QPushButton("Open Quiz File")
474
+ self.open_button.clicked.connect(self._open_quiz_file)
475
+ toolbar_layout.addWidget(self.open_button)
476
+
477
+ # Recent files dropdown
478
+ self.recent_combo = QComboBox()
479
+ self.recent_combo.setMinimumWidth(300)
480
+ self.recent_combo.currentIndexChanged.connect(self._load_recent_file)
481
+ toolbar_layout.addWidget(self.recent_combo)
482
+
483
+ # Random order checkbox
484
+ self.random_checkbox = QCheckBox("Random Order")
485
+ self.random_checkbox.setChecked(self.config.random_order)
486
+ self.random_checkbox.stateChanged.connect(self._on_random_changed)
487
+ toolbar_layout.addWidget(self.random_checkbox)
488
+
489
+ # Adaptive mode checkbox
490
+ self.adaptive_checkbox = QCheckBox("Adaptive Mode")
491
+ self.adaptive_checkbox.setChecked(False)
492
+ self.adaptive_checkbox.stateChanged.connect(self._on_adaptive_changed)
493
+ toolbar_layout.addWidget(self.adaptive_checkbox)
494
+
495
+ toolbar_layout.addStretch()
496
+
497
+ # Create sample button
498
+ self.create_sample_button = QPushButton("Create Sample")
499
+ self.create_sample_button.clicked.connect(self._create_sample_quiz)
500
+ toolbar_layout.addWidget(self.create_sample_button)
501
+
502
+ main_layout.addLayout(toolbar_layout)
503
+
504
+ # Tab widget
505
+ self.tab_widget = QTabWidget()
506
+ main_layout.addWidget(self.tab_widget)
507
+
508
+ # Quiz tab
509
+ self.quiz_tab = self._create_quiz_tab()
510
+ self.tab_widget.addTab(self.quiz_tab, "Quiz")
511
+
512
+ # Summary tab
513
+ self.summary_tab = self._create_summary_tab()
514
+ self.tab_widget.addTab(self.summary_tab, "Summary")
515
+
516
+ # Wrong answers tab
517
+ self.wrong_tab = self._create_wrong_answers_tab()
518
+ self.tab_widget.addTab(self.wrong_tab, "Wrong Answers")
519
+
520
+ # Status bar
521
+ self.status_label = QLabel("No quiz loaded")
522
+ self.statusBar().addWidget(self.status_label)
523
+
524
+ # Initially disable tabs
525
+ self._set_quiz_enabled(False)
526
+
527
+ def _create_quiz_tab(self) -> QWidget:
528
+ """Create quiz tab."""
529
+ widget = QWidget()
530
+ layout = QVBoxLayout(widget)
531
+
532
+ # Progress info
533
+ progress_layout = QHBoxLayout()
534
+ self.progress_label = QLabel("Progress: - / -")
535
+ progress_layout.addWidget(self.progress_label)
536
+ progress_layout.addStretch()
537
+
538
+ # Score info
539
+ self.score_label = QLabel("Score: 0 / 0")
540
+ progress_layout.addWidget(self.score_label)
541
+
542
+ layout.addLayout(progress_layout)
543
+
544
+ # Question info
545
+ info_group = QGroupBox("Question Information")
546
+ info_layout = QFormLayout()
547
+
548
+ self.question_type_label = QLabel("-")
549
+ self.question_points_label = QLabel("-")
550
+
551
+ info_layout.addRow("Type:", self.question_type_label)
552
+ info_layout.addRow("Points:", self.question_points_label)
553
+ info_group.setLayout(info_layout)
554
+ layout.addWidget(info_group)
555
+
556
+ # Question text
557
+ question_group = QGroupBox("Question")
558
+ question_layout = QVBoxLayout()
559
+ self.question_text_label = QLabel("No question loaded")
560
+ self.question_text_label.setWordWrap(True)
561
+ self.question_text_label.setStyleSheet(
562
+ "font-size: 14px; font-weight: bold; margin: 10px;"
563
+ )
564
+ question_layout.addWidget(self.question_text_label)
565
+ question_group.setLayout(question_layout)
566
+ layout.addWidget(question_group)
567
+
568
+ # Answer area
569
+ answer_group = QGroupBox("Your Answer")
570
+ answer_layout = QVBoxLayout()
571
+ self.answer_widget = QuestionAnswerWidget()
572
+ answer_layout.addWidget(self.answer_widget)
573
+ answer_group.setLayout(answer_layout)
574
+ layout.addWidget(answer_group)
575
+
576
+ # Result area
577
+ self.result_label = QLabel()
578
+ self.result_label.setWordWrap(True)
579
+ self.result_label.setStyleSheet("padding: 10px; margin: 10px;")
580
+ layout.addWidget(self.result_label)
581
+
582
+ # Buttons
583
+ button_layout = QHBoxLayout()
584
+
585
+ self.submit_button = QPushButton("Submit Answer")
586
+ self.submit_button.setEnabled(False)
587
+ self.submit_button.clicked.connect(self._submit_answer)
588
+ button_layout.addWidget(self.submit_button)
589
+
590
+ self.next_button = QPushButton("Next Question")
591
+ self.next_button.setEnabled(False)
592
+ self.next_button.clicked.connect(self._next_question)
593
+ button_layout.addWidget(self.next_button)
594
+
595
+ self.finish_button = QPushButton("Finish Quiz")
596
+ self.finish_button.setEnabled(False)
597
+ self.finish_button.clicked.connect(self._finish_quiz)
598
+ button_layout.addWidget(self.finish_button)
599
+
600
+ self.reset_button = QPushButton("Reset Quiz")
601
+ self.reset_button.setEnabled(False)
602
+ self.reset_button.clicked.connect(self._reset_quiz)
603
+ button_layout.addWidget(self.reset_button)
604
+
605
+ layout.addLayout(button_layout)
606
+
607
+ return widget
608
+
609
+ def _create_summary_tab(self) -> QWidget:
610
+ """Create summary tab."""
611
+ widget = QWidget()
612
+ layout = QVBoxLayout(widget)
613
+
614
+ # Summary text
615
+ self.summary_text = QTextEdit()
616
+ self.summary_text.setReadOnly(True)
617
+ self.summary_text.setPlainText("Quiz summary will appear here...")
618
+ layout.addWidget(self.summary_text)
619
+
620
+ return widget
621
+
622
+ def _create_wrong_answers_tab(self) -> QWidget:
623
+ """Create wrong answers tab."""
624
+ widget = QWidget()
625
+ layout = QVBoxLayout(widget)
626
+
627
+ # Instructions
628
+ instruction = QLabel(
629
+ "Wrong answers will be displayed here after quiz completion."
630
+ )
631
+ layout.addWidget(instruction)
632
+
633
+ # Wrong answers list
634
+ self.wrong_answers_list = QListWidget()
635
+ self.wrong_answers_list.setSelectionMode(QAbstractItemView.SingleSelection)
636
+ layout.addWidget(self.wrong_answers_list)
637
+
638
+ # View detail button
639
+ self.view_detail_button = QPushButton("View Details")
640
+ self.view_detail_button.setEnabled(False)
641
+ self.view_detail_button.clicked.connect(self._view_wrong_answer_detail)
642
+ layout.addWidget(self.view_detail_button)
643
+
644
+ return widget
645
+
646
+ def _set_quiz_enabled(self, enabled: bool) -> bool:
647
+ """Enable or disable quiz-related controls."""
648
+ self.submit_button.setEnabled(enabled)
649
+ self.next_button.setEnabled(enabled)
650
+ self.finish_button.setEnabled(enabled)
651
+ self.reset_button.setEnabled(enabled)
652
+ return enabled
653
+
654
+ def _load_settings(self) -> None:
655
+ """Load window settings."""
656
+ self.resize(self.config.window_width, self.config.window_height)
657
+ self.move(self.config.window_x, self.config.window_y)
658
+ self._update_recent_files()
659
+
660
+ def _save_settings(self) -> None:
661
+ """Save window settings."""
662
+ self.config_manager.set("window_width", self.width())
663
+ self.config_manager.set("window_height", self.height())
664
+ self.config_manager.set("window_x", self.x())
665
+ self.config_manager.set("window_y", self.y())
666
+ self.config_manager.set("random_order", self.random_checkbox.isChecked())
667
+ self.config_manager.save_config()
668
+
669
+ def _update_recent_files(self) -> None:
670
+ """Update recent files dropdown."""
671
+ self.recent_combo.clear()
672
+ self.recent_combo.addItem("-- Recent Files --", None)
673
+ for file_path in self.config.recent_files or []:
674
+ self.recent_combo.addItem(Path(file_path).name, file_path)
675
+
676
+ def _open_quiz_file(self) -> None:
677
+ """Open quiz file dialog."""
678
+ file_path, _ = QFileDialog.getOpenFileName(
679
+ self, "Open Quiz File", "", "JSON Files (*.json);;All Files (*)"
680
+ )
681
+
682
+ if file_path:
683
+ self._load_quiz(file_path)
684
+
685
+ def _load_recent_file(self, index: int) -> None:
686
+ """Load recently used file."""
687
+ if index > 0: # Skip placeholder
688
+ file_path = self.recent_combo.currentData()
689
+ if file_path:
690
+ self._load_quiz(file_path)
691
+ # Reset selection
692
+ self.recent_combo.setCurrentIndex(0)
693
+
694
+ def _load_quiz(self, file_path: str) -> None:
695
+ """Load quiz from file."""
696
+ try:
697
+ # Check if adaptive mode is enabled
698
+ if self.adaptive_checkbox.isChecked():
699
+ self.session = AdaptiveQuizSession(
700
+ random_order=self.random_checkbox.isChecked()
701
+ )
702
+ # Load performance history if available
703
+ self.session.load_performance_history()
704
+ else:
705
+ self.session = QuizSession(
706
+ random_order=self.random_checkbox.isChecked()
707
+ )
708
+
709
+ self.session.load_from_json(file_path)
710
+
711
+ self.config_manager.add_recent_file(file_path)
712
+ self._update_recent_files()
713
+
714
+ self._set_quiz_enabled(True)
715
+ self.status_label.setText(f"Quiz loaded: {Path(file_path).name}")
716
+ self.tab_widget.setCurrentIndex(0)
717
+
718
+ self._load_current_question()
719
+ self._update_summary()
720
+
721
+ except Exception as e:
722
+ QMessageBox.critical(self, "Error", f"Failed to load quiz: {e}")
723
+
724
+ def _create_sample_quiz(self) -> None:
725
+ """Create sample quiz data."""
726
+ file_path, _ = QFileDialog.getSaveFileName(
727
+ self, "Save Sample Quiz", "sample_quiz.json", "JSON Files (*.json)"
728
+ )
729
+
730
+ if file_path:
731
+ try:
732
+ create_sample_quiz_data(file_path)
733
+ self._load_quiz(file_path)
734
+ QMessageBox.information(
735
+ self, "Success", "Sample quiz created successfully!"
736
+ )
737
+ except Exception as e:
738
+ QMessageBox.critical(
739
+ self, "Error", f"Failed to create sample quiz: {e}"
740
+ )
741
+
742
+ def _on_random_changed(self, state: int) -> None:
743
+ """Handle random order checkbox change."""
744
+ if self.session and not self.session.results:
745
+ # Only reset if no answers yet
746
+ self.session.random_order = state == Qt.Checked
747
+ self.session.reset()
748
+
749
+ def _on_adaptive_changed(self, state: int) -> None:
750
+ """Handle adaptive mode checkbox change."""
751
+ if (
752
+ self.session
753
+ and hasattr(self.session, "adaptive_mode")
754
+ and not self.session.results
755
+ ):
756
+ # Only reset if no answers yet
757
+ self.session.adaptive_mode = state == Qt.Checked
758
+ self.session.reset()
759
+
760
+ def _load_current_question(self) -> None:
761
+ """Load current question into UI."""
762
+ if not self.session:
763
+ return
764
+
765
+ question = self.session.get_current_question()
766
+
767
+ if question is None:
768
+ self._finish_quiz()
769
+ return
770
+
771
+ # Update question info
772
+ self.question_type_label.setText(question.question_type.value)
773
+ self.question_points_label.setText(str(question.points))
774
+ self.question_text_label.setText(question.question_text)
775
+
776
+ # Load answer widget
777
+ self.answer_widget.set_question(question)
778
+
779
+ # Update progress
780
+ self._update_progress()
781
+
782
+ # Clear result
783
+ self.result_label.clear()
784
+ self.result_label.setStyleSheet("padding: 10px; margin: 10px;")
785
+
786
+ # Enable submit, disable next
787
+ self.submit_button.setEnabled(True)
788
+ self.next_button.setEnabled(False)
789
+
790
+ def _update_progress(self) -> None:
791
+ """Update progress display."""
792
+ if not self.session:
793
+ return
794
+
795
+ summary = self.session.get_summary()
796
+ self.progress_label.setText(
797
+ f"Progress: {summary['answered']} / {summary['total_questions']}"
798
+ )
799
+ self.score_label.setText(
800
+ f"Score: {summary['earned_points']:.1f} / {summary['total_points']:.1f}"
801
+ )
802
+
803
+ def _submit_answer(self) -> None:
804
+ """Submit answer for current question."""
805
+ if not self.session:
806
+ return
807
+
808
+ answer = self.answer_widget.get_answer()
809
+
810
+ if answer is None:
811
+ QMessageBox.warning(self, "Warning", "Please provide an answer.")
812
+ return
813
+
814
+ # Submit to session
815
+ self.current_result = self.session.submit_answer(answer)
816
+
817
+ if self.current_result:
818
+ # Display result
819
+ if self.current_result.is_correct:
820
+ self.result_label.setText(
821
+ f"✓ Correct!\n{self.current_result.explanation}"
822
+ )
823
+ self.result_label.setStyleSheet(
824
+ "background-color: #d4edda; color: #155724; padding: 10px; margin: 10px; border-radius: 5px;"
825
+ )
826
+ else:
827
+ self.result_label.setText(
828
+ f"✗ Incorrect!\n{self.current_result.explanation}"
829
+ )
830
+ self.result_label.setStyleSheet(
831
+ "background-color: #f8d7da; color: #721c24; padding: 10px; margin: 10px; border-radius: 5px;"
832
+ )
833
+
834
+ # Update progress
835
+ self._update_progress()
836
+ self._update_summary()
837
+
838
+ # Disable submit, enable next
839
+ self.submit_button.setEnabled(False)
840
+ self.answer_widget.clear_answer()
841
+
842
+ if not self.session.is_finished():
843
+ # Automatically advance to next question after a brief delay
844
+ self._load_current_question() # Auto-advance to next question
845
+ else:
846
+ self.finish_button.setEnabled(True)
847
+ self.next_button.setEnabled(False)
848
+
849
+ def _next_question(self) -> None:
850
+ """Load next question."""
851
+ self._load_current_question()
852
+
853
+ def _finish_quiz(self) -> None:
854
+ """Finish quiz and show summary."""
855
+ if not self.session:
856
+ return
857
+
858
+ # Save wrong answers
859
+ wrong_answers_file = self.config_manager.get(
860
+ "wrong_answers_file", str(_WRONG_ANSWERS_FILE)
861
+ )
862
+ self.session.wrong_answer_file = wrong_answers_file
863
+ self.session.save_wrong_answers()
864
+
865
+ # Update summary tab
866
+ self._update_summary()
867
+ self._update_wrong_answers()
868
+
869
+ # Switch to summary tab
870
+ self.tab_widget.setCurrentIndex(1)
871
+
872
+ # Show completion message
873
+ summary = self.session.get_summary()
874
+ message = (
875
+ f"Quiz Completed!\n\n"
876
+ f"Total Questions: {summary['total_questions']}\n"
877
+ f"Correct: {summary['correct']}\n"
878
+ f"Wrong: {summary['wrong']}\n"
879
+ f"Score: {summary['earned_points']:.1f} / {summary['total_points']:.1f}\n"
880
+ f"Accuracy: {summary['accuracy']:.1f}%"
881
+ )
882
+ QMessageBox.information(self, "Quiz Completed", message)
883
+
884
+ self.status_label.setText("Quiz completed")
885
+
886
+ def _update_summary(self) -> None:
887
+ """Update summary tab."""
888
+ if not self.session:
889
+ return
890
+
891
+ summary = self.session.get_summary()
892
+
893
+ summary_text = (
894
+ f"Quiz Summary\n"
895
+ f"{'=' * 50}\n\n"
896
+ f"Total Questions: {summary['total_questions']}\n"
897
+ f"Answered: {summary['answered']}\n"
898
+ f"Correct: {summary['correct']}\n"
899
+ f"Wrong: {summary['wrong']}\n\n"
900
+ f"Points Earned: {summary['earned_points']:.1f} / {summary['total_points']:.1f}\n"
901
+ f"Accuracy: {summary['accuracy']:.1f}%\n"
902
+ f"Status: {'Completed' if summary['is_finished'] else 'In Progress'}\n"
903
+ )
904
+
905
+ # Add question-by-question breakdown
906
+ if self.session.results:
907
+ summary_text += f"\n{'=' * 50}\n\nQuestion Breakdown:\n\n"
908
+ for i, result in enumerate(self.session.results, 1):
909
+ status = "✓" if result.is_correct else "✗"
910
+ summary_text += f"{i}. {status} ({result.question.points} pts)\n"
911
+
912
+ self.summary_text.setPlainText(summary_text)
913
+
914
+ def _update_wrong_answers(self) -> None:
915
+ """Update wrong answers tab."""
916
+ if not self.session:
917
+ return
918
+
919
+ self.wrong_answers_list.clear()
920
+
921
+ wrong_results = self.session.get_wrong_answers()
922
+
923
+ if not wrong_results:
924
+ self.wrong_answers_list.addItem("No wrong answers! Great job!")
925
+ self.view_detail_button.setEnabled(False)
926
+ return
927
+
928
+ for i, result in enumerate(wrong_results, 1):
929
+ self.wrong_answers_list.addItem(
930
+ f"{i}. {result.question.question_text[:50]}..."
931
+ )
932
+
933
+ self.wrong_answers = wrong_results
934
+ self.view_detail_button.setEnabled(True)
935
+
936
+ def _view_wrong_answer_detail(self) -> None:
937
+ """View detail of selected wrong answer."""
938
+ if not hasattr(self, "wrong_answers") or not self.wrong_answers:
939
+ return
940
+
941
+ selected = self.wrong_answers_list.currentRow()
942
+ if selected < 0 or selected >= len(self.wrong_answers):
943
+ return
944
+
945
+ result = self.wrong_answers[selected]
946
+ dialog = WrongAnswersDialog([result], self)
947
+ dialog.exec_()
948
+
949
+ def _reset_quiz(self) -> None:
950
+ """Reset quiz to beginning."""
951
+ if not self.session:
952
+ return
953
+
954
+ reply = QMessageBox.question(
955
+ self,
956
+ "Reset Quiz",
957
+ "Are you sure you want to reset this quiz? All progress will be lost.",
958
+ QMessageBox.Yes | QMessageBox.No,
959
+ )
960
+
961
+ if reply == QMessageBox.Yes:
962
+ self.session.reset(random_order=self.random_checkbox.isChecked())
963
+ self.current_result = None
964
+ self._load_current_question()
965
+ self._update_summary()
966
+ self.tab_widget.setCurrentIndex(0)
967
+ self.status_label.setText("Quiz reset")
968
+
969
+ def closeEvent(self, event) -> None: # noqa: N802
970
+ """Handle window close event."""
971
+ self._save_settings()
972
+ event.accept()
973
+
974
+
975
+ def main():
976
+ """Main entry point for GUI application."""
977
+ app = QApplication(sys.argv)
978
+ app.setStyle("Fusion")
979
+
980
+ window = QuizBaseMainWindow()
981
+ window.show()
982
+
983
+ sys.exit(app.exec_())
984
+
985
+
986
+ if __name__ == "__main__":
987
+ main()