diff --git a/src/scwidgets/exercise/__init__.py b/src/scwidgets/exercise/__init__.py
index 802b032..99bce9a 100644
--- a/src/scwidgets/exercise/__init__.py
+++ b/src/scwidgets/exercise/__init__.py
@@ -1,5 +1,6 @@
from ._widget_code_exercise import CodeExercise
from ._widget_exercise_registry import ExerciseRegistry, ExerciseWidget
from ._widget_text_exercise import TextExercise
+from ._widget_multiplechoice_exercise import MultipleChoiceExercise
-__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..b05b9db
--- /dev/null
+++ b/src/scwidgets/exercise/_widget_multiplechoice_exercise.py
@@ -0,0 +1,273 @@
+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
+
+ 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")
+
+ 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) -> tuple:
+ if self.allow_multiple:
+ return tuple(self._selection_widget.value)
+ else:
+ return (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()
+
+ if len(answer) == 1:
+ self._selection_widget.value = answer[0]
+ else:
+ self._selection_widget.value = answer
+
+ 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))
+
+ def check_correct_answers(self, *correct_answers) -> bool:
+ if isinstance(correct_answers[0], (list, tuple, set)):
+ correct_answers = correct_answers[0]
+ if self.allow_multiple:
+ return sorted(self.answer) == sorted(correct_answers)
+ else:
+ return self.answer[0] == correct_answers[0]