Skip to content

marimo-learn

Formative assessment widgets for marimo notebooks. Open in molab

Installation

pip install marimo-learn

Usage in marimo

from marimo_learn import FlashcardWidget, MultipleChoiceWidget, OrderingWidget
from marimo_learn import MatchingWidget, LabelingWidget, ConceptMapWidget

Each widget is an anywidget component. Pass the configuration as constructor arguments; the widget syncs state back to Python via the value traitlet.

Standalone JavaScript bundle

The widgets are also published as an npm package:

npm install marimo-learn

Or load the bundle directly in HTML:

<script type="module" src="marimo-learn.js"></script>

The bundle auto-mounts any .marimo-* divs it finds in the page (see HTML configuration below). You can also call the render functions manually:

import { renderFlashcard, renderMultipleChoice, renderOrdering,
         renderMatching, renderLabeling, renderConceptMap } from 'marimo-learn';

renderMultipleChoice(document.getElementById('target'), {
  question: 'What is 2 + 2?',
  options: ['3', '4', '5'],
  correct_answer: 1,
  lang: 'en',
});

HTML configuration

When the bundle is loaded, autoMount() scans the page for divs with the class marimo-<widget>, parses the HTML markup inside each div, and replaces it with a live widget. This lets you author exercises in plain HTML or Markdown.

Multiple choice

<div class="marimo-multiple-choice" data-correct="2">
  <p>Question text</p>
  <ol>
    <li>Option A</li>
    <li>Option B</li>
    <li>Option C (correct — zero-based index matches data-correct)</li>
    <li>Option D</li>
  </ol>
</div>
  • data-correct — zero-based index of the correct option (required)
  • data-lang — language code, default en

Flashcard

<div class="marimo-flashcard">
  <p>Deck title (optional)</p>
  <dl>
    <dt>Front of card 1</dt>
    <dd>Back of card 1</dd>
    <dt>Front of card 2</dt>
    <dd>Back of card 2</dd>
  </dl>
</div>

Cards are shuffled automatically. data-lang sets the language (default en).

Ordering

<div class="marimo-ordering">
  <p>Question text</p>
  <ol>
    <li>First step (correct position)</li>
    <li>Second step</li>
    <li>Third step</li>
  </ol>
</div>

The <ol> lists items in the correct order. The widget shuffles them before presenting them to the student. data-lang sets the language (default en).

Matching

<div class="marimo-matching">
  <p>Question text</p>
  <table>
    <tr><td>Left item 1</td><td>Right item 1</td></tr>
    <tr><td>Left item 2</td><td>Right item 2</td></tr>
    <tr><td>Left item 3</td><td>Right item 3</td></tr>
  </table>
</div>

Each row defines a matched pair. The right column is shuffled automatically. data-lang sets the language (default en).

Labeling

<div class="marimo-labeling">
  <p>Question text</p>
  <table>
    <tr><td>Text line 1</td><td>Label name</td></tr>
    <tr><td>Text line 2</td><td>Label name</td></tr>
    <tr><td>Text line 3</td><td>Label A, Label B</td></tr>
  </table>
</div>

Column 1 is the text to label; column 2 is the correct label name. Multiple correct labels for one line are written as a comma-separated list. The available label set is derived automatically from the unique names in column 2. data-lang sets the language (default en).

Concept map

<div class="marimo-concept-map">
  <p>Question text</p>
  <table>
    <tr><td>Source node</td><td>relationship</td><td>Target node</td></tr>
    <tr><td>Node A</td>    <td>leads to</td>    <td>Node B</td></tr>
    <tr><td>Node B</td>    <td>leads to</td>    <td>Node C</td></tr>
  </table>
</div>

Each row defines one correct directed edge: source → label → target. The node list and term list are inferred automatically from the table in first-appearance order. data-lang sets the language (default en).

Utilities for use in marimo notebooks.

Color

Bases: str, Enum

Standard colors available to Turtle turtles.

Source code in src/marimo_learn/turtle.py
38
39
40
41
42
43
44
45
46
47
class Color(str, Enum):
    """Standard colors available to Turtle turtles."""

    CORNFLOWER = "#8ecae6"
    CRIMSON = "#e63946"
    GOLD = "#e9c46a"
    SAGE = "#b5e48c"
    SANDY = "#f4a261"
    SKY = "#a8dadc"
    TEAL = "#2ec4b6"

