plum

#treesitter#compiler#wasm

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

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


34c3f06 — Peter John 2026-11-02T18:21:33+05:30
std/Scene: z-index, toFront/toBack, stable in-place sortChildren, and Scene2D-style culling areas
Files changed (1) hide show
  1. plum-std/Scene.plum +294 -7
plum-std/Scene.plum CHANGED
@@ -93,7 +93,9 @@ record Node(
93
93
  tex: Int, srcX: Float, srcY: Float, srcW: Float, srcH: Float,
94
94
  text: Str, size: Float,
95
95
  acting: Bool,
96
- act: fn(Node, Float)) =
96
+ act: fn(Node, Float),
97
+ cull: Bool, cullX: Float, cullY: Float, cullW: Float, cullH: Float,
98
+ scratch: List[Node]) =
97
99
 
98
100
  fun make(kind: Int, w: Float, h: Float, c: Color) -> Node =
99
101
  Node(
@@ -101,7 +103,8 @@ record Node(
101
103
  scaleX: 1.0, scaleY: 1.0, rotation: 0.0, color: c, visible: true,
102
104
  children: List[Node](), parent: null, kind: kind,
103
105
  tex: -1, srcX: 0.0, srcY: 0.0, srcW: 0.0, srcH: 0.0, text: "", size: 0.0,
104
- acting: false, act: noAct)
106
+ acting: false, act: noAct,
107
+ cull: false, cullX: 0.0, cullY: 0.0, cullW: 0.0, cullH: 0.0, scratch: List[Node]())
105
108
 
106
109
  # A node that only holds children.
107
110
  fun group() -> Node =
