Skip to content

marimo-learn

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: AnyWidget

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
 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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class ConceptMapWidget(anywidget.AnyWidget):
    """
    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)
    lang = traitlets.Unicode("en").tag(sync=True)
    value = traitlets.Dict(default_value=None, allow_none=True).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__(**kwargs)
        self.question = question
        self.concepts = concepts
        self.terms = terms
        self.correct_edges = correct_edges if correct_edges is not None else []
        self.lang = lang

FlashcardWidget

Bases: AnyWidget

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
 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
33
34
35
36
class FlashcardWidget(anywidget.AnyWidget):
    """
    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)
    lang = traitlets.Unicode("en").tag(sync=True)
    value = traitlets.Dict(default_value=None, allow_none=True).tag(sync=True)

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

LabelingWidget

Bases: AnyWidget

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
 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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
class LabelingWidget(anywidget.AnyWidget):
    """
    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
    """

    # Load JavaScript from external file
    _esm = Path(__file__).parent / "static" / "labeling.js"

    # Traitlets
    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)
    lang = traitlets.Unicode("en").tag(sync=True)
    value = traitlets.Dict(default_value=None, allow_none=True).tag(sync=True)

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

        Args:
            question: The question text
            labels: List of label texts (e.g., ["Variable declaration", "Function call", "Loop"])
            text_lines: List of text lines to be labeled (e.g., code lines, sentences)
            correct_labels: Dict mapping line index to list of correct label indices
                           Example: {0: [0, 1], 2: [2]} means line 0 should have labels 0 and 1,
                           line 2 should have label 2
        """
        super().__init__(**kwargs)
        self.question = question
        self.labels = labels
        self.text_lines = text_lines
        self.correct_labels = correct_labels
        self.lang = lang

__init__(question, labels, text_lines, correct_labels, lang='en', **kwargs)

Initialize a labeling widget.

Parameters:

Name Type Description Default
question str

The question text

required
labels list[str]

List of label texts (e.g., ["Variable declaration", "Function call", "Loop"])

required
text_lines list[str]

List of text lines to be labeled (e.g., code lines, sentences)

required
correct_labels dict

Dict mapping line index to list of correct label indices Example: {0: [0, 1], 2: [2]} means line 0 should have labels 0 and 1, line 2 should have label 2

required
Source code in src/marimo_learn/labeling.py
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
def __init__(
    self,
    question: str,
    labels: list[str],
    text_lines: list[str],
    correct_labels: dict,
    lang: str = "en",
    **kwargs,
):
    """
    Initialize a labeling widget.

    Args:
        question: The question text
        labels: List of label texts (e.g., ["Variable declaration", "Function call", "Loop"])
        text_lines: List of text lines to be labeled (e.g., code lines, sentences)
        correct_labels: Dict mapping line index to list of correct label indices
                       Example: {0: [0, 1], 2: [2]} means line 0 should have labels 0 and 1,
                       line 2 should have label 2
    """
    super().__init__(**kwargs)
    self.question = question
    self.labels = labels
    self.text_lines = text_lines
    self.correct_labels = correct_labels
    self.lang = lang

MatchingWidget

Bases: AnyWidget

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
 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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class MatchingWidget(anywidget.AnyWidget):
    """
    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
    """

    # Load JavaScript from external file
    _esm = Path(__file__).parent / "static" / "matching.js"

    # Traitlets
    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)
    lang = traitlets.Unicode("en").tag(sync=True)
    value = traitlets.Dict(default_value=None, allow_none=True).tag(sync=True)

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

        Args:
            question: The question text
            left: Items in the left column
            right: Items in the right column
            correct_matches: Dict mapping left indices to right indices (e.g., {0: 2, 1: 0, 2: 1})
        """
        super().__init__(**kwargs)
        self.question = question
        self.left = left
        self.right = right
        self.correct_matches = correct_matches
        self.lang = lang

__init__(question, left, right, correct_matches, lang='en', **kwargs)

Initialize a matching widget.

Parameters:

Name Type Description Default
question str

The question text

required
left list[str]

Items in the left column

required
right list[str]

Items in the right column

required
correct_matches dict

Dict mapping left indices to right indices (e.g., {0: 2, 1: 0, 2: 1})

required
Source code in src/marimo_learn/matching.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
def __init__(
    self,
    question: str,
    left: list[str],
    right: list[str],
    correct_matches: dict,
    lang: str = "en",
    **kwargs,
):
    """
    Initialize a matching widget.

    Args:
        question: The question text
        left: Items in the left column
        right: Items in the right column
        correct_matches: Dict mapping left indices to right indices (e.g., {0: 2, 1: 0, 2: 1})
    """
    super().__init__(**kwargs)
    self.question = question
    self.left = left
    self.right = right
    self.correct_matches = correct_matches
    self.lang = lang

