ludic.ui: <option disabled="{...}"> - a select steps past it, clamps off it and greys the arrow toward it

- The arrows, a press on the select and the pad step past a disabled option (ct_opt_step), wrapping
  as before.
- A value naming a disabled option is shown as the nearest enabled one (the lower of two as near) and
  set to it once the build is done: an event fired while the tree is built is cleared with the frame's,
  so the clamp is queued (ct_clamp_n / _i) and fired after nt_fired_clear.
- The < or > whose next option is disabled is drawn disabled (its part's :disabled), so a cycler shows
  where the options stop being on; it still steps past them.
- ct_build_select moves to controls_opts.ludic with the rest (controls_build.ludic 119 -> 104 lines).

Golden ui_select_disabled: options 3 and 4 disabled, a value of 4 clamps to 2, > is drawn disabled
there, > wraps to 0 and < from 0 steps back to 2. The 38 ui examples pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-29 16:01:43 +03:00
parent 6af48afdda
commit 181d317d20
8 changed files with 140 additions and 18 deletions

View file

@ -0,0 +1,55 @@
# ui_select_disabled.ludic - <option disabled="{...}">: a value on a disabled option is set to the
# nearest enabled one, the arrow toward disabled options is drawn disabled, and the arrows step past
# them (a host's cap on the fog): clamped 2 next-on 0 next 0 prev 2
import "ludic.ui"
program UiSelectDisabled {
numbers float
state SelState { v: int = 4 }
view V (sel_st: mut SelState) {
v = sel_st.v
on set(x: int) { sel_st.v = x }
}
const PAGE: string = "<ui><screen name='s'><select label='Fog' value='{v}' on-change='set(event.value)'><option value='{0}'>0</option><option value='{1}'>1</option><option value='{2}'>2</option><option value='{3}' disabled='{v >= 0}'>3</option><option value='{4}' disabled='{true}'>4</option></select></screen></ui>"
function frame(ui_st: mut UiState, x: float, y: float, down: bool) -> UiNode {
let i = new UiInput
i.x = x
i.y = y
i.down = down
ui_input(ui_st, i)
ui_show(ui_st, "s", view_v(), 0.0, 0.0, 400.0, 400.0)
let root: UiNode = ui_nodes(ui_st, "s", view_v())
ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0)
return root
}
function part(n: UiNode, cls: string) -> UiNode {
for i in 0 .. len(n.children) {
if List.contains(n.children[i].classes, cls) { return n.children[i] }
let f = part(n.children[i], cls)
if f != null { return f }
}
return null
}
function click(ui_st: mut UiState, cls: string) -> void {
let root = frame(ui_st, -1.0, -1.0, false)
let p = part(root, cls)
let x = p.x + p.cw / 2.0
let y = p.y + p.ch / 2.0
frame(ui_st, x, y, false)
frame(ui_st, x, y, true)
frame(ui_st, x, y, false)
frame(ui_st, x, y, false)
}
entry (ui_st: mut UiState, sel_st: SelState) {
ui_backend(ui_st, new UiBackend)
ui_load_text(ui_st, PAGE, "s.xml")
frame(ui_st, -1.0, -1.0, false)
let root = frame(ui_st, -1.0, -1.0, false)
let clamped = sel_st.v
let nx = part(root, "ui-next")
let at_cap = `{nx.enabled}`
click(ui_st, "ui-next")
let wrapped = sel_st.v
click(ui_st, "ui-prev")
print(`clamped {clamped} next-on {at_cap} next {wrapped} prev {sel_st.v}`)
}
}

View file

