mini-arcade-core 0.9.1__tar.gz → 0.9.3__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.
Files changed (21) hide show
  1. {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/PKG-INFO +1 -1
  2. {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/pyproject.toml +1 -1
  3. {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/__init__.py +8 -2
  4. {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/backend.py +12 -0
  5. {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/game.py +20 -5
  6. mini_arcade_core-0.9.3/src/mini_arcade_core/registry.py +62 -0
  7. mini_arcade_core-0.9.3/src/mini_arcade_core/ui/menu.py +371 -0
  8. mini_arcade_core-0.9.1/src/mini_arcade_core/ui/menu.py +0 -135
  9. {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/LICENSE +0 -0
  10. {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/README.md +0 -0
  11. {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/boundaries2d.py +0 -0
  12. {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/collision2d.py +0 -0
  13. {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/entity.py +0 -0
  14. {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/geometry2d.py +0 -0
  15. {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/keymaps/__init__.py +0 -0
  16. {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/keymaps/sdl.py +0 -0
  17. {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/keys.py +0 -0
  18. {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/kinematics2d.py +0 -0
  19. {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/physics2d.py +0 -0
  20. {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/scene.py +0 -0
  21. {mini_arcade_core-0.9.1 → mini_arcade_core-0.9.3}/src/mini_arcade_core/ui/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mini-arcade-core
3
- Version: 0.9.1
3
+ Version: 0.9.3
4
4
  Summary: Tiny scene-based game loop core for small arcade games.
5
5
  License: Copyright (c) 2025 Santiago Rincón
6
6
 
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [project]
6
6
  name = "mini-arcade-core"
7
- version = "0.9.1"
7
+ version = "0.9.3"
8
8
  description = "Tiny scene-based game loop core for small arcade games."
9
9
  authors = [
10
10
  { name = "Santiago Rincon", email = "rincores@gmail.com" },
@@ -22,12 +22,17 @@ from .geometry2d import Bounds2D, Position2D, Size2D
22
22
  from .keys import Key, keymap
23
23
  from .kinematics2d import KinematicData
24
24
  from .physics2d import Velocity2D
25
+ from .registry import SceneRegistry
25
26
  from .scene import Scene
26
27
 
27
28
  logger = logging.getLogger(__name__)
28
29
 
29
30
 
30
- def run_game(initial_scene_cls: type[Scene], config: GameConfig | None = None):
31
+ def run_game(
32
+ initial_scene_cls: type[Scene],
33
+ config: GameConfig | None = None,
34
+ registry: SceneRegistry | None = None,
35
+ ):
31
36
  """
32
37
  Convenience helper to bootstrap and run a game with a single scene.
33
38
 
@@ -44,7 +49,7 @@ def run_game(initial_scene_cls: type[Scene], config: GameConfig | None = None):
44
49
  raise ValueError(
45
50
  "GameConfig.backend must be set to a Backend instance"
46
51
  )
47
- game = Game(cfg)
52
+ game = Game(cfg, registry=registry)
48
53
  scene = initial_scene_cls(game)
49
54
  game.run(scene)
50
55
 
@@ -72,6 +77,7 @@ __all__ = [
72
77
  "RectKinematic",
73
78
  "Key",
74
79
  "keymap",
80
+ "SceneRegistry",
75
81
  ]
76
82
 
77
83
  PACKAGE_NAME = "mini-arcade-core" # or whatever is in your pyproject.toml
@@ -214,6 +214,18 @@ class Backend(Protocol):
214
214
  :type color: Color
215
215
  """
216
216
 
217
+ def measure_text(self, text: str) -> tuple[int, int]:
218
+ """
219
+ Measure the width and height of the given text string in pixels.
220
+
221
+ :param text: The text string to measure.
222
+ :type text: str
223
+
224
+ :return: A tuple (width, height) in pixels.
225
+ :rtype: tuple[int, int]
226
+ """
227
+ raise NotImplementedError
228
+
217
229
  def capture_frame(self, path: str | None = None) -> bytes | None:
218
230
  """
219
231
  Capture the current frame.
@@ -9,15 +9,18 @@ from dataclasses import dataclass
9
9
  from datetime import datetime
10
10
  from pathlib import Path
11
11
  from time import perf_counter, sleep
12
- from typing import TYPE_CHECKING
12
+ from typing import TYPE_CHECKING, Union
13
13
 
14
14
  from PIL import Image # type: ignore[import]
15
15
 
16
16
  from .backend import Backend
17
+ from .registry import SceneRegistry
17
18
 
18
19
  if TYPE_CHECKING: # avoid runtime circular import
19
20
  from .scene import Scene
20
21
 
22
+ SceneOrId = Union["Scene", str]
23
+
21
24
 
22
25
  @dataclass
23
26
  class GameConfig:
@@ -49,7 +52,9 @@ class _StackEntry:
49
52
  class Game:
50
53
  """Core game object responsible for managing the main loop and active scene."""
51
54
 
52
- def __init__(self, config: GameConfig):
55
+ def __init__(
56
+ self, config: GameConfig, registry: SceneRegistry | None = None
57
+ ):
53
58
  """
54
59
  :param config: Game configuration options.
55
60
  :type config: GameConfig
@@ -65,6 +70,7 @@ class Game:
65
70
  "GameConfig.backend must be set to a Backend instance"
66
71
  )
67
72
  self.backend: Backend = config.backend
73
+ self.registry = registry or SceneRegistry(_factories={})
68
74
  self._scene_stack: list[_StackEntry] = []
69
75
 
70
76
  def current_scene(self) -> "Scene | None":
@@ -76,7 +82,7 @@ class Game:
76
82
  """
77
83
  return self._scene_stack[-1].scene if self._scene_stack else None
78
84
 
79
- def change_scene(self, scene: Scene):
85
+ def change_scene(self, scene: SceneOrId):
80
86
  """
81
87
  Swap the active scene. Concrete implementations should call
82
88
  ``on_exit``/``on_enter`` appropriately.
@@ -84,6 +90,8 @@ class Game:
84
90
  :param scene: The new scene to activate.
85
91
  :type scene: Scene
86
92
  """
93
+ scene = self._resolve_scene(scene)
94
+
87
95
  while self._scene_stack:
88
96
  entry = self._scene_stack.pop()
89
97
  entry.scene.on_exit()
@@ -91,11 +99,13 @@ class Game:
91
99
  self._scene_stack.append(_StackEntry(scene=scene, as_overlay=False))
92
100
  scene.on_enter()
93
101
 
94
- def push_scene(self, scene: "Scene", as_overlay: bool = False):
102
+ def push_scene(self, scene: SceneOrId, as_overlay: bool = False):
95
103
  """
96
104
  Push a scene on top of the current one.
97
105
  If as_overlay=True, underlying scene(s) may still be drawn but never updated.
98
106
  """
107
+ scene = self._resolve_scene(scene)
108
+
99
109
  top = self.current_scene()
100
110
  if top is not None:
101
111
  top.on_pause()
@@ -142,7 +152,7 @@ class Game:
142
152
  """Request that the main loop stops."""
143
153
  self._running = False
144
154
 
145
- def run(self, initial_scene: Scene):
155
+ def run(self, initial_scene: SceneOrId):
146
156
  """
147
157
  Run the main loop starting with the given scene.
148
158
 
@@ -241,3 +251,8 @@ class Game:
241
251
  self._convert_bmp_to_image(bmp_path, str(out_path))
242
252
  return str(out_path)
243
253
  return None
254
+
255
+ def _resolve_scene(self, scene: SceneOrId) -> "Scene":
256
+ if isinstance(scene, str):
257
+ return self.registry.create(scene, self)
258
+ return scene
@@ -0,0 +1,62 @@
1
+ """
2
+ Scene registry for mini arcade core.
3
+ Allows registering and creating scenes by string IDs.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from typing import TYPE_CHECKING, Dict, Protocol
10
+
11
+ if TYPE_CHECKING:
12
+ from mini_arcade_core.game import Game
13
+ from mini_arcade_core.scene import Scene
14
+
15
+
16
+ class SceneFactory(Protocol):
17
+ """
18
+ Protocol for scene factory callables.
19
+ """
20
+
21
+ def __call__(self, game: "Game") -> "Scene": ...
22
+
23
+
24
+ @dataclass
25
+ class SceneRegistry:
26
+ """
27
+ Registry for scene factories, allowing registration and creation of scenes by string IDs.
28
+ """
29
+
30
+ _factories: Dict[str, SceneFactory]
31
+
32
+ def register(self, scene_id: str, factory: SceneFactory) -> None:
33
+ """
34
+ Register a scene factory under a given scene ID.
35
+
36
+ :param scene_id: The string ID for the scene.
37
+ :type scene_id: str
38
+
39
+ :param factory: A callable that creates a Scene instance.
40
+ :type factory: SceneFactory
41
+ """
42
+ self._factories[scene_id] = factory
43
+
44
+ def create(self, scene_id: str, game: "Game") -> "Scene":
45
+ """
46
+ Create a scene instance using the registered factory for the given scene ID.
47
+
48
+ :param scene_id: The string ID of the scene to create.
49
+ :type scene_id: str
50
+
51
+ :param game: The Game instance to pass to the scene factory.
52
+ :type game: Game
53
+
54
+ :return: A new Scene instance.
55
+ :rtype: Scene
56
+
57
+ :raises KeyError: If no factory is registered for the given scene ID.
58
+ """
59
+ try:
60
+ return self._factories[scene_id](game)
61
+ except KeyError as e:
62
+ raise KeyError(f"Unknown scene_id={scene_id!r}") from e
@@ -0,0 +1,371 @@
1
+ """
2
+ Menu system for mini arcade core.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Callable, Sequence
9
+
10
+ from mini_arcade_core.backend import Backend, Color, Event, EventType
11
+ from mini_arcade_core.geometry2d import Size2D
12
+
13
+ MenuAction = Callable[[], None]
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class MenuItem:
18
+ """
19
+ Represents a single item in a menu.
20
+
21
+ :ivar label (str): The text label of the menu item.
22
+ :ivar on_select (MenuAction): The action to perform when the item is selected.
23
+ """
24
+
25
+ label: str
26
+ on_select: MenuAction
27
+
28
+
29
+ # Justification: Data container for styling options needs
30
+ # some attributes.
31
+ # pylint: disable=too-many-instance-attributes
32
+ @dataclass
33
+ class MenuStyle:
34
+ """
35
+ Styling options for the Menu.
36
+
37
+ :ivar normal (Color): Color for unselected items.
38
+ :ivar selected (Color): Color for the selected item.
39
+ :ivar line_height (int): Vertical spacing between items.
40
+ """
41
+
42
+ normal: Color = (220, 220, 220)
43
+ selected: Color = (255, 255, 0)
44
+
45
+ # Layout
46
+ line_height: int = 28
47
+ title_color: Color = (255, 255, 255)
48
+ title_spacing: int = 18
49
+
50
+ # Scene background (solid)
51
+ background_color: Color | None = None # e.g. BACKGROUND
52
+
53
+ # Optional full-screen overlay (dim)
54
+ overlay_color: Color | None = None # e.g. (0,0,0,0.5) for pause
55
+
56
+ # Panel behind content (optional)
57
+ panel_color: Color | None = None
58
+ panel_padding_x: int = 24
59
+ panel_padding_y: int = 18
60
+
61
+ # Button rendering (optional)
62
+ button_enabled: bool = False
63
+ button_fill: Color = (30, 30, 30, 1.0)
64
+ button_border: Color = (120, 120, 120, 1.0)
65
+ button_selected_border: Color = (255, 255, 0, 1.0)
66
+ button_width: int | None = (
67
+ None # if None -> auto-fit to longest label + padding
68
+ )
69
+ button_height: int = 40
70
+ button_gap: int = 20
71
+ button_padding_x: int = 20 # used for auto-fit + text centering
72
+
73
+ # Hint footer (optional)
74
+ hint: str | None = None
75
+ hint_color: Color = (200, 200, 200)
76
+ hint_margin_bottom: int = 50
77
+
78
+
79
+ # pylint: enable=too-many-instance-attributes
80
+
81
+
82
+ class Menu:
83
+ """A simple text-based menu system."""
84
+
85
+ def __init__(
86
+ self,
87
+ items: Sequence[MenuItem],
88
+ *,
89
+ viewport: Size2D | None = None,
90
+ title: str | None = None,
91
+ style: MenuStyle | None = None,
92
+ ):
93
+ """
94
+ :param items: Sequence of MenuItem instances to display.
95
+ type items: Sequence[MenuItem]
96
+
97
+ :param x: X coordinate for the menu's top-left corner.
98
+ :param y: Y coordinate for the menu's top-left corner.
99
+
100
+ :param style: Optional MenuStyle for customizing appearance.
101
+ :type style: MenuStyle | None
102
+ """
103
+ self.items = list(items)
104
+ self.viewport = viewport
105
+ self.title = title
106
+ self.style = style or MenuStyle()
107
+ self.selected_index = 0
108
+
109
+ def move_up(self):
110
+ """Move the selection up by one item, wrapping around if necessary."""
111
+ if self.items:
112
+ self.selected_index = (self.selected_index - 1) % len(self.items)
113
+
114
+ def move_down(self):
115
+ """Move the selection down by one item, wrapping around if necessary."""
116
+ if self.items:
117
+ self.selected_index = (self.selected_index + 1) % len(self.items)
118
+
119
+ def select(self):
120
+ """Select the currently highlighted item, invoking its action."""
121
+ if self.items:
122
+ self.items[self.selected_index].on_select()
123
+
124
+ def handle_event(
125
+ self,
126
+ event: Event,
127
+ *,
128
+ up_key: int,
129
+ down_key: int,
130
+ select_key: int,
131
+ ):
132
+ """
133
+ Handle an input event to navigate the menu.
134
+
135
+ :param event: The input event to handle.
136
+ :type event: Event
137
+
138
+ :param up_key: Key code for moving selection up.
139
+ type up_key: int
140
+
141
+ :param down_key: Key code for moving selection down.
142
+ :type down_key: int
143
+
144
+ :param select_key: Key code for selecting the current item.
145
+ :type select_key: int
146
+ """
147
+ if event.type != EventType.KEYDOWN or event.key is None:
148
+ return
149
+ if event.key == up_key:
150
+ self.move_up()
151
+ elif event.key == down_key:
152
+ self.move_down()
153
+ elif event.key == select_key:
154
+ self.select()
155
+
156
+ def _measure_content(self, surface: Backend) -> tuple[int, int, int]:
157
+ """
158
+ Returns (content_width, content_height, title_height)
159
+ where content is items-only (no padding).
160
+ """
161
+ if not self.items and not self.title:
162
+ return 0, 0, 0
163
+
164
+ max_w = 0
165
+ title_h = 0
166
+
167
+ if self.title:
168
+ tw, th = surface.measure_text(self.title)
169
+ max_w = max(max_w, tw)
170
+ title_h = th
171
+
172
+ # Items
173
+ for it in self.items:
174
+ w, _ = surface.measure_text(it.label)
175
+ max_w = max(max_w, w)
176
+
177
+ items_h = len(self.items) * self.style.line_height
178
+
179
+ # Total content height includes title block if present
180
+ content_h = items_h
181
+ if self.title:
182
+ content_h += title_h + self.style.title_spacing
183
+
184
+ return max_w, content_h, title_h
185
+
186
+ def draw(self, surface: Backend):
187
+ """
188
+ Draw the menu onto the given backend surface.
189
+
190
+ :param surface: The backend surface to draw on.
191
+ :type surface: Backend
192
+ """
193
+ if self.viewport is None:
194
+ raise ValueError(
195
+ "Menu requires viewport=Size2D for centering/layout"
196
+ )
197
+
198
+ vw, vh = self.viewport.width, self.viewport.height
199
+
200
+ # 0) Solid background (for main menus)
201
+ if self.style.background_color is not None:
202
+ surface.draw_rect(0, 0, vw, vh, color=self.style.background_color)
203
+
204
+ # 1) Overlay (for pause, etc.)
205
+ if self.style.overlay_color is not None:
206
+ surface.draw_rect(0, 0, vw, vh, color=self.style.overlay_color)
207
+
208
+ # 2) Compute menu content bounds (panel area)
209
+ content_w, content_h, title_h = self._measure_content(surface)
210
+
211
+ pad_x, pad_y = self.style.panel_padding_x, self.style.panel_padding_y
212
+ panel_w = content_w + pad_x * 2
213
+ panel_h = content_h + pad_y * 2
214
+
215
+ x0 = (vw - panel_w) // 2
216
+ y0 = (vh - panel_h) // 2
217
+
218
+ # Optional vertical offset if you add it later:
219
+ # y0 += self.style.center_offset_y
220
+
221
+ # 3) Panel (optional)
222
+ if self.style.panel_color is not None:
223
+ surface.draw_rect(
224
+ x0, y0, panel_w, panel_h, color=self.style.panel_color
225
+ )
226
+
227
+ # 4) Draw title + items
228
+ cursor_y = y0 + pad_y
229
+ x_center = x0 + (panel_w // 2)
230
+
231
+ if self.title:
232
+ self._draw_text_center_x(
233
+ surface,
234
+ x_center,
235
+ cursor_y,
236
+ self.title,
237
+ color=self.style.title_color,
238
+ )
239
+ cursor_y += title_h + self.style.title_spacing
240
+
241
+ if self.style.button_enabled:
242
+ self._draw_buttons(surface, x_center, cursor_y)
243
+ else:
244
+ self._draw_text_items(surface, x_center, cursor_y)
245
+
246
+ # 5) Hint footer (optional)
247
+ if self.style.hint:
248
+ self._draw_text_center_x(
249
+ surface,
250
+ vw // 2,
251
+ vh - self.style.hint_margin_bottom,
252
+ self.style.hint,
253
+ color=self.style.hint_color,
254
+ )
255
+
256
+ def _draw_text_items(
257
+ self, surface: Backend, x_center: int, cursor_y: int
258
+ ) -> None:
259
+ for i, item in enumerate(self.items):
260
+ color = (
261
+ self.style.selected
262
+ if i == self.selected_index
263
+ else self.style.normal
264
+ )
265
+ self._draw_text_center_x(
266
+ surface,
267
+ x_center,
268
+ cursor_y + i * self.style.line_height,
269
+ item.label,
270
+ color=color,
271
+ )
272
+
273
+ # Justification: Local variables for layout calculations
274
+ # pylint: disable=too-many-locals
275
+ def _draw_buttons(
276
+ self, surface: Backend, x_center: int, cursor_y: int
277
+ ) -> None:
278
+ # Determine button width: fixed or auto-fit
279
+ if self.style.button_width is not None:
280
+ bw = self.style.button_width
281
+ else:
282
+ max_label_w = 0
283
+ for it in self.items:
284
+ w, _ = surface.measure_text(it.label)
285
+ max_label_w = max(max_label_w, w)
286
+ bw = max_label_w + self.style.button_padding_x * 2
287
+
288
+ bh = self.style.button_height
289
+ gap = self.style.button_gap
290
+
291
+ # We treat cursor_y as “top of first button”
292
+ for i, item in enumerate(self.items):
293
+ y = cursor_y + i * (bh + gap)
294
+ x = x_center - bw // 2
295
+
296
+ selected = i == self.selected_index
297
+ border = (
298
+ self.style.button_selected_border
299
+ if selected
300
+ else self.style.button_border
301
+ )
302
+
303
+ # Border rect
304
+ surface.draw_rect(x - 4, y - 4, bw + 8, bh + 8, color=border)
305
+ # Fill rect
306
+ surface.draw_rect(x, y, bw, bh, color=self.style.button_fill)
307
+
308
+ # Label color
309
+ text_color = self.style.selected if selected else self.style.normal
310
+ tw, th = surface.measure_text(item.label)
311
+ tx = x + (bw - tw) // 2
312
+ ty = y + (bh - th) // 2
313
+ surface.draw_text(tx, ty, item.label, color=text_color)
314
+
315
+ # pylint: enable=too-many-locals
316
+
317
+ def _measure_content(self, surface: Backend) -> tuple[int, int, int]:
318
+ # If button mode: content height differs (button_height + gaps)
319
+ max_w = 0
320
+ title_h = 0
321
+
322
+ if self.title:
323
+ tw, th = surface.measure_text(self.title)
324
+ max_w = max(max_w, tw)
325
+ title_h = th
326
+
327
+ if not self.items:
328
+ content_h = 0
329
+ if self.title:
330
+ content_h = title_h
331
+ return max_w, content_h, title_h
332
+
333
+ if self.style.button_enabled:
334
+ # width: either fixed or auto-fit
335
+ if self.style.button_width is not None:
336
+ items_w = self.style.button_width
337
+ else:
338
+ max_label_w = 0
339
+ for it in self.items:
340
+ w, _ = surface.measure_text(it.label)
341
+ max_label_w = max(max_label_w, w)
342
+ items_w = max_label_w + self.style.button_padding_x * 2
343
+
344
+ max_w = max(max_w, items_w)
345
+
346
+ bh = self.style.button_height
347
+ gap = self.style.button_gap
348
+ items_h = len(self.items) * bh + (len(self.items) - 1) * gap
349
+ else:
350
+ for it in self.items:
351
+ w, _ = surface.measure_text(it.label)
352
+ max_w = max(max_w, w)
353
+ items_h = len(self.items) * self.style.line_height
354
+
355
+ content_h = items_h
356
+ if self.title:
357
+ content_h += title_h + self.style.title_spacing
358
+
359
+ return max_w, content_h, title_h
360
+
361
+ @staticmethod
362
+ def _draw_text_center_x(
363
+ surface: Backend,
364
+ x_center: int,
365
+ y: int,
366
+ text: str,
367
+ *,
368
+ color: Color,
369
+ ) -> None:
370
+ w, _ = surface.measure_text(text)
371
+ surface.draw_text(x_center - (w // 2), y, text, color=color)
@@ -1,135 +0,0 @@
1
- """
2
- Menu system for mini arcade core.
3
- """
4
-
5
- from __future__ import annotations
6
-
7
- from dataclasses import dataclass
8
- from typing import Callable, Sequence
9
-
10
- from mini_arcade_core.backend import Backend, Color, Event, EventType
11
-
12
- MenuAction = Callable[[], None]
13
-
14
-
15
- @dataclass(frozen=True)
16
- class MenuItem:
17
- """
18
- Represents a single item in a menu.
19
-
20
- :ivar label (str): The text label of the menu item.
21
- :ivar on_select (MenuAction): The action to perform when the item is selected.
22
- """
23
-
24
- label: str
25
- on_select: MenuAction
26
-
27
-
28
- @dataclass
29
- class MenuStyle:
30
- """
31
- Styling options for the Menu.
32
-
33
- :ivar normal (Color): Color for unselected items.
34
- :ivar selected (Color): Color for the selected item.
35
- :ivar line_height (int): Vertical spacing between items.
36
- """
37
-
38
- normal: Color = (220, 220, 220)
39
- selected: Color = (255, 255, 0)
40
- line_height: int = 28
41
-
42
-
43
- class Menu:
44
- """A simple text-based menu system."""
45
-
46
- def __init__(
47
- self,
48
- items: Sequence[MenuItem],
49
- *,
50
- x: int = 40,
51
- y: int = 40,
52
- style: MenuStyle | None = None,
53
- ):
54
- """
55
- :param items: Sequence of MenuItem instances to display.
56
- type items: Sequence[MenuItem]
57
-
58
- :param x: X coordinate for the menu's top-left corner.
59
- :param y: Y coordinate for the menu's top-left corner.
60
-
61
- :param style: Optional MenuStyle for customizing appearance.
62
- :type style: MenuStyle | None
63
- """
64
- self.items = list(items)
65
- self.x = x
66
- self.y = y
67
- self.style = style or MenuStyle()
68
- self.selected_index = 0
69
-
70
- def move_up(self):
71
- """Move the selection up by one item, wrapping around if necessary."""
72
- if self.items:
73
- self.selected_index = (self.selected_index - 1) % len(self.items)
74
-
75
- def move_down(self):
76
- """Move the selection down by one item, wrapping around if necessary."""
77
- if self.items:
78
- self.selected_index = (self.selected_index + 1) % len(self.items)
79
-
80
- def select(self):
81
- """Select the currently highlighted item, invoking its action."""
82
- if self.items:
83
- self.items[self.selected_index].on_select()
84
-
85
- def handle_event(
86
- self,
87
- event: Event,
88
- *,
89
- up_key: int,
90
- down_key: int,
91
- select_key: int,
92
- ):
93
- """
94
- Handle an input event to navigate the menu.
95
-
96
- :param event: The input event to handle.
97
- :type event: Event
98
-
99
- :param up_key: Key code for moving selection up.
100
- type up_key: int
101
-
102
- :param down_key: Key code for moving selection down.
103
- :type down_key: int
104
-
105
- :param select_key: Key code for selecting the current item.
106
- :type select_key: int
107
- """
108
- if event.type != EventType.KEYDOWN or event.key is None:
109
- return
110
- if event.key == up_key:
111
- self.move_up()
112
- elif event.key == down_key:
113
- self.move_down()
114
- elif event.key == select_key:
115
- self.select()
116
-
117
- def draw(self, surface: Backend):
118
- """
119
- Draw the menu onto the given backend surface.
120
-
121
- :param surface: The backend surface to draw on.
122
- :type surface: Backend
123
- """
124
- for i, item in enumerate(self.items):
125
- color = (
126
- self.style.selected
127
- if i == self.selected_index
128
- else self.style.normal
129
- )
130
- surface.draw_text(
131
- self.x,
132
- self.y + i * self.style.line_height,
133
- item.label,
134
- color=color,
135
- )