diff --git a/docs/src/exercises.ipynb b/docs/src/exercises.ipynb index e64f99e..2891d90 100644 --- a/docs/src/exercises.ipynb +++ b/docs/src/exercises.ipynb @@ -382,7 +382,7 @@ "id": "22", "metadata": {}, "source": [ - "## ParametersPanel short constructors" + "### ParametersPanel short constructors" ] }, { @@ -412,11 +412,40 @@ " const=fixed(1) # this argument will be passed but is not changeable and therefore not displayed\n", ")" ] + }, + { + "cell_type": "markdown", + "id": "25", + "metadata": {}, + "source": [ + "## Multiple choice exercise" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26", + "metadata": {}, + "outputs": [], + "source": [ + "from scwidgets import MultipleChoiceExercise\n", + "mcq_ex = MultipleChoiceExercise(\n", + " options = [\"Hydrophobic surfaces\",\n", + " \"Self-healing glass\",\n", + " \"Transparent aluminum\",\n", + " \"Conductive wood\",\n", + " \"Spider silk paper\"],\n", + " description = \"Which of the following are actual applications of materials science in use today?\",\n", + " allow_multiple = False,\n", + " randomize_order = True\n", + ")\n", + "display(mcq_ex)" + ] } ], "metadata": { "kernelspec": { - "display_name": "scicode", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -430,7 +459,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.11" + "version": "3.12.0" } }, "nbformat": 4, diff --git a/src/scwidgets/exercise/__init__.py b/src/scwidgets/exercise/__init__.py index 802b032..13ed793 100644 --- a/src/scwidgets/exercise/__init__.py +++ b/src/scwidgets/exercise/__init__.py @@ -1,5 +1,12 @@ from ._widget_code_exercise import CodeExercise from ._widget_exercise_registry import ExerciseRegistry, ExerciseWidget +from ._widget_multiplechoice_exercise import MultipleChoiceExercise from ._widget_text_exercise import TextExercise -__all__ = ["CodeExercise", "TextExercise", "ExerciseWidget", "ExerciseRegistry"] +__all__ = [ + "CodeExercise", + "TextExercise", + "MultipleChoiceExercise", + "ExerciseWidget", + "ExerciseRegistry", +] diff --git a/src/scwidgets/exercise/_widget_multiplechoice_exercise.py b/src/scwidgets/exercise/_widget_multiplechoice_exercise.py new file mode 100644 index 0000000..c2d948d --- /dev/null +++ b/src/scwidgets/exercise/_widget_multiplechoice_exercise.py @@ -0,0 +1,261 @@ +import random +from typing import Any, Dict, List, Optional, Union + +from ipywidgets import ( + HTML, + HBox, + HTMLMath, + Layout, + Output, + RadioButtons, + SelectMultiple, + VBox, +) + +from .._utils import Formatter +from ..css_style import CssStyle +from ..cue import SaveCueBox, SaveResetCueButton +from ._widget_exercise_registry import ExerciseRegistry, ExerciseWidget + + +class MultipleChoiceExercise(VBox, ExerciseWidget): + """ + :param options: + Either a dict or a list. If a dict is provided, the widget will display the + dictionary’s value but save its key to the registry. + + :param key: + Unique key for the exercise. + + :param description: + A string describing the exercise that will be put into an HTML widget above + the exercise. + + :param title: + A title for the exercise. If not provided, the key is used. + + :param exercise_registry: + An exercise registry that is used to register the answers to save them later. + If specified the save and load panel will appear. + + :param allow_multiple: + Whether multiple selections are allowed. + + :param randomize_order: + Whether to randomize order of options. + """ + + def __init__( + self, + options: Union[List[Any], Dict[Any, Any]], + key: Optional[str] = None, + description: Optional[str] = None, + title: Optional[str] = None, + exercise_registry: Optional[ExerciseRegistry] = None, + allow_multiple: bool = False, + randomize_order: bool = False, + *args, + **kwargs, + ): + self._description = description + if description is not None: + self._description_html = HTMLMath(self._description) + self._description_html.add_class("exercise-description") + else: + self._description_html = None + + self._title: Union[str, None] + if title is None: + if key is not None: + self._title = key + self._title_html = HTML(f"{key}") + else: + self._title = None + self._title_html = None + else: + self._title = title + self._title_html = HTML(f"{title}") + if self._title_html is not None: + self._title_html.add_class("exercise-title") + + self._options_dict: Union[dict, None] + if isinstance(options, dict): + self._options_dict = options + options_list = [(value, key) for key, value in options.items()] + elif isinstance(options, list): + self._options_dict = None + options_list = options + else: + raise ValueError("Options must be provided as a dict or a list.") + + if randomize_order: + random.shuffle(options_list) + + self._options_list = options_list + self.allow_multiple = allow_multiple + + if allow_multiple: + self._selection_widget = SelectMultiple( + options=options_list, + description="", + layout=Layout(width="auto"), + ) + else: + self._selection_widget = RadioButtons( + options=options_list, + description="", + layout=Layout(width="auto"), + ) + + if exercise_registry is None: + self._cue_selection = self._selection_widget + self._save_button = None + self._load_button = None + self._button_panel = None + else: + self._cue_selection = SaveCueBox( + self._selection_widget, "value", self._selection_widget, cued=True + ) + self._save_button = SaveResetCueButton( + self._cue_selection, + self._on_click_save_action, + disable_on_successful_action=kwargs.pop( + "disable_save_button_on_successful_action", False + ), + disable_during_action=kwargs.pop( + "disable_save_button_during_action", True + ), + description="Save answer", + button_tooltip="Saves answer to the loaded file", + ) + self._load_button = SaveResetCueButton( + self._cue_selection, + self._on_click_load_action, + disable_on_successful_action=kwargs.pop( + "disable_load_button_on_successful_action", False + ), + disable_during_action=kwargs.pop( + "disable_load_button_during_action", True + ), + description="Load answer", + button_tooltip="Loads answer from the loaded file", + ) + self._save_button.set_cue_widgets([self._cue_selection, self._load_button]) + self._load_button.set_cue_widgets([self._cue_selection, self._save_button]) + self._button_panel = HBox( + [self._save_button, self._load_button], + layout=Layout(justify_content="flex-end"), + ) + + self._output = Output() + + if exercise_registry is not None: + ExerciseWidget.__init__(self, exercise_registry, key) + else: + # otherwise ExerciseWidget constructor will raise an error + ExerciseWidget.__init__(self, None, None) + + widget_children = [CssStyle()] + if self._title_html is not None: + widget_children.append(self._title_html) + if self._description_html is not None: + widget_children.append(self._description_html) + widget_children.append(self._cue_selection) + if self._button_panel is not None: + widget_children.append(self._button_panel) + widget_children.append(self._output) + + VBox.__init__(self, widget_children, *args, **kwargs) + + @property + def title(self) -> Union[str, None]: + return self._title + + @property + def description(self) -> Union[str, None]: + return self._description + + @property + def answer(self) -> dict: + return {"selection": self._selection_widget.value} + + @answer.setter + def answer(self, answer) -> None: + if hasattr(self._cue_selection, "unobserve_widgets"): + self._cue_selection.unobserve_widgets() + if self._save_button is not None: + self._save_button.unobserve_widgets() + if self._load_button is not None: + self._load_button.unobserve_widgets() + + self._selection_widget.value = answer["selection"] + + if hasattr(self._cue_selection, "observe_widgets"): + self._cue_selection.observe_widgets() + if self._save_button is not None: + self._save_button.observe_widgets() + if self._load_button is not None: + self._load_button.observe_widgets() + + def _on_click_save_action(self) -> bool: + self._output.clear_output(wait=True) + raised_error = False + with self._output: + try: + result = self.save() + if isinstance(result, str): + print(Formatter.color_success_message(result)) + elif isinstance(result, Exception): + raise result + else: + print(result) + except Exception as e: + print(Formatter.color_error_message("Error raised while saving file:")) + raised_error = True + raise e + return not raised_error + + def _on_click_load_action(self) -> bool: + self._output.clear_output(wait=True) + raised_error = False + with self._output: + try: + result = self.load() + if isinstance(result, str): + print(Formatter.color_success_message(result)) + elif isinstance(result, Exception): + raise result + else: + print(result) + return True + except Exception as e: + print(Formatter.color_error_message("Error raised while loading file:")) + raised_error = True + raise e + return not raised_error + + def handle_save_result(self, result: Union[str, Exception]) -> None: + self._output.clear_output(wait=True) + with self._output: + if isinstance(result, Exception): + print(Formatter.color_error_message("Error raised while saving file:")) + raise result + else: + if self._load_button is not None: + self._load_button.cued = False + if self._save_button is not None: + self._save_button.cued = False + print(Formatter.color_success_message(result)) + + def handle_load_result(self, result: Union[str, Exception]) -> None: + self._output.clear_output(wait=True) + with self._output: + if isinstance(result, Exception): + print(Formatter.color_error_message("Error raised while loading file:")) + raise result + else: + if self._load_button is not None: + self._load_button.cued = False + if self._save_button is not None: + self._save_button.cued = False + print(Formatter.color_success_message(result))