ConceptMapWidget

Bases: BaseWidget

A concept mapping widget where students draw labeled directed edges between concepts.

Students select a relationship term then click two concept nodes to connect them. Concept nodes can be dragged to rearrange the layout.

Attributes:

Name Type Description
question str

The question or prompt shown above the map

concepts list

List of concept names (nodes)

terms list

List of relationship terms that can label edges

correct_edges list

List of dicts with 'from', 'to', 'label' keys

value dict

State with 'edges', 'score', 'total', and 'correct' keys

Source code in src/marimo_learn/concept_map.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class ConceptMapWidget(BaseWidget):
    """
    A concept mapping widget where students draw labeled directed edges between concepts.

    Students select a relationship term then click two concept nodes to connect them.
    Concept nodes can be dragged to rearrange the layout.

    Attributes:
        question (str): The question or prompt shown above the map
        concepts (list): List of concept names (nodes)
        terms (list): List of relationship terms that can label edges
        correct_edges (list): List of dicts with 'from', 'to', 'label' keys
        value (dict): State with 'edges', 'score', 'total', and 'correct' keys
    """

    _esm = Path(__file__).parent / "static" / "concept-map.js"

    question = traitlets.Unicode("").tag(sync=True)
    concepts = traitlets.List(trait=traitlets.Unicode()).tag(sync=True)
    terms = traitlets.List(trait=traitlets.Unicode()).tag(sync=True)
    correct_edges = traitlets.List().tag(sync=True)

    def __init__(
        self,
        question: str,
        concepts: list[str],
        terms: list[str],
        correct_edges: list[dict] | None = None,
        lang: str = "en",
        **kwargs,
    ):
        super().__init__(
            question=question,
            concepts=concepts,
            terms=terms,
            correct_edges=correct_edges if correct_edges is not None else [],
            lang=lang,
            **kwargs,
        )

FlashcardWidget

Bases: BaseWidget

A flashcard widget with self-reported spaced repetition.

Students flip cards to reveal the answer, then rate themselves (Got it / Almost / No). Cards rated "Almost" or "No" are re-inserted into the queue; the deck is complete when all cards are rated "Got it".

Attributes:

Name Type Description
question str

Optional heading shown above the deck

cards list

List of dicts with 'front' and 'back' keys

shuffle bool

Whether to shuffle the deck initially

value dict

State with 'results' (per-card ratings/attempts) and 'complete'

Source code in src/marimo_learn/flashcard.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class FlashcardWidget(BaseWidget):
    """
    A flashcard widget with self-reported spaced repetition.

    Students flip cards to reveal the answer, then rate themselves
    (Got it / Almost / No). Cards rated "Almost" or "No" are re-inserted
    into the queue; the deck is complete when all cards are rated "Got it".

    Attributes:
        question (str): Optional heading shown above the deck
        cards (list): List of dicts with 'front' and 'back' keys
        shuffle (bool): Whether to shuffle the deck initially
        value (dict): State with 'results' (per-card ratings/attempts) and 'complete'
    """

    _esm = Path(__file__).parent / "static" / "flashcard.js"

    question = traitlets.Unicode("").tag(sync=True)
    cards = traitlets.List().tag(sync=True)
    shuffle = traitlets.Bool(True).tag(sync=True)

    def __init__(self, cards, question="", shuffle=True, lang="en", **kwargs):
        super().__init__(cards=cards, question=question, shuffle=shuffle, lang=lang, **kwargs)

LabelingWidget

Bases: BaseWidget

A text labeling widget where students drag numbered labels to text lines.

Attributes:

Name Type Description
question str

The question text to display

labels list

List of label texts (shown on left)

text_lines list

List of text lines to be labeled (shown on right)

correct_labels dict

Mapping of line indices to lists of correct label indices

value dict

Current state with 'placed_labels', 'score', 'total', and 'correct' keys

