mcqpy-shiny 0.2.0__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.
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.3
2
+ Name: mcqpy-shiny
3
+ Version: 0.2.0
4
+ Summary: Static-friendly Shiny quiz app for mcqpy bundles
5
+ Author: Mads-Peter
6
+ Author-email: Mads-Peter <machri@phys.au.dk>
7
+ Requires-Dist: mcqpy-core>=0.1.1
8
+ Requires-Dist: shiny>=1.2.1
9
+ Requires-Python: >=3.12, <3.14
10
+ Description-Content-Type: text/markdown
11
+
12
+ # mcqpy-shiny
13
+
14
+ Minimal Py-Shiny quiz runner for browser-ready bundles exported by `mcqpy`.
15
+
16
+ This package is built from its package-local source tree under `packages/mcqpy-shiny/src/mcqpy_shiny`.
17
+ The Quarto/Shinylive example project now lives under `shiny-pages/` at the monorepo root.
@@ -0,0 +1,6 @@
1
+ # mcqpy-shiny
2
+
3
+ Minimal Py-Shiny quiz runner for browser-ready bundles exported by `mcqpy`.
4
+
5
+ This package is built from its package-local source tree under `packages/mcqpy-shiny/src/mcqpy_shiny`.
6
+ The Quarto/Shinylive example project now lives under `shiny-pages/` at the monorepo root.
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "mcqpy-shiny"
3
+ version = "0.2.0"
4
+ description = "Static-friendly Shiny quiz app for mcqpy bundles"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Mads-Peter", email = "machri@phys.au.dk" }
8
+ ]
9
+ requires-python = ">=3.12,<3.14"
10
+ dependencies = [
11
+ "mcqpy-core>=0.1.1",
12
+ "shiny>=1.2.1",
13
+ ]
14
+
15
+ [tool.uv.sources]
16
+ mcqpy-core = { workspace = true }
17
+
18
+ [project.scripts]
19
+ mcqpy-shiny = "mcqpy_shiny.app:run_app"
20
+
21
+ [dependency-groups]
22
+ dev = [
23
+ "pytest>=8.4.1",
24
+ "shinylive>=0.8.5",
25
+ ]
26
+
27
+ [build-system]
28
+ requires = ["uv_build>=0.8.18,<0.9.0"]
29
+ build-backend = "uv_build"
30
+
31
+ [tool.uv.build-backend]
32
+ module-root = "src"
@@ -0,0 +1,22 @@
1
+ """Py-Shiny app factory for mcqpy web bundles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def create_app(*args: Any, **kwargs: Any):
9
+ from .app import create_app as _create_app
10
+
11
+ return _create_app(*args, **kwargs)
12
+
13
+
14
+ def __getattr__(name: str):
15
+ if name == "app":
16
+ from .app import app as _app
17
+
18
+ return _app
19
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
20
+
21
+
22
+ __all__ = ["app", "create_app"]
@@ -0,0 +1,49 @@
1
+ """Py-Shiny app for taking mcqpy web quizzes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from shiny import App
6
+
7
+ from mcqpy_core.web import WebQuizBundle, decode_quiz_token, grade_web_quiz
8
+ from .loader import load_bundle
9
+ from .quiz_app import create_quiz_app
10
+
11
+ async def _load_bundle(source: str) -> dict:
12
+ return await load_bundle(source)
13
+
14
+
15
+ def _grade_bundle(bundle: dict, answers: dict) -> dict:
16
+ validated_bundle = WebQuizBundle.model_validate(bundle)
17
+ return grade_web_quiz(validated_bundle, answers).model_dump()
18
+
19
+
20
+ def create_app(
21
+ *,
22
+ fixed_url: str | None = None,
23
+ fixed_token: str | None = None,
24
+ allow_manual_load: bool = True,
25
+ title: str = "MCQPy Quiz",
26
+ card_width: str = "900px",
27
+ ) -> App:
28
+ return create_quiz_app(
29
+ load_bundle=_load_bundle,
30
+ decode_token=decode_quiz_token,
31
+ grade_bundle=_grade_bundle,
32
+ missing_bundle_message="Load a quiz bundle to begin. Bundles are exported with `mcqpy export web`.",
33
+ fixed_url=fixed_url,
34
+ fixed_token=fixed_token,
35
+ allow_manual_load=allow_manual_load,
36
+ title=title,
37
+ card_width=card_width,
38
+ )
39
+
40
+
41
+ app = create_app()
42
+
43
+
44
+ def run_app() -> None:
45
+ """Entry point for `mcqpy-shiny`."""
46
+
47
+ from shiny import run_app
48
+
49
+ run_app(app)
@@ -0,0 +1,9 @@
1
+ """Shared callable types for quiz app construction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable
6
+
7
+ BundleLoader = Callable[[str], Awaitable[dict]]
8
+ TokenDecoder = Callable[[str], str]
9
+ BundleGrader = Callable[[dict, dict], dict]
@@ -0,0 +1,76 @@
1
+ """Browser-safe Py-Shiny app for Shinylive embeds."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import urljoin
6
+
7
+ from shiny import App
8
+ from mcqpy_core.web import WebQuizBundle, decode_quiz_token, grade_web_quiz
9
+
10
+ from .quiz_app import create_quiz_app
11
+
12
+
13
+ def load_bundle_json(raw: str) -> dict:
14
+ return WebQuizBundle.model_validate_json(raw).model_dump()
15
+
16
+
17
+ def _resolve_question_images(bundle: dict, source: str) -> dict:
18
+ updated_questions = []
19
+ for question in bundle.get("questions", []):
20
+ updated = dict(question)
21
+ updated["images"] = [
22
+ image if image.startswith(("http://", "https://", "data:")) else urljoin(source, image)
23
+ for image in question.get("images", [])
24
+ ]
25
+ updated_questions.append(updated)
26
+
27
+ updated_bundle = dict(bundle)
28
+ updated_bundle["questions"] = updated_questions
29
+ return updated_bundle
30
+
31
+
32
+ async def _fetch_text(url: str) -> str:
33
+ try:
34
+ from pyodide.http import pyfetch # type: ignore
35
+ except ImportError: # pragma: no cover
36
+ from urllib.request import urlopen
37
+
38
+ with urlopen(url) as response: # noqa: S310
39
+ return response.read().decode("utf-8")
40
+
41
+ response = await pyfetch(url)
42
+ if not response.ok:
43
+ raise ValueError(f"Failed to load quiz bundle from {url}")
44
+ return await response.string()
45
+
46
+
47
+ async def load_bundle(source: str) -> dict:
48
+ raw = await _fetch_text(source)
49
+ bundle = load_bundle_json(raw)
50
+ return _resolve_question_images(bundle, source)
51
+
52
+
53
+ def _grade_bundle(bundle: dict, answers: dict) -> dict:
54
+ validated_bundle = WebQuizBundle.model_validate(bundle)
55
+ return grade_web_quiz(validated_bundle, answers).model_dump()
56
+
57
+
58
+ def create_app(
59
+ *,
60
+ fixed_url: str | None = None,
61
+ fixed_token: str | None = None,
62
+ allow_manual_load: bool = True,
63
+ title: str = "MCQPy Quiz",
64
+ card_width: str = "900px",
65
+ ) -> App:
66
+ return create_quiz_app(
67
+ load_bundle=load_bundle,
68
+ decode_token=decode_quiz_token,
69
+ grade_bundle=_grade_bundle,
70
+ missing_bundle_message="Load a quiz bundle to begin.",
71
+ fixed_url=fixed_url,
72
+ fixed_token=fixed_token,
73
+ allow_manual_load=allow_manual_load,
74
+ title=title,
75
+ card_width=card_width,
76
+ )
@@ -0,0 +1,98 @@
1
+ """Bundle loading helpers for local and static-hosted quizzes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import mimetypes
7
+ from pathlib import Path
8
+ from urllib.parse import urljoin
9
+ from urllib.request import urlopen
10
+
11
+ from mcqpy_core.web import WebQuizBundle
12
+
13
+
14
+ def load_bundle_file(path: str | Path) -> dict:
15
+ return WebQuizBundle.load_from_file(path).model_dump()
16
+
17
+
18
+ def load_bundle_json(raw: str) -> dict:
19
+ return WebQuizBundle.model_validate_json(raw).model_dump()
20
+
21
+
22
+ def _is_remote_url(value: str) -> bool:
23
+ return value.startswith("http://") or value.startswith("https://")
24
+
25
+
26
+ def _path_to_data_url(path: Path) -> str:
27
+ mime_type, _ = mimetypes.guess_type(path.name)
28
+ if mime_type is None:
29
+ mime_type = "application/octet-stream"
30
+ encoded = base64.b64encode(path.read_bytes()).decode("ascii")
31
+ return f"data:{mime_type};base64,{encoded}"
32
+
33
+
34
+ def _resolve_question_images(bundle: dict, source: str) -> dict:
35
+ updated_questions = []
36
+ for question in bundle["questions"]:
37
+ updated = dict(question)
38
+ updated["images"] = [
39
+ image if _is_remote_url(image) else urljoin(source, image)
40
+ for image in question.get("images", [])
41
+ ]
42
+ updated_questions.append(updated)
43
+ updated_bundle = dict(bundle)
44
+ updated_bundle["questions"] = updated_questions
45
+ return updated_bundle
46
+
47
+
48
+ def _resolve_local_question_images(bundle: dict, bundle_dir: Path) -> dict:
49
+ updated_questions = []
50
+ for question in bundle["questions"]:
51
+ images = []
52
+ for image in question.get("images", []):
53
+ image_path = Path(image)
54
+ if image_path.is_absolute():
55
+ resolved = image_path
56
+ else:
57
+ resolved = (bundle_dir / image_path).resolve()
58
+
59
+ images.append(_path_to_data_url(resolved))
60
+
61
+ updated = dict(question)
62
+ updated["images"] = images
63
+ updated_questions.append(updated)
64
+
65
+ updated_bundle = dict(bundle)
66
+ updated_bundle["questions"] = updated_questions
67
+ return updated_bundle
68
+
69
+
70
+ async def _fetch_text(url: str) -> str:
71
+ try:
72
+ from pyodide.http import pyfetch # type: ignore
73
+ except ImportError:
74
+ with urlopen(url) as response: # noqa: S310
75
+ return response.read().decode("utf-8")
76
+
77
+ response = await pyfetch(url)
78
+ if not response.ok:
79
+ raise ValueError(f"Failed to load quiz bundle from {url}")
80
+ return await response.string()
81
+
82
+
83
+ def load_bundle_from_path(path: str | Path) -> dict:
84
+ bundle_path = Path(path).resolve()
85
+ bundle = load_bundle_file(bundle_path)
86
+ return _resolve_local_question_images(bundle, bundle_path.parent)
87
+
88
+
89
+ async def load_bundle_from_url(url: str) -> dict:
90
+ raw = await _fetch_text(url)
91
+ bundle = load_bundle_json(raw)
92
+ return _resolve_question_images(bundle, url)
93
+
94
+
95
+ async def load_bundle(source: str) -> dict:
96
+ if _is_remote_url(source):
97
+ return await load_bundle_from_url(source)
98
+ return load_bundle_from_path(source)
@@ -0,0 +1,254 @@
1
+ """Shared quiz app factory used by local and Shinylive runtimes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import traceback
6
+
7
+ from shiny import App, reactive, render, ui
8
+
9
+ from .app_types import BundleGrader, BundleLoader, TokenDecoder
10
+ from .styles import MATHJAX_BOOTSTRAP, MATHJAX_HEAD, build_css
11
+ from .views import (
12
+ answer_input_id,
13
+ render_error_card,
14
+ render_load_panel,
15
+ render_question_card,
16
+ render_results_card,
17
+ render_review_card,
18
+ )
19
+
20
+
21
+ def create_quiz_app(
22
+ *,
23
+ load_bundle: BundleLoader,
24
+ decode_token: TokenDecoder,
25
+ grade_bundle: BundleGrader,
26
+ missing_bundle_message: str,
27
+ fixed_url: str | None = None,
28
+ fixed_token: str | None = None,
29
+ allow_manual_load: bool = True,
30
+ title: str = "MCQPy Quiz",
31
+ card_width: str = "900px",
32
+ ) -> App:
33
+ app_ui = ui.page_fillable(
34
+ ui.head_content(
35
+ ui.tags.script(MATHJAX_HEAD),
36
+ ui.tags.script(src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js"),
37
+ ui.tags.style(build_css(card_width)),
38
+ ui.tags.script(MATHJAX_BOOTSTRAP),
39
+ ),
40
+ ui.div(
41
+ ui.h1(title),
42
+ ui.output_ui("load_panel"),
43
+ ui.hr(),
44
+ ui.output_ui("content"),
45
+ class_="mcqpy-shell",
46
+ ),
47
+ )
48
+
49
+ def server(input, output, session):
50
+ bundle = reactive.value(None)
51
+ answers = reactive.value({})
52
+ current_index = reactive.value(0)
53
+ load_error = reactive.value(None)
54
+ result = reactive.value(None)
55
+ result_error = reactive.value(None)
56
+ auto_loaded = reactive.value(False)
57
+
58
+ def _bundle() -> dict | None:
59
+ return bundle.get()
60
+
61
+ def _current_questions() -> list[dict]:
62
+ loaded = _bundle()
63
+ return [] if loaded is None else loaded["questions"]
64
+
65
+ def _store_current_answer() -> None:
66
+ loaded = _bundle()
67
+ if loaded is None:
68
+ return
69
+
70
+ index = current_index.get()
71
+ questions = loaded["questions"]
72
+ if not (0 <= index < len(questions)):
73
+ return
74
+
75
+ input_id = answer_input_id(index)
76
+ current_value = input[input_id]()
77
+ next_answers = dict(answers.get())
78
+ next_answers[questions[index]["qid"]] = current_value
79
+ answers.set(next_answers)
80
+
81
+ async def _load_source(source: str) -> None:
82
+ if not source:
83
+ load_error.set("Provide a quiz bundle URL, token, or local path.")
84
+ return
85
+
86
+ try:
87
+ loaded = await load_bundle(source)
88
+ except Exception as exc:
89
+ load_error.set(str(exc))
90
+ bundle.set(None)
91
+ result.set(None)
92
+ result_error.set(None)
93
+ return
94
+
95
+ load_error.set(None)
96
+ bundle.set(loaded)
97
+ answers.set({})
98
+ result.set(None)
99
+ result_error.set(None)
100
+ current_index.set(0)
101
+
102
+ @reactive.effect
103
+ async def _auto_load_fixed_bundle():
104
+ if auto_loaded.get():
105
+ return
106
+
107
+ source = None
108
+ if fixed_token:
109
+ source = decode_token(fixed_token)
110
+ elif fixed_url:
111
+ source = fixed_url
112
+
113
+ if source is None:
114
+ return
115
+
116
+ auto_loaded.set(True)
117
+ await _load_source(source)
118
+
119
+ @reactive.effect
120
+ @reactive.event(input.load_link)
121
+ async def _load_link():
122
+ await _load_source(input.quiz_url().strip())
123
+
124
+ @reactive.effect
125
+ @reactive.event(input.load_token)
126
+ async def _load_token():
127
+ token = input.quiz_token().strip()
128
+ try:
129
+ source = decode_token(token)
130
+ except ValueError as exc:
131
+ load_error.set(str(exc))
132
+ return
133
+ await _load_source(source)
134
+
135
+ @reactive.effect
136
+ @reactive.event(input.next_question)
137
+ def _next_question():
138
+ _store_current_answer()
139
+ questions = _current_questions()
140
+ if questions:
141
+ current_index.set(min(len(questions), current_index.get() + 1))
142
+
143
+ @reactive.effect
144
+ @reactive.event(input.prev_question)
145
+ def _prev_question():
146
+ _store_current_answer()
147
+ current_index.set(max(0, current_index.get() - 1))
148
+
149
+ @reactive.effect
150
+ @reactive.event(input.jump_slider)
151
+ def _jump_from_slider():
152
+ loaded = _bundle()
153
+ if loaded is None:
154
+ return
155
+ _store_current_answer()
156
+ target = input.jump_slider()
157
+ if target is None:
158
+ return
159
+ current_index.set(min(max(target - 1, 0), len(loaded["questions"])))
160
+
161
+ @reactive.effect
162
+ @reactive.event(input.jump_number)
163
+ def _jump_from_number():
164
+ loaded = _bundle()
165
+ if loaded is None:
166
+ return
167
+ _store_current_answer()
168
+ target = input.jump_number()
169
+ if target is None:
170
+ return
171
+ current_index.set(min(max(target - 1, 0), len(loaded["questions"])))
172
+
173
+ @reactive.effect
174
+ @reactive.event(input.jump_grid)
175
+ def _jump_from_grid():
176
+ loaded = _bundle()
177
+ if loaded is None:
178
+ return
179
+ _store_current_answer()
180
+ target = input.jump_grid()
181
+ if target is None:
182
+ return
183
+ current_index.set(min(max(int(target) - 1, 0), len(loaded["questions"])))
184
+
185
+ @reactive.effect
186
+ @reactive.event(input.submit_quiz)
187
+ def _submit_quiz():
188
+ loaded = _bundle()
189
+ if loaded is None:
190
+ return
191
+ _store_current_answer()
192
+ try:
193
+ graded = grade_bundle(loaded, answers.get())
194
+ except Exception:
195
+ result.set(None)
196
+ result_error.set(traceback.format_exc())
197
+ return
198
+
199
+ result_error.set(None)
200
+ result.set(graded)
201
+
202
+ @reactive.effect
203
+ @reactive.event(input.restart_quiz)
204
+ def _restart_quiz():
205
+ answers.set({})
206
+ result.set(None)
207
+ result_error.set(None)
208
+ current_index.set(0)
209
+
210
+ @output
211
+ @render.ui
212
+ def load_panel():
213
+ return render_load_panel(
214
+ bundle_loaded=_bundle() is not None,
215
+ fixed_url=fixed_url,
216
+ fixed_token=fixed_token,
217
+ allow_manual_load=allow_manual_load,
218
+ )
219
+
220
+ @output
221
+ @render.ui
222
+ def content():
223
+ if load_error.get():
224
+ return ui.p(load_error.get(), class_="text-danger")
225
+
226
+ if result_error.get():
227
+ return render_error_card(result_error.get())
228
+
229
+ loaded = _bundle()
230
+ if loaded is None:
231
+ return ui.markdown(missing_bundle_message)
232
+
233
+ if result.get() is not None:
234
+ graded = result.get()
235
+ try:
236
+ return render_results_card(loaded, graded)
237
+ except Exception:
238
+ result_error.set(traceback.format_exc())
239
+ return render_error_card(result_error.get())
240
+
241
+ questions = loaded["questions"]
242
+ index = current_index.get()
243
+
244
+ if index >= len(questions):
245
+ return render_review_card(questions, answers.get(), index)
246
+
247
+ return render_question_card(
248
+ loaded=loaded,
249
+ answers=answers.get(),
250
+ index=index,
251
+ title=title,
252
+ )
253
+
254
+ return App(app_ui, server)
@@ -0,0 +1,174 @@
1
+ """Styling and MathJax bootstrap for the quiz app."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def build_css(card_width: str) -> str:
7
+ return """
8
+ .mcqpy-shell {
9
+ max-width: 1200px;
10
+ margin: 0 auto;
11
+ padding: 1.5rem 1rem 3rem;
12
+ }
13
+ .mcqpy-card {
14
+ width: __CARD_WIDTH__;
15
+ max-width: __CARD_WIDTH__;
16
+ min-width: __CARD_WIDTH__;
17
+ margin: 0 auto;
18
+ box-sizing: border-box;
19
+ overflow-x: hidden;
20
+ }
21
+ .mcqpy-card .card-body {
22
+ overflow-x: hidden;
23
+ }
24
+ .mcqpy-question-text,
25
+ .mcqpy-question-text p,
26
+ .mcqpy-question-text li {
27
+ font-size: 1.05rem;
28
+ line-height: 1.65;
29
+ overflow-wrap: anywhere;
30
+ word-break: break-word;
31
+ }
32
+ .mcqpy-question-meta {
33
+ margin: 0.25rem 0 1rem;
34
+ color: #475569;
35
+ font-size: 0.98rem;
36
+ }
37
+ .mcqpy-media {
38
+ margin: 1rem 0 1.5rem;
39
+ min-height: 0;
40
+ }
41
+ .mcqpy-media img {
42
+ display: block;
43
+ max-width: min(100%, 900px);
44
+ height: auto;
45
+ margin: 0 auto;
46
+ }
47
+ .mcqpy-media pre {
48
+ max-width: 100%;
49
+ overflow-x: auto;
50
+ white-space: pre-wrap;
51
+ }
52
+ .mcqpy-media code,
53
+ .mcqpy-question-text code {
54
+ overflow-wrap: anywhere;
55
+ word-break: break-word;
56
+ }
57
+ .mcqpy-answer-group .shiny-input-radiogroup,
58
+ .mcqpy-answer-group .shiny-input-checkboxgroup {
59
+ width: 100%;
60
+ }
61
+ .mcqpy-answer-group .radio,
62
+ .mcqpy-answer-group .checkbox {
63
+ display: block;
64
+ width: 100%;
65
+ margin-bottom: 0.9rem;
66
+ }
67
+ .mcqpy-answer-group label.radio,
68
+ .mcqpy-answer-group label.checkbox {
69
+ display: block;
70
+ width: 100%;
71
+ padding: 0.9rem 1rem;
72
+ border: 1px solid #d9dee5;
73
+ border-radius: 0.75rem;
74
+ background: #fff;
75
+ }
76
+ .mcqpy-answer-group input[type="radio"],
77
+ .mcqpy-answer-group input[type="checkbox"] {
78
+ margin-right: 0.65rem;
79
+ }
80
+ .mcqpy-nav-row {
81
+ margin-top: 1.25rem;
82
+ }
83
+ .mcqpy-jump-row {
84
+ margin: 0.5rem 0 1rem;
85
+ align-items: end;
86
+ }
87
+ .mcqpy-results-chart {
88
+ margin: 1.25rem 0 0.75rem;
89
+ padding: 0.75rem;
90
+ border: 1px solid #e2e8f0;
91
+ border-radius: 0.85rem;
92
+ background: #fcfdff;
93
+ }
94
+ .mcqpy-results-chart svg {
95
+ display: block;
96
+ width: 100%;
97
+ height: auto;
98
+ }
99
+ .mcqpy-overview-grid {
100
+ display: grid;
101
+ grid-template-columns: repeat(auto-fit, minmax(68px, 1fr));
102
+ gap: 0.6rem;
103
+ margin: 0.9rem 0 1.25rem;
104
+ }
105
+ .mcqpy-overview-button {
106
+ display: flex;
107
+ align-items: center;
108
+ justify-content: center;
109
+ width: 100%;
110
+ min-height: 58px;
111
+ padding: 0.7rem;
112
+ border: 1px solid #d9dee5;
113
+ border-radius: 0.75rem;
114
+ background: #fff;
115
+ text-align: center;
116
+ }
117
+ .mcqpy-overview-button.is-answered {
118
+ background: #eff6ff;
119
+ border-color: #93c5fd;
120
+ }
121
+ .mcqpy-overview-button.is-current {
122
+ border-color: #2563eb;
123
+ box-shadow: inset 0 0 0 1px #2563eb;
124
+ }
125
+ .mcqpy-overview-number {
126
+ font-size: 1rem;
127
+ font-weight: 700;
128
+ color: #2563eb;
129
+ }
130
+ @media (max-width: 820px) {
131
+ .mcqpy-card {
132
+ width: 100%;
133
+ max-width: 100%;
134
+ min-width: 0;
135
+ }
136
+ }
137
+ """.replace("__CARD_WIDTH__", card_width)
138
+
139
+
140
+ MATHJAX_HEAD = """
141
+ window.MathJax = {
142
+ tex: { inlineMath: [['$', '$'], ['\\\\(', '\\\\)']], displayMath: [['$$', '$$'], ['\\\\[', '\\\\]']] },
143
+ svg: { fontCache: 'global' }
144
+ };
145
+ """
146
+
147
+
148
+ MATHJAX_BOOTSTRAP = """
149
+ (() => {
150
+ let timer = null;
151
+ let observer = null;
152
+ const typeset = () => {
153
+ if (!window.MathJax || !window.MathJax.typesetPromise) return;
154
+ const nodes = document.querySelectorAll('.mcqpy-math');
155
+ if (nodes.length) window.MathJax.typesetPromise(Array.from(nodes)).catch(() => {});
156
+ };
157
+ const schedule = () => {
158
+ clearTimeout(timer);
159
+ timer = setTimeout(typeset, 125);
160
+ };
161
+ const install = () => {
162
+ schedule();
163
+ if (observer || !document.body) return;
164
+ observer = new MutationObserver(schedule);
165
+ observer.observe(document.body, { childList: true, subtree: true });
166
+ };
167
+ if (document.readyState === 'loading') {
168
+ document.addEventListener('DOMContentLoaded', install, { once: true });
169
+ } else {
170
+ install();
171
+ }
172
+ window.addEventListener('load', schedule);
173
+ })();
174
+ """
@@ -0,0 +1,273 @@
1
+ """UI helpers for quiz rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from html import escape
6
+
7
+ from shiny import ui
8
+
9
+
10
+ def answer_input_id(index: int) -> str:
11
+ return f"answer_{index}"
12
+
13
+
14
+ def _choice_map(choices: list[str]) -> dict[str, str]:
15
+ return {
16
+ chr(65 + index): f"({chr(65 + index)}) {choice}"
17
+ for index, choice in enumerate(choices)
18
+ }
19
+
20
+
21
+ def render_question_media(question: dict) -> list:
22
+ media = []
23
+ captions = question.get("image_captions", {})
24
+
25
+ for index, image in enumerate(question.get("images", [])):
26
+ media.append(ui.img(src=image, style="max-width: 100%; height: auto;"))
27
+ caption = captions.get(str(index), captions.get(index))
28
+ if caption:
29
+ media.append(ui.p(caption, class_="text-muted"))
30
+
31
+ shared_caption = captions.get("-1", captions.get(-1))
32
+ if shared_caption:
33
+ media.append(ui.p(shared_caption, class_="text-muted"))
34
+
35
+ for block in question.get("code_blocks", []):
36
+ media.append(
37
+ ui.tags.pre(
38
+ ui.tags.code(
39
+ block.get("code", ""),
40
+ **{"data-language": block.get("language") or "text"},
41
+ )
42
+ )
43
+ )
44
+ return media
45
+
46
+
47
+ def question_answer_ui(question: dict, index: int):
48
+ input_id = answer_input_id(index)
49
+ choices = _choice_map(question["choices"])
50
+ if question["question_type"] == "single":
51
+ return ui.input_radio_buttons(input_id, "Select your answer", choices=choices)
52
+ return ui.input_checkbox_group(input_id, "Select your answers", choices=choices)
53
+
54
+
55
+ def result_chart_svg(questions: list[dict], graded: dict) -> ui.HTML:
56
+ width = 760
57
+ row_height = 30
58
+ top = 28
59
+ left = 92
60
+ right = 58
61
+ bottom = 36
62
+ plot_width = width - left - right
63
+ height = top + bottom + row_height * max(len(graded["question_results"]), 1)
64
+ max_points = max((item["max_points"] for item in graded["question_results"]), default=1)
65
+
66
+ parts = [
67
+ f'<svg viewBox="0 0 {width} {height}" width="100%" height="auto" role="img" aria-label="Points by question">',
68
+ f'<rect x="0" y="0" width="{width}" height="{height}" fill="#ffffff"/>',
69
+ f'<line x1="{left}" y1="{top - 8}" x2="{left}" y2="{height - bottom + 4}" stroke="#c9d2dc" stroke-width="1"/>',
70
+ ]
71
+
72
+ for idx, (question, item) in enumerate(zip(questions, graded["question_results"], strict=False)):
73
+ y = top + idx * row_height
74
+ bar_width = 0 if max_points == 0 else (item["points"] / max_points) * plot_width
75
+ max_width = 0 if max_points == 0 else (item["max_points"] / max_points) * plot_width
76
+ label = escape(str(idx + 1))
77
+ title = escape(question["slug"])
78
+
79
+ parts.extend(
80
+ [
81
+ f'<title>{title}</title>',
82
+ f'<text x="{left - 10}" y="{y + 17}" text-anchor="end" font-size="13" fill="#334155">{label}</text>',
83
+ f'<rect x="{left}" y="{y + 4}" rx="6" ry="6" width="{max_width}" height="18" fill="#e9eef4"/>',
84
+ f'<rect x="{left}" y="{y + 4}" rx="6" ry="6" width="{bar_width}" height="18" fill="#3b82f6"/>',
85
+ f'<text x="{left + max_width + 8}" y="{y + 18}" font-size="12" fill="#475569">{item["points"]}/{item["max_points"]}</text>',
86
+ ]
87
+ )
88
+
89
+ for tick in range(max_points + 1):
90
+ x = left + (0 if max_points == 0 else (tick / max_points) * plot_width)
91
+ parts.extend(
92
+ [
93
+ f'<line x1="{x}" y1="{top - 8}" x2="{x}" y2="{height - bottom + 4}" stroke="#f1f5f9" stroke-width="1"/>',
94
+ f'<text x="{x}" y="{height - 10}" text-anchor="middle" font-size="12" fill="#64748b">{tick}</text>',
95
+ ]
96
+ )
97
+
98
+ parts.append("</svg>")
99
+ return ui.HTML("".join(parts))
100
+
101
+
102
+ def question_overview_grid(questions: list[dict], answers: dict, current_index: int):
103
+ buttons = []
104
+ for idx, question in enumerate(questions):
105
+ saved = answers.get(question["qid"])
106
+ is_answered = saved not in (None, [], ())
107
+ classes = ["mcqpy-overview-button"]
108
+ if is_answered:
109
+ classes.append("is-answered")
110
+ if idx == current_index:
111
+ classes.append("is-current")
112
+
113
+ buttons.append(
114
+ ui.tags.button(
115
+ ui.tags.span(str(idx + 1), class_="mcqpy-overview-number"),
116
+ type="button",
117
+ onclick=f"Shiny.setInputValue('jump_grid', {idx + 1}, {{priority: 'event'}})",
118
+ class_=" ".join(classes),
119
+ title=question["slug"],
120
+ )
121
+ )
122
+
123
+ return ui.div(*buttons, class_="mcqpy-overview-grid")
124
+
125
+
126
+ def render_error_card(error_text: str):
127
+ return ui.card(
128
+ ui.h2("Grading Error"),
129
+ ui.tags.pre(error_text),
130
+ ui.input_action_button("restart_quiz", "Restart quiz"),
131
+ class_="mcqpy-card",
132
+ )
133
+
134
+
135
+ def render_load_panel(
136
+ *,
137
+ bundle_loaded: bool,
138
+ fixed_url: str | None,
139
+ fixed_token: str | None,
140
+ allow_manual_load: bool,
141
+ ):
142
+ if bundle_loaded:
143
+ return ui.div()
144
+
145
+ if fixed_url or fixed_token:
146
+ if not allow_manual_load:
147
+ return ui.div()
148
+ helper = "A fixed quiz is configured for this app, or you can load another one."
149
+ else:
150
+ helper = "Load a quiz bundle from a public link or an obfuscated token."
151
+
152
+ return ui.card(
153
+ ui.p(helper),
154
+ ui.row(
155
+ ui.column(
156
+ 6,
157
+ ui.input_text("quiz_url", "Quiz bundle URL", placeholder="https://.../quiz.json"),
158
+ ui.input_action_button("load_link", "Load from link"),
159
+ ),
160
+ ui.column(
161
+ 6,
162
+ ui.input_text("quiz_token", "Obfuscated token", placeholder="mcqpy:..."),
163
+ ui.input_action_button("load_token", "Load from token"),
164
+ ),
165
+ ),
166
+ class_="mcqpy-card",
167
+ )
168
+
169
+
170
+ def render_results_card(loaded: dict, graded: dict):
171
+ summary = [
172
+ ui.h2("Results"),
173
+ ui.p(f"Score: {graded['points']} / {graded['max_points']}"),
174
+ ui.div(
175
+ ui.h3("Points by question"),
176
+ result_chart_svg(loaded["questions"], graded),
177
+ ui.p(
178
+ "Bars show earned points for each question number; hover labels reflect the full slug.",
179
+ class_="text-muted",
180
+ ),
181
+ class_="mcqpy-results-chart",
182
+ ),
183
+ ui.tags.ul(
184
+ *[
185
+ ui.tags.li(f"{question['slug']}: {item['points']}/{item['max_points']}")
186
+ for question, item in zip(
187
+ loaded["questions"], graded["question_results"], strict=False
188
+ )
189
+ ]
190
+ ),
191
+ ui.input_action_button("restart_quiz", "Restart quiz"),
192
+ ]
193
+ return ui.card(*summary, class_="mcqpy-card")
194
+
195
+
196
+ def render_review_card(questions: list[dict], answers: dict, index: int):
197
+ items = []
198
+ for question in questions:
199
+ saved = answers.get(question["qid"])
200
+ items.append(
201
+ ui.tags.li(
202
+ f"{question['slug']}: {saved if saved not in (None, [], ()) else 'No answer selected'}"
203
+ )
204
+ )
205
+
206
+ return ui.card(
207
+ ui.h2("Review"),
208
+ ui.p("Submit the quiz when you are ready."),
209
+ question_overview_grid(questions, answers, index),
210
+ ui.tags.ul(*items),
211
+ ui.row(
212
+ ui.column(6, ui.input_action_button("prev_question", "Previous question")),
213
+ ui.column(6, ui.input_action_button("submit_quiz", "Submit and grade")),
214
+ ),
215
+ class_="mcqpy-card mcqpy-math",
216
+ )
217
+
218
+
219
+ def render_question_card(loaded: dict, answers: dict, index: int, title: str):
220
+ questions = loaded["questions"]
221
+ question = questions[index]
222
+ progress = f"Question {index + 1} of {len(questions)}"
223
+ question_kind = (
224
+ "Single answer" if question["question_type"] == "single" else "Multiple answers"
225
+ )
226
+
227
+ body = [
228
+ ui.h2(loaded.get("metadata", {}).get("title", title)),
229
+ ui.p(progress, class_="text-muted"),
230
+ ui.row(
231
+ ui.column(
232
+ 8,
233
+ ui.input_slider(
234
+ "jump_slider",
235
+ "Jump to question",
236
+ min=1,
237
+ max=len(questions) + 1,
238
+ value=index + 1,
239
+ step=1,
240
+ width="100%",
241
+ ),
242
+ ),
243
+ ui.column(
244
+ 4,
245
+ ui.input_numeric(
246
+ "jump_number",
247
+ "Question number",
248
+ value=index + 1,
249
+ min=1,
250
+ max=len(questions) + 1,
251
+ width="100%",
252
+ ),
253
+ ),
254
+ class_="mcqpy-jump-row",
255
+ ),
256
+ ui.h3(question["slug"]),
257
+ ui.p(
258
+ f"{question_kind} • {question['point_value']} point"
259
+ f"{'' if question['point_value'] == 1 else 's'}",
260
+ class_="mcqpy-question-meta",
261
+ ),
262
+ ui.div(ui.markdown(question["text"]), class_="mcqpy-question-text"),
263
+ ui.div(*render_question_media(question), class_="mcqpy-media"),
264
+ ui.div(question_answer_ui(question, index), class_="mcqpy-answer-group"),
265
+ ui.row(
266
+ ui.column(4, ui.input_action_button("prev_question", "Previous")),
267
+ ui.column(4, ui.input_action_button("next_question", "Next")),
268
+ ui.column(4, ui.div()),
269
+ class_="mcqpy-nav-row",
270
+ ),
271
+ question_overview_grid(questions, answers, index),
272
+ ]
273
+ return ui.card(*body, class_="mcqpy-card mcqpy-math")