From d2b0413c302832af6ca999f362aec1e77b6c62aa Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Thu, 24 Sep 2026 21:42:32 +0300 Subject: [PATCH] feat(ui): the gamepad moves the focus, presses and goes back; a held direction repeats on the clock Co-Authored-By: Claude Opus 5.5 --- LANGUAGE.md | 5 ++ changes/defaults-views-templates.md | 3 ++ examples/library/ui_gamepad.ludic | 52 ++++++++++++++++++++ examples/library/ui_gamepad_parts/Pad.ludic | 7 +++ examples/library/ui_gamepad_parts/Pad.xml | 8 ++++ packages/ludic.ui/index.ludic | 1 + packages/ludic.ui/input.ludic | 12 ++++- packages/ludic.ui/input_pad.ludic | 53 +++++++++++++++++++++ tools/ludic-cli/test.ludic | 1 + 9 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 examples/library/ui_gamepad.ludic create mode 100644 examples/library/ui_gamepad_parts/Pad.ludic create mode 100644 examples/library/ui_gamepad_parts/Pad.xml create mode 100644 packages/ludic.ui/input_pad.ludic diff --git a/LANGUAGE.md b/LANGUAGE.md index 5b9cec36..d802a789 100644 --- a/LANGUAGE.md +++ b/LANGUAGE.md @@ -449,6 +449,11 @@ applies to both: track jumps the thumb's middle there and holds it. A press on the bar presses nothing under it. - `:focus`, `:focus-visible` and `:focus-within` style the focus, and `outline` (which follows `border-radius`) draws the ring. + - The first gamepad works it too: the d-pad and the left stick move the focus as the arrows do, + left and right step a range or a select, A is Enter and B is Escape (it closes a popover). A + direction held, on the pad or the keyboard, presses once, again after 0.42 s and then every + 0.11 s, on the ui clock. A host sets `held_up` / `held_down` / `held_left` / `held_right`, + `pad_a` and `pad_b` on a `UiInput` to drive the same. - A test drives all of it with `ui_input(i)`. - **Controls are built in**, each made of plain parts a stylesheet styles (`.ui-label`, `.ui-track`, `.ui-knob`, `.ui-fill`, `.ui-thumb`, `.ui-field`, `.ui-value`, `.ui-caret`, diff --git a/changes/defaults-views-templates.md b/changes/defaults-views-templates.md index 8be7744a..65043f6e 100644 --- a/changes/defaults-views-templates.md +++ b/changes/defaults-views-templates.md @@ -78,6 +78,9 @@ What a game's screens found missing, now in `ludic.ui`: a press on the track jumps the thumb's middle there, and neither presses what is under the bar; - a popover's own controls take its presses whatever lies under it, a press outside only closes it, and while one is up the scroll boxes outside it do not take the pointer; +- the first gamepad moves the focus (d-pad, left stick), steps ranges and selects, and presses (A) + and goes back (B); a held direction, on the pad or the arrow keys, repeats after 0.42 s and then + every 0.11 s on the ui clock (`UiInput.held_*`, `pad_a`, `pad_b` for a host); - a transition lands exactly on its end value (it had stopped a rounding error short of it, at every frame rate). diff --git a/examples/library/ui_gamepad.ludic b/examples/library/ui_gamepad.ludic new file mode 100644 index 00000000..dc4a27bb --- /dev/null +++ b/examples/library/ui_gamepad.ludic @@ -0,0 +1,52 @@ +# ui_gamepad.ludic — the pad and held keys, on the program's clock: a direction held presses once, +# again after 0.42 s and every 0.11 s after that, at any frame rate; A presses what has the focus, +# left and right step a range and a select, and B closes a popover. +import "ludic.ui" +import "ui_gamepad_parts/Pad.ludic" +program UiGamepad { + numbers float + var now: float = 0.0 + function clock() -> float { return now } + function frame(i: UiInput) -> string { + ui_input(i) + ui_show("Pad", null, 0.0, 0.0, 400.0, 600.0) + let page = ui_nodes("Pad", null).children[0] + return page.children[len(page.children) - 1].text + } + # `what` held for `secs` at `hz`, then let go + function hold(what: string, secs: float, hz: int) -> string { + let frames = int(secs * float(hz) + 0.5) + for k in 0 .. frames + 1 { + now = now + 1.0 / float(hz) + let i = new UiInput + i.held_up = what == "up" + i.held_down = what == "down" + i.held_left = what == "left" + i.held_right = what == "right" + i.pad_a = what == "a" + i.pad_b = what == "b" + frame(i) + } + now = now + 1.0 / float(hz) + return frame(new UiInput) + } + function tap(what: string) -> string { return hold(what, 0.0, 60) } + entry { + ui_backend(new UiBackend) + ui_clock(fn clock) + frame(new UiInput) + hold("down", 1.0, 240) + let fast = tap("a") + hold("down", 1.0, 60) + hold("up", 0.5, 60) + let slow = tap("a") + for k in 0 .. 8 { tap("down") } + let vol = hold("right", 0.5, 60) + tap("down") + let q = tap("left") + tap("down") + let open = tap("a") + let shut = tap("b") + print(`{fast} | {slow} | {vol} | {q} | {open} | {shut}`) + } +} diff --git a/examples/library/ui_gamepad_parts/Pad.ludic b/examples/library/ui_gamepad_parts/Pad.ludic new file mode 100644 index 00000000..6d3776cc --- /dev/null +++ b/examples/library/ui_gamepad_parts/Pad.ludic @@ -0,0 +1,7 @@ +# Pad.ludic - eight buttons, a range, a select and a button that opens a popover, for a gamepad +component Pad { + state last: int = -1 + state vol: int = 0 + state q: int = 0 + state open: bool = false +} diff --git a/examples/library/ui_gamepad_parts/Pad.xml b/examples/library/ui_gamepad_parts/Pad.xml new file mode 100644 index 00000000..0596d59f --- /dev/null +++ b/examples/library/ui_gamepad_parts/Pad.xml @@ -0,0 +1,8 @@ +
+ + + + +
+