Source code in src/marimo_learn/labeling.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class LabelingWidget(BaseWidget):
    """
    A text labeling widget where students drag numbered labels to text lines.

    Attributes:
        question (str): The question text to display
        labels (list): List of label texts (shown on left)
        text_lines (list): List of text lines to be labeled (shown on right)
        correct_labels (dict): Mapping of line indices to lists of correct label indices
        value (dict): Current state with 'placed_labels', 'score', 'total', and 'correct' keys
    """

    _esm = Path(__file__).parent / "static" / "labeling.js"

    question = traitlets.Unicode("").tag(sync=True)
    labels = traitlets.List(trait=traitlets.Unicode()).tag(sync=True)
    text_lines = traitlets.List(trait=traitlets.Unicode()).tag(sync=True)
    correct_labels = traitlets.Dict().tag(sync=True)

    def __init__(
        self,
        question: str,
        labels: list[str],
        text_lines: list[str],
        correct_labels: dict,
        lang: str = "en",
        **kwargs,
    ):
        super().__init__(
            question=question,
            labels=labels,
            text_lines=text_lines,
            correct_labels=correct_labels,
            lang=lang,
            **kwargs,
        )

MatchingWidget

Bases: BaseWidget

A matching question widget where students pair items from two columns using drag-and-drop.

Attributes:

Name Type Description
question str

The question text to display

left list

Items in the left column

right list

Items in the right column

correct_matches dict

Mapping of left column indices to right column indices

value dict

Current state with 'matches', 'correct', and 'score' keys

Source code in src/marimo_learn/matching.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class MatchingWidget(BaseWidget):
    """
    A matching question widget where students pair items from two columns using drag-and-drop.

    Attributes:
        question (str): The question text to display
        left (list): Items in the left column
        right (list): Items in the right column
        correct_matches (dict): Mapping of left column indices to right column indices
        value (dict): Current state with 'matches', 'correct', and 'score' keys
    """

    _esm = Path(__file__).parent / "static" / "matching.js"

    question = traitlets.Unicode("").tag(sync=True)
    left = traitlets.List(trait=traitlets.Unicode()).tag(sync=True)
    right = traitlets.List(trait=traitlets.Unicode()).tag(sync=True)
    correct_matches = traitlets.Dict().tag(sync=True)

    def __init__(
        self,
        question: str,
        left: list[str],
        right: list[str],
        correct_matches: dict,
        lang: str = "en",
        **kwargs,
    ):
        super().__init__(
            question=question,
            left=left,
            right=right,
            correct_matches=correct_matches,
            lang=lang,
            **kwargs,
        )

MultipleChoiceWidget

Bases: BaseWidget

A multiple choice question widget.

Attributes:

Name Type Description
question str

The question text to display

options list

List of answer options

correct_answer int

Index of the correct answer (0-based)

explanation str

Optional explanation text shown after answering

value dict

Current state with 'selected', 'correct', and 'answered' keys

Source code in src/marimo_learn/multiple_choice.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class MultipleChoiceWidget(BaseWidget):
    """
    A multiple choice question widget.

    Attributes:
        question (str): The question text to display
        options (list): List of answer options
        correct_answer (int): Index of the correct answer (0-based)
        explanation (str): Optional explanation text shown after answering
        value (dict): Current state with 'selected', 'correct', and 'answered' keys
    """

    _esm = Path(__file__).parent / "static" / "multiple-choice.js"

    question = traitlets.Unicode("").tag(sync=True)
    options = traitlets.List(trait=traitlets.Unicode()).tag(sync=True)
    correct_answer = traitlets.Int(0).tag(sync=True)
    explanation = traitlets.Unicode("").tag(sync=True)

    def __init__(
        self,
        question: str,
        options: list[str],
        correct_answer: int,
        explanation: str = "",
        lang: str = "en",
        **kwargs,
    ):
        super().__init__(
            question=question,
            options=options,
            correct_answer=correct_answer,
            explanation=explanation,
            lang=lang,
            **kwargs,
        )

NumericEntryWidget

Bases: BaseWidget

A numeric entry question widget.

Attributes:

Name Type Description
question str

The question text to display

correct_answer float

The expected numeric answer

tolerance float

Acceptance window; answer is correct if |entered - correct_answer| < tolerance

explanation str

Optional explanation shown after answering

value dict

Current state with 'entered', 'correct', 'ok', and 'answered' keys