@ -53,6 +53,8 @@ export state UiState {
nt_ev_at: int = 0
nt_nums: []Val = nt_nums_new() # fired.ludic: the numbers a scroll or a slider reports, a ring
nt_num_at: int = 0
ct_clamp_n: []UiNode = new []UiNode # controls_opts.ludic: selects whose value a build clamped
ct_clamp_i: []int = new []int
hd_ev: Val = hd_ev_new() # hold.ludic: the held control's event, filled in place
cv_texts: []string = new []string # units_pieces.ludic: a value's var() pieces, kept
tx_pieces: [][]string = new [][]string # eval_text.ludic: a text's pieces, a list a depth

View file

@ -31,9 +31,9 @@ function ct_activate(ui_st: mut UiState, n: UiNode) -> void {
function ct_step(ui_st: mut UiState, n: UiNode, d: int) -> void {
let t = ct_type(n)
if t == "select" and n.opts != null and len(n.opts) > 0 {
let at = (ct_option_at(n, n.opts) + d + len(n.opts)) % len(n.opts)
let ov = ui_attr(n.opts[at], "value")
if Value.kind(ov) == 0 { ct_change(ui_st, n, Value.int(at)) } else { ct_change(ui_st, n, ov) }
let from = ct_option_at(n, n.opts)
let at = ct_opt_step(n.opts, from, d) # past a disabled option
if at != from { ct_opt_pick(ui_st, n, n.opts, at) }
}
if t == "number" { ct_num_step(ui_st, n, d) }
if t == "range" {

View file

@ -67,21 +67,6 @@ function ct_build_range(ui_st: mut UiState, n: UiNode) -> void {
ui_kept(n.children, track)
ui_kept(n.children, ct_part(ui_st, n, "ui-value", ct_value_shown(ui_st, n)))
}
function ct_build_select(ui_st: mut UiState, n: UiNode) -> void {
@alloc_ok("once per pooled node: its list is kept and cleared")
if n.opts == null { n.opts = new []UiNode } # its options moved to a list the node keeps, not a new one
let opts = n.opts
List.clear(opts)
for i in 0 .. len(n.children) { ui_kept(opts, n.children[i]) }
List.clear(n.children)
ct_label(ui_st, n)
var shown = ""
let at = ct_option_at(n, opts)
if at >= 0 { shown = opts[at].text }
ui_kept(n.children, ct_part(ui_st, n, "ui-prev", "<"))
ui_kept(n.children, ct_part(ui_st, n, "ui-value", shown))
ui_kept(n.children, ct_part(ui_st, n, "ui-next", ">"))
}
# which option the select's value names: an option's value="...", else its place
function ct_option_at(n: UiNode, opts: []UiNode) -> int {
let nv = ui_attr(n, "value")

View file

@ -0,0 +1,76 @@
# controls_opts.ludic - <option disabled="{...}">: a select steps past a disabled option (arrows, a
# press, the pad), a value naming one is shown and set as the nearest enabled option, and the arrow
# whose next option is disabled is drawn disabled - the options that way are off (a host's cap)
function ct_opt_on(opts: []UiNode, i: int) -> bool { return i >= 0 and i < len(opts) and opts[i].enabled }
# from `at` d steps at a time, wrapping, to the first enabled option; `at` itself when none is
function ct_opt_step(opts: []UiNode, at: int, d: int) -> int {
let n = len(opts)
var i = at
for k in 0 .. n {
i = (i + d + n) % n
if opts[i].enabled { return i }
}
return at
}
# the enabled option nearest `at` (the lower of two as near); -1 when none is
function ct_opt_nearest(opts: []UiNode, at: int) -> int {
if ct_opt_on(opts, at) { return at }
for r in 1 .. len(opts) {
if ct_opt_on(opts, at - r) { return at - r }
if ct_opt_on(opts, at + r) { return at + r }
}
return -1
}
# a select's value on a disabled option: the nearest enabled one is what it shows, and is set
# (set once the build is done: an event fired while the tree is built is cleared with the frame's)
function ct_opt_clamp(ui_st: mut UiState, n: UiNode, opts: []UiNode, at: int) -> int {
if at < 0 or ct_opt_on(opts, at) { return at }
let to = ct_opt_nearest(opts, at)
if to >= 0 {
ui_kept(ui_st.ct_clamp_n, n)
ui_kept(ui_st.ct_clamp_i, to)
}
return to
}
# the clamps a build found, set now that the frame's events are cleared; stale ones are dropped
function ct_opt_flush(ui_st: mut UiState) -> void {
for i in 0 .. len(ui_st.ct_clamp_n) {
let n = ui_st.ct_clamp_n[i]
if n.opts != null and ui_st.ct_clamp_i[i] < len(n.opts) { ct_opt_pick(ui_st, n, n.opts, ui_st.ct_clamp_i[i]) }
}
ct_opt_forget(ui_st)
}
function ct_opt_forget(ui_st: mut UiState) -> void {
List.clear(ui_st.ct_clamp_n)
List.clear(ui_st.ct_clamp_i)
}
function ct_opt_pick(ui_st: mut UiState, n: UiNode, opts: []UiNode, at: int) -> void {
let ov = ui_attr(opts[at], "value")
if Value.kind(ov) == 0 { ct_change(ui_st, n, Value.int(at)) } else { ct_change(ui_st, n, ov) }
}
# a select: its label, < and >, and the option its value names between them (<option>s kept aside)
function ct_build_select(ui_st: mut UiState, n: UiNode) -> void {
@alloc_ok("once per pooled node: its list is kept and cleared")
if n.opts == null { n.opts = new []UiNode } # its options moved to a list the node keeps, not a new one
let opts = n.opts
List.clear(opts)
for i in 0 .. len(n.children) { ui_kept(opts, n.children[i]) }
List.clear(n.children)
ct_label(ui_st, n)
var shown = ""
let at = ct_opt_clamp(ui_st, n, opts, ct_option_at(n, opts))
if at >= 0 { shown = opts[at].text }
let prev = ct_part(ui_st, n, "ui-prev", "<")
let next = ct_part(ui_st, n, "ui-next", ">")
if at >= 0 and len(opts) > 1 {
prev.enabled = ct_opt_on(opts, (at - 1 + len(opts)) % len(opts))
next.enabled = ct_opt_on(opts, (at + 1) % len(opts))
}
ui_kept(n.children, prev)
ui_kept(n.children, ct_part(ui_st, n, "ui-value", shown))
ui_kept(n.children, next)
}

View file

@ -5,6 +5,7 @@
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)
ct_opt_forget(ui_st) # a clamp found by a build this frame did not show
dv_before(ui_st)
ui_st.an_frame += 1
ui_viewport(ui_st, w, h)
@ -12,6 +13,7 @@ export function ui_show(ui_st: mut UiState, screen: string, view: UiView, x: flo
if root == null { return }
ui_place(ui_st, root, x, y, w, h)
nt_fired_clear(ui_st)
ct_opt_flush(ui_st) # a select's value on a disabled option, set now
sc_undo(root)
sc_update(ui_st, root)
fr_pointer(ui_st, root)

View file

@ -29,6 +29,7 @@ import "fired.ludic"
import "native_room.ludic"
import "controls.ludic"
import "controls_build.ludic"
import "controls_opts.ludic"
import "controls_parts.ludic"
import "controls_key.ludic"
import "controls_key_mouse.ludic"

View file

@ -1249,6 +1249,7 @@ function cmd_dev_test() -> int {
controller_case("library/ui_select_arrows", "", "prev 0 next next 2", "ui_select_arrows.ludic (L11: a select's < steps back and its > forward)")
controller_case("library/ui_pointer_through", "", "pressed 1, hovered #4a5058; > hovered 1", "ui_pointer_through.ludic (L11: the pointer looks through a root with no size to a positioned panel's button; a select's > answers :hover)")
controller_case("library/ui_first_show", "", "bad 0 shown 3", "ui_first_show.ludic (L11: a component first shown once the fence is judging makes its props and model declared, once per instance; shown again, it is reused)")
controller_case("library/ui_select_disabled", "", "clamped 2 next-on 0 next 0 prev 2", "ui_select_disabled.ludic (L11: <option disabled>: a value on one is set to the nearest enabled, the arrow toward them drawn disabled, the arrows step past them)")
controller_case("library/ui_submit", "", "[hi] chat '' | [hi][yo][Ada] chat '' name 'Ada'", "ui_submit.ludic (L11: on-submit on a text field by Enter or A, clear-on-submit, the focus kept)")
controller_case("library/ui_zoom", "", "plain 100 pad 10 text 16 h 20 | half 50 pad 5 text 8 h 10 | half of twice 100 | fit 50 em 16", "ui_zoom.ludic (L11: zoom scales a subtree's lengths, text and layout, multiplies, and takes min() and a length over a length)")
controller_case("library/ui_hold", "", "31 frames 500 ms t 500 | 121 frames 500 ms t 500 | pad 16 frames 250 ms t 250 | dududu", "ui_hold.ludic (L11: on-hold every frame a button is held, by the pointer or A, with dt and t on the clock at any rate)")