plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


217e404 — Peter John 2026-11-04T15:26:24+05:30
Input step 3: typed messages for the functional scene; TRMC typing fix
README.md CHANGED
@@ -1404,17 +1404,19 @@ Three scene styles ship, each in its own folder: `std/game/imperative/Scene` (ac
1404
1404
  The functional game is written in a component style: each kind of thing is a record with an `update(self, ..)` that returns the next value and a `render(self, ..)` that returns its `SceneNode` (`Ship.update`/`Ship.render`, `Orb`, `Mine`, `Star`), and `State.render` composes them. `update` never changes a value it was given.
1405
1405
 
1406
1406
  - **`Anim`** describes motion as a function of time: `MoveBy`, `RotateBy`, `ScaleTo`, `Delay`, `Seq`, `Par` and `Forever`. `Anim.at(anim, t)` returns an `AnimPose` (dx, dy, rotation, scaleX, scaleY) for any `t`, so a view just applies the pose for `state.clock`.
1407
- - **`Ui`** is pure pointer state. `view.hitId(x, y)` finds the `Tagged(id, child)` node under the pointer; `Ui.step(ui, input, hit)` returns a `UiStep` with the new `UiState` and the `UiEvent`s raised (`Press`, `Release`, `Click`, `Enter`, `Exit`, with pointer capture). `Ui.button(id, label, x, y, w, h, ui)` builds a tagged button that shows hover and pressed looks.
1407
+ - **`Ui`** is pure pointer state. `view.hitId(x, y)` finds the `Clickable(id, msg, child)` node under the pointer; `Ui.step(ui, input, hit)` returns a `UiStep` with the new `UiState` and the `UiEvent`s raised (`Press`, `Release`, `Click`, `Enter`, `Exit`, with pointer capture), and `Ui.messages(view, events)` turns the clicks into the game's own messages. `Ui.button(id, msg, label, x, y, w, h, ui)` builds a button that shows hover and pressed looks and sends `msg` when clicked. The picture is a `SceneNode[Msg]`, generic over the game's message type.
1408
1408
 
1409
1409
  From [`game_state_view.plum`](plum-examples/games/functional/game_state_view.plum) (a method of `State`): `feed` runs `Ui.step` against the picture the player sees, then hands each event to `update`.
1410
1410
 
1411
1411
  ```
1412
- fun feed(self, input: UiInput) -> State =
1412
+ fun feed(self, input: Input) -> State =
1413
+ pos := input.position() ?: return self
1414
+ view := self.render()
1413
- r := Ui.step(self.ui, input, self.render().hitId(input.x, input.y))
1415
+ r := Ui.step(self.ui, input, view.hitId(pos.x, pos.y))
1414
- r.events.fold(State(..self, ui: r.ui), |s, e| s.update(UiEv(event: e)))
1416
+ Ui.messages(view, r.events).fold(State(..self, ui: r.ui), |s, m| s.update(m))
1415
1417
  ```
1416
1418
 
1417
- Record `==` is identity today (structural `==` is a deferred item), so compare events by their fields (`e.id`, `e.kind`) as above.
1419
+ The button's id only identifies it for hover and press state (record `==` is identity today, structural `==` is a deferred item); what a click means is its message.
1418
1420
 
1419
1421
  **Browser check:** `cargo run --manifest-path plum-game/web-test/Cargo.toml -- plum-examples/games/imperative/game.plum --screenshot /tmp/orb-run.png` builds the static web version, serves it and drives headless Chromium (software WebGPU; `--browser webkit` drives Playwright's WebKit, the Safari engine, instead) through [playwright-rs](https://crates.io/crates/playwright-rs): it fails if the canvas stays blank or stops changing, or the page logs an error, and reports whether audio started. It also takes `--assets DIR`, `--require-audio`, `--measure SECS` (with `--json`, `draws` is the GPU draw calls per frame), `--headed`, `--dpr N` (a device pixel ratio of N), `--lose-device` (destroys the GPU device after a few frames and requires rendering to resume) and `--expect-pixel x,y,r,g,b[,tol]` (repeatable; the screenshot pixel at CSS (x, y)), `--expect-unsupported-webgpu` (hides `navigator.gpu` and requires the page's "WebGPU is not supported" message while plum.wasm still loads) and `--no-module-introspection` (makes `WebAssembly.Module.imports/exports/customSections` throw, as Safari does). `plum-game/web-test/fixtures/` has `interleave.plum`, `text_sizes.plum` and `rendertarget.plum` for these. One-time setup: `cargo run --manifest-path plum-game/web-test/Cargo.toml --example install-browsers`. See [`plum-game/web-test/README.md`](plum-game/web-test/README.md); `docs/game.png` above was made with `--screenshot`. [`plum-game/web-test/BENCHMARK.md`](plum-game/web-test/BENCHMARK.md) records the renderer benchmark that led to this design.
1420
1422
 
docs/backlog.md CHANGED
@@ -39,4 +39,4 @@ Open work, in the order the user queued it, plus smaller items found during buil
39
39
  - **Retire PureScene?** — after the component-style functional game, decide whether `std/game/pure` + `games/pure` stay. (Decision record §4b)
40
40
  - **`adder(1)(2)`** (calling a returned closure directly) is not supported. (functional-gaps plan)
41
41
  - **Fusion past a closure-spec copy on `Nil` miscompiles** — in `drive.rs` `quiet`, resolving `Nil.fold$c0(..)` to `List.fold$c0` (read-only) lets a value move past it, and codegen then fails with `unknown method 'List.fold$c0'` (5 `closure_spec_test` cases). `typeNameMethodKey` skips `$` copies until this is fixed.
42
- - **Input model** — steps 1 (shared `Input`, keys included) and 2 (`onInput` on actors, focus) built 2026-11-04; step 3 (typed `Msg` events) in [game-scene-styles.md](game-scene-styles.md) section 6.
42
+ - **Input model** — steps 1-3 built 2026-11-04 (typed `Msg` events for the functional style only); see [game-scene-styles.md](game-scene-styles.md) section 6.
docs/game-scene-styles.md CHANGED
@@ -21,7 +21,7 @@ Decisions behind the styles are in [decisions/2026-11-language-and-scene-decisio
21
21
  ### Main API (after the 2026-11-04 method refactor)
22
22
 
23
23
  - **Pure:** `Node.group/sprite/label/button/custom(..)` build nodes. `node.update(dt)`, `node.drawTree(io, m, tint)`, `node.findHit(m, p)`, `node.findPath(id)`, `node.modify(id, f)`, `node.route(path, depth, e)` and `node.onPointer(e)` work on them. `Stage.create(root)`, `stage.frame(dt, inputs)`, `stage.draw(io)` and `Input.poll(io, wasDown)` run a frame. `Action.moveBy/rotateBy/delay/sequence/parallel/forever(..)` and `action.step(pose, dt)` drive animations.
24
- - **Functional:** `SceneNode` is an enum (`Group`, `Filter`, `Sprite`, `Text`, `RectShape`, `CircleShape`, `LineShape`, `Tagged`). `node.draw(io)`, `node.bounds()`, `node.hitPath(x, y)` and `node.hitId(x, y)` use it. `Transform.matrix()`, `Effect.tint(alpha)` and `Rect` methods are the helpers. `Ui.step(ui, input, hitId)` and `Ui.button(..)` handle UI.
24
+ - **Functional:** `SceneNode[M]` is an enum over the game's message type `M` (`Group`, `Filter`, `Sprite`, `Text`, `RectShape`, `CircleShape`, `LineShape`, `Clickable`). `node.draw(io)`, `node.bounds()`, `node.hitPath(x, y)` and `node.hitId(x, y)` use it. `Transform.matrix()`, `Effect.tint(alpha)` and `Rect` methods are the helpers. `Ui.step(ui, input, hitId)` and `Ui.button(..)` handle UI.
25
25
 
26
26
  ## 2. Engine analogies
27
27
 
@@ -36,7 +36,7 @@ Decisions behind the styles are in [decisions/2026-11-language-and-scene-decisio
36
36
  Both avoid mutation in game logic. The difference is **where the state lives**:
37
37
 
38
38
  - In **pure**, the tree *is* the game state. Changing the game means finding a node (`findPath`, `modify`) and returning a new tree. Nodes carry their own behaviour (`Actor`) and animations (`actions`), and pointer input changes nodes (a button's `pressed`/`over`).
39
- - In **functional**, the tree is a *picture of* the game state. Changing the game means changing `State`; the next `render()` reflects it. Hover and press state lives in `UiState` inside `State`, and buttons are found with `Tagged(id, ..)` + `hitId`.
39
+ - In **functional**, the tree is a *picture of* the game state. Changing the game means changing `State`; the next `render()` reflects it. Hover and press state lives in `UiState` inside `State`, and a click on a `Clickable(id, msg, ..)` becomes the game message `msg`.
40
40
 
41
41
  Cost: pure updates run in place when the compiler proves uniqueness. Functional describes the whole frame each time, but fusion removes the tree (see `fusion_alloc_test.rs`, `state_view_draw_has_no_scenenode_alloc`).
42
42
 
@@ -83,7 +83,7 @@ Steps 1 and 2 of the proposal below are built (2026-11-04).
83
83
 
84
84
  The example games and fixtures poll once per frame and feed every input in. Nothing else reads input from the host.
85
85
 
86
- Remaining problem: events still identify UI elements by integer id (`e.id == Id.Restart && e.kind == Click`); step 3 addresses it.
86
+ In the functional style, clicks arrive as the game's own messages (step 3). The pure and imperative styles still identify UI elements by integer id.
87
87
 
88
88
  ## 6. Proposal: one input model with `onInput`
89
89
 
@@ -114,16 +114,18 @@ Its ideas:
114
114
  - **Imperative:** `trait Actor` has `fun onInput(self, e: Input) -> Bool` (e in the actor's coordinates; true = consumed). `stage.input(e) -> Bool` bubbles from the hit actor through `parent`. A press sets `stage.focus`; keys bubble from the focused actor.
115
115
  - **Functional:** no methods on nodes (they are data). Inputs reach `State.update` as `Event(input)` messages.
116
116
 
117
- ### Step 3 (optional): typed messages instead of ids (needs generics)
117
+ ### Step 3: typed messages (built for the functional style)
118
118
 
119
- - **Functional:** `SceneNode[Msg]` gains `OnClick(msg: Msg, child: SceneNode[Msg])` and similar, and `Ui.step` returns `List[Msg]`. The game writes `Ui.button(Restart, "Again", ..)` and `match msg Restart => ..`; no ids, and the compiler checks every message is handled.
120
- - **Pure:** `Node[Msg]`, `Button(.., onClick: Msg)`, `Actor[Msg]`.
121
- - **This reopens a decision:** generic `Node[K]`/`Actor[Msg]` was rejected on 2026-11-03 ("Generics can be tried later"), and generic traits are not supported yet. A generic *enum* (`SceneNode[Msg]`) is supported, so the functional style could try it first, but fusion of `render().draw(io)` must be checked on a generic `SceneNode`.
119
+ - `SceneNode[M]` is generic over the game's message type. `Clickable(id, msg: M, child)` replaces `Tagged(id, child)`. `node.msgOf(id)` reads a clickable's message.
120
+ - `Ui.button(id, msg, label, ..)` builds one. `Ui.messages(view, events)` turns the Clicks that `Ui.step` raised into the game's own messages, read from the view the pointer was on.
121
+ - The game declares `RestartClicked` in its `Msg` and handles it in `update` like any other message. The `UiEv` wrapper and the `e.id == Id.Restart && e.kind == Click` check are gone. The id stays only to identify the button for hover and press state (`Ui` needs identity, and `==` on records is identity).
122
+ - Cost: every scene signature says `SceneNode[Msg]`, and a local built from a bare constructor needs an annotation (`x: SceneNode[Msg] := Group(..)`); passing it straight to its use (`Filter(child: Group(..))`) avoids that, and also keeps fusion. Helpers that do not mention `M` (`hitLeaf`, `hitPrepend`, `nearLine`) are top-level functions, because a static method of a generic type must mention its parameter. Fusion of `render().draw(io)` still holds (`fusion_alloc_test`); allocations in the exempt panel/animation functions went from 15 to 12.
123
+ - Found on the way: TRMC hoisted a list cell's head into an untyped temporary, so `Cons(head: Full(..), ..)` in a function returning `List[Box[Int]]` gave the temporary a `Box` with its own parameter left open, and produced invalid wasm. Fixed in `trmc.rs` (the temporary takes the field's concrete type); regression test `trmc_list_of_generic_enum` in `plum-examples/recursion.plum`.
124
+ - Pure and imperative styles keep integer ids (generic traits, `Actor[Msg]`, are not supported).
122
125
 
123
126
  ### Next
124
127
 
125
- 1. Step 3 on the functional style only, kept if fusion still holds.
126
- 2. Decide whether PureScene stays (backlog: "Retire PureScene?").
128
+ 1. Decide whether PureScene stays (backlog: "Retire PureScene?").
127
129
 
128
130
  ### Open questions
129
131
 
plum-core/src/trmc.rs CHANGED
@@ -60,6 +60,43 @@ struct Cx<'a> {
60
60
  found: bool,
61
61
  }
62
62
 
63
+ impl Cx<'_> {
64
+ /// The declared type of field `field` of the cons variant `cons`, with the return
65
+ /// enum's own generic parameters replaced by the return type's arguments
66
+ /// (`head: T` of `List[T]` is `Box[Int]` when returning `List[Box[Int]]`). None
67
+ /// when it can't be told.
68
+ fn fieldType(&self, cons: &str, field: &str) -> Option<Type> {
69
+ let owner = self.enums.iter().find(|e| e.name == self.ret.name)?;
70
+ let v = owner.variants.iter().find(|v| v.name == cons).or_else(|| {
71
+ self.enums.iter().find(|e| e.name == cons).and_then(|e| e.variants.first())
72
+ })?;
73
+ let i = v.field_names.iter().position(|n| n == field)?;
74
+ let declared = v.fields.get(i)?;
75
+ if owner.generics.len() != self.ret.generics.len() {
76
+ return None;
77
+ }
78
+ let map: Vec<(&str, &Type)> =
79
+ owner.generics.iter().map(|g| g.name.as_str()).zip(self.ret.generics.iter()).collect();
80
+ fn subst(t: &Type, map: &[(&str, &Type)]) -> Type {
81
+ if t.generics.is_empty() {
82
+ if let Some((_, r)) = map.iter().find(|(n, _)| *n == t.name) {
83
+ return (*r).clone();
84
+ }
85
+ }
86
+ Type { name: t.name.clone(), generics: t.generics.iter().map(|g| subst(g, map)).collect() }
87
+ }
88
+ fn open(t: &Type) -> bool {
89
+ (t.name.len() == 1 && t.name.chars().all(|c| c.is_ascii_uppercase())) || t.generics.iter().any(open)
90
+ }
91
+ // Only a concrete type helps (and a generic fn's own `B` would not re-check).
92
+ let t = subst(declared, &map);
93
+ if open(&t) {
94
+ return None;
95
+ }
96
+ Some(t)
97
+ }
98
+ }
99
+
63
100
  fn rewriteFn(f: &Fn, enums: &[Enum]) -> Option<Fn> {
64
101
  if f.is_export
65
102
  || f.is_extern
@@ -442,7 +479,11 @@ impl<'a> Cx<'a> {
442
479
  fields.push(FieldArg { name: f.name.clone(), value: Expr::TypeName(self.nullary.clone()) });
443
480
  } else if i < k && !pure(&f.value) {
444
481
  let t = self.fresh("f");
482
+ // Typed from the field's declaration: a bare `Full(v: 1)` for a field
483
+ // `head: T` of `List[Box[Int]]` would otherwise leave `Box`'s own
484
+ // parameter open and get a different instantiation than the cell.
485
+ let ty = self.fieldType(&c.type_name, &f.name);
445
- stmts.push(declare(&t, None, f.value.clone()));
486
+ stmts.push(declare(&t, ty, f.value.clone()));
446
487
  fields.push(FieldArg { name: f.name.clone(), value: var(&t) });
447
488
  } else {
448
489
  fields.push(f.clone());
plum-examples/games/functional/game_state_view.plum CHANGED
@@ -16,7 +16,9 @@
16
16
  # player's input once a frame (`Input.poll`, its only input read) and sends each
17
17
  # `Input` to the state as an `Event`: keys update the held keys that steer the ship,
18
18
  # and a pointer input finds what is under it with `SceneNode.hitId` on the picture the
19
- # player is looking at, runs `Ui.step`, and feeds each `UiEvent` to `State.update`.
19
+ # player is looking at and runs `Ui.step`. A click on a button sends the button's own
20
+ # message (`RestartClicked`): `Ui.messages` reads it from the picture, and it goes to
21
+ # `State.update` like any other `Msg`. The picture is a `SceneNode[Msg]`.
20
22
  # The game-over title and the ship's hurt flash are `Anim`s of time since they began.
21
23
  # The `World` record is the host shell: it holds the current `State`, polls the
22
24
  # keyboard and mouse into messages, and plays sounds.
@@ -71,10 +73,10 @@ record Spawned(at: Motion, rng: Random)
71
73
  fun frameOf(clock: Float, count: Int, fps: Float) -> Int =
72
74
  Int(clock * fps) % count
73
75
 
74
- fun nothing() -> SceneNode =
76
+ fun nothing() -> SceneNode[Msg] =
75
77
  Group(transform: Identity, children: Nil)
76
78
 
77
- fun cell(sheet: Int, x: Float, y: Float, size: Float, col: Int, row: Int) -> SceneNode =
79
+ fun cell(sheet: Int, x: Float, y: Float, size: Float, col: Int, row: Int) -> SceneNode[Msg] =
78
80
  Sprite(
79
81
  tex: sheet,
80
82
  x: x,
@@ -177,7 +179,7 @@ record Ship(x: Float, y: Float, vx: Float, vy: Float, rotation: Float, hurt: Flo
177
179
  overlaps(self.x, self.y, Rules.ShipRadius, at.x, at.y, radius)
178
180
 
179
181
  # The ship blinks while hurt (`hurtAge` seconds since the hit).
180
- fun render(self, sheet: Int, clock: Float, hurtAge: Float) -> SceneNode =
182
+ fun render(self, sheet: Int, clock: Float, hurtAge: Float) -> SceneNode[Msg] =
181
183
  blink := self.hurt > 0.0 && flashPose(hurtAge).scaleX < 0.7
182
184
  if blink
183
185
  nothing()
@@ -204,18 +206,21 @@ record Orb(id: Int, at: Motion, phase: Float, clock: Float) =
204
206
  Orb(..self, at: Motion(..p, vx: 0.0, vy: 0.0))
205
207
 
206
208
  # The glow is drawn additively, then the sprite on top.
207
- fun render(self, sheet: Int) -> SceneNode =
209
+ fun render(self, sheet: Int) -> SceneNode[Msg] =
208
- glow := Group(
209
- transform: Identity,
210
- children: List(
211
- CircleShape(cx: 0.0, cy: 0.0, r: 26.0, color: Color(r: 0.35, g: 0.22, b: 0.0, a: 0.5)),
212
- CircleShape(cx: 0.0, cy: 0.0, r: 17.0, color: Color(r: 0.6, g: 0.4, b: 0.05, a: 0.5))
213
- )
214
- )
215
210
  Group(
216
211
  transform: Translate(dx: self.at.x, dy: self.at.y),
217
212
  children: List(
213
+ Filter(
218
- Filter(effect: BlendFx(mode: Blend.Add), child: glow, target: 0 - 1),
214
+ effect: BlendFx(mode: Blend.Add),
215
+ child: Group(
216
+ transform: Identity,
217
+ children: List(
218
+ CircleShape(cx: 0.0, cy: 0.0, r: 26.0, color: Color(r: 0.35, g: 0.22, b: 0.0, a: 0.5)),
219
+ CircleShape(cx: 0.0, cy: 0.0, r: 17.0, color: Color(r: 0.6, g: 0.4, b: 0.05, a: 0.5))
220
+ )
221
+ ),
222
+ target: 0 - 1
223
+ ),
219
224
  cell(sheet, 0.0 - Rules.Half, 0.0 - Rules.Half, Rules.Size, frameOf(self.clock + self.phase, 4, 8.0), 1)
220
225
  )
221
226
  )
@@ -233,7 +238,7 @@ record Mine(id: Int, at: Motion, rotation: Float, spin: Float, clock: Float) =
233
238
  p := self.at
234
239
  Mine(..self, at: Motion(..p, vx: 0.0, vy: 0.0), spin: 0.0)
235
240
 
236
- fun render(self, sheet: Int) -> SceneNode =
241
+ fun render(self, sheet: Int) -> SceneNode[Msg] =
237
242
  Group(
238
243
  transform: Compose(first: Translate(dx: self.at.x, dy: self.at.y), then: Rotate(radians: self.rotation)),
239
244
  children: List(cell(sheet, 0.0 - Rules.Half, 0.0 - Rules.Half, Rules.Size, frameOf(self.clock, 4, 4.0), 2))
@@ -246,7 +251,7 @@ record Star(i: Int) =
246
251
  fun field(i: Int) -> List[Star] =
247
252
  if i >= 40 then Nil else Cons(head: Star(i: i), tail: Star.field(i + 1))
248
253
 
249
- fun render(self, clock: Float) -> SceneNode =
254
+ fun render(self, clock: Float) -> SceneNode[Msg] =
250
255
  r := 1.0 + Float(self.i % 3) * 0.5
251
256
  twinkle := 0.25 + 0.2 * Math.sin(clock * 2.0 + Float(self.i))
252
257
  CircleShape(cx: Float({self.i * 7919} % 800), cy: Float({self.i * 104729} % 600), r: r, color: Color(r: 0.7, g: 0.8, b: 1.0, a: twinkle))
@@ -257,7 +262,7 @@ enum Msg =
257
262
  | Resize(w: Float, h: Float)
258
263
  | Restart(seed: Int)
259
264
  | Event(input: Input)
260
- | UiEv(event: UiEvent)
265
+ | RestartClicked
261
266
 
262
267
  # Everything that changes during a run. `sheet` and `target` are resource handles
263
268
  # the picture needs: the sprite sheet and the render target of the game-over filter.
@@ -314,7 +319,7 @@ record State(
314
319
  Resize(w, h) => State(..self, sw: w, sh: h)
315
320
  Restart(seed) => self.restarted(seed)
316
321
  Event(input) => self.onInput(input)
317
- UiEv(event) => self.onUi(event)
322
+ RestartClicked => if self.over then self.restarted(self.rng.nextInt().value) else self
318
323
 
319
324
  # One frame of time. Once the game is over the actors only animate.
320
325
  fun tick(self, dt: Float, ax: Float, ay: Float) -> State =
@@ -417,16 +422,14 @@ record State(
417
422
  fun axisY(self) -> Float =
418
423
  axis(self.held.isDown(Key.Up) || self.held.isDown(Key.KeyW), self.held.isDown(Key.Down) || self.held.isDown(Key.KeyS))
419
424
 
420
- # A click on the restart button restarts a finished game; other events change nothing.
421
- fun onUi(self, e: UiEvent) -> State =
422
- if self.over && e.id == Id.Restart && e.kind == Click then self.restarted(self.rng.nextInt().value) else self
423
425
 
424
- # One pointer input: finds what is under it in the current picture, runs the Ui, and
426
+ # One pointer input: finds what is under it in the current picture, runs the Ui,
425
- # feeds each raised event to `update`.
427
+ # and sends the messages of the buttons it clicked to `update`.
426
428
  fun feed(self, input: Input) -> State =
427
429
  pos := input.position() ?: return self
430
+ view := self.render()
428
- r := Ui.step(self.ui, input, self.render().hitId(pos.x, pos.y))
431
+ r := Ui.step(self.ui, input, view.hitId(pos.x, pos.y))
429
- r.events.fold(State(..self, ui: r.ui), |s, e| s.update(UiEv(event: e)))
432
+ Ui.messages(view, r.events).fold(State(..self, ui: r.ui), |s, m| s.update(m))
430
433
 
431
434
  # --- render ---
432
435
 
@@ -441,7 +444,7 @@ record State(
441
444
  {self.sh - self.zoom() * Rules.ArenaH} / 2.0
442
445
 
443
446
  # The whole picture, in drawing order, under the camera that fits the arena on screen.
444
- fun render(self) -> SceneNode =
447
+ fun render(self) -> SceneNode[Msg] =
445
448
  z := self.zoom()
446
449
  Group(
447
450
  transform: Compose(first: Translate(dx: self.offsetX(), dy: self.offsetY()), then: Scale(sx: z, sy: z)),
@@ -457,18 +460,18 @@ record State(
457
460
  )
458
461
 
459
462
  # The ship is gone once the game is over (an empty group keeps the slots).
460
- fun renderShip(self) -> SceneNode =
463
+ fun renderShip(self) -> SceneNode[Msg] =
461
464
  if self.over then nothing() else self.ship.render(self.sheet, self.clock, self.clock - self.hurtAt)
462
465
 
463
466
  # One life icon per remaining life, from the right edge going left.
464
- fun lifeIcons(self, i: Int) -> List[SceneNode] =
467
+ fun lifeIcons(self, i: Int) -> List[SceneNode[Msg]] =
465
468
  if i >= self.lives
466
469
  Nil
467
470
  else
468
471
  Cons(head: cell(self.sheet, Rules.ArenaW - 40.0 - Float(i) * 36.0, 12.0, 32.0, 0, 0), tail: self.lifeIcons(i + 1))
469
472
 
470
473
  # `Cons` (not `List(..).append(..)`) so the life icons fuse into the draw walk too.
471
- fun renderHud(self) -> SceneNode =
474
+ fun renderHud(self) -> SceneNode[Msg] =
472
475
  Group(
473
476
  transform: Identity,
474
477
  children: Cons(
@@ -477,7 +480,7 @@ record State(
477
480
  )
478
481
  )
479
482
 
480
- fun renderTitle(self) -> SceneNode =
483
+ fun renderTitle(self) -> SceneNode[Msg] =
481
484
  p := titlePose(self.clock - self.overAt)
482
485
  Group(
483
486
  transform: Compose(first: Translate(dx: 400.0 + p.dx, dy: 268.0 + p.dy), then: Scale(sx: p.scaleX, sy: p.scaleY)),
@@ -485,22 +488,25 @@ record State(
485
488
  )
486
489
 
487
490
  # The game-over panel is empty while playing, so nothing in it can be hit.
488
- fun renderOverlay(self) -> SceneNode =
491
+ fun renderOverlay(self) -> SceneNode[Msg] =
489
492
  if self.over then self.renderPanel() else nothing()
490
493
 
491
494
  # The panel with the restart button, faded in through a render-target filter.
492
- fun renderPanel(self) -> SceneNode =
495
+ fun renderPanel(self) -> SceneNode[Msg] =
493
- button := Ui.button(Id.Restart, "Again", 340.0, 342.0, 120.0, 44.0, self.ui)
496
+ button := Ui.button(Id.Restart, RestartClicked, "Again", 340.0, 342.0, 120.0, 44.0, self.ui)
497
+ Filter(
498
+ effect: Alpha(alpha: {self.overClock * 2.0}.min(1.0)),
494
- panel := Group(
499
+ child: Group(
495
- transform: Identity,
500
+ transform: Identity,
496
- children: List(
501
+ children: List(
497
- RectShape(x: 0.0, y: 230.0, w: Rules.ArenaW, h: 170.0, color: Color(r: 0.0, g: 0.0, b: 0.0, a: 0.6)),
502
+ RectShape(x: 0.0, y: 230.0, w: Rules.ArenaW, h: 170.0, color: Color(r: 0.0, g: 0.0, b: 0.0, a: 0.6)),
498
- self.renderTitle(),
503
+ self.renderTitle(),
499
- Text(text: "Press Enter to play again", x: 270.0, y: 300.0, size: 24.0, color: Color.White),
504
+ Text(text: "Press Enter to play again", x: 270.0, y: 300.0, size: 24.0, color: Color.White),
500
- button
505
+ button
501
- )
506
+ )
507
+ ),
508
+ target: self.target
502
509
  )
503
- Filter(effect: Alpha(alpha: {self.overClock * 2.0}.min(1.0)), child: panel, target: self.target)
504
510
 
505
511
  # True when (x, y) in screen pixels is on the restart button of the picture `render` builds.
506
512
  fun onRestart(self, x: Float, y: Float) -> Bool =
@@ -604,12 +610,12 @@ fun onShip(s: State) -> Motion =
604
610
  fun forceOver(s: State) -> State =
605
611
  State(..s, lives: 0, over: true, overAt: s.clock, best: s.best.max(s.score))
606
612
 
607
- fun countNodes(n: SceneNode) -> Int = match n
613
+ fun countNodes(n: SceneNode[Msg]) -> Int = match n
608
614
  Group(_, kids) => 1 + countKids(kids)
609
615
  Filter(_, child, _) => 1 + countNodes(child)
610
616
  _ => 1
611
617
 
612
- fun countKids(ks: List[SceneNode]) -> Int = match ks
618
+ fun countKids(ks: List[SceneNode[Msg]]) -> Int = match ks
613
619
  Nil => 0
614
620
  Cons(h, t) => countNodes(h) + countKids(t)
615
621
 
plum-examples/recursion.plum CHANGED
@@ -76,6 +76,22 @@ record Cell(x: Float)
76
76
 
77
77
  # ==== tail calls tests ====
78
78
 
79
+ # A generic enum kept in a List: TRMC must give the hoisted head the list's element
80
+ # type (`Box[Int]`), not a `Box` with its own parameter left open (invalid wasm before).
81
+ enum TrBox[M] =
82
+ | TrEmpty
83
+ | TrFull(v: Int)
84
+ | TrTag(id: Int, msg: M)
85
+
86
+ fun trBoxes(xs: List[Int], rest: List[TrBox[Int]]) -> List[TrBox[Int]] = match xs
87
+ Nil => rest
88
+ Cons(h, t) => Cons(head: TrFull(v: h * 2), tail: trBoxes(t, rest))
89
+
90
+ fun trSum(bs: List[TrBox[Int]]) -> Int = match bs
91
+ Nil => 0
92
+ Cons(TrFull(v), t) => v + trSum(t)
93
+ Cons(_, t) => trSum(t)
94
+
79
95
  test "tail_sum_million"
80
96
  assert sumTo(1000000, 0) == 500000500000
81
97
 
@@ -219,3 +235,6 @@ test "put_after_take_keeps_length"
219
235
  c := xs.takeAt(0)
220
236
  xs.putAt(0, c)
221
237
  assert xs.length() == 3
238
+
239
+ test "trmc_list_of_generic_enum"
240
+ assert trSum(trBoxes(List(1, 2, 3), Nil)) == 12
plum-game/web-test/fixtures/stress_state_view.plum CHANGED
@@ -57,11 +57,11 @@ fun nthOr(xs: List[T], i: Int, dflt: T) -> T = match xs
57
57
  Cons(h, t) => if i <= 0 then h else nthOr(t, i - 1, dflt)
58
58
 
59
59
  # The nodes f(x) for every x of `xs`, followed by `rest`.
60
- fun toNodes(xs: List[T], f: fn(T) -> SceneNode, rest: List[SceneNode]) -> List[SceneNode] = match xs
60
+ fun toNodes(xs: List[T], f: fn(T) -> SceneNode[Int], rest: List[SceneNode[Int]]) -> List[SceneNode[Int]] = match xs
61
61
  Nil => rest
62
62
  Cons(h, t) => Cons(head: f(h), tail: toNodes(t, f, rest))
63
63
 
64
- fun countNodes(ns: List[SceneNode]) -> Int = match ns
64
+ fun countNodes(ns: List[SceneNode[Int]]) -> Int = match ns
65
65
  Nil => 0
66
66
  Cons(_, t) => 1 + countNodes(t)
67
67
 
@@ -153,27 +153,27 @@ fun stepUi(s: State, input: Input) -> State =
153
153
 
154
154
  # --- view: pure, the whole tree every frame ---
155
155
 
156
- fun dancerView(sheet: Int, d: Dancer) -> SceneNode =
156
+ fun dancerView(sheet: Int, d: Dancer) -> SceneNode[Int] =
157
157
  Group(
158
158
  transform: Compose(first: Translate(dx: d.x + 8.0, dy: d.y + 8.0), then: Compose(first: Rotate(radians: d.rotation), then: Translate(dx: 0.0 - 8.0, dy: 0.0 - 8.0))),
159
159
  children: Cons(head: Sprite(
160
160
  tex: sheet, x: 0.0, y: 0.0, w: 16.0, h: 16.0, srcX: d.srcX, srcY: 0.0, srcW: 16.0, srcH: 16.0,
161
161
  tint: Color.White), tail: Nil))
162
162
 
163
- fun spinnerView(sheet: Int, p: Spinner) -> SceneNode =
163
+ fun spinnerView(sheet: Int, p: Spinner) -> SceneNode[Int] =
164
164
  Group(
165
165
  transform: Compose(first: Translate(dx: p.x + 75.0, dy: p.y + 85.0), then: Compose(first: Rotate(radians: p.rotation), then: Translate(dx: 0.0 - 75.0, dy: 0.0 - 85.0))),
166
166
  children: toNodes(p.dancers, |d| dancerView(sheet, d), Nil))
167
167
 
168
- fun drifterView(white: Int, d: Drifter) -> SceneNode =
168
+ fun drifterView(white: Int, d: Drifter) -> SceneNode[Int] =
169
169
  Sprite(
170
170
  tex: white, x: d.x, y: d.y, w: d.w, h: d.h, srcX: 0.0, srcY: 0.0, srcW: 0.0, srcH: 0.0,
171
171
  tint: d.color)
172
172
 
173
- fun counterView(c: Counter) -> SceneNode =
173
+ fun counterView(c: Counter) -> SceneNode[Int] =
174
174
  Text(text: "n {c.n}", x: c.x, y: c.y, size: 14.0, color: Color.White)
175
175
 
176
- fun pulserView(sheet: Int, anim: Anim, clock: Float, p: Pulser) -> SceneNode =
176
+ fun pulserView(sheet: Int, anim: Anim, clock: Float, p: Pulser) -> SceneNode[Int] =
177
177
  pose := Anim.at(anim, clock - p.start)
178
178
  Group(
179
179
  transform: Compose(first: Translate(dx: p.x + pose.dx + 8.0, dy: p.y + pose.dy + 8.0), then: Compose(first: Scale(sx: pose.scaleX, sy: pose.scaleY), then: Compose(first: Rotate(radians: pose.rotation), then: Translate(dx: 0.0 - 8.0, dy: 0.0 - 8.0)))),
@@ -181,17 +181,17 @@ fun pulserView(sheet: Int, anim: Anim, clock: Float, p: Pulser) -> SceneNode =
181
181
  tex: sheet, x: 0.0, y: 0.0, w: 16.0, h: 16.0, srcX: p.srcX, srcY: 0.0, srcW: 16.0, srcH: 16.0,
182
182
  tint: Color.White), tail: Nil))
183
183
 
184
- fun buttonNodes(i: Int, ui: UiState) -> List[SceneNode] =
184
+ fun buttonNodes(i: Int, ui: UiState) -> List[SceneNode[Int]] =
185
- if i >= Counts.Buttons then Nil else Cons(head: Ui.button(40000 + i, "b{i}", Float(i % 10 * 70 + 10), Float(i / 10 * 30 + 10), 64.0, 24.0, ui), tail: buttonNodes(i + 1, ui))
185
+ if i >= Counts.Buttons then Nil else Cons(head: Ui.button(40000 + i, 40000 + i, "b{i}", Float(i % 10 * 70 + 10), Float(i / 10 * 30 + 10), 64.0, 24.0, ui), tail: buttonNodes(i + 1, ui))
186
186
 
187
- fun buttonsView(ui: UiState) -> SceneNode =
187
+ fun buttonsView(ui: UiState) -> SceneNode[Int] =
188
188
  Group(transform: Identity, children: buttonNodes(0, ui))
189
189
 
190
- fun view(s: State) -> SceneNode =
190
+ fun view(s: State) -> SceneNode[Int] =
191
191
  texts := toNodes(s.counters, |c| counterView(c), Nil)
192
192
  drifts := toNodes(s.drifters, |d| drifterView(s.white, d), texts)
193
193
  pulses := toNodes(s.pulsers, |p| pulserView(s.sheet, s.anim, s.clock, p), drifts)
194
- world := Group(transform: Scale(sx: 0.8, sy: 0.8), children: toNodes(s.spinners, |p| spinnerView(s.sheet, p), pulses))
194
+ world: SceneNode[Int] := Group(transform: Scale(sx: 0.8, sy: 0.8), children: toNodes(s.spinners, |p| spinnerView(s.sheet, p), pulses))
195
195
  Group(transform: Identity, children: List(world, buttonsView(s.ui)))
196
196
 
197
197
  fun whiteTexture(io: Io) -> Int =
plum-game/web-test/fixtures/stress_state_view_deep.plum CHANGED
@@ -48,11 +48,11 @@ fun nthOr(xs: List[T], i: Int, dflt: T) -> T = match xs
48
48
  Cons(h, t) => if i <= 0 then h else nthOr(t, i - 1, dflt)
49
49
 
50
50
  # The nodes f(x) for every x of `xs`, followed by `rest`.
51
- fun toNodes(xs: List[T], f: fn(T) -> SceneNode, rest: List[SceneNode]) -> List[SceneNode] = match xs
51
+ fun toNodes(xs: List[T], f: fn(T) -> SceneNode[Int], rest: List[SceneNode[Int]]) -> List[SceneNode[Int]] = match xs
52
52
  Nil => rest
53
53
  Cons(h, t) => Cons(head: f(h), tail: toNodes(t, f, rest))
54
54
 
55
- fun countNodes(ns: List[SceneNode]) -> Int = match ns
55
+ fun countNodes(ns: List[SceneNode[Int]]) -> Int = match ns
56
56
  Nil => 0
57
57
  Cons(_, t) => 1 + countNodes(t)
58
58
 
@@ -122,7 +122,7 @@ fun step(s: State, dt: Float) -> State =
122
122
 
123
123
  # --- view: pure, the whole tree every frame ---
124
124
 
125
- fun dancerView(sheet: Int, d: Dancer) -> SceneNode =
125
+ fun dancerView(sheet: Int, d: Dancer) -> SceneNode[Int] =
126
126
  Group(
127
127
  transform: Compose(first: Translate(dx: d.x + 8.0, dy: d.y + 8.0), then: Compose(first: Rotate(radians: d.rotation), then: Translate(dx: 0.0 - 8.0, dy: 0.0 - 8.0))),
128
128
  children: Cons(head: Sprite(
@@ -130,7 +130,7 @@ fun dancerView(sheet: Int, d: Dancer) -> SceneNode =
130
130
  tint: Color.White), tail: Nil))
131
131
 
132
132
  # The leading dancers of `ds` that belong to group `gi`, as nodes.
133
- fun groupNodes(sheet: Int, ds: List[Dancer], gi: Int) -> List[SceneNode] = match ds
133
+ fun groupNodes(sheet: Int, ds: List[Dancer], gi: Int) -> List[SceneNode[Int]] = match ds
134
134
  Nil => Nil
135
135
  Cons(h, t) => if h.gi == gi then Cons(head: dancerView(sheet, h), tail: groupNodes(sheet, t, gi)) else Nil
136
136
 
@@ -139,25 +139,25 @@ fun dropGroup(ds: List[Dancer], gi: Int) -> List[Dancer] = match ds
139
139
  Nil => Nil
140
140
  Cons(h, t) => if h.gi == gi then dropGroup(t, gi) else ds
141
141
 
142
- fun spinnerView(sheet: Int, p: Spinner, ds: List[Dancer]) -> SceneNode =
142
+ fun spinnerView(sheet: Int, p: Spinner, ds: List[Dancer]) -> SceneNode[Int] =
143
143
  Group(
144
144
  transform: Compose(first: Translate(dx: p.x + 75.0, dy: p.y + 85.0), then: Compose(first: Rotate(radians: p.rotation), then: Translate(dx: 0.0 - 75.0, dy: 0.0 - 85.0))),
145
145
  children: groupNodes(sheet, ds, p.id - 100))
146
146
 
147
147
  # Spinner groups in order, each taking its dancers from the front of the one list.
148
- fun spinnerNodes(sheet: Int, ps: List[Spinner], ds: List[Dancer], rest: List[SceneNode]) -> List[SceneNode] = match ps
148
+ fun spinnerNodes(sheet: Int, ps: List[Spinner], ds: List[Dancer], rest: List[SceneNode[Int]]) -> List[SceneNode[Int]] = match ps
149
149
  Nil => rest
150
150
  Cons(p, t) => Cons(head: spinnerView(sheet, p, ds), tail: spinnerNodes(sheet, t, dropGroup(ds, p.id - 100), rest))
151
151
 
152
- fun drifterView(white: Int, d: Drifter) -> SceneNode =
152
+ fun drifterView(white: Int, d: Drifter) -> SceneNode[Int] =
153
153
  Sprite(
154
154
  tex: white, x: d.x, y: d.y, w: d.w, h: d.h, srcX: 0.0, srcY: 0.0, srcW: 0.0, srcH: 0.0,
155
155
  tint: d.color)
156
156
 
157
- fun counterView(c: Counter) -> SceneNode =
157
+ fun counterView(c: Counter) -> SceneNode[Int] =
158
158
  Text(text: "n {c.n}", x: c.x, y: c.y, size: 14.0, color: Color.White)
159
159
 
160
- fun view(s: State) -> SceneNode =
160
+ fun view(s: State) -> SceneNode[Int] =
161
161
  texts := toNodes(s.counters, |c| counterView(c), Nil)
162
162
  drifts := toNodes(s.drifters, |d| drifterView(s.white, d), texts)
163
163
  Group(transform: Scale(sx: 0.8, sy: 0.8), children: spinnerNodes(s.sheet, s.spinners, s.dancers, drifts))
plum-std/game/functional/FunctionalScene.plum CHANGED
@@ -11,10 +11,13 @@ import std/game/Affine
11
11
  import std/game/Game
12
12
  import std/Io
13
13
 
14
- # The functional scene: a `SceneNode` is plain data (shapes, sprites, text, groups
14
+ # The functional scene: a `SceneNode[M]` is plain data (shapes, sprites, text, groups
15
- # with a transform, filters), rebuilt every frame and never mutated. Its methods draw
15
+ # with a transform, filters, clickable areas), rebuilt every frame and never mutated.
16
+ # `M` is the game's own message type: a `Clickable(id, msg, child)` carries the
17
+ # message a click on it sends (see `Ui.messages`), so games match on their messages
18
+ # instead of comparing ids; the id identifies the element for hover and press state. Its methods draw
16
19
  # it, measure it and hit-test it by structural recursion, passing the parent matrix
17
- # and alpha down as arguments. Children are a `List[SceneNode]`; the static
20
+ # and alpha down as arguments. Children are a `List[SceneNode[M]]`; the static
18
21
  # `SceneNode` helpers over such a list (`drawKids`, `boundsKids`, ..) do the same walk
19
22
  # for each child. `Compose(first, then)` applies `then` first, then `first` (`first`
20
23
  # is the outermost).
@@ -83,15 +86,15 @@ struct Rect(x: Float, y: Float, w: Float, h: Float) =
83
86
  # creates nothing, so a view does not leak a texture per frame. The size of
84
87
  # `target` bounds what the filter can show: child pixels beyond it are cut off.
85
88
  # A negative target draws nothing.
86
- enum SceneNode =
89
+ enum SceneNode[M] =
87
- | Group(transform: Transform, children: List[SceneNode])
90
+ | Group(transform: Transform, children: List[SceneNode[M]])
88
- | Filter(effect: Effect, child: SceneNode, target: Int)
91
+ | Filter(effect: Effect, child: SceneNode[M], target: Int)
89
92
  | Sprite(tex: Int, x: Float, y: Float, w: Float, h: Float, srcX: Float, srcY: Float, srcW: Float, srcH: Float, tint: Color)
90
93
  | Text(text: Str, x: Float, y: Float, size: Float, color: Color)
91
94
  | RectShape(x: Float, y: Float, w: Float, h: Float, color: Color)
92
95
  | CircleShape(cx: Float, cy: Float, r: Float, color: Color)
93
96
  | LineShape(x1: Float, y1: Float, x2: Float, y2: Float, thickness: Float, color: Color)
94
- | Tagged(id: Int, child: SceneNode)
97
+ | Clickable(id: Int, msg: M, child: SceneNode[M])
95
98
 
96
99
  # Draws the scene to the current target.
97
100
  fun draw(self, io: Io) =
@@ -109,10 +112,25 @@ enum SceneNode =
109
112
  return p
110
113
  Slice[Int]()
111
114
 
112
- # The id of the innermost Tagged node under (x, y), or null. Later siblings win.
115
+ # The id of the innermost Clickable node under (x, y), or null. Later siblings win.
113
116
  fun hitId(self, x: Float, y: Float) -> Int? =
114
117
  self.hitIdWith(null, x, y)
115
118
 
119
+ # The message of the first Clickable with `id`, depth first, or null.
120
+ fun msgOf(self, id: Int) -> M? = match self
121
+ Clickable(cid, msg, child) => if cid == id then msg else child.msgOf(id)
122
+ Group(_, kids) => SceneNode.msgOfKids(kids, id)
123
+ Filter(_, child, _) => child.msgOf(id)
124
+ _ => null
125
+
126
+ fun msgOfKids(kids: List[SceneNode[M]], id: Int) -> M? = match kids
127
+ Nil => null
128
+ Cons(head, tail) =>
129
+ if m := head.msgOf(id)
130
+ m
131
+ else
132
+ SceneNode.msgOfKids(tail, id)
133
+
116
134
  # --- drawing ---
117
135
 
118
136
  # Draws under the parent matrix `m` and alpha into `target` (negative: the screen).
@@ -139,15 +157,15 @@ enum SceneNode =
139
157
  io.drawText(txt, tp.x, tp.y, size * m.scaleY(), tcolor.withAlpha(tcolor.a * alpha))
140
158
  Group(tr, kids) => SceneNode.drawKids(io, kids, m.mul(tr.matrix()), alpha, target)
141
159
  Filter(effect, child, tex) => SceneNode.drawFiltered(io, effect, child, tex, m, alpha, target)
142
- Tagged(_, child) => child.drawWith(io, m, alpha, target)
160
+ Clickable(_, _, child) => child.drawWith(io, m, alpha, target)
143
161
 
144
- fun drawKids(io: Io, kids: List[SceneNode], m: Affine, alpha: Float, target: Int) = match kids
162
+ fun drawKids(io: Io, kids: List[SceneNode[M]], m: Affine, alpha: Float, target: Int) = match kids
145
163
  Nil => 0
146
164
  Cons(head, tail) =>
147
165
  head.drawWith(io, m, alpha, target)
148
166
  SceneNode.drawKids(io, tail, m, alpha, target)
149
167
 
150
- fun drawFiltered(io: Io, effect: Effect, child: SceneNode, tex: Int, m: Affine, alpha: Float, target: Int) = match effect
168
+ fun drawFiltered(io: Io, effect: Effect, child: SceneNode[M], tex: Int, m: Affine, alpha: Float, target: Int) = match effect
151
169
  BlendFx(mode) =>
152
170
  io.blendMode(mode)
153
171
  child.drawWith(io, m, alpha, target)
@@ -175,9 +193,9 @@ enum SceneNode =
175
193
  Text(txt, tx, ty, size, _) => Rect.through(tx, ty, size * 0.6 * Float(txt.length()), size, m)
176
194
  Group(tr, kids) => SceneNode.boundsKids(kids, m.mul(tr.matrix()), null)
177
195
  Filter(_, child, _) => child.boundsWith(m)
178
- Tagged(_, child) => child.boundsWith(m)
196
+ Clickable(_, _, child) => child.boundsWith(m)
179
197
 
180
- fun boundsKids(kids: List[SceneNode], m: Affine, acc: Rect?) -> Rect? = match kids
198
+ fun boundsKids(kids: List[SceneNode[M]], m: Affine, acc: Rect?) -> Rect? = match kids
181
199
  Nil => acc
182
200
  Cons(head, tail) =>
183
201
  if cr := head.boundsWith(m)
@@ -190,11 +208,11 @@ enum SceneNode =
190
208
  # The child indices from the node down to the hit leaf, or null on a miss. A leaf
191
209
  # that is hit is an empty path. (px, py) is in the node's own space.
192
210
  fun hitWith(self, px: Float, py: Float) -> Slice[Int]? = match self
193
- RectShape(x, y, w, h, _) => SceneNode.leaf(Rect(x: x, y: y, w: w, h: h).contains(px, py))
211
+ RectShape(x, y, w, h, _) => hitLeaf(Rect(x: x, y: y, w: w, h: h).contains(px, py))
194
- CircleShape(cx, cy, r, _) => SceneNode.leaf(Math.hypot(px - cx, py - cy) <= r)
212
+ CircleShape(cx, cy, r, _) => hitLeaf(Math.hypot(px - cx, py - cy) <= r)
195
- LineShape(x1, y1, x2, y2, th, _) => SceneNode.leaf(SceneNode.nearLine(x1, y1, x2, y2, th, px, py))
213
+ LineShape(x1, y1, x2, y2, th, _) => hitLeaf(nearLine(x1, y1, x2, y2, th, px, py))
196
- Sprite(_, sx, sy, sw, sh, _, _, _, _, _) => SceneNode.leaf(Rect(x: sx, y: sy, w: sw, h: sh).contains(px, py))
214
+ Sprite(_, sx, sy, sw, sh, _, _, _, _, _) => hitLeaf(Rect(x: sx, y: sy, w: sw, h: sh).contains(px, py))
197
- Text(txt, tx, ty, size, _) => SceneNode.leaf(Rect(x: tx, y: ty, w: size * 0.6 * Float(txt.length()), h: size).contains(px, py))
215
+ Text(txt, tx, ty, size, _) => hitLeaf(Rect(x: tx, y: ty, w: size * 0.6 * Float(txt.length()), h: size).contains(px, py))
198
216
  Group(tr, kids) =>
199
217
  if gi := tr.matrix().invert()
200
218
  lp := gi.apply(Vec2(x: px, y: py))
@@ -203,29 +221,29 @@ enum SceneNode =
203
221
  null
204
222
  Filter(_, child, _) =>
205
223
  if fp := child.hitWith(px, py)
206
- SceneNode.prepend(0, fp)
224
+ hitPrepend(0, fp)
207
225
  else
208
226
  null
209
- Tagged(_, child) =>
227
+ Clickable(_, _, child) =>
210
228
  if tp := child.hitWith(px, py)
211
- SceneNode.prepend(0, tp)
229
+ hitPrepend(0, tp)
212
230
  else
213
231
  null
214
232
 
215
233
  # Tries the later children first (they are drawn on top); `i` is the index of
216
234
  # `kids`' head. The first hit found from the back wins.
217
- fun hitKids(kids: List[SceneNode], i: Int, px: Float, py: Float) -> Slice[Int]? = match kids
235
+ fun hitKids(kids: List[SceneNode[M]], i: Int, px: Float, py: Float) -> Slice[Int]? = match kids
218
236
  Nil => null
219
237
  Cons(head, tail) =>
220
238
  if later := SceneNode.hitKids(tail, i + 1, px, py)
221
239
  later
222
240
  else
223
241
  if sub := head.hitWith(px, py)
224
- SceneNode.prepend(i, sub)
242
+ hitPrepend(i, sub)
225
243
  else
226
244
  null
227
245
 
228
- # The id of the innermost Tagged node whose subtree is hit at (px, py), or null.
246
+ # The id of the innermost Clickable node whose subtree is hit at (px, py), or null.
229
247
  # `outer` is the id of the nearest enclosing tag (null at the root).
230
248
  fun hitIdWith(self, outer: Int?, px: Float, py: Float) -> Int? = match self
231
249
  Group(tr, kids) =>
@@ -235,7 +253,7 @@ enum SceneNode =
235
253
  else
236
254
  null
237
255
  Filter(_, child, _) => child.hitIdWith(outer, px, py)
238
- Tagged(id, child) => child.hitIdWith(id, px, py)
256
+ Clickable(id, _, child) => child.hitIdWith(id, px, py)
239
257
  _ =>
240
258
  if self.hitWith(px, py) != null
241
259
  outer
@@ -243,7 +261,7 @@ enum SceneNode =
243
261
  null
244
262
 
245
263
  # Later siblings are drawn on top, so they are tried first.
246
- fun hitIdKids(kids: List[SceneNode], outer: Int?, px: Float, py: Float) -> Int? = match kids
264
+ fun hitIdKids(kids: List[SceneNode[M]], outer: Int?, px: Float, py: Float) -> Int? = match kids
247
265
  Nil => null
248
266
  Cons(head, tail) =>
249
267
  if later := SceneNode.hitIdKids(tail, outer, px, py)
@@ -251,32 +269,35 @@ enum SceneNode =
251
269
  else
252
270
  head.hitIdWith(outer, px, py)
253
271
 
272
+ # Hit-test helpers that do not mention `M`. They are not static methods of
273
+ # `SceneNode[M]` because a call like `SceneNode.leaf(..)` could not tell which `M`.
274
+
254
- fun leaf(hit: Bool) -> Slice[Int]? =
275
+ fun hitLeaf(hit: Bool) -> Slice[Int]? =
255
- if hit
276
+ if hit
256
- return Slice[Int]()
277
+ return Slice[Int]()
257
- null
278
+ null
258
-
279
+
259
- # `i` followed by the path `sub`.
280
+ # `i` followed by the path `sub`.
260
- fun prepend(i: Int, sub: Slice[Int]) -> Slice[Int] =
281
+ fun hitPrepend(i: Int, sub: Slice[Int]) -> Slice[Int] =
261
- path := Slice(i)
282
+ path := Slice(i)
262
- for j := range sub.length()
283
+ for j := range sub.length()
263
- path.push(sub.at(j))
284
+ path.push(sub.at(j))
264
- path
285
+ path
265
-
286
+
266
- fun nearLine(x1: Float, y1: Float, x2: Float, y2: Float, thickness: Float, px: Float, py: Float) -> Bool =
287
+ fun nearLine(x1: Float, y1: Float, x2: Float, y2: Float, thickness: Float, px: Float, py: Float) -> Bool =
267
- dx := x2 - x1
288
+ dx := x2 - x1
268
- dy := y2 - y1
289
+ dy := y2 - y1
269
- len2 := dx * dx + dy * dy
290
+ len2 := dx * dx + dy * dy
270
- t := if len2 == 0.0 then 0.0 else {{px - x1} * dx + {py - y1} * dy} / len2
291
+ t := if len2 == 0.0 then 0.0 else {{px - x1} * dx + {py - y1} * dy} / len2
271
- tc := t.max(0.0).min(1.0)
292
+ tc := t.max(0.0).min(1.0)
272
- qx := x1 + dx * tc
293
+ qx := x1 + dx * tc
273
- qy := y1 + dy * tc
294
+ qy := y1 + dy * tc
274
- Math.hypot(px - qx, py - qy) <= thickness.max(1.0) / 2.0
295
+ Math.hypot(px - qx, py - qy) <= thickness.max(1.0) / 2.0
275
296
 
276
297
  fun picNear(a: Float, b: Float) -> Bool =
277
298
  {a - b}.abs() < 1e-6
278
299
 
279
- fun picRect(x: Float, y: Float, w: Float, h: Float, c: Color) -> SceneNode =
300
+ fun picRect(x: Float, y: Float, w: Float, h: Float, c: Color) -> SceneNode[Int] =
280
301
  RectShape(x: x, y: y, w: w, h: h, color: c)
281
302
 
282
303
  test "bounds_translate_scale"
@@ -325,7 +346,7 @@ test "filter_bounds_is_child_bounds"
325
346
  assert picNear(r.h, 4.0)
326
347
 
327
348
  test "tagged_bounds_and_path"
328
- g := Group(transform: Identity, children: Cons(head: Tagged(id: 7, child: picRect(1.0, 2.0, 10.0, 4.0, Color.Red)), tail: Nil))
349
+ g := Group(transform: Identity, children: Cons(head: Clickable(id: 7, msg: 7 * 10, child: picRect(1.0, 2.0, 10.0, 4.0, Color.Red)), tail: Nil))
329
350
  r := g.bounds()
330
351
  assert picNear(r.x, 1.0)
331
352
  assert picNear(r.w, 10.0)
@@ -335,9 +356,9 @@ test "tagged_bounds_and_path"
335
356
  assert p.at(1) == 0
336
357
 
337
358
  test "hit_id_innermost_and_topmost"
338
- inner := Tagged(id: 2, child: picRect(0.0, 0.0, 10.0, 10.0, Color.Blue))
359
+ inner := Clickable(id: 2, msg: 2 * 10, child: picRect(0.0, 0.0, 10.0, 10.0, Color.Blue))
339
- outer := Tagged(id: 1, child: Group(transform: Identity, children: Cons(head: picRect(0.0, 0.0, 20.0, 20.0, Color.Red), tail: Cons(head: inner, tail: Nil))))
360
+ outer := Clickable(id: 1, msg: 1 * 10, child: Group(transform: Identity, children: Cons(head: picRect(0.0, 0.0, 20.0, 20.0, Color.Red), tail: Cons(head: inner, tail: Nil))))
340
- top := Tagged(id: 3, child: picRect(15.0, 15.0, 10.0, 10.0, Color.Red))
361
+ top := Clickable(id: 3, msg: 3 * 10, child: picRect(15.0, 15.0, 10.0, 10.0, Color.Red))
341
362
  g := Group(transform: Translate(dx: 1.0, dy: 0.0), children: Cons(head: outer, tail: Cons(head: top, tail: Nil)))
342
363
  a := g.hitId(6.0, 5.0)
343
364
  if va := a
@@ -355,3 +376,8 @@ test "hit_id_innermost_and_topmost"
355
376
  else
356
377
  assert false
357
378
  assert g.hitId(100.0, 100.0) == null
379
+
380
+ test "msg_of_finds_the_clickable_message"
381
+ g := Group(transform: Identity, children: List(picRect(0.0, 0.0, 1.0, 1.0, Color.Red), Clickable(id: 4, msg: 40, child: picRect(0.0, 0.0, 1.0, 1.0, Color.Red))))
382
+ assert {g.msgOf(4) ?: -1} == 40
383
+ assert g.msgOf(5) == null
plum-std/game/functional/Ui.plum CHANGED
@@ -10,7 +10,9 @@ import std/game/Input
10
10
 
11
11
  # A pure UI layer for the functional scene style. `Ui.step` takes the previous
12
12
  # UiState, one pointer input and the id under the pointer (from SceneNode.hitId on
13
- # the current view) and returns the new state plus the UiEvents it raised. Same
13
+ # the current view) and returns the new state plus the UiEvents it raised.
14
+ # `Ui.messages` turns the Clicks among them into the game's own messages, read from
15
+ # the `Clickable` nodes of that view. Same
14
16
  # button rules as PureScene: Down captures the pressed id; while captured only
15
17
  # that id gets Enter/Exit; Up releases (Click only when still over it).
16
18
 
@@ -93,13 +95,25 @@ primitive Ui =
93
95
  false
94
96
 
95
97
  # A tagged, translated rectangle with a label whose colour follows the state.
98
+ # The messages of the Clickables that `events` clicked, in order, read from `view`.
99
+ fun messages(view: SceneNode[M], events: List[UiEvent]) -> List[M] = match events
100
+ Nil => Nil
101
+ Cons(e, rest) =>
102
+ later := Ui.messages(view, rest)
103
+ if e.kind != Click
104
+ return later
105
+ if m := view.msgOf(e.id)
106
+ return Cons(head: m, tail: later)
107
+ later
108
+
109
+ # A button that sends `msg` when clicked; `id` identifies it for hover and press.
96
- fun button(id: Int, label: Str, x: Float, y: Float, w: Float, h: Float, ui: UiState) -> SceneNode =
110
+ fun button(id: Int, msg: M, label: Str, x: Float, y: Float, w: Float, h: Float, ui: UiState) -> SceneNode[M] =
97
111
  color := uiIdleColor()
98
112
  if Ui.pressed(ui, id)
99
113
  color = uiPressedColor()
100
114
  else if Ui.hovered(ui, id)
101
115
  color = uiHoverColor()
102
- Tagged(id: id, child: Group(transform: Translate(dx: x, dy: y), children: List(
116
+ Clickable(id: id, msg: msg, child: Group(transform: Translate(dx: x, dy: y), children: List(
103
117
  RectShape(x: 0.0, y: 0.0, w: w, h: h, color: color),
104
118
  Text(text: label, x: 4.0, y: 4.0, size: h * 0.5, color: Color.White))))
105
119
 
@@ -126,8 +140,8 @@ fun uiCount(s: UiStep, k: UiKind) -> Int =
126
140
 
127
141
  fun uiIs(e: UiEvent, id: Int, kind: UiKind) -> Bool = e.id == id && e.kind == kind
128
142
 
129
- fun uiButtonColor(n: SceneNode) -> Color = match n
143
+ fun uiButtonColor(n: SceneNode[Int]) -> Color = match n
130
- Tagged(_, child) => uiButtonColor(child)
144
+ Clickable(_, _, child) => uiButtonColor(child)
131
145
  Group(_, children) => uiButtonColor(children.at(0))
132
146
  RectShape(_, _, _, _, c) => c
133
147
  _ => Color.Black
@@ -186,9 +200,9 @@ test "hover_without_press_routes_move"
186
200
  assert !Ui.hovered(s3.ui, 5)
187
201
 
188
202
  test "button_visual_reflects_state"
189
- idle := Ui.button(3, "Go", 10.0, 20.0, 100.0, 40.0, Ui.init())
203
+ idle := Ui.button(3, 30, "Go", 10.0, 20.0, 100.0, 40.0, Ui.init())
190
- hov := Ui.button(3, "Go", 10.0, 20.0, 100.0, 40.0, Ui.step(Ui.init(), uiMove(1.0), 3).ui)
204
+ hov := Ui.button(3, 30, "Go", 10.0, 20.0, 100.0, 40.0, Ui.step(Ui.init(), uiMove(1.0), 3).ui)
191
- prs := Ui.button(3, "Go", 10.0, 20.0, 100.0, 40.0, Ui.step(Ui.init(), uiDown(1.0), 3).ui)
205
+ prs := Ui.button(3, 30, "Go", 10.0, 20.0, 100.0, 40.0, Ui.step(Ui.init(), uiDown(1.0), 3).ui)
192
206
  assert uiButtonColor(idle).eq(uiIdleColor())
193
207
  assert uiButtonColor(hov).eq(uiHoverColor())
194
208
  assert uiButtonColor(prs).eq(uiPressedColor())
@@ -196,3 +210,11 @@ test "button_visual_reflects_state"
196
210
  assert hid == 3
197
211
  else
198
212
  assert false
213
+
214
+ test "messages_are_the_clicked_buttons_messages"
215
+ view := Group(transform: Identity, children: List(Ui.button(3, 30, "Go", 0.0, 0.0, 10.0, 10.0, Ui.init()), Ui.button(4, 40, "Stop", 20.0, 0.0, 10.0, 10.0, Ui.init())))
216
+ evs := List(UiEvent(id: 4, kind: Press), UiEvent(id: 4, kind: Click), UiEvent(id: 9, kind: Click), UiEvent(id: 3, kind: Click))
217
+ ms := Ui.messages(view, evs)
218
+ assert ms.length() == 2
219
+ assert {ms.get(0) ?: -1} == 40
220
+ assert {ms.get(1) ?: -1} == 30