Source code in src/marimo_learn/numeric_entry.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class NumericEntryWidget(BaseWidget):
    """
    A numeric entry question widget.

    Attributes:
        question (str): The question text to display
        correct_answer (float): The expected numeric answer
        tolerance (float): Acceptance window; answer is correct if
            |entered - correct_answer| < tolerance
        explanation (str): Optional explanation shown after answering
        value (dict): Current state with 'entered', 'correct', 'ok',
            and 'answered' keys
    """

    _esm = Path(__file__).parent / "static" / "numeric-entry.js"

    question = traitlets.Unicode("").tag(sync=True)
    correct_answer = traitlets.Float(0.0).tag(sync=True)
    tolerance = traitlets.Float(DEFAULT_TOLERANCE).tag(sync=True)
    explanation = traitlets.Unicode("").tag(sync=True)

    def __init__(
        self,
        question: str,
        correct_answer: float,
        tolerance: float = DEFAULT_TOLERANCE,
        explanation: str = "",
        lang: str = "en",
        **kwargs,
    ):
        super().__init__(
            question=question,
            correct_answer=correct_answer,
            tolerance=tolerance,
            explanation=explanation,
            lang=lang,
            **kwargs,
        )

OrderingWidget

Bases: BaseWidget

An ordering question widget where students arrange items in sequence using drag-and-drop.

Attributes:

Name Type Description
question str

The question text to display

items list

Items in the correct order

shuffle bool

Whether to shuffle items initially

value dict

Current state with 'order' and 'correct' keys

Source code in src/marimo_learn/ordering.py
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class OrderingWidget(BaseWidget):
    """
    An ordering question widget where students arrange items in sequence using drag-and-drop.

    Attributes:
        question (str): The question text to display
        items (list): Items in the correct order
        shuffle (bool): Whether to shuffle items initially
        value (dict): Current state with 'order' and 'correct' keys
    """

    _esm = Path(__file__).parent / "static" / "ordering.js"

    question = traitlets.Unicode("").tag(sync=True)
    items = traitlets.List(trait=traitlets.Unicode()).tag(sync=True)
    current_order = traitlets.List(trait=traitlets.Unicode()).tag(sync=True)
    shuffle = traitlets.Bool(True).tag(sync=True)

    def __init__(
        self,
        question: str,
        items: list[str],
        shuffle: bool = True,
        lang: str = "en",
        **kwargs,
    ):
        current = items.copy()
        if shuffle:
            random.shuffle(current)
        super().__init__(
            question=question,
            items=items,
            shuffle=shuffle,
            current_order=current,
            lang=lang,
            **kwargs,
        )

PredictThenCheckWidget

Bases: BaseWidget

A predict-then-check question widget.

The learner reads a code snippet, selects their predicted output from multiple choice options, receives immediate feedback, and can then reveal the actual output to verify by running the code themselves.

Attributes:

Name Type Description
question str

The question text to display

code str

The code block shown to the learner

output str

The actual output revealed when the learner clicks "Reveal Output"

options list

List of candidate output strings

correct_answer int

Index of the correct option (0-based)

explanations list

Per-option explanation strings shown after answering

explanation str

Fallback explanation if explanations is omitted

value dict

Current state with 'selected', 'correct', and 'answered' keys

Source code in src/marimo_learn/predict_then_check.py
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
class PredictThenCheckWidget(BaseWidget):
    """
    A predict-then-check question widget.

    The learner reads a code snippet, selects their predicted output from
    multiple choice options, receives immediate feedback, and can then
    reveal the actual output to verify by running the code themselves.

    Attributes:
        question (str): The question text to display
        code (str): The code block shown to the learner
        output (str): The actual output revealed when the learner clicks
            "Reveal Output"
        options (list): List of candidate output strings
        correct_answer (int): Index of the correct option (0-based)
        explanations (list): Per-option explanation strings shown after
            answering
        explanation (str): Fallback explanation if explanations is omitted
        value (dict): Current state with 'selected', 'correct', and
            'answered' keys
    """

    _esm = Path(__file__).parent / "static" / "predict-then-check.js"

    question = traitlets.Unicode("").tag(sync=True)
    code = traitlets.Unicode("").tag(sync=True)
    output = traitlets.Unicode("").tag(sync=True)
    options = traitlets.List(trait=traitlets.Unicode()).tag(sync=True)
    correct_answer = traitlets.Int(0).tag(sync=True)
    explanations = traitlets.List(trait=traitlets.Unicode()).tag(sync=True)
    explanation = traitlets.Unicode("").tag(sync=True)

    def __init__(
        self,
        question: str,
        code: str,
        output: str,
        options: list[str],
        correct_answer: int,
        explanations: list[str] | None = None,
        explanation: str = "",
        lang: str = "en",
        **kwargs,
    ):
        super().__init__(
            question=question,
            code=code,
            output=output,
            options=options,
            correct_answer=correct_answer,
            explanations=explanations or [],
            explanation=explanation,
            lang=lang,
            **kwargs,
        )

