fix(ui): 0.R4 - an action a UI button dispatches is reduced before the frame is presented

ludic.ui runs a frame's presses after drawing it, and what they dispatched waited for the end of the
phase - after the host had presented - so a button's change showed a frame late. Decided: drain, not a
phase per action. ui_show and ui_press drain the queue once the presses have run, so the code after
them and the frame presented next see the change; a host that runs presses some other way calls
drain_actions() before it presents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-26 07:08:26 +03:00
parent d46f0adb5e
commit 259b4d9b35
7 changed files with 46 additions and 3 deletions

View file

@ -371,8 +371,10 @@ program Pack {
dispatching needs no state parameter.
- **When the queue is drained:** at the end of every phase of the frame loop (so what the `Input`
phase dispatches is reduced before `Update`); after every phase of the ludic.base system runner
(`core_tick_all`); and wherever the program calls `drain_actions()` (an `entry` program, a test,
a loop of its own). Draining runs the actions in the order they were dispatched, and each action's
(`core_tick_all`); when ludic.ui has run a frame's presses (`ui_show`, `ui_press`), so a button's
action is reduced before the host presents the frame, not a frame later; and wherever the program
calls `drain_actions()` (an `entry` program, a test, a loop of its own - and a host that runs UI
presses some other way calls it before it presents). Draining runs the actions in the order they were dispatched, and each action's
reducers in the order of their states' names - never the order of imports - so the same actions
make the same changes on every machine and in a replay.
- **An action a reducer dispatches** is queued behind the rest and reduced in the same drain, never

View file

@ -0,0 +1,7 @@
bump: patch
type: fix
**An action a UI button dispatches is reduced before the frame is presented.** ludic.ui runs a frame's
presses after drawing it, and an action they dispatched waited for the end of the phase - after the
host had presented - so what the button changed showed a frame late. `ui_show` and `ui_press` now
drain the queue once the presses have run. A host that runs presses some other way calls
`drain_actions()` before it presents.

View file

@ -0,0 +1,22 @@
# ui_press.ludic - 0.R4: an action a UI control dispatches is reduced before the UI hands control
# back, so the code after it - and the frame the host presents - already sees the change
import "ludic.ui"
program UiPress {
numbers float
state Score { points: int = 0 }
action Scored { n: int = 1 }
reducer Score on Scored(s: mut Score, a: Scored) { s.points += a.n }
import "ui_press_parts/Adder.ludic"
function frame(ui_st: mut UiState) -> UiNode {
let root: UiNode = ui_nodes(ui_st, "Adder", null)
ui_place(ui_st, root, 0.0, 0.0, 300.0, 200.0)
return root
}
entry (ui_st: mut UiState, score: Score) {
let root = frame(ui_st)
ui_press(ui_st, root.children[0].children[0])
let after_one = score.points
ui_press(ui_st, root.children[0].children[0])
print(`{after_one} {score.points}`)
}
}

View file

@ -0,0 +1,4 @@
# Adder.ludic - a button that says what happened and changes nothing itself
component Adder {
on add(n: int) { dispatch Scored { n: n } }
}

View file

@ -0,0 +1,3 @@
<div>
<button on-click="add(5)">+5</button>
</div>

View file

@ -1,5 +1,7 @@
# frame.ludic - a screen shown: built from the view's model, laid out, drawn, and its presses
# run once the frame is drawn, so nothing the view does can change a frame half way through it
# run once the frame is drawn, so nothing the view does can change a frame half way through it;
# the actions they dispatch are reduced before ui_show returns, so the frame presented next is built
# from what they changed
export function ui_show(ui_st: mut UiState, screen: string, view: UiView, x: float, y: float, w: float, h: float) -> void {
in_read(ui_st)
@ -25,6 +27,7 @@ export function ui_show(ui_st: mut UiState, screen: string, view: UiView, x: flo
dv_after(ui_st, screen, root)
lc_run(ui_st, screen)
nt_fired_run(ui_st)
drain_actions() # 0.R4: what the presses dispatched is reduced before the host presents
}
# the screen's nodes for this frame, not yet laid out - for a test, or a host that places them
export function ui_nodes(ui_st: mut UiState, screen: string, view: UiView) -> UiNode {
@ -78,6 +81,7 @@ export function ui_place(ui_st: mut UiState, root: UiNode, x: float, y: float, w
# a press, as if the pointer had made it
export function ui_press(ui_st: mut UiState, n: UiNode) -> void {
if n.act != null and n.enabled { act_run(ui_st, n.act, n.env) }
drain_actions()
}
# any event, as if the element had fired it - for a test
export function ui_event(ui_st: mut UiState, n: UiNode, name: string, value: Val) -> void { nt_run(ui_st, n, name, value) }

View file

@ -1012,6 +1012,7 @@ function cmd_dev_test() -> int {
reject_case("rejected/layer_reach", "hud uses items.item_count", "a layered module is still held to its uses outside the layer")
reject_case("rejected/layer_cycle", "go round in a circle: items -> layer app -> items", "a cycle through a layer and out of it is refused")
feat_case("actions/pack", "", "0 1 9 13 picked 7 picked 9 too heavy", "pack.ludic (0.R: actions, reducers - one per state, in the order of the states' names - dispatch, drain_actions, an action a reducer dispatches goes behind)")
controller_case("actions/ui_press", "", "5 10", "ui_press.ludic (0.R4: an action a UI press dispatches is reduced before ludic.ui hands control back)")
feat_case("actions/modules", "", "2", "modules.ludic (0.R: an action in one module, its reducer in another)")
actions_deps_case()
deps_reach_case()