Coverage for denofo/questionnaire/questionnaire_gui.py: 80%
384 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-09 15:27 +0200
« prev ^ index » next coverage.py v7.6.12, created at 2025-04-09 15:27 +0200
1import sys
2from typing import Any
3from enum import Enum
4from pathlib import Path
5from pydantic import BaseModel, ValidationError
6from denofo.utils.constants import SECTIONS, GoQBack
7from denofo.converter.convert import convert_to_json
8from denofo.models import ModelValidError
9from denofo.utils.ncbiTaxDBcheck import check_NCBI_taxDB
10from denofo.questionnaire.questions import DeNovoQuestionnaire
11from PyQt6.QtCore import Qt, QObject
12from PyQt6.QtWidgets import (
13 QApplication,
14 QMainWindow,
15 QWidget,
16 QVBoxLayout,
17 QHBoxLayout,
18 QPushButton,
19 QLabel,
20 QListWidget,
21 QLineEdit,
22 QMessageBox,
23 QListWidgetItem,
24 QRadioButton,
25 QButtonGroup,
26 QDialog,
27 QFileDialog,
28)
31def clearLayout(layout):
32 """
33 Function to clear the layout of a QWidget.
34 """
35 while layout.count():
36 child = layout.takeAt(0)
37 if child.widget():
38 child.widget().deleteLater()
41class QSingleton(type(QObject)):
42 """Metaclass for Qt classes that are singletons."""
44 def __init__(self, *args, **kwargs):
45 super().__init__(*args, **kwargs)
46 self.instance = None
48 def __call__(self, *args, **kwargs):
49 if self.instance is None:
50 self.instance = super().__call__(*args, **kwargs)
51 return self.instance
54class MainWindow(QMainWindow, metaclass=QSingleton):
55 """
56 Main window class for the GUI application.
57 """
59 _geometry = None
60 _centered = False
62 def __init__(self, *args, **kwargs):
63 super().__init__(*args, **kwargs)
64 self.setWindowTitle("DeNoFo Questionnaire")
65 self.setStyleSheet("background-color: #454746; color: white;")
66 self.resize(500, 250) # Set default size
68 if MainWindow._geometry is not None:
69 self.setGeometry(*MainWindow._geometry)
70 else:
71 self.center_on_screen()
73 def center_on_screen(self):
74 screen = QApplication.primaryScreen()
75 if screen is not None:
76 screen_geometry = screen.availableGeometry()
77 window_geometry = self.frameGeometry()
78 center_point = screen_geometry.center()
79 window_geometry.moveCenter(center_point)
80 self.move(window_geometry.topLeft())
81 MainWindow._geometry = self.geometry().getRect()
82 MainWindow._centered = True
83 else:
84 # Fallback position if screen cannot be detected
85 MainWindow._geometry = (100, 100, 500, 250)
86 self.setGeometry(*MainWindow._geometry)
87 MainWindow._centered = True
89 def closeEvent(self, event):
90 MainWindow._geometry = self.geometry().getRect()
91 if event.spontaneous():
92 if (
93 QMessageBox.question(
94 self,
95 "Quit",
96 "Are you sure you want to quit?",
97 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
98 )
99 == QMessageBox.StandardButton.Yes
100 ):
101 event.accept()
102 QApplication.closeAllWindows()
103 sys.exit(0)
104 else:
105 event.ignore()
106 else:
107 event.accept()
110class ErrorDialog(QDialog):
111 """
112 Error dialog class for the GUI application.
113 """
115 def __init__(self, err_warn_type: str = "Error", error_message: str = ""):
116 super().__init__()
118 self.err_warn_type = err_warn_type
119 self.error_message = error_message
121 self.initUI()
123 def initUI(self):
124 self.setWindowTitle(self.err_warn_type)
126 layout = QVBoxLayout()
127 error_label = QLabel(self.error_message)
128 error_label.setStyleSheet("font-weight: bold; color: white;")
129 error_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
130 layout.addWidget(error_label)
131 ok_button = QPushButton("Ok")
132 layout.addWidget(ok_button)
134 if self.err_warn_type == "Error":
135 self.setStyleSheet("background-color: #8D3832;")
136 else:
137 self.setStyleSheet("background-color: #4F6B90;")
139 self.setLayout(layout)
140 ok_button.clicked.connect(self.close)
143def show_error_message(warn_type: str = "Error", message: str = ""):
144 """
145 Function to show an error message to the user.
146 """
147 # Create main window
148 main_window = MainWindow()
149 main_layout = QVBoxLayout()
151 # Create a central widget
152 central_widget = QWidget(parent=main_window)
154 # Add ErrorDialog widget
155 error_dialog = ErrorDialog(warn_type, message)
156 main_layout.addWidget(error_dialog, stretch=1)
158 # Set the layout
159 central_widget.setLayout(main_layout)
160 main_window.setCentralWidget(central_widget)
162 error_dialog.exec()
164 return
167class ProgressBar(QWidget):
168 """
169 Progress bar class for the GUI application.
170 """
172 def __init__(self, section_idx):
173 super().__init__()
174 self.section_idx = section_idx
175 self.initUI()
177 def initUI(self):
178 layout = QHBoxLayout()
179 for idx, section in enumerate(SECTIONS):
180 section_button = QPushButton(section)
181 section_button.setStyleSheet(
182 "background-color: #545A61; font-weight: bold;" # lowlight
183 )
184 if idx == self.section_idx:
185 section_button.setStyleSheet(
186 "background-color: #4F6B90; font-weight: bold;" # highlight
187 )
188 layout.addWidget(section_button)
189 if idx < len(SECTIONS) - 1:
190 dot_label = QLabel(" • ")
191 layout.addWidget(dot_label)
192 self.setLayout(layout)
195class Back_button(QPushButton):
196 """
197 Back button class for the GUI application.
198 """
200 def __init__(self):
201 super().__init__()
202 self.choice = None
203 self.initUI()
205 def initUI(self):
206 self.setText("← Go Back")
207 self.setStyleSheet("background-color: #8D3832; padding: 5px;")
208 self.clicked.connect(self.on_click)
210 def on_click(self):
211 self.choice = GoQBack()
212 # clearLayout removes all widgets from the layout of the center_widget (such as ProgressBar, Enum_choice_selection and other open Dialogs)
213 # without this the Back_button just disappears, but the center_widget stays open
214 clearLayout(self.parent().layout())
217class Enum_choice_selection(QDialog):
218 """
219 Enum choice selection class for the GUI application.
220 """
222 def __init__(self, enum_choices, question, multi_choice=False, prev_answer=None):
223 super().__init__()
225 self.enum_choices = enum_choices
226 self.question = question
227 self.multi_choice = multi_choice
228 self.prev_answer = prev_answer
230 self.choice = None
232 self.initUI()
234 def initUI(self):
235 self.setWindowTitle("Select an option")
237 layout = QVBoxLayout()
238 if self.question:
239 question_label = QLabel(self.question)
240 layout.addWidget(question_label)
242 if self.multi_choice:
243 self.choices = QListWidget()
244 self.choices.setSelectionMode(QListWidget.SelectionMode.MultiSelection)
245 self.choices.setStyleSheet(
246 "QListWidget::item:selected { background-color: #7E8A97; }"
247 )
248 for choice in self.enum_choices:
249 item = QListWidgetItem(choice.value)
250 self.choices.addItem(item)
251 if self.prev_answer and choice.value in self.prev_answer:
252 self.choices.setCurrentItem(item)
253 layout.addWidget(self.choices)
254 self.choices.itemSelectionChanged.connect(self.update_submit_button)
255 else:
256 self.choices = QButtonGroup()
257 for idx, choice in enumerate(self.enum_choices):
258 radio_button = QRadioButton(choice.value)
259 if self.prev_answer and choice.value == self.prev_answer.value:
260 radio_button.setChecked(True)
261 self.choices.addButton(radio_button, idx)
262 layout.addWidget(radio_button)
263 radio_button.toggled.connect(self.update_submit_button)
265 submit_button = QPushButton("Continue →")
266 submit_button.setStyleSheet(
267 """
268 QPushButton {
269 background-color: #454746;
270 color: grey;
271 padding: 5px;
272 }
273 QPushButton:enabled {
274 background-color: #545A61;
275 color: white;
276 }
277 QPushButton:disabled {
278 background-color: #454746;
279 color: gray;
280 }
281 """
282 )
283 submit_button.setEnabled(bool(self.prev_answer))
284 layout.addWidget(submit_button)
285 self.setLayout(layout)
287 submit_button.clicked.connect(self.on_submit)
288 self.submit_button = submit_button
290 def update_submit_button(self):
291 if self.multi_choice:
292 has_selection = len(self.choices.selectedItems()) > 0
293 else:
294 has_selection = self.choices.checkedId() != -1
295 self.submit_button.setEnabled(has_selection)
297 def on_submit(self):
298 if self.multi_choice:
299 self.choice = [item.text() for item in self.choices.selectedItems()]
300 else:
301 self.choice = list(self.enum_choices)[self.choices.checkedId()]
302 self.close()
305class Custom_entry(QDialog):
306 """
307 Custom entry class for the GUI application.
308 """
310 def __init__(
311 self, question, multi_choice: bool, prev_answer: str | list[str] | None = None
312 ):
313 super().__init__()
315 self.question = question
316 self.multi_choice = multi_choice
317 self.prev_answer = prev_answer
319 self.choice = None
321 self.initUI()
323 def initUI(self):
324 self.setWindowTitle("Enter a value")
326 layout = QVBoxLayout()
327 if self.question:
328 question_label = QLabel(self.question)
329 layout.addWidget(question_label)
331 if self.multi_choice:
332 self.entries = []
333 self.add_entry_button = QPushButton("+")
334 self.remove_entry_button = QPushButton("-")
335 self.entry_layout = QVBoxLayout()
336 entry = QLineEdit()
337 self.entries.append(entry)
338 self.entry_layout.addWidget(entry)
339 layout.addLayout(self.entry_layout)
340 button_layout = QHBoxLayout()
341 button_layout.addWidget(self.add_entry_button)
342 button_layout.addWidget(self.remove_entry_button)
343 layout.addLayout(button_layout)
344 self.add_entry_button.clicked.connect(self.add_entry)
345 self.remove_entry_button.clicked.connect(self.remove_entry)
346 if self.prev_answer:
347 self.remove_entry(ignore_warning=True)
348 for entry_text in self.prev_answer:
349 self.add_entry(ignore_warning=True)
350 self.entries[-1].setText(entry_text)
351 else:
352 self.entry = QLineEdit()
353 if self.prev_answer:
354 self.entry.setText(self.prev_answer)
355 layout.addWidget(self.entry)
357 submit_button = QPushButton("Continue →")
358 submit_button.setStyleSheet(
359 """
360 QPushButton {
361 background-color: #454746;
362 color: grey;
363 padding: 5px;
364 }
365 QPushButton:enabled {
366 background-color: #545A61;
367 color: white;
368 }
369 QPushButton:disabled {
370 background-color: #454746;
371 color: gray;
372 }
373 """
374 )
375 layout.addWidget(submit_button)
376 self.setLayout(layout)
377 submit_button.clicked.connect(self.on_submit)
378 self.submit_button = submit_button
379 self.update_submit_button()
380 self.connect_signals()
382 def connect_signals(self):
383 if self.multi_choice:
384 for entry in self.entries:
385 entry.textChanged.connect(self.update_submit_button)
386 self.add_entry_button.clicked.connect(self.update_submit_button)
387 self.remove_entry_button.clicked.connect(self.update_submit_button)
388 else:
389 self.entry.textChanged.connect(self.update_submit_button)
391 def update_submit_button(self):
392 if self.multi_choice:
393 has_text = all(entry.text().strip() for entry in self.entries)
394 else:
395 has_text = bool(self.entry.text().strip())
396 self.submit_button.setEnabled(has_text)
398 def on_submit(self):
399 if self.multi_choice:
400 if any(not entry.text().strip() for entry in self.entries):
401 QMessageBox.warning(
402 self,
403 "Incomplete Entries",
404 "Please fill in all entries before submitting.",
405 )
406 return
407 self.choice = [entry.text() for entry in self.entries]
408 else:
409 if not self.entry.text().strip():
410 QMessageBox.warning(
411 self,
412 "Empty Field",
413 "Please fill in the text field before submitting.",
414 )
415 return
416 self.choice = self.entry.text()
417 self.close()
419 def add_entry(self, ignore_warning: bool = False):
420 if all(entry.text().strip() for entry in self.entries) or ignore_warning:
421 entry = QLineEdit()
422 self.entries.append(entry)
423 self.entry_layout.addWidget(entry)
424 if not ignore_warning:
425 entry.textChanged.connect(self.update_submit_button)
426 else:
427 QMessageBox.warning(
428 self,
429 "Incomplete Entries",
430 "Please fill in all existing entries before adding a new one.",
431 )
433 def remove_entry(self, ignore_warning: bool = False):
434 if (self.multi_choice and len(self.entries) > 1) or ignore_warning:
435 entry = self.entries.pop()
436 self.entry_layout.removeWidget(entry)
437 entry.deleteLater()
438 if not ignore_warning:
439 self.update_submit_button()
440 else:
441 QMessageBox.warning(
442 self,
443 "Cannot Remove",
444 "At least one entry is required.",
445 )
448class Yes_no(QDialog):
449 """
450 Yes or No class for the GUI application.
451 """
453 def __init__(self, question, prev_answer=None):
454 super().__init__()
456 self.question = question
457 self.prev_answer = prev_answer
459 self.choice = None
461 self.initUI()
463 def initUI(self):
464 self.setWindowTitle("Yes or No")
466 layout = QVBoxLayout()
467 if self.question:
468 question_label = QLabel(self.question)
469 layout.addWidget(question_label)
470 self.yes_button = QPushButton("Yes")
471 self.no_button = QPushButton("No")
472 if self.prev_answer is not None:
473 if self.prev_answer is True:
474 self.yes_button.setStyleSheet("background-color: #2E5539")
475 elif self.prev_answer is False:
476 self.no_button.setStyleSheet("background-color: #73403F")
477 layout.addWidget(self.yes_button)
478 layout.addWidget(self.no_button)
479 self.setLayout(layout)
481 self.yes_button.clicked.connect(self.on_yes)
482 self.no_button.clicked.connect(self.on_no)
484 def on_yes(self):
485 self.choice = True
486 self.close()
488 def on_no(self):
489 self.choice = False
490 self.close()
493def get_enum_choice_conversion(
494 my_enum: Enum,
495 question: str = "",
496 multi_choice: bool = False,
497 section_idx: int = 0,
498 prev_answer: Any = None,
499) -> Any:
500 """
501 Function to get the user's choice for an Enum.
503 :param my_enum: Enum class to get the choices from
504 :type my_enum: Enum
505 :param question: Question to ask the user
506 :type question: str
507 :param multi_choice: Whether the user can select multiple choices
508 :type multi_choice: bool
509 :param section_idx: Index of the section in the progress bar
510 :type section_idx: int
511 :param prev_answer: Previous answer given by the user
512 :type prev_answer: Any
513 :return: User's choice
514 :rtype: Any
515 """
516 choice = None
518 # Create main window
519 main_window = MainWindow()
520 main_layout = QVBoxLayout()
522 # Create a central widget and set the layout
523 central_widget = QWidget(parent=main_window)
525 # Add progress bar
526 progress_bar = ProgressBar(section_idx)
527 main_layout.addWidget(progress_bar, stretch=2)
529 # Add Enum_choice_selection widget
530 enum_selection_widget = Enum_choice_selection(
531 my_enum, question, multi_choice, prev_answer
532 )
533 main_layout.addWidget(enum_selection_widget, stretch=7)
535 # Add Back_button widget
536 back_button = Back_button()
537 main_layout.addWidget(back_button, stretch=1)
539 # Set the layout
540 central_widget.setLayout(main_layout)
541 main_window.setCentralWidget(central_widget)
543 # Execute the Yes_no dialog
544 enum_selection_widget.exec()
546 if isinstance(back_button.choice, GoQBack):
547 choice = GoQBack()
548 else:
549 choice = enum_selection_widget.choice
551 return choice
554def get_custom_entry(
555 question: str = "",
556 multi_choice: bool = False,
557 section_idx: int = 0,
558 prev_answer: any = None,
559) -> Any:
560 """
561 Function to get the user's custom entry.
563 :param question: Question to ask the user
564 :type question: str
565 :param multi_choice: Whether the user can enter multiple values
566 :type multi_choice: bool
567 :param section_idx: Index of the section in the progress bar
568 :type section_idx: int
569 :param prev_answer: Previous answer given by the user
570 :type prev_answer: any
571 :return: User's choice
572 :rtype: Any
573 """
574 choice = None
576 # Create main window
577 main_window = MainWindow()
578 main_layout = QVBoxLayout()
580 # Create a central widget
581 central_widget = QWidget(parent=main_window)
583 # Add progress bar
584 progress_bar = ProgressBar(section_idx)
585 main_layout.addWidget(progress_bar, stretch=2)
587 # Add Custom_entry widget
588 custom_entry_widget = Custom_entry(question, multi_choice, prev_answer)
589 main_layout.addWidget(custom_entry_widget, stretch=7)
591 # Add Back_button widget
592 back_button = Back_button()
593 main_layout.addWidget(back_button, stretch=1)
595 # Set the layout
596 central_widget.setLayout(main_layout)
597 main_window.setCentralWidget(central_widget)
599 # Execute the Yes_no dialog
600 custom_entry_widget.exec()
602 if isinstance(back_button.choice, GoQBack):
603 choice = GoQBack()
604 else:
605 choice = custom_entry_widget.choice
607 return choice
610def get_yes_no(
611 question: str = "", section_idx: int = 0, prev_answer: any = None
612) -> bool:
613 """
614 Function to get the user's choice for Yes or No.
616 :param question: Question to ask the user
617 :type question: str
618 :param section_idx: Index of the section in the progress bar
619 :type section_idx: int
620 :param prev_answer: Previous answer given by the user
621 :type prev_answer: any
622 :return: User's choice
623 :rtype: bool
624 """
625 choice = None
627 # Create main window
628 main_window = MainWindow()
629 main_layout = QVBoxLayout()
631 # Create a central widget
632 central_widget = QWidget(parent=main_window)
634 # Add progress bar
635 progress_bar = ProgressBar(section_idx)
636 main_layout.addWidget(progress_bar, stretch=2)
638 # Add Yes_no widget
639 yes_no_widget = Yes_no(question, prev_answer)
640 main_layout.addWidget(yes_no_widget, stretch=7)
642 # Add Back_button widget
643 back_button = Back_button()
644 main_layout.addWidget(back_button, stretch=1)
646 # Set the layout
647 central_widget.setLayout(main_layout)
648 main_window.setCentralWidget(central_widget)
650 # Execute the Yes_no dialog
651 yes_no_widget.exec()
653 if isinstance(back_button.choice, GoQBack):
654 choice = GoQBack()
655 else:
656 choice = yes_no_widget.choice
658 return choice
661def valid_input_for_pydmodel(
662 pydmodel: BaseModel, field_name: str, inp_val: Any
663) -> bool:
664 """
665 Validate the input value with a certain pydantic model and model field
666 to ask the user for input again if the input is invalid.
668 :param pydmodel: Pydantic model to validate the input with
669 :type pydmodel: BaseModel
670 :param field_name: Field name of the model to validate the input with
671 :type field_name: str
672 :param inp_val: Input value to validate
673 :type inp_val: Any
674 :return: Whether the input is valid
675 :rtype: bool
676 """
677 try:
678 # pydmodel.validate({field_name: inp_val})
679 pydmodel.__pydantic_validator__.validate_assignment(
680 pydmodel.model_construct(), field_name, inp_val
681 )
682 return True
683 except UserWarning as w:
684 warning = str(w)
685 show_error_message("Warning", warning)
686 return True
687 except ValidationError as e:
688 errors = e.errors()
689 modelValErr = errors[0].get("ctx", dict()).get("error", None)
690 if isinstance(modelValErr, ModelValidError):
691 return True
692 else:
693 val_err = e
694 err_msg = ", ".join(val_err.errors()[0]["msg"].split(",")[1:])
695 show_error_message("Error", err_msg)
696 return False
699def save_annotation(gene_annotation: BaseModel):
700 """
701 Function to save the gene annotation in the dngf format into a file.
703 :param gene_annotation: Gene annotation to save
704 :type gene_annotation: BaseModel
705 """
706 fileName = None
707 main_window = MainWindow()
709 while not fileName:
710 fileName, _ = QFileDialog.getSaveFileName(
711 main_window, "Save File", "my_model.dngf", "dngf (*.dngf)"
712 )
713 if not fileName:
714 reply = QMessageBox.question(
715 main_window,
716 "Quit without Saving?",
717 "Do you want to close the application without saving the annotation file?",
718 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
719 )
720 if reply == QMessageBox.StandardButton.Yes:
721 # quit without saving file
722 return None
724 convert_to_json(gene_annotation, Path(fileName))
727def main_app():
728 """
729 The main function for the GUI application. Calls questionnaire functions
730 and saves the gene annotation, while showing Widgets in the privously initiated
731 MainWindow.
732 """
733 GUI_INTERFACE_FUNCTS = {
734 "get_enum_choice_conversion": get_enum_choice_conversion,
735 "get_custom_entry": get_custom_entry,
736 "get_yes_no": get_yes_no,
737 "valid_input_for_pydmodel": valid_input_for_pydmodel,
738 }
740 try:
741 # Start of the questionnaire
742 de_novo_questionnaire = DeNovoQuestionnaire(GUI_INTERFACE_FUNCTS)
743 gene_annotation = de_novo_questionnaire.deNovoGeneAnnotation
745 # save model in the dngf (de novo gene format) format (JSON)
746 save_annotation(gene_annotation)
748 # close all windows and quit the application
749 QApplication.closeAllWindows()
750 sys.exit(0)
752 except Exception as e:
753 raise NotImplementedError(f"Error: {e}")
756def main():
757 """
758 The main function of the program. Entry point for the GUI executable.
759 """
760 # Check the NCBI Taxonomy Database
761 check_NCBI_taxDB()
763 app = QApplication([])
764 main_window = MainWindow()
765 main_window.show()
766 main_app()
767 sys.exit(app.exec())
770if __name__ == "__main__":
771 main()