Turtle

Async turtle that draws into a World.

Create via World.turtle() rather than directly. Each movement method is a coroutine that yields to the event loop after moving so that other turtles can run concurrently.

Source code in src/marimo_learn/turtle.py
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
class Turtle:
    """
    Async turtle that draws into a World.

    Create via ``World.turtle()`` rather than directly.  Each movement
    method is a coroutine that yields to the event loop after moving so
    that other turtles can run concurrently.
    """

    def __init__(self, world: World):
        self._world = world
        self.x = world.width / 2
        self.y = world.height / 2
        self.angle = INITIAL_ANGLE
        self.pen = True
        self.segments: list = []
        self.color: str = Color.CRIMSON.value

    @property
    def width(self) -> int:
        return self._world.width

    @property
    def height(self) -> int:
        return self._world.height

    def pen_up(self) -> None:
        self.pen = False

    def pen_down(self) -> None:
        self.pen = True

    def goto(self, x: float, y: float) -> None:
        self.x, self.y = x, y

    def set_heading(self, a: float) -> None:
        self.angle = a

    def set_color(self, color: "Color | str") -> None:
        self.color = color.value if isinstance(color, Color) else color

    async def forward(self, dist: float) -> None:
        if self._world._stop:
            return
        r = math.radians(self.angle)
        nx = self.x + dist * math.cos(r)
        ny = self.y + dist * math.sin(r)
        if self.pen:
            self.segments.append(((self.x, self.y), (nx, ny), self.color))
            self.x, self.y = nx, ny
            self._world._dirty = True
            await asyncio.sleep(self._world.delay)
            self._world._maybe_render()
        else:
            self.x, self.y = nx, ny

    async def backward(self, dist: float) -> None:
        await self.forward(-dist)

    def right(self, deg: float) -> None:
        self.angle += deg

    def left(self, deg: float) -> None:
        self.angle -= deg

World

Bases: AnyWidget

Canvas widget that owns rendering and hosts one or more turtles.

Typical notebook usage::

world = World()

async def my_drawing(world, turtle):
    for i in range(60):
        await turtle.forward(i * 3)
        turtle.right(91)

world.set_coroutine(my_drawing)
mo.ui.anywidget(world)   # display via mo.ui.anywidget for live comm

Drawing runs as an asyncio task in Marimo's event loop, so the kernel stays free and Stop works immediately.

For testing, pass output_fn to bypass widget rendering::