MultipleChoiceWidget

Bases: AnyWidget

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
 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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class MultipleChoiceWidget(anywidget.AnyWidget):
    """
    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
    """

    # Load JavaScript from external file
    _esm = Path(__file__).parent / "static" / "multiple-choice.js"

    # Traitlets
    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)
    lang = traitlets.Unicode("en").tag(sync=True)
    value = traitlets.Dict(default_value=None, allow_none=True).tag(sync=True)

    def __init__(
        self,
        question: str,
        options: list[str],
        correct_answer: int,
        explanation: str = "",
        lang: str = "en",
        **kwargs,
    ):
        """
        Initialize a multiple choice widget.

        Args:
            question: The question text
            options: List of answer options
            correct_answer: Index of the correct answer (0-based)
            explanation: Optional explanation text
        """
        super().__init__(**kwargs)
        self.question = question
        self.options = options
        self.correct_answer = correct_answer
        self.explanation = explanation
        self.lang = lang

__init__(question, options, correct_answer, explanation='', lang='en', **kwargs)

Initialize a multiple choice widget.

Parameters:

Name Type Description Default
question str

The question text

required
options list[str]

List of answer options

required
correct_answer int

Index of the correct answer (0-based)

required
explanation str

Optional explanation text

''
Source code in src/marimo_learn/multiple_choice.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
def __init__(
    self,
    question: str,
    options: list[str],
    correct_answer: int,
    explanation: str = "",
    lang: str = "en",
    **kwargs,
):
    """
    Initialize a multiple choice widget.

    Args:
        question: The question text
        options: List of answer options
        correct_answer: Index of the correct answer (0-based)
        explanation: Optional explanation text
    """
    super().__init__(**kwargs)
    self.question = question
    self.options = options
    self.correct_answer = correct_answer
    self.explanation = explanation
    self.lang = lang

OrderingWidget

Bases: AnyWidget

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
 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
class OrderingWidget(anywidget.AnyWidget):
    """
    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
    """

    # Load JavaScript from external file
    _esm = Path(__file__).parent / "static" / "ordering.js"

    # Traitlets
    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)
    lang = traitlets.Unicode("en").tag(sync=True)
    value = traitlets.Dict(default_value=None, allow_none=True).tag(sync=True)

    def __init__(
        self,
        question: str,
        items: list[str],
        shuffle: bool = True,
        lang: str = "en",
        **kwargs,
    ):
        """
        Initialize an ordering widget.

        Args:
            question: The question text
            items: Items in the correct order
            shuffle: Whether to shuffle items initially (default: True)
        """
        super().__init__(**kwargs)
        self.question = question
        self.items = items
        self.shuffle = shuffle
        self.lang = lang

        # Create shuffled initial order if requested
        if shuffle:
            current = items.copy()
            random.shuffle(current)
            self.current_order = current
        else:
            self.current_order = items.copy()

__init__(question, items, shuffle=True, lang='en', **kwargs)

Initialize an ordering widget.

Parameters:

Name Type Description Default
question str

The question text

required
items list[str]

Items in the correct order

required
shuffle bool

Whether to shuffle items initially (default: True)

True
Source code in src/marimo_learn/ordering.py
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
def __init__(
    self,
    question: str,
    items: list[str],
    shuffle: bool = True,
    lang: str = "en",
    **kwargs,
):
    """
    Initialize an ordering widget.

    Args:
        question: The question text
        items: Items in the correct order
        shuffle: Whether to shuffle items initially (default: True)
    """
    super().__init__(**kwargs)
    self.question = question
    self.items = items
    self.shuffle = shuffle
    self.lang = lang

    # Create shuffled initial order if requested
    if shuffle:
        current = items.copy()
        random.shuffle(current)
        self.current_order = current
    else:
        self.current_order = items.copy()

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

is_pyodide()

Is this notebook running in pyodide?

Source code in src/marimo_learn/utilities.py
 8
 9
10
11
def is_pyodide():
    """Is this notebook running in pyodide?"""

    return "pyodide" in sys.modules

localize_file(filepath)

Download a file from the 'public' directory, returning the local path.

Parameters:

Name Type Description Default
filepath str

path relative to 'public' directory

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
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
def localize_file(filepath: str) -> str:
    """
    Download a file from the 'public' directory, returning the
    local path.

    Args:
        filepath: path relative to 'public' directory

    Returns:
        local file path

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

    if not is_pyodide():
        return str(mo.notebook_dir() / "public" / filepath)

    url = str(mo.notebook_location() / "public" / filepath)
    response = httpx.get(url)
    if response.status_code != 200:
        raise FileNotFoundError(f"unable to get {filepath} from {url}")

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

    return str(local_path)