@@ -169,6 +172,86 @@ record Node(
169
172
  self.children.at(i).parent = null
170
173
  self.children.clear()
171
174
 
175
+ # The node's index among its siblings (its draw order), -1 without a parent.
176
+ fun zIndex(self) -> Int =
177
+ if p := self.parent
178
+ for i := range p.children.length()
179
+ if p.children.at(i) == self
180
+ return i
181
+ -1
182
+
183
+ # Moves the node to index `i` of its parent's children (clamped to the valid
184
+ # range), shifting the siblings in between. Without a parent it does nothing.
185
+ fun setZIndex(self, i: Int) =
186
+ if p := self.parent
187
+ src := self.zIndex()
188
+ dst := i
189
+ if dst < 0
190
+ dst = 0
191
+ if dst > p.children.length() - 1
192
+ dst = p.children.length() - 1
193
+ d := p.children.data
194
+ base := p.children.start
195
+ while src < dst
196
+ d.set(base + src, d.get(base + src + 1))
197
+ src = src + 1
198
+ while src > dst
199
+ d.set(base + src, d.get(base + src - 1))
200
+ src = src - 1
201
+ d.set(base + dst, self)
202
+
203
+ # Draws last (on top) and is hit first.
204
+ fun toFront(self) =
205
+ if p := self.parent
206
+ self.setZIndex(p.children.length() - 1)
207
+
208
+ # Draws first (underneath) and is hit last.
209
+ fun toBack(self) =
210
+ self.setZIndex(0)
211
+
212
+ # Reorders the children in place so that `before(a, b)` puts a ahead of b;
213
+ # equal children (neither before the other) keep their order. A natural merge
214
+ # sort: it finds the ascending runs, then merges neighbouring runs, trimming
215
+ # the part of each merge that is already in place by binary search. A list that
216
+ # is already sorted costs n - 1 comparisons, a nearly sorted one about 2n (one
217
+ # scan to find the runs, one to confirm the merge) plus the distance each
218
+ # displaced child moves; the worst case is O(n log n). The merge buffer
219
+ # is the node's own `scratch` list, which keeps its capacity between calls, so
220
+ # sorting every frame allocates nothing once it has grown.
221
+ fun sortChildren(self, before: fn(Node, Node) -> Bool) =
222
+ n := self.children.length()
223
+ d := self.children.data
224
+ base := self.children.start
225
+ runs := 2
226
+ while runs > 1
227
+ runs = 0
228
+ i := 0
229
+ while i < n
230
+ a := sortRunEnd(d, base, i, n, before)
231
+ runs = runs + 1
232
+ if a >= n
233
+ i = n
234
+ else
235
+ b := sortRunEnd(d, base, a, n, before)
236
+ runs = runs + 1
237
+ sortMerge(self.scratch, d, base, i, a, b, before)
238
+ i = b
239
+ runs = {runs + 1} / 2
240
+
241
+ # Culls this node's direct children against the rectangle (x, y, w, h), in this
242
+ # node's own coordinates: a child whose box (position and size, scaled about its
243
+ # origin; rotation ignored) misses it is neither drawn nor hit, with its whole
244
+ # subtree. A child with no size (a plain group) is never culled.
245
+ fun setCullingArea(self, x: Float, y: Float, w: Float, h: Float) =
246
+ self.cull = true
247
+ self.cullX = x
248
+ self.cullY = y
249
+ self.cullW = w
250
+ self.cullH = h
251
+
252
+ fun clearCullingArea(self) =
253
+ self.cull = false
254
+
172
255
  # A point in this node's coordinates, in the stage's.
173
256
  fun localToStage(self, p: Vec2) -> Vec2 =
174
257
  worldMatrix(self).apply(p)
@@ -180,6 +263,78 @@ record Node(
180
263
  fun noAct(n: Node, dt: Float) =
181
264
  n.x = n.x
182
265
 
266
+ # The end of the ascending run of `d` starting at `i` (exclusive), within n.
267
+ fun sortRunEnd(d: Array[Node], base: Int, i: Int, n: Int, before: fn(Node, Node) -> Bool) -> Int =
268
+ j := i + 1
269
+ while j < n && !before(d.get(base + j), d.get(base + j - 1))
270
+ j = j + 1
271
+ j
272
+
273
+ # Merges the sorted runs [lo, a) and [a, b) of `d`, stably. The left elements that
274
+ # already precede the first right one and the right elements that already follow
275
+ # the last left one are found by binary search and left alone.
276
+ fun sortMerge(scratch: List[Node], d: Array[Node], base: Int, lo: Int, a: Int, b: Int, before: fn(Node, Node) -> Bool) =
277
+ first := d.get(base + a)
278
+ l := lo
279
+ h := a
280
+ while l < h
281
+ mid := {l + h} / 2
282
+ if before(first, d.get(base + mid))
283
+ h = mid
284
+ else
285
+ l = mid + 1
286
+ lo2 := l
287
+ if lo2 >= a
288
+ return
289
+ last := d.get(base + a - 1)
290
+ l = a
291
+ h = b
292
+ while l < h
293
+ mid := {l + h} / 2
294
+ if before(d.get(base + mid), last)
295
+ l = mid + 1
296
+ else
297
+ h = mid
298
+ hi := l
299
+ scratch.clear()
300
+ for k := range a - lo2
301
+ scratch.push(d.get(base + lo2 + k))
302
+ ln := a - lo2
303
+ i := 0
304
+ j := a
305
+ o := lo2
306
+ while i < ln && j < hi
307
+ r := d.get(base + j)
308
+ s := scratch.at(i)
309
+ if before(r, s)
310
+ d.set(base + o, r)
311
+ j = j + 1
312
+ else
313
+ d.set(base + o, s)
314
+ i = i + 1
315
+ o = o + 1
316
+ while i < ln
317
+ d.set(base + o, scratch.at(i))
318
+ i = i + 1
319
+ o = o + 1
320
+
321
+ # Whether `parent` culls `c`: the child's box in the parent's coordinates lies
322
+ # outside the parent's culling area.
323
+ fun culled(parent: Node, c: Node) -> Bool =
324
+ if !parent.cull || c.width == 0.0 && c.height == 0.0
325
+ return false
326
+ w := c.width * c.scaleX
327
+ h := c.height * c.scaleY
328
+ x := c.x + c.originX - c.originX * c.scaleX
329
+ y := c.y + c.originY - c.originY * c.scaleY
330
+ if w < 0.0
331
+ x = x + w
332
+ w = 0.0 - w
333
+ if h < 0.0
334
+ y = y + h
335
+ h = 0.0 - h
336
+ x > parent.cullX + parent.cullW || x + w < parent.cullX || y > parent.cullY + parent.cullH || y + h < parent.cullY
337
+
183
338
  # The node's transform into its parent's coordinates: translate to the position,
184
339
  # rotate and scale about the origin.
185
340
  fun localMatrix(n: Node) -> Affine =
@@ -211,10 +366,12 @@ fun collect(n: Node, parent: Affine, alpha: Float, out: List[Drawn]) =
211
366
  a := alpha * n.color.a
212
367
  out.push(Drawn(node: n, m: m, alpha: a))
213
368
  for i := range n.children.length()
369
+ kid := n.children.at(i)
370
+ if !culled(n, kid)
214
- collect(n.children.at(i), m, a, out)
371
+ collect(kid, m, a, out)
215
372
 
216
373
  # What `draw` would draw, in order: a parent before its children, siblings in
217
- # insertion order, invisible subtrees left out.
374
+ # list order, invisible and culled subtrees left out.
218
375
  fun flatten(root: Node) -> List[Drawn] =
219
376
  out := List[Drawn]()
220
377
  collect(root, Affine.Identity, 1.0, out)
@@ -243,7 +400,9 @@ fun drawNode(n: Node, parent: Affine, alpha: Float) =
243
400
  p := m.apply(Vec2(x: 0.0, y: 0.0))
244
401
  drawText(n.text, p.x, p.y, n.size * m.scaleY(), n.color.withAlpha(a))
245
402
  for i := range n.children.length()
403
+ kid := n.children.at(i)
404
+ if !culled(n, kid)
246
- drawNode(n.children.at(i), m, a)
405
+ drawNode(kid, m, a)
247
406
 
248
407
  fun actNode(n: Node, dt: Float) =
249
408
  if n.acting
@@ -263,8 +422,9 @@ fun hitNode(n: Node, p: Vec2) -> Node? =
263
422
  q := localMatrix(n).inverse().apply(p)
264
423
  for i := range n.children.length()
265
424
  child := n.children.at(n.children.length() - 1 - i)
425
+ if !culled(n, child)
266
- if found := hitNode(child, q)
426
+ if found := hitNode(child, q)
267
- return found
427
+ return found
268
428
  if n.width > 0.0 && n.height > 0.0 && q.x >= 0.0 && q.x <= n.width && q.y >= 0.0 && q.y <= n.height
269
429
  return n
270
430
  null
@@ -301,6 +461,18 @@ fun sceneNear(a: Float, b: Float) -> Bool =
301
461
  fun sceneNearV(p: Vec2, x: Float, y: Float) -> Bool =
302
462
  sceneNear(p.x, x) && sceneNear(p.y, y)
303
463
 
464
+ fun sceneKids(n: Int) -> Node =
465
+ g := Node.group()
466
+ for i := range n
467
+ g.add(Node.rect(1.0, 1.0, Color.Red).at(Float(i), 0.0))
468
+ g
469
+
470
+ fun sceneOrder(g: Node) -> Str =
471
+ s := ""
472
+ for i := range g.children.length()
473
+ s = "{s}{Int(g.children.at(i).x)},"
474
+ s
475
+
304
476
  fun sceneSpin(n: Node, dt: Float) =
305
477
  n.rotation = n.rotation + dt
306
478
 
@@ -455,3 +627,118 @@ test "sizes of sprites, circles and text"
455
627
  assert s.tex == 3
456
628
  assert s.kind == NodeKind.Sprite
457
629
  assert Node.text("ab", 10.0, Color.White).height == 10.0
630
+
631
+ test "setZIndex, toFront and toBack reorder the siblings"
632
+ g := sceneKids(4)
633
+ a := g.children.at(0)
634
+ b := g.children.at(1)
635
+ d := g.children.at(3)
636
+ assert a.zIndex() == 0
637
+ assert d.zIndex() == 3
638
+ a.toFront()
639
+ assert sceneOrder(g) == "1,2,3,0,"
640
+ assert a.zIndex() == 3
641
+ d.toBack()
642
+ assert sceneOrder(g) == "3,1,2,0,"
643
+ b.setZIndex(2)
644
+ assert sceneOrder(g) == "3,2,1,0,"
645
+ b.setZIndex(99)
646
+ assert sceneOrder(g) == "3,2,0,1,"
647
+ b.setZIndex(-5)
648
+ assert sceneOrder(g) == "1,3,2,0,"
649
+ assert Node.group().zIndex() == -1
650
+ Node.group().toFront()
651
+ assert g.children.length() == 4
652
+
653
+ test "sortChildren is stable and keeps ties in order"
654
+ g := Node.group()
655
+ for i := range 10
656
+ k := Node.rect(1.0, 1.0, Color.Red).at(Float(i), Float(i * 7 % 3))
657
+ g.add(k)
658
+ g.sortChildren(|a, b| a.y < b.y)
659
+ assert sceneOrder(g) == "0,3,6,9,1,4,7,2,5,8,"
660
+ g.sortChildren(|a, b| a.y < b.y)
661
+ assert sceneOrder(g) == "0,3,6,9,1,4,7,2,5,8,"
662
+
663
+ test "sortChildren sorts 5000 random nodes and updates hit order"
664
+ g := Node.group()
665
+ for i := range 5000
666
+ g.add(Node.rect(1.0, 1.0, Color.Red).at(Float(i), Float({i * 7919 + 13} % 5003)))
667
+ g.sortChildren(|a, b| a.y < b.y)
668
+ assert g.children.length() == 5000
669
+ for i := range 4999
670
+ assert g.children.at(i).y <= g.children.at(i + 1).y
671
+ assert {g.children.at(10).parent ?: crash "orphan"} == g
672
+
673
+ test "sorting a nearly sorted list takes about n comparisons and reuses the scratch buffer"
674
+ g := Node.group()
675
+ for i := range 5000
676
+ g.add(Node.rect(1.0, 1.0, Color.Red).at(0.0, Float(i)))
677
+ c := List(0)
678
+ less: fn(Node, Node) -> Bool := |a, b|
679
+ _ := c.set(0, c.at(0) + 1)
680
+ a.y < b.y
681
+ g.sortChildren(less)
682
+ assert c.at(0) == 4999
683
+ g.children.at(2500).y = 2498.5
684
+ g.children.at(100).y = 105.5
685
+ _ := c.set(0, 0)
686
+ g.sortChildren(less)
687
+ assert c.at(0) < 10100
688
+ for i := range 4999
689
+ assert g.children.at(i).y <= g.children.at(i + 1).y
690
+ scratch := g.scratch
691
+ cap := scratch.cap
692
+ assert cap > 0
693
+ scratch.clear()
694
+ g.children.at(3000).y = 2990.5
695
+ g.sortChildren(less)
696
+ assert scratch.length() > 0
697
+ assert scratch.cap == cap
698
+ _ := c.set(0, 0)
699
+ g.children.clear()
700
+ for i := range 5000
701
+ g.add(Node.rect(1.0, 1.0, Color.Red).at(0.0, Float({i * 7919 + 13} % 5003)))
702
+ g.sortChildren(less)
703
+ assert c.at(0) < 130000
704
+
705
+ test "culling skips children outside the area in flatten and draw order"
706
+ g := Node.group()
707
+ inside := Node.rect(10.0, 10.0, Color.Red).at(20.0, 20.0)
708
+ outside := Node.rect(10.0, 10.0, Color.Red).at(500.0, 20.0)
709
+ g.add(inside)
710
+ g.add(outside)
711
+ outside.add(Node.rect(5.0, 5.0, Color.Red))
712
+ assert flatten(g).length() == 4
713
+ g.setCullingArea(0.0, 0.0, 100.0, 100.0)
714
+ list := flatten(g)
715
+ assert list.length() == 2
716
+ assert list.at(1).node == inside
717
+ g.setCullingArea(0.0, 100.0, 100.0, 100.0)
718
+ assert flatten(g).length() == 1
719
+ g.clearCullingArea()
720
+ assert flatten(g).length() == 4
721
+
722
+ test "hit does not return culled children, and a scaled child near the edge is kept"
723
+ stage := Stage.create()
724
+ k := Node.rect(10.0, 10.0, Color.Red).at(105.0, 5.0)
725
+ stage.root.add(k)
726
+ stage.root.setCullingArea(0.0, 0.0, 100.0, 100.0)
727
+ assert stage.hit(107.0, 7.0) == null
728
+ k.scaleX = 4.0
729
+ k.originX = 10.0
730
+ assert {stage.hit(90.0, 7.0) ?: crash "miss"} == k
731
+ assert flatten(stage.root).length() == 2
732
+ k.scaleX = 1.0
733
+ k.originX = 0.0
734
+ assert stage.hit(107.0, 7.0) == null
735
+ stage.root.clearCullingArea()
736
+ assert {stage.hit(107.0, 7.0) ?: crash "miss"} == k
737
+
738
+ test "a child without a size is never culled"
739
+ g := Node.group()
740
+ slot := Node.group().at(900.0, 900.0)
741
+ g.add(slot)
742
+ slot.add(Node.rect(10.0, 10.0, Color.Red).at(-890.0, -890.0))
743
+ g.setCullingArea(0.0, 0.0, 100.0, 100.0)
744
+ assert flatten(g).length() == 3