world = World(output_fn=lambda _: None)
await world.run(my_drawing())
Source code in src/marimo_learn/turtle.py
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
class World(anywidget.AnyWidget):
    """
    Canvas widget that owns rendering and hosts one or more turtles.

    Typical notebook usage::

        world = World()

        async def my_drawing(world, turtle):
            for i in range(60):
                await turtle.forward(i * 3)
                turtle.right(91)

        world.set_coroutine(my_drawing)
        mo.ui.anywidget(world)   # display via mo.ui.anywidget for live comm

    Drawing runs as an asyncio task in Marimo's event loop, so the kernel
    stays free and Stop works immediately.

    For testing, pass ``output_fn`` to bypass widget rendering::

        world = World(output_fn=lambda _: None)
        await world.run(my_drawing())
    """

    _esm = Path(__file__).parent / "static" / "turtle.js"

    width = traitlets.Int(WIDTH).tag(sync=True)
    height = traitlets.Int(HEIGHT).tag(sync=True)
    delay = traitlets.Float(DEFAULT_DELAY).tag(sync=True)
    # Render state pushed to JS on each frame: {segments, turtles, done, ts}
    _render = traitlets.Dict({}).tag(sync=True)
    # Incremented by JS each time Start is pressed
    _start_counter = traitlets.Int(0).tag(sync=True)
    # Set True by JS when Stop is pressed; Python clears after handling
    _stop_requested = traitlets.Bool(False).tag(sync=True)

    def __init__(
        self,
        width: int = WIDTH,
        height: int = HEIGHT,
        delay: float = DEFAULT_DELAY,
        output_fn: Callable[[str], None] | None = None,
    ):
        super().__init__(width=width, height=height, delay=delay)
        self._turtles: list["Turtle"] = []
        self._dirty = False
        self._last_render: float = 0.0
        self._stop = False
        self._coro_fns: list = []
        # output_fn is used in test / non-widget mode; None means widget mode
        self._output_fn = output_fn

    def turtle(self) -> "Turtle":
        """Create a new turtle that belongs to this world."""
        t = Turtle(self)
        self._turtles.append(t)
        return t

    def set_coroutine(self, *coro_fns) -> None:
        """Register async drawing functions to run when Start is pressed.

        Each function must accept ``(world, turtle)`` and move that turtle.
        One :class:`Turtle` is created automatically per function.

        Pass the function itself (not a coroutine object) so that a fresh
        coroutine is created on each Start press, enabling clean restarts.
        """
        self._coro_fns = list(coro_fns)
        self._turtles = [Turtle(self) for _ in coro_fns]

    # ------------------------------------------------------------------
    # Widget signal handling (JS → Python via synced traitlets)
    # ------------------------------------------------------------------

    @traitlets.observe("_start_counter")
    def _on_start(self, change) -> None:
        if change["new"] > 0:
            self._start_drawing()

    @traitlets.observe("_stop_requested")
    def _on_stop(self, change) -> None:
        if change["new"]:
            self._stop = True
            self._stop_requested = False  # reset for next press

    # ------------------------------------------------------------------
    # Drawing lifecycle
    # ------------------------------------------------------------------

    def _reset_turtles(self) -> None:
        for t in self._turtles:
            t.segments = []
            t.x = self.width / 2
            t.y = self.height / 2
            t.angle = INITIAL_ANGLE
            t.pen = True
            t.color = Color.CRIMSON.value

    def _start_drawing(self) -> None:
        """Launch the registered drawing coroutines.

        Prefers scheduling an asyncio Task in the running event loop (the
        normal Marimo case, where _on_start fires inside the event loop).
        Falls back to a background thread when called from a non-async
        context (e.g. tests that call _start_drawing directly).
        """
        self._stop = True   # cancel any currently-running drawing
        self._stop = False  # clear immediately for the new run
        self._last_render = 0.0
        self._dirty = False
        self._reset_turtles()

        coros = [fn(self, t) for fn, t in zip(self._coro_fns, self._turtles)]

        async def _run() -> None:
            try:
                if len(coros) == 1:
                    await coros[0]
                else:
                    await asyncio.gather(*coros, return_exceptions=True)
            finally:
                self._flush(show_turtle=False, done=True)

        # Schedule as a concurrent task in Marimo's running event loop.
        # The cell that called set_coroutine() has already returned, so the
        # kernel is free — Start/Stop signals are processed normally.
        asyncio.get_running_loop().create_task(_run())

    # ------------------------------------------------------------------
    # Rendering
    # ------------------------------------------------------------------

    def _flush(self, show_turtle: bool = True, done: bool = False) -> None:
        """Push current state to the JS frontend via the _render traitlet."""
        all_segs = [
            [x1, y1, x2, y2, color]
            for t in self._turtles
            for (x1, y1), (x2, y2), color in t.segments
        ]
        turtle_data = (
            [[t.x, t.y, t.angle] for t in self._turtles] if show_turtle else []
        )
        # Include a monotonic timestamp so the dict is always a new value
        # even when segments haven't changed, ensuring the JS observer fires.
        self._render = {
            "segments": all_segs,
            "turtles": turtle_data,
            "done": done,
            "ts": time.monotonic(),
        }

    def _maybe_render(self) -> None:
        """Rate-limited render: push to JS frontend or call output_fn."""
        now = time.monotonic()
        if self._dirty and (now - self._last_render) >= self.delay:
            if self._output_fn is not None:
                self._output_fn(self._draw())
            else:
                self._flush()
            self._dirty = False
            self._last_render = now

    def _draw(self, show_turtle: bool = True) -> str:
        """Build an SVG string compositing all turtles (used in output_fn / test mode)."""
        lines = ""
        for t in self._turtles:
            for (x1, y1), (x2, y2), color in t.segments:
                lines += (
                    f'<line x1="{x1:.1f}" y1="{y1:.1f}" '
                    f'x2="{x2:.1f}" y2="{y2:.1f}" '
                    f'stroke="{color}" stroke-width="{STROKE_WIDTH}" '
                    f'stroke-linecap="round"/>'
                )
        if show_turtle:
            for t in self._turtles:
                r = math.radians(t.angle)
                pts = " ".join(
                    f"{t.x + TURTLE_RADIUS * math.cos(r + a):.1f},"
                    f"{t.y + TURTLE_RADIUS * math.sin(r + a):.1f}"
                    for a in [0, 2 * math.pi / 3, -2 * math.pi / 3]
                )
                lines += (
                    f'<polygon points="{pts}" fill="{TURTLE_COLOR}"'
                    f' opacity="{TURTLE_OPACITY}"/>'
                )
        return (
            f'<svg xmlns="http://www.w3.org/2000/svg" width="{self.width}"'
            f' height="{self.height}" style="background:{BACKGROUND_COLOR};'
            f'border-radius:{BORDER_RADIUS}px;display:block">'
            f"{lines}</svg>"
        )

    # ------------------------------------------------------------------
    # Direct async run (used by tests)
    # ------------------------------------------------------------------

    async def run(self, *coroutines) -> None:
        """Run coroutines in the current event loop (test / direct-use path).

        Notebook users should use set_coroutine() + the Start button instead.
        """
        self._last_render = 0.0
        try:
            if len(coroutines) == 1:
                await coroutines[0]
            else:
                await asyncio.gather(*coroutines, return_exceptions=True)
        finally:
            if self._output_fn is not None:
                self._output_fn(self._draw(show_turtle=False))
            else:
                self._flush(show_turtle=False, done=True)

