From d15cc79822f77b058c1f7f6e01401c6d9fe92fe9 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Thu, 24 Sep 2026 22:04:47 +0300 Subject: [PATCH] feat(ui): a key field takes a mouse button, and is :capturing while it listens Co-Authored-By: Claude Opus 5.5 --- LANGUAGE.md | 5 +- changes/defaults-views-templates.md | 2 + examples/library/ui_keycap.ludic | 55 ++++++++++++++++++++++ packages/ludic.ui/controls_key.ludic | 4 +- packages/ludic.ui/controls_key_mouse.ludic | 22 +++++++++ packages/ludic.ui/index.ludic | 1 + packages/ludic.ui/pointer.ludic | 2 +- packages/ludic.ui/select_match.ludic | 1 + tools/ludic-cli/test.ludic | 1 + 9 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 examples/library/ui_keycap.ludic create mode 100644 packages/ludic.ui/controls_key_mouse.ludic diff --git a/LANGUAGE.md b/LANGUAGE.md index e5577cd5..8179a05f 100644 --- a/LANGUAGE.md +++ b/LANGUAGE.md @@ -488,7 +488,10 @@ applies to both: - ``: Enter or a click starts it listening, and the next key is its value - Tab, the arrows and Enter included. Esc stops it listening, Backspace clears it (0), and `shown` is what it says for its value instead of the keyboard's label. While it - listens `ui_capturing()` is true, so the program can leave its own keys alone. + listens `ui_capturing()` is true, so the program can leave its own keys alone, and it is + `:capturing` for a stylesheet. A mouse button pressed while it listens is its value too: + `UI_MOUSE_LEFT` (256), `UI_MOUSE_RIGHT` (257) or `UI_MOUSE_MIDDLE` (258), past every key code, + shown as "Left click", "Right click" or "Middle click"; that press does nothing else. Any control's `note="..."` is a line of help under its label: `.ui-labels > .ui-label + .ui-note`. diff --git a/changes/defaults-views-templates.md b/changes/defaults-views-templates.md index c72f1a27..d4d09e30 100644 --- a/changes/defaults-views-templates.md +++ b/changes/defaults-views-templates.md @@ -102,6 +102,8 @@ What a game's screens found missing, now in `ludic.ui`: `image_w` / `image_h`) and `ui_object_fit` for natives; - `translate="no"` keeps an element's text as written; a title of several lines is translated whole, else line by line; +- `` takes a mouse button (`UI_MOUSE_LEFT` / `RIGHT` / `MIDDLE`, 256-258) and is + `:capturing` while it listens; - 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_keycap.ludic b/examples/library/ui_keycap.ludic new file mode 100644 index 00000000..39e9898f --- /dev/null +++ b/examples/library/ui_keycap.ludic @@ -0,0 +1,55 @@ +# ui_keycap.ludic — a key field takes a mouse button as well as a key: listening (and :capturing, +# which a stylesheet can colour), the next press of the left, right or middle button anywhere is its +# value (UI_MOUSE_LEFT / RIGHT / MIDDLE), named for what it is, and that press presses nothing else. +import "ludic.ui" +program UiKeycap { + numbers float + var bind: int = 32 + var pressed: int = 0 + view Keys { + bind = bind + pressed = pressed + on set_bind(v: int) { bind = v } + on press() { pressed = pressed + 1 } + } + const PAGE: string = "" + function frame(x: float, y: float, b: int) -> UiNode { + let i = new UiInput + i.x = x + i.y = y + i.down = b == 0 + i.down_right = b == 1 + i.down_middle = b == 2 + ui_input(i) + ui_show("k", view_keys(), 0.0, 0.0, 400.0, 400.0) + let r: UiNode = ui_nodes("k", view_keys()) + ui_place(r, 0.0, 0.0, 400.0, 400.0) + return r + } + function listen(k: UiNode) -> int { + frame(k.x + 5.0, k.y + 5.0, 0) + frame(k.x + 5.0, k.y + 5.0, -1) + return frame(-1.0, -1.0, -1).children[0].bg + } + function take(at: UiNode, b: int) -> string { + frame(at.x + 5.0, at.y + 5.0, b) + frame(at.x + 5.0, at.y + 5.0, -1) + let r = frame(-1.0, -1.0, -1) + let f = r.children[0] + return `{bind} {f.children[1].children[0].text} {f.bg}` + } + entry { + ui_backend(new UiBackend) + ui_load_text(PAGE, "k.xml") + let r = frame(-1.0, -1.0, -1) + let k = r.children[0] + let btn = r.children[1] + let red = listen(k) + let right = take(btn, 1) + listen(k) + let middle = take(btn, 2) + listen(k) + let left = take(btn, 0) + print(`listening {red} | {right} | {middle} | {left} | pressed {pressed}`) + } +} diff --git a/packages/ludic.ui/controls_key.ludic b/packages/ludic.ui/controls_key.ludic index 945d284b..837e8a51 100644 --- a/packages/ludic.ui/controls_key.ludic +++ b/packages/ludic.ui/controls_key.ludic @@ -46,7 +46,9 @@ function ct_key_pressed_away(hit: UiNode) -> void { function ct_key_text(n: UiNode) -> string { if ct_capture == n.key { return ui_tr("press a key...") } if Value.kind(ui_attr(n, "shown")) != 0 { return ui_tr(ui_attr_text(n, "shown")) } - return Input.key_label(Value.as_int(ui_attr(n, "value"))) + let v = Value.as_int(ui_attr(n, "value")) + if v >= UI_MOUSE_LEFT and v <= UI_MOUSE_MIDDLE { return ct_mouse_name(v) } + return Input.key_label(v) } # a key field is listening: the host should leave the keys alone until it is done (it stays true # for the frame in which Esc or a key ended it, so that key is not read twice) diff --git a/packages/ludic.ui/controls_key_mouse.ludic b/packages/ludic.ui/controls_key_mouse.ludic new file mode 100644 index 00000000..7c021afe --- /dev/null +++ b/packages/ludic.ui/controls_key_mouse.ludic @@ -0,0 +1,22 @@ +# controls_key_mouse.ludic - a key field listening takes a mouse button too: the left, right or +# middle button pressed anywhere is its value, as UI_MOUSE_LEFT, UI_MOUSE_RIGHT or UI_MOUSE_MIDDLE +# (past every key code, for the host to map), and that press does nothing else +export const UI_MOUSE_LEFT: int = 256 +export const UI_MOUSE_RIGHT: int = 257 +export const UI_MOUSE_MIDDLE: int = 258 +function ct_key_mouse(root: UiNode) -> bool { + if ct_capture == "" or ct_capture_at == an_frame { return false } + let b = pe_pressed(in_now, in_last) + if b < 0 { return false } + let n = fr_node(root, ct_capture) + ct_capture = "" + ct_capture_seen = an_frame + fr_press_key = "" + if n != null { ct_change(n, Value.int(UI_MOUSE_LEFT + b)) } + return true +} +function ct_mouse_name(v: int) -> string { + if v == UI_MOUSE_RIGHT { return ui_tr("Right click") } + if v == UI_MOUSE_MIDDLE { return ui_tr("Middle click") } + return ui_tr("Left click") +} diff --git a/packages/ludic.ui/index.ludic b/packages/ludic.ui/index.ludic index f9e674b0..5ce0a015 100644 --- a/packages/ludic.ui/index.ludic +++ b/packages/ludic.ui/index.ludic @@ -19,6 +19,7 @@ import "controls.ludic" import "controls_build.ludic" import "controls_parts.ludic" import "controls_key.ludic" +import "controls_key_mouse.ludic" import "controls_number.ludic" import "input.ludic" import "input_pad.ludic" diff --git a/packages/ludic.ui/pointer.ludic b/packages/ludic.ui/pointer.ludic index 14a544e5..2889af42 100644 --- a/packages/ludic.ui/pointer.ludic +++ b/packages/ludic.ui/pointer.ludic @@ -17,7 +17,7 @@ function fr_pointer(root: UiNode) -> void { fr_hover = keys tt_track(scope, i.x, i.y) if top != null and pp_pointer(top) { return } - if sc_drag != "" { return } + if sc_drag != "" or ct_key_mouse(root) { return } let hit = fr_hit(scope, i.x, i.y) pe_frame(root, scope) if i.down and not in_was_down { fr_press(hit, i.x, i.y) } diff --git a/packages/ludic.ui/select_match.ludic b/packages/ludic.ui/select_match.ludic index e117db61..26d60fa9 100644 --- a/packages/ludic.ui/select_match.ludic +++ b/packages/ludic.ui/select_match.ludic @@ -76,6 +76,7 @@ function sel_pseudo_ok(p: string, arg: string, n: UiNode) -> bool { if p == "root" { return n.parent == null } if p == "checked" { return ui_attr_on(n, "checked") } if p == "active" { return fr_active(n) } + if p == "capturing" { return ct_capture != "" and n.key == ct_capture } if p == "nth-child" { return sel_nth(arg, n.index + 1) } ui_err(`:{p}: there is no such pseudo-class here`) return false diff --git a/tools/ludic-cli/test.ludic b/tools/ludic-cli/test.ludic index 3a6ae557..b69a3fc0 100644 --- a/tools/ludic-cli/test.ludic +++ b/tools/ludic-cli/test.ludic @@ -772,6 +772,7 @@ function cmd_dev_test() -> int { controller_case("library/ui_scroll_hold", "", "120 50 0 top 120 | 80 50 0 top 80 | 80 50 0 top 80 | 80 50 70 top 80 | 80 50 30 top 80", "ui_scroll_hold.ludic (L11: scroll-top holds a box at an offset, on-scroll follows the wheel, ui_scroll_set moves one once)") controller_case("library/ui_fill", "", "0,0 2x20 #030303 .. 98,0 2x20 #fcfcfc | 0,20 50x2 #f2000d .. 0,38 50x2 #0d00f2 .. 0,58 50x2 #0000ff of 70 | 0,85 100x50 | -50,160 200x100 clipped 1 | ratio 90 knob 60", "ui_fill.ludic (L11: linear-gradient backgrounds, object-fit contain and cover, aspect-ratio for boxes and natives)") controller_case("library/ui_translate", "", "Merhaba Hello Hello Merhaba | c/Kaydet ve cik | Satir bir/Satir iki | Line one/Line two", "ui_translate.ludic (L11: translate=no and yes, a title of several lines translated whole or line by line)") + controller_case("library/ui_keycap", "", "listening 16711680 | 257 Right click -2 | 258 Middle click -2 | 256 Left click -2 | pressed 0", "ui_keycap.ludic (L11: a key field takes the left, right and middle mouse buttons, and is :capturing while it listens)") 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)")