From aac58ec6fadac14ef6284e755597124d0377056b Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Tue, 29 Sep 2026 22:04:20 +0300 Subject: [PATCH] ui-preview: hold - hover, active, focus, focus-visible, checked or disabled held on from the next frame on top of the real input, by the element's key (so through model and load), let go with "" or reset; ludic.ui's held.ludic keeps them and the matcher asks it first; protocol-v1.md and smoke.txt Co-Authored-By: Claude Opus 5.5 --- changes/ui-preview-hold.md | 7 +++++ packages/ludic.ui/backend.ludic | 2 ++ packages/ludic.ui/held.ludic | 47 ++++++++++++++++++++++++++++ packages/ludic.ui/index.ludic | 1 + packages/ludic.ui/select_match.ludic | 1 + tools/ui-preview/cmd_hold.ludic | 21 +++++++++++++ tools/ui-preview/cmds.ludic | 3 +- tools/ui-preview/main.ludic | 1 + tools/ui-preview/protocol-v1.md | 1 + tools/ui-preview/smoke.txt | 39 +++++++++++++++++++++++ 10 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 changes/ui-preview-hold.md create mode 100644 packages/ludic.ui/held.ludic create mode 100644 tools/ui-preview/cmd_hold.ludic diff --git a/changes/ui-preview-hold.md b/changes/ui-preview-hold.md new file mode 100644 index 00000000..22dd64db --- /dev/null +++ b/changes/ui-preview-hold.md @@ -0,0 +1,7 @@ +bump: minor +type: feature +**ui-preview `hold`: a pseudo-class held on without input.** `hold "" "hover"` (also `active`, +`focus`, `focus-visible`, `checked`, `disabled`) shows a story's element as if it were hovered or focused +from the next frame, on top of the real input; `tree`, `box` and `rules` answer as if it were real. `""` +lets go of one element's holds, `hold "" ""` of all, and `reset` clears them. ludic.ui keeps the holds +(`ui_hold`, `ui_hold_clear`, held.ludic); a game holds none, and the matcher's check of an empty table is all it costs. diff --git a/packages/ludic.ui/backend.ludic b/packages/ludic.ui/backend.ludic index c21ead72..a822b597 100644 --- a/packages/ludic.ui/backend.ludic +++ b/packages/ludic.ui/backend.ludic @@ -34,6 +34,8 @@ function ui_default_backend() -> UiBackend { return UiRenderers[0].make() } export state UiState { + @max(256) ph_keys: []string = new []string # held.ludic: elements a preview holds a pseudo-class on for ... + @max(256) ph_bits: []int = new []int # ... and which (hover 1, active 2, focus 4, focus-visible 8, checked 16, disabled 32) @max(512) pl_pools: []UiPool = new []UiPool # pool.ludic: each screen's nodes and envs, used again pl_cur: UiPool = null uv_on: bool = false # pool.ludic: a build is under way, so expressions' Values are pooled diff --git a/packages/ludic.ui/held.ludic b/packages/ludic.ui/held.ludic new file mode 100644 index 00000000..47208095 --- /dev/null +++ b/packages/ludic.ui/held.ludic @@ -0,0 +1,47 @@ +# held.ludic - a pseudo-class held on for an element whatever the input says: a preview shows a story's +# button hovered or focused without playing the pointer (ui-preview's `hold`). Keyed by the element's +# key, so a hold outlives a rebuild; nothing holds anything in a game, and the matcher's check is empty. +function ph_bit(p: string) -> int { + if p == "hover" { return 1 } + if p == "active" { return 2 } + if p == "focus" { return 4 } + if p == "focus-visible" { return 12 } # focus-visible is focus too + if p == "checked" { return 16 } + if p == "disabled" { return 32 } + return 0 +} +# hold `pseudo` on for the element keyed `key`; false for a pseudo-class that cannot be held +@alloc_ok("a preview's hold: an element and a pseudo-class a story asks for, kept until cleared") +export function ui_hold(ui_st: mut UiState, key: string, pseudo: string) -> bool { + let b = ph_bit(pseudo) + if b == 0 { return false } + for i in 0 .. len(ui_st.ph_keys) { + if ui_st.ph_keys[i] == key { + ui_st.ph_bits[i] = ui_st.ph_bits[i] | b + return true + } + } + push(ui_st.ph_keys, intern(key)) + push(ui_st.ph_bits, b) + return true +} +# let go of what an element holds, or of every hold ("") +export function ui_hold_clear(ui_st: mut UiState, key: string) -> void { + for i in 0 .. len(ui_st.ph_keys) { + if key == "" or ui_st.ph_keys[i] == key { ui_st.ph_bits[i] = 0 } + } + if key == "" { + List.clear(ui_st.ph_keys) + List.clear(ui_st.ph_bits) + } +} +# is pseudo-class p held on for n +function ph_on(ui_st: UiState, p: string, n: UiNode) -> bool { + var want = ph_bit(p) + if p == "focus-visible" { want = 8 } + if want == 0 { return false } + for i in 0 .. len(ui_st.ph_keys) { + if ui_st.ph_keys[i] == n.key { return (ui_st.ph_bits[i] & want) != 0 } + } + return false +} diff --git a/packages/ludic.ui/index.ludic b/packages/ludic.ui/index.ludic index c3e4530f..0612a511 100644 --- a/packages/ludic.ui/index.ludic +++ b/packages/ludic.ui/index.ludic @@ -109,3 +109,4 @@ import "draw_nine.ludic" import "draw_gradient.ludic" import "dump.ludic" import "inspect.ludic" +import "held.ludic" diff --git a/packages/ludic.ui/select_match.ludic b/packages/ludic.ui/select_match.ludic index c1a4781e..19aa9667 100644 --- a/packages/ludic.ui/select_match.ludic +++ b/packages/ludic.ui/select_match.ludic @@ -63,6 +63,7 @@ function sel_attr_of(n: UiNode, key: string) -> string { return null } function sel_pseudo_ok(ui_st: mut UiState, p: string, arg: string, n: UiNode) -> bool { + if len(ui_st.ph_keys) > 0 and ph_on(ui_st, p, n) { return true } # held.ludic: a preview's held pseudo-class if p == "hover" { return n.hovered } if p == "focus" { return ui_st.fc_key != "" and n.key == ui_st.fc_key } if p == "focus-visible" { return ui_st.fc_visible and ui_st.fc_key != "" and n.key == ui_st.fc_key } diff --git a/tools/ui-preview/cmd_hold.ludic b/tools/ui-preview/cmd_hold.ludic new file mode 100644 index 00000000..37b475e9 --- /dev/null +++ b/tools/ui-preview/cmd_hold.ludic @@ -0,0 +1,21 @@ +# cmd_hold.ludic - `hold `: a pseudo-class held on for an element from the next frame, +# on top of the real input (ludic.ui's held.ludic); "" as the pseudo lets that element's holds go, and +# "" "" every hold. Holds are by the element's key, so they survive `model` and `load`; `reset` clears. +function pv_hold(ui_st: mut UiState, w: []string) -> string { + if len(w) < 3 { return "hold: , \"\" to let go" } + let id = pv_text(w[1]) + let pseudo = pv_text(w[2]) + if id == "" { + if pseudo != "" { return "hold: a pseudo-class needs an element" } + ui_hold_clear(ui_st, "") + return "" + } + let n = ui_find(ui_st, id) + if n == null { return `no element {id}` } + if pseudo == "" { + ui_hold_clear(ui_st, n.key) + return "" + } + if not ui_hold(ui_st, n.key, pseudo) { return `hold: {pseudo} cannot be held (hover, active, focus, focus-visible, checked, disabled)` } + return "" +} diff --git a/tools/ui-preview/cmds.ludic b/tools/ui-preview/cmds.ludic index f06d6db1..fd9e37c7 100644 --- a/tools/ui-preview/cmds.ludic +++ b/tools/ui-preview/cmds.ludic @@ -12,7 +12,7 @@ function pv_run(pv: mut PvState, ui_st: mut UiState, i18n_st: mut I18nState, w: if c == "hello" { pv.ok_more = pv_hello(pv) } else if c == "quit" { pv_end(pv, "") return false - } else if c == "frame" { why = pv_frame(pv, ui_st, w) } else if c == "load" { why = pv_load(pv, ui_st, w) } else if c == "model" { why = pv_story_cmd(pv, w, false) } else if c == "calls" { why = pv_story_cmd(pv, w, true) } else if c == "show" { why = pv_show(pv, ui_st, w) } else if c == "native" { why = pv_native(pv, ui_st, w) } else if c == "input" { why = pv_input(pv, w) } else if c == "viewport" { why = pv_viewport(pv, w) } else if c == "font" { why = pv_font_cmd(pv, w) } else if c == "locale" { why = pv_locale(pv, i18n_st, w) } else if c == "atlas" { why = pv_atlas(pv, w) } else if c == "image" { why = pv_image_cmd(pv, w) } else if c == "file" { why = pv_file(pv, ui_st, w) } else if c == "tree" { why = pv_tree(pv, ui_st) } else if c == "box" { why = pv_id_cmd(pv, ui_st, w, false) } else if c == "rules" { why = pv_id_cmd(pv, ui_st, w, true) } else if c == "reset" { why = pv_reset(pv, ui_st) } else { why = `unknown request {c}` } + } else if c == "frame" { why = pv_frame(pv, ui_st, w) } else if c == "load" { why = pv_load(pv, ui_st, w) } else if c == "model" { why = pv_story_cmd(pv, w, false) } else if c == "calls" { why = pv_story_cmd(pv, w, true) } else if c == "show" { why = pv_show(pv, ui_st, w) } else if c == "native" { why = pv_native(pv, ui_st, w) } else if c == "input" { why = pv_input(pv, w) } else if c == "viewport" { why = pv_viewport(pv, w) } else if c == "font" { why = pv_font_cmd(pv, w) } else if c == "locale" { why = pv_locale(pv, i18n_st, w) } else if c == "atlas" { why = pv_atlas(pv, w) } else if c == "image" { why = pv_image_cmd(pv, w) } else if c == "file" { why = pv_file(pv, ui_st, w) } else if c == "tree" { why = pv_tree(pv, ui_st) } else if c == "box" { why = pv_id_cmd(pv, ui_st, w, false) } else if c == "rules" { why = pv_id_cmd(pv, ui_st, w, true) } else if c == "reset" { why = pv_reset(pv, ui_st) } else if c == "hold" { why = pv_hold(ui_st, w) } else { why = `unknown request {c}` } pv_end(pv, why) return true } @@ -67,6 +67,7 @@ function pv_frame(pv: mut PvState, ui_st: mut UiState, w: []string) -> string { } # `reset`: the root's template state and instances forgotten, as a screen shown for the first time function pv_reset(pv: mut PvState, ui_st: mut UiState) -> string { + ui_hold_clear(ui_st, "") if pv.root != "" { ui_forget(ui_st, pv.root) } return "" } diff --git a/tools/ui-preview/main.ludic b/tools/ui-preview/main.ludic index 8b05394d..2282db96 100644 --- a/tools/ui-preview/main.ludic +++ b/tools/ui-preview/main.ludic @@ -20,6 +20,7 @@ program UiPreview { import "input.ludic" import "input_keys.ludic" import "inspect.ludic" + import "cmd_hold.ludic" import "computed.ludic" import "cmd_load.ludic" import "cmd_world.ludic" diff --git a/tools/ui-preview/protocol-v1.md b/tools/ui-preview/protocol-v1.md index 541f03e9..d270d37a 100644 --- a/tools/ui-preview/protocol-v1.md +++ b/tools/ui-preview/protocol-v1.md @@ -71,6 +71,7 @@ A template error is NOT a `fail`: the request succeeded and the error is reporte | `native [ ]` | none. `` becomes an element the game draws itself: laid out with that content size in design pixels (default 0 0), drawn as a `native` line. Declare natives before the templates that use them are loaded | | `atlas []` | none. `` is a cell of atlas `name`: a `cols` x `rows` grid, cells numbered row by row from 0, named by the list (a cell is also reachable by its number) | | `image ` | none. A picture's natural size in pixels, for `object-fit`; an undeclared picture has none (it fills its box) | +| `hold ` | none, or `fail "no element …"`. Holds a pseudo-class on for that element from the next frame, on top of the real input: `hover`, `active`, `focus`, `focus-visible` (which is `focus` too), `checked`, `disabled`. `""` as the pseudo lets go of that element's holds; `hold "" ""` lets go of every hold. Holds are by the element's key, so they survive `model` and `load`; `reset` clears them. `tree`, `box` and `rules` answer as if the pseudo-class were real: `rules` lists the `:hover` / `:focus` rules it makes match. Added in v1 (a host without it answers `fail "unknown request hold"`) | | `file ` | none. Reading `path` gives these contents (an imported stylesheet, a `.po`) instead of the disk's, until replaced; every class re-reads its styles at its next show | | `input pointer ` | none. Held until changed. Screen pixels; buttons is a mask: 1 left, 2 right, 4 middle | | `input wheel ` | none. Added to the next frame's wheel only | diff --git a/tools/ui-preview/smoke.txt b/tools/ui-preview/smoke.txt index 42a940e6..4b53a6df 100644 --- a/tools/ui-preview/smoke.txt +++ b/tools/ui-preview/smoke.txt @@ -168,6 +168,45 @@ < "dem Nordbecken" <= ok +# hold: a pseudo-class held on without input - the :hover rule draws from the next frame, rules lists +# it, and letting go puts the plain background back +> load "Hov" "" ".b { background: #0000ff; }\n.b:hover { background: #00ff00; }" +<= ok + +> show "Hov" +<= ok + +> frame 0 +< #0000ff + hold "b" "hover" +<= ok + +> frame 1 +< #00ff00 +<= ok + +> rules "b" +< ".b:hover" +<= ok + +> hold "nothing" "hover" +<= fail "no element nothing" + +> hold "b" "wobble" +< fail +<= fail "hold: wobble cannot be held (hover, active, focus, focus-visible, checked, disabled)" + +> hold "" "" +<= ok + +> frame 2 +< #0000ff + frobnicate <= fail "unknown request frobnicate"