run(*coroutines) async

Run coroutines in the current event loop (test / direct-use path).

Notebook users should use set_coroutine() + the Start button instead.

Source code in src/marimo_learn/turtle.py
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
async def run(self, *coroutines) -> None:
    """Run coroutines in the current event loop (test / direct-use path).

    Notebook users should use set_coroutine() + the Start button instead.
    """
    self._last_render = 0.0
    try:
        if len(coroutines) == 1:
            await coroutines[0]
        else:
            await asyncio.gather(*coroutines, return_exceptions=True)
    finally:
        if self._output_fn is not None:
            self._output_fn(self._draw(show_turtle=False))
        else:
            self._flush(show_turtle=False, done=True)

set_coroutine(*coro_fns)

Register async drawing functions to run when Start is pressed.

Each function must accept (world, turtle) and move that turtle. One :class:Turtle is created automatically per function.

Pass the function itself (not a coroutine object) so that a fresh coroutine is created on each Start press, enabling clean restarts.

Source code in src/marimo_learn/turtle.py
109
110
111
112
113
114
115
116
117
118
119
def set_coroutine(self, *coro_fns) -> None:
    """Register async drawing functions to run when Start is pressed.

    Each function must accept ``(world, turtle)`` and move that turtle.
    One :class:`Turtle` is created automatically per function.

    Pass the function itself (not a coroutine object) so that a fresh
    coroutine is created on each Start press, enabling clean restarts.
    """
    self._coro_fns = list(coro_fns)
    self._turtles = [Turtle(self) for _ in coro_fns]

turtle()

Create a new turtle that belongs to this world.

Source code in src/marimo_learn/turtle.py
103
104
105
106
107
def turtle(self) -> "Turtle":
    """Create a new turtle that belongs to this world."""
    t = Turtle(self)
    self._turtles.append(t)
    return t

localize_file(filename, url)

Download a file from the url, returning the local path.

Parameters:

Name Type Description Default
filename str

name for local copy of file

required
url str

URL of file to download

required

Returns:

Type Description
str

local file path

Raises:

Type Description
FileNotFoundError

if remote file not found

Source code in src/marimo_learn/utilities.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
def localize_file(filename: str, url: str) -> str:
    """
    Download a file from the url, returning the local path.

    Args:
        filename: name for local copy of file
        url: URL of file to download

    Returns:
        local file path

    Raises:
        FileNotFoundError: if remote file not found
    """

    response = httpx.get(url)
    if response.status_code != 200:
        raise FileNotFoundError(f"unable to get file from '{url}'")

    local_path = mo.notebook_dir() / filename
    local_path.parent.mkdir(parents=True, exist_ok=True)
    with open(local_path, "wb") as writer:
        writer.write(response.content)

    return str(local_path)