last {last} vol {vol} q {q} open {open}

+
diff --git a/packages/ludic.ui/index.ludic b/packages/ludic.ui/index.ludic index 38ff3596..9b2f93dc 100644 --- a/packages/ludic.ui/index.ludic +++ b/packages/ludic.ui/index.ludic @@ -21,6 +21,7 @@ import "controls_parts.ludic" import "controls_key.ludic" import "controls_number.ludic" import "input.ludic" +import "input_pad.ludic" import "focus.ludic" import "scroll.ludic" import "scroll_bar.ludic" diff --git a/packages/ludic.ui/input.ludic b/packages/ludic.ui/input.ludic index ac62809b..f0c80289 100644 --- a/packages/ludic.ui/input.ludic +++ b/packages/ludic.ui/input.ludic @@ -1,5 +1,6 @@ # input.ludic - what the player did this frame, read once from the runtime's Input: the pointer, its -# button, the wheel, the keys a UI answers and what was typed. A test or a host can set it instead. +# button, the wheel, the keys a UI answers, the first pad and what was typed. A test or a host can set +# it instead (input_pad.ludic makes a held direction repeat). export property UiInput { x: float = -1.0 y: float = -1.0 @@ -17,6 +18,12 @@ export property UiInput { backspace: bool = false typed: string = "" key: int = 0 # any key pressed this frame, for a key-capture field + held_up: bool = false # a direction held - an arrow key, the d-pad or the left stick - + held_down: bool = false # which presses its arrow now and repeats while it is held + held_left: bool = false + held_right: bool = false + pad_a: bool = false # the pad's south button held: Enter + pad_b: bool = false # its east button held: Escape } var in_now: UiInput = new UiInput var in_was_down: bool = false @@ -45,7 +52,9 @@ function in_read() -> void { i.backspace = Input.key_pressed(Key.Backspace) i.typed = Input.text() i.key = in_any_key() + in_held(i) } + in_repeat(i) in_now = i } function in_any_key() -> int { @@ -57,6 +66,7 @@ function in_any_key() -> int { # this frame's input, given by a test or a host that reads its own export function ui_input(i: UiInput) -> void { in_was_down = in_now.down + in_repeat(i) in_now = i in_given = true } diff --git a/packages/ludic.ui/input_pad.ludic b/packages/ludic.ui/input_pad.ludic new file mode 100644 index 00000000..cbfa896c --- /dev/null +++ b/packages/ludic.ui/input_pad.ludic @@ -0,0 +1,53 @@ +# input_pad.ludic - the first gamepad, and a held direction: the d-pad and the left stick move the +# focus as the arrows do, A is Enter and B is Escape; any direction held presses once, then again +# after 0.42 s and every 0.11 s after that, on the ui clock +const RP_FIRST: float = 0.42 +const RP_EVERY: float = 0.11 +const PAD_STICK: float = 0.5 +var rp_next: []float = [-1.0, -1.0, -1.0, -1.0] +var rp_a: bool = false +var rp_b: bool = false +# what is held this frame: the arrow keys, the d-pad (SDL's order: 11 up, 12 down, 13 left, 14 +# right) and the stick past half way along its larger axis +function in_held(i: UiInput) -> void { + i.held_up = Input.key_down(Key.Up) + i.held_down = Input.key_down(Key.Down) + i.held_left = Input.key_down(Key.Left) + i.held_right = Input.key_down(Key.Right) + if not Input.pad_connected(0) { return } + let ax = float(Input.pad_axis(0, 0)) + let ay = float(Input.pad_axis(0, 1)) + let flat = Math.abs(ax) >= Math.abs(ay) + i.held_up = i.held_up or Input.pad_button(0, 11) or (not flat and ay < 0.0 - PAD_STICK) + i.held_down = i.held_down or Input.pad_button(0, 12) or (not flat and ay > PAD_STICK) + i.held_left = i.held_left or Input.pad_button(0, 13) or (flat and ax < 0.0 - PAD_STICK) + i.held_right = i.held_right or Input.pad_button(0, 14) or (flat and ax > PAD_STICK) + i.pad_a = Input.pad_button(0, 0) + i.pad_b = Input.pad_button(0, 1) +} +# the held directions and buttons as this frame's presses +function in_repeat(i: UiInput) -> void { + let now = an_now() + if rp_fire(0, i.held_up, now) { i.up = true } + if rp_fire(1, i.held_down, now) { i.down_key = true } + if rp_fire(2, i.held_left, now) { i.left = true } + if rp_fire(3, i.held_right, now) { i.right = true } + if i.pad_a and not rp_a { i.enter = true } + if i.pad_b and not rp_b { i.escape = true } + rp_a = i.pad_a + rp_b = i.pad_b +} +function rp_fire(k: int, held: bool, now: float) -> bool { + if not held { + rp_next[k] = -1.0 + return false + } + if rp_next[k] < 0.0 { + rp_next[k] = now + RP_FIRST + return true + } + if now + 0.000001 < rp_next[k] { return false } + rp_next[k] = rp_next[k] + RP_EVERY + if rp_next[k] <= now { rp_next[k] = now + RP_EVERY } + return true +} diff --git a/tools/ludic-cli/test.ludic b/tools/ludic-cli/test.ludic index bcbf9a49..339279b1 100644 --- a/tools/ludic-cli/test.ludic +++ b/tools/ludic-cli/test.ludic @@ -763,6 +763,7 @@ function cmd_dev_test() -> int { controller_case("library/ui_popover_mouse", "", "over the cell 1 | opened 1 dropped 0 closed 0 true | opened 1 dropped 1 closed 0 false | opened 2 dropped 1 closed 1 false | opened 3 dropped 1 closed 2 false", "ui_popover_mouse.ludic (L11: a popover takes the mouse - its own button wins over the cell under it, and a press outside closes it and presses nothing)") controller_case("library/ui_scrollbar", "", "grab 0 moved 120 far 300 back 60 kept 60 | jump 270 | wheel 230 | pressed 1", "ui_scrollbar.ludic (L11: a scrollbar's thumb held where it was taken and dragged, a press on the track jumps there, the wheel, nothing under the bar pressed)") controller_case("library/ui_ease", "", "60: 1480 680 slid 1 landed 1 left 0; 120: 1480 680 slid 1 landed 1 left 0; 180: 1480 680 slid 1 landed 1 left 0; 240: 1480 680 slid 1 landed 1 left 0;", "ui_ease.ludic (L11: a transition and an animation at 60, 120, 180 and 240 Hz are the same half way and land exactly on their targets)") + controller_case("library/ui_gamepad", "", "last 6 vol 0 q 0 open false | last 0 vol 0 q 0 open false | last 0 vol 2 q 0 open false | last 0 vol 2 q 2 open false | last 0 vol 2 q 2 open true | last 0 vol 2 q 2 open false", "ui_gamepad.ludic (L11: the pad and held keys - a held direction repeats on the clock at any rate, A presses, left and right step, B closes a popover)") controller_case("library/ui_inputs", "", "count 12 12 32 0.5 0 | 20 32 0.5 0 | 19 32 0.5 0 | How many to carry | Space bar listening 1 19 9 0.5 0 | 19 9 0.5 0 | 19 0 0.5 0 | 60% 0.50 s | 19 0 0.6 9 | 7!", "ui_inputs.ludic (L11: number fields, key capture with Esc and Backspace, notes, range formats, an event named set, a string prop given a number)") controller_case("library/ui_boxes", "", "list 120 of 200 | text at 32 152 wide, 3 lines, row 60 | zero 0x0 | half 140x36 pad 4 2 | order abc at 16 | fill 98x8 of 200x12", "ui_boxes.ludic (L11: flex-shrink to the leftover room, text wrapping beside its siblings, zero sizes, calc(), order, the range track as a row)") controller_case("library/ui_look", "", "root div.in.i.m pad 5 margin 9 color #ff0000 text Hi 2,53 #102030 | text Hi 0,50 #eeeeee | opacity 0.25 | nine 40 #ffffff nine 40 #ff0000 of 2 image photo.png #ffffff | image icon:tent #00ff00 | text first line 29,205 #eeeeee | text second line 29,223 #eeeeee by id at 84,50 | flipped at 246,268 100x32", "ui_look.ludic (L11: nested components styled by specificity, text-shadow, ui_opacity, border-image tints, pictures, tooltip lines, anchored popovers)")