From cfff036e76dcb89083509f5c146253c42ab13dcf Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Sun, 12 Jul 2026 19:24:16 -0500 Subject: [PATCH] =?UTF-8?q?feat(ui):=20input-event=20trio=20=E2=80=94=20mo?= =?UTF-8?q?use=20modifiers,=20canvas=20drag=20capture,=20wheel=20seam=20(#?= =?UTF-8?q?567=20#568=20#569)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First-consumer findings from the DeslanStudio arrangement timeline. #568 (foundation): gfx_poll now attaches shift/ctrl/alt (0/1) to mousemove/mousedown/mouseup/wheel events via SDL_GetModState (optional symbol, defaults 0), mirroring the key-event fields. Additive dict fields only. #567: dispatch's drag-in-progress path is registry-driven instead of a hardcoded type list — the seven built-in draggable types register on_drag(w, ev) handlers, and a custom widget that claimed the pointer (new claim_drag/release_drag API) receives drag mousemoves and the terminating mouseup through its own on_mouse(w, ev). Hover mousemoves are also delivered to on_mouse. The sharp edge is fixed: a claimant outside the old type list no longer throws type_mismatch in the slider updater; a handler-less claimant is a silent no-op. #569: a widget under the cursor exposing on_wheel(w, ev) consumes wheel input before the scroll_panel walk. Also fixes the scroll_panel _ax/_ay staleness: _layout now subtracts the scroll offset when caching child absolute positions, matching render and hit-test. Tests: UI suite [63] 81 -> 115 checks (canvas gesture capture, modifier passthrough, per-type drag back-compat for all seven draggable types, wheel consume + fallback, scroll-offset cache). New demo examples/ui_canvas_events.eigs exercises all three seams. Docs: BUILTINS.md gfx_poll row, STDLIB.md UI seams paragraph, lib headers. Closes #567 Closes #568 Closes #569 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 33 ++++++ docs/BUILTINS.md | 2 +- docs/STDLIB.md | 14 +++ examples/ui_canvas_events.eigs | 200 +++++++++++++++++++++++++++++++++ lib/ui.eigs | 112 +++++++++--------- lib/ui_layout.eigs | 12 +- lib/ui_w_dialog.eigs | 28 +++++ lib/ui_w_slider.eigs | 20 ++++ lib/ui_w_special.eigs | 11 ++ lib/ui_w_viz.eigs | 10 ++ src/ext_gfx.c | 31 +++-- tests/run_all_tests.sh | 12 +- tests/test_ui.eigs | 193 +++++++++++++++++++++++++++++++ 13 files changed, 612 insertions(+), 66 deletions(-) create mode 100644 examples/ui_canvas_events.eigs diff --git a/CHANGELOG.md b/CHANGELOG.md index fd033ca3..521e9749 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,39 @@ All notable changes to EigenScript are documented here. ## [Unreleased] +### Added +- **lib/ui input-event trio (#567, #568, #569)** — first-consumer + findings from the DeslanStudio arrangement timeline: + - **#568 — mouse events carry modifier state.** `gfx_poll` now + attaches `shift`/`ctrl`/`alt` (0/1) to `mousemove`, `mousedown`, + `mouseup`, and `wheel` events (via `SDL_GetModState`), mirroring + what key events already exposed. Additive dict fields — headless + tests that synthesize event dicts are unaffected (absent keys read + null). + - **#567 — canvas drag/mousemove seam.** `dispatch`'s drag-in-progress + path is now registry-driven: each built-in draggable type (slider, + vslider, knob, scrollbar, waveform_view, splitter, color_picker) + registers an `on_drag(w, ev)` handler, and a custom widget that + claimed the pointer (new `claim_drag of w` / `release_drag of null` + API) receives every drag mousemove *and the terminating mouseup* + through its `on_mouse(w, ev)` callback — press-move-release gestures + now complete on a canvas. Hover mousemoves are also delivered to + `on_mouse` (branch on `ev.type`). The sharp edge is gone: a claimant + outside the old hardcoded type list no longer falls into the slider + updater's `type_mismatch`; a claimant with no handler is a silent + no-op. + - **#569 — wheel seam.** A widget under the cursor exposing + `on_wheel(w, ev)` consumes wheel input before the `scroll_panel` + walk (`ev.x`/`ev.y` are the scroll deltas; modifiers ride along for + ctrl+wheel zoom). Also fixed: `_layout` now subtracts a + `scroll_panel`'s scroll offset when caching children's `_ax`/`_ay`, + matching render and hit-test, so a canvas inside a scrolled panel + keeps correct mouse math. + - `examples/ui_canvas_events.eigs` (timeline-style canvas: clip + drag-move, rubber-band select, ctrl+click toggle, wheel pan, + ctrl+wheel zoom-at-cursor) exercises all three seams; UI suite [63] + extended 81 → 115 checks. + ### Fixed - **Entropy walk: cycle + shared-structure detection (#571).** The observer's container entropy walk (`compute_entropy`) had only a diff --git a/docs/BUILTINS.md b/docs/BUILTINS.md index 376f5e5a..a7f79b84 100644 --- a/docs/BUILTINS.md +++ b/docs/BUILTINS.md @@ -528,7 +528,7 @@ libSDL2 at runtime — no SDL2 headers needed at build time. | `gfx_circle` | `gfx_circle of [cx, cy, radius, r, g, b]` | Filled circle (midpoint) | | `gfx_text` | `gfx_text of [x, y, text, r, g, b]` or `[..., scale]` | Bitmap-font text | | `gfx_present` | `gfx_present of null` | Flip backbuffer to screen | -| `gfx_poll` | `gfx_poll of null` | Return next event as dict (`quit`, `keydown`, `keyup`, `mousemove`, `mousedown`, `mouseup`), or null | +| `gfx_poll` | `gfx_poll of null` | Return next event as dict (`quit`, `keydown`, `keyup`, `mousemove`, `mousedown`, `mouseup`, `wheel`, `resize`), or null. Key, mouse, and wheel events carry `shift`/`ctrl`/`alt` (0/1); wheel `x`/`y` are scroll deltas | | `gfx_ticks` | `gfx_ticks of null` | Milliseconds since `SDL_Init` | | `gfx_delay` | `gfx_delay of ms` | Sleep for ms (SDL-coordinated) | | `gfx_title` | `gfx_title of "text"` | Update window title | diff --git a/docs/STDLIB.md b/docs/STDLIB.md index 9ab99b3c..c60a0c8d 100644 --- a/docs/STDLIB.md +++ b/docs/STDLIB.md @@ -397,6 +397,20 @@ widget families `lib/ui_w_basic.eigs`, `lib/ui_w_button.eigs`, Start from `lib/ui.eigs`'s header; the modules document their own widget signatures. +Custom-widget input seams (`canvas` is the reference consumer; +`examples/ui_canvas_events.eigs` exercises all three): a widget's +`on_mouse(w, ev)` receives mousedown, hover mousemove, and — after the +mousedown handler claims the pointer with `claim_drag of w` — every +drag mousemove plus the terminating mouseup (branch on `ev.type`; +`release_drag of null` abandons a capture early). Setting `on_wheel` +(`fn(w, ev)`, `ev.x`/`ev.y` are scroll deltas) on any widget consumes +wheel input under the cursor before the `scroll_panel` walk sees it. +Drag handling for the built-in draggable types goes through the widget +registry's `on_drag` entry, so a registered custom type can supply its +own. Mouse and wheel events from `gfx_poll` carry `shift`/`ctrl`/`alt` +(0/1) like key events; headless tests synthesize event dicts, where +absent keys read null. + ### lib/invariant.eigs — Runtime Invariant Checks Declare-and-check invariants inside programs; see the module header diff --git a/examples/ui_canvas_events.eigs b/examples/ui_canvas_events.eigs new file mode 100644 index 00000000..6f724ba5 --- /dev/null +++ b/examples/ui_canvas_events.eigs @@ -0,0 +1,200 @@ +# ============================================================ +# Canvas input-event seams demo — #567 / #568 / #569 +# ============================================================ +# Run: make gfx && ./src/eigenscript examples/ui_canvas_events.eigs +# +# A minimal timeline-style canvas exercising the three lib/ui seams a +# DAW arrangement view needs: +# - drag capture (#567): press a clip and drag = move it; press empty +# space and drag = rubber-band select. The mousedown handler claims +# the pointer with ui.claim_drag, so every mousemove and the +# terminating mouseup arrive at on_mouse — branch on ev.type. +# - modifiers (#568): ctrl+click toggles a clip in/out of the +# selection instead of replacing it (ev.ctrl on mouse events). +# - wheel (#569): wheel pans the view; ctrl+wheel zooms at the cursor +# (cursor tracked from hover mousemoves — also new in #567). +# +# Escape quits. + +import ui + +WINDOW_W is 800 +WINDOW_H is 300 + +gfx_open of [WINDOW_W, WINDOW_H, "Canvas event seams (#567/#568/#569)"] + +# ---- Timeline state (world coordinates: beats) ---- + +clips is [] +for i in range of 8: + append of [clips, {"start": i * 4.0, "len": 3.0, "sel": 0}] +view_x is 0.0 # left edge of the view, in beats +px_per_beat is 20.0 # zoom +last_mx is 0 # cursor x (canvas-relative), from hover mousemoves +status is "drag clip = move | drag empty = rubber-band | ctrl+click = toggle | wheel = pan | ctrl+wheel = zoom" + +# Gesture state: "" | "move" | "rubber" +gesture is "" +grab is null +grab_dx is 0.0 +gx0 is 0 +gy0 is 0 +gx1 is 0 +gy1 is 0 + +define beat_to_px(b) as: + return floor of ((b - view_x) * px_per_beat) + +define px_to_beat(px) as: + return view_x + px / px_per_beat + +define clip_at(mx, my) as: + if my < 40 or my >= 100: + return null + for i in range of (len of clips): + local c is clips[i] + local x0 is beat_to_px of c.start + local x1 is beat_to_px of (c.start + c.len) + if mx >= x0 and mx < x1: + return c + return null + +# ---- Canvas callbacks ---- + +define on_tl_mouse(w, ev) as: + local mx is ev.x - w._ax + local my is ev.y - w._ay + if ev.type == "mousedown": + local ctrl is 0 + if ev.ctrl != null: + ctrl is ev.ctrl + local c is clip_at of [mx, my] + if c != null: + if ctrl == 1: + # #568: ctrl+click toggles membership + c.sel is 1 - c.sel + status is "ctrl+click: toggled clip" + else: + for i in range of (len of clips): + clips[i].sel is 0 + c.sel is 1 + grab is c + grab_dx is (px_to_beat of mx) - c.start + gesture is "move" + ui.claim_drag of w + status is "moving clip (release to drop)" + else: + gesture is "rubber" + gx0 is mx + gy0 is my + gx1 is mx + gy1 is my + ui.claim_drag of w + status is "rubber-band select..." + elif ev.type == "mousemove": + last_mx is mx + if gesture == "move" and grab != null: + local ns is (px_to_beat of mx) - grab_dx + if ns < 0: + ns is 0 + grab.start is ns + elif gesture == "rubber": + gx1 is mx + gy1 is my + elif ev.type == "mouseup": + if gesture == "rubber": + local lo is gx0 + local hi is gx1 + if hi < lo: + lo is gx1 + hi is gx0 + local b_lo is px_to_beat of lo + local b_hi is px_to_beat of hi + local n_sel is 0 + for i in range of (len of clips): + local c is clips[i] + if c.start < b_hi and c.start + c.len > b_lo: + c.sel is 1 + n_sel is n_sel + 1 + else: + c.sel is 0 + status is f"rubber-band: {n_sel} clip(s) selected" + elif gesture == "move": + status is "clip dropped" + gesture is "" + grab is null + +define on_tl_wheel(w, ev) as: + local ctrl is 0 + if ev.ctrl != null: + ctrl is ev.ctrl + if ctrl == 1: + # #569 + #568: ctrl+wheel = zoom at the cursor + local anchor is px_to_beat of last_mx + px_per_beat is px_per_beat * (1.0 + 0.1 * ev.y) + if px_per_beat < 4: + px_per_beat is 4 + if px_per_beat > 200: + px_per_beat is 200 + view_x is anchor - last_mx / px_per_beat + status is f"zoom: {floor of px_per_beat} px/beat" + else: + # plain wheel = pan + view_x is view_x - ev.y * 2.0 + status is "pan" + if view_x < 0: + view_x is 0 + +define paint_timeline(w, ax, ay) as: + gfx_rect of [ax, ay, w.w, w.h, 18, 18, 26] + # Beat grid + local b0 is floor of view_x + local nb is (floor of (w.w / px_per_beat)) + 2 + for k in range of nb: + local bx is ax + (beat_to_px of (b0 + k)) + if bx >= ax and bx < ax + w.w: + gfx_line of [bx, ay, bx, ay + w.h, 30, 30, 42] + # Clips + for i in range of (len of clips): + local c is clips[i] + local x0 is ax + (beat_to_px of c.start) + local x1 is ax + (beat_to_px of (c.start + c.len)) + if x1 > ax and x0 < ax + w.w: + if c.sel == 1: + gfx_rrect of [x0, ay + 40, x1 - x0, 60, 4, 90, 160, 240] + else: + gfx_rrect of [x0, ay + 40, x1 - x0, 60, 4, 60, 90, 130] + # Rubber band overlay + if gesture == "rubber": + local rx is gx0 + local rw is gx1 - gx0 + if rw < 0: + rx is gx1 + rw is 0 - rw + local ry is gy0 + local rh is gy1 - gy0 + if rh < 0: + ry is gy1 + rh is 0 - rh + gfx_rect of [ax + rx, ay + ry, rw, rh, 120, 160, 220, 60] + +# ---- Build UI ---- + +root is ui.panel of ["root", 0, 0, WINDOW_W, WINDOW_H] +tl is ui.canvas of ["tl", 8, 8, WINDOW_W - 16, WINDOW_H - 46, paint_timeline, on_tl_mouse] +tl.on_wheel is on_tl_wheel +ui.add_child of [root, tl] +sb is ui.label of ["sb", 8, WINDOW_H - 28, status] +ui.add_child of [root, sb] + +define on_key(ev) as: + if ev.type == "keydown" and ev.key == "escape": + return 0 + return 1 + +define on_tick(r) as: + sb.text is status + +ui._layout of [root, 0, 0] +ui.app_loop of [root, on_key, on_tick] +gfx_close of null diff --git a/lib/ui.eigs b/lib/ui.eigs index abcfe544..1b9b41e4 100644 --- a/lib/ui.eigs +++ b/lib/ui.eigs @@ -12,6 +12,21 @@ # All widgets are plain dicts with a "type" field. # Panels have a "children" list and act as containers. # Positions are relative to parent panel origin. +# +# Custom-widget event seams (canvas is the reference consumer): +# - on_mouse(w, ev): raw event callback. Receives mousedown (via the +# registry), hover mousemove, and — after the handler claims the +# pointer with claim_drag — every drag mousemove plus the +# terminating mouseup. Branch on ev.type. +# - on_wheel(w, ev): set the field on any widget to consume wheel +# input under the cursor (ev.x/ev.y are the scroll deltas) before +# the scroll_panel walk sees it. +# - registry "on_drag" entry: per-type drag handler `fn(w, ev)` used +# by the built-in draggable types; a registered custom type may +# provide its own instead of on_mouse. +# Mouse/wheel events from gfx_poll carry shift/ctrl/alt (0/1), same +# as key events; headless synthesized event dicts may omit them +# (absent keys read null). load_file of "lib/ui_theme.eigs" load_file of "lib/ui_draw.eigs" @@ -479,6 +494,18 @@ define show_menu(menu_widget, x, y) as: define show_dialog(dlg, parent_w, parent_h) as: push_modal of [dlg, parent_w, parent_h] +# Pointer capture (#567) — a mousedown handler (e.g. a canvas on_mouse) +# claims the pointer; every following mousemove and the terminating +# mouseup are then delivered to the claiming widget (registry on_drag +# entry if it has one, else its on_mouse callback) instead of the hover +# path. Capture is released automatically on mouseup. + +define claim_drag(widget) as: + _ui.drag_widget is widget + +define release_drag() as: + _ui.drag_widget is null + # Event dispatch — registry-driven define dispatch(root, ev) as: @@ -504,54 +531,20 @@ define dispatch(root, ev) as: src.reorder_insert is -1 return null - # Handle drag in progress (slider, vslider, or knob) + # Handle drag in progress — registry-driven (#567). The widget that + # claimed the pointer receives every mousemove until release: through + # its registry entry's on_drag handler (slider family, waveform_view, + # scrollbar, splitter, color_picker), or — for custom widgets like + # canvas that claimed via claim_drag — through its own on_mouse + # callback with the raw event dict. A claimant with neither is a + # silent no-op (it used to throw type_mismatch in the slider updater). if ev.type == "mousemove" and _ui.drag_widget != null: - if _ui.drag_widget.type == "knob": - _update_knob_drag of [_ui.drag_widget, my] - elif _ui.drag_widget.type == "vslider": - _update_vdrag of [_ui.drag_widget, my] - elif _ui.drag_widget.type == "waveform_view": - sample_pos is floor of (_ui.drag_widget.offset + (mx - _ui.drag_ox) * _ui.drag_widget.zoom) - _ui.drag_widget.sel_end is sample_pos - elif _ui.drag_widget.type == "scrollbar": - _update_scrollbar_drag of [_ui.drag_widget, mx, my] - elif _ui.drag_widget.type == "splitter": - new_pos is mx - _ui.drag_ox - if new_pos < 50: - new_pos is 50 - if new_pos > _ui.drag_widget.w - 50: - new_pos is _ui.drag_widget.w - 50 - _ui.drag_widget.split_pos is new_pos - if _ui.drag_widget.on_resize != null: - _ui.drag_widget.on_resize of _ui.drag_widget - elif _ui.drag_widget.type == "color_picker": - abs_x is _ui.drag_widget._ax - abs_y is _ui.drag_widget._ay - if _ui.drag_widget.drag_part == "sv": - s_val is (mx - abs_x) / 150.0 - v_val is 1.0 - (my - abs_y) / 120.0 - if s_val < 0: - s_val is 0 - if s_val > 1: - s_val is 1 - if v_val < 0: - v_val is 0 - if v_val > 1: - v_val is 1 - _ui.drag_widget.sat is s_val - _ui.drag_widget.val is v_val - elif _ui.drag_widget.drag_part == "hue": - h_val is (my - abs_y) * 360 / 120.0 - if h_val < 0: - h_val is 0 - if h_val > 359: - h_val is 359 - _ui.drag_widget.hue is h_val - _ui.drag_widget.color is _hsv_to_rgb of [_ui.drag_widget.hue, _ui.drag_widget.sat, _ui.drag_widget.val] - if _ui.drag_widget.on_change != null: - _ui.drag_widget.on_change of _ui.drag_widget - else: - _update_drag of [_ui.drag_widget, mx] + local dw is _ui.drag_widget + local entry is _widget_registry[dw.type] + if entry != null and entry.on_drag != null: + entry.on_drag of [dw, ev] + elif dw.on_mouse != null: + dw.on_mouse of [dw, ev] return null if ev.type == "mouseup" and _ui.dnd_state.active == 1: @@ -576,11 +569,17 @@ define dispatch(root, ev) as: _ui.dnd_state.text is "" return null + # Drag release — capture ends before the handlers run (a handler may + # re-claim). A capturing custom widget sees the terminating mouseup + # through on_mouse (#567), so press-move-release gestures complete. if ev.type == "mouseup" and _ui.drag_widget != null: - _ui.drag_widget.dragging is 0 - if _ui.drag_widget.on_change != null: - _ui.drag_widget.on_change of _ui.drag_widget + local dw is _ui.drag_widget _ui.drag_widget is null + dw.dragging is 0 + if dw.on_mouse != null: + dw.on_mouse of [dw, ev] + if dw.on_change != null: + dw.on_change of dw return null # Mouse move — update hover + tooltip @@ -605,6 +604,10 @@ define dispatch(root, ev) as: entry is _widget_registry[hit.type] if entry != null and entry.on_mousemove != null: entry.on_mousemove of [hit, root, mx, my] + # Raw-event seam (#567): a widget exposing on_mouse (canvas) + # also receives hover mousemoves with the full event dict. + if hit.on_mouse != null: + hit.on_mouse of [hit, ev] else: _ui.tooltip_widget is null _ui.tooltip_start_time is gfx_ticks of null @@ -686,8 +689,15 @@ define dispatch(root, ev) as: _clear_pressed of root return null - # Scroll wheel — target the scrollable widget under the cursor + # Scroll wheel — a widget under the cursor exposing on_wheel gets + # first crack at the raw event (#569: ev.x/ev.y are the scroll + # deltas; shift/ctrl/alt ride along); otherwise the scrollable walk + # handles it as before. if ev.type == "wheel": + local whit is _hit_test of [root, 0, 0, _ui.last_mouse_x, _ui.last_mouse_y] + if whit != null and whit.on_wheel != null: + whit.on_wheel of [whit, ev] + return null scroll_target is _find_scrollable_at of [root, 0, 0, _ui.last_mouse_x, _ui.last_mouse_y] if scroll_target != null: _scroll_panels of [scroll_target, ev.x * -20, ev.y * -20] diff --git a/lib/ui_layout.eigs b/lib/ui_layout.eigs index be2d5a3d..fc1b5671 100644 --- a/lib/ui_layout.eigs +++ b/lib/ui_layout.eigs @@ -25,8 +25,18 @@ define _layout(widget, ox, oy) as: else: # Recurse into containers but don't reposition their children if _is_container of widget.type: + local cox is widget._ax + local coy is widget._ay + if widget.type == "scroll_panel": + # #569: children's cached _ax/_ay must account for the + # panel's scroll offset — render and hit-test already + # subtract it, so a stale cache broke widget mouse math + # under scroll. (The cache refreshes on the next _layout + # pass; app_loop runs one every frame.) + cox is cox - widget.scroll_x + coy is coy - widget.scroll_y for i in range of (len of widget.children): - _layout of [widget.children[i], widget._ax, widget._ay] + _layout of [widget.children[i], cox, coy] if widget.type == "tabs": for i in range of (len of widget.tab_panels): if widget.tab_panels[i] != null: diff --git a/lib/ui_w_dialog.eigs b/lib/ui_w_dialog.eigs index c71e4d6b..80970c46 100644 --- a/lib/ui_w_dialog.eigs +++ b/lib/ui_w_dialog.eigs @@ -214,6 +214,33 @@ define _mousedown_color_picker(hit, root, mx, my, ev) as: if hit.on_change != null: hit.on_change of hit +define _drag_color_picker(w, ev) as: + local abs_x is w._ax + local abs_y is w._ay + if w.drag_part == "sv": + local s_val is (ev.x - abs_x) / 150.0 + local v_val is 1.0 - (ev.y - abs_y) / 120.0 + if s_val < 0: + s_val is 0 + if s_val > 1: + s_val is 1 + if v_val < 0: + v_val is 0 + if v_val > 1: + v_val is 1 + w.sat is s_val + w.val is v_val + elif w.drag_part == "hue": + local h_val is (ev.y - abs_y) * 360 / 120.0 + if h_val < 0: + h_val is 0 + if h_val > 359: + h_val is 359 + w.hue is h_val + w.color is _hsv_to_rgb of [w.hue, w.sat, w.val] + if w.on_change != null: + w.on_change of w + # ---- Dialog click handler ---- define _handle_dialog_click(dlg, mx, my) as: @@ -250,6 +277,7 @@ _register_widget of ["color_picker", { "hit_test": null, "on_mousedown": _mousedown_color_picker, "on_mousemove": null, + "on_drag": _drag_color_picker, "clear_hover": null, "clear_pressed": null, "scroll": null, diff --git a/lib/ui_w_slider.eigs b/lib/ui_w_slider.eigs index 844d9522..a17d8caa 100644 --- a/lib/ui_w_slider.eigs +++ b/lib/ui_w_slider.eigs @@ -255,6 +255,22 @@ define _update_scrollbar_drag(widget, mx, my) as: if widget.on_change != null: widget.on_change of widget +# ---- Registry drag handlers (#567: drag capture is registry-driven) ---- +# Called by dispatch with the raw mousemove event while this widget +# holds _ui.drag_widget. + +define _drag_slider(w, ev) as: + _update_drag of [w, ev.x] + +define _drag_vslider(w, ev) as: + _update_vdrag of [w, ev.y] + +define _drag_knob(w, ev) as: + _update_knob_drag of [w, ev.y] + +define _drag_scrollbar(w, ev) as: + _update_scrollbar_drag of [w, ev.x, ev.y] + # ---- Registration ---- _register_widget of ["slider", { @@ -262,6 +278,7 @@ _register_widget of ["slider", { "hit_test": null, "on_mousedown": _mousedown_slider, "on_mousemove": null, + "on_drag": _drag_slider, "clear_hover": null, "clear_pressed": null, "scroll": null, @@ -274,6 +291,7 @@ _register_widget of ["vslider", { "hit_test": null, "on_mousedown": _mousedown_vslider, "on_mousemove": null, + "on_drag": _drag_vslider, "clear_hover": null, "clear_pressed": null, "scroll": null, @@ -286,6 +304,7 @@ _register_widget of ["knob", { "hit_test": null, "on_mousedown": _mousedown_knob, "on_mousemove": null, + "on_drag": _drag_knob, "clear_hover": null, "clear_pressed": null, "scroll": null, @@ -298,6 +317,7 @@ _register_widget of ["scrollbar", { "hit_test": null, "on_mousedown": _mousedown_scrollbar, "on_mousemove": null, + "on_drag": _drag_scrollbar, "clear_hover": null, "clear_pressed": null, "scroll": null, diff --git a/lib/ui_w_special.eigs b/lib/ui_w_special.eigs index 47ff76e6..52f19a96 100644 --- a/lib/ui_w_special.eigs +++ b/lib/ui_w_special.eigs @@ -200,6 +200,16 @@ define _mousedown_splitter(hit, root, mx, my, ev) as: _ui.drag_widget is hit _ui.drag_ox is hit._ax +define _drag_splitter(w, ev) as: + local new_pos is ev.x - _ui.drag_ox + if new_pos < 50: + new_pos is 50 + if new_pos > w.w - 50: + new_pos is w.w - 50 + w.split_pos is new_pos + if w.on_resize != null: + w.on_resize of w + define _mousedown_piano_kb(hit, root, mx, my, ev) as: abs_x is hit._ax abs_y is hit._ay @@ -224,6 +234,7 @@ _register_widget of ["splitter", { "hit_test": _hit_test_splitter, "on_mousedown": _mousedown_splitter, "on_mousemove": null, + "on_drag": _drag_splitter, "clear_hover": _clear_hover_splitter, "clear_pressed": null, "scroll": null, diff --git a/lib/ui_w_viz.eigs b/lib/ui_w_viz.eigs index 5ba68099..9eb299f9 100644 --- a/lib/ui_w_viz.eigs +++ b/lib/ui_w_viz.eigs @@ -55,6 +55,10 @@ define meter(id, x, y, w, h) as: "visible": 1 } +# canvas — the custom-widget escape hatch. on_mouse(w, ev) receives +# mousedown, hover mousemove, and (after claim_drag from the mousedown +# handler) drag mousemoves plus the terminating mouseup; branch on +# ev.type. Set w.on_wheel (fn(w, ev)) to consume wheel input (#569). define canvas(id, x, y, w, h, on_paint, on_mouse) as: return { "type": "canvas", @@ -62,6 +66,7 @@ define canvas(id, x, y, w, h, on_paint, on_mouse) as: "x": x, "y": y, "w": w, "h": h, "on_paint": on_paint, "on_mouse": on_mouse, + "on_wheel": null, "hover": 0, "visible": 1 } @@ -411,6 +416,10 @@ define _mousedown_waveform_view(hit, root, mx, my, ev) as: _ui.drag_widget is hit _ui.drag_ox is abs_x +define _drag_waveform_view(w, ev) as: + local sample_pos is floor of (w.offset + (ev.x - _ui.drag_ox) * w.zoom) + w.sel_end is sample_pos + define _mousedown_bar_chart(hit, root, mx, my, ev) as: abs_x is hit._ax n is len of hit.values @@ -513,6 +522,7 @@ _register_widget of ["waveform_view", { "hit_test": null, "on_mousedown": _mousedown_waveform_view, "on_mousemove": null, + "on_drag": _drag_waveform_view, "clear_hover": null, "clear_pressed": null, "scroll": null, diff --git a/src/ext_gfx.c b/src/ext_gfx.c index 95d79c52..14345c2a 100644 --- a/src/ext_gfx.c +++ b/src/ext_gfx.c @@ -92,6 +92,7 @@ static int (*p_SDL_RenderDrawLine)(SDL_Renderer*, int, int, int, int); static int (*p_SDL_RenderDrawPoint)(SDL_Renderer*, int, int); static void (*p_SDL_RenderPresent)(SDL_Renderer*); static int (*p_SDL_PollEvent)(SDL_Event*); +static int (*p_SDL_GetModState)(void); static Uint32 (*p_SDL_GetTicks)(void); static void (*p_SDL_Delay)(Uint32); static int (*p_SDL_RenderSetClipRect)(SDL_Renderer*, const SDL_Rect*); @@ -145,6 +146,7 @@ static int load_sdl2(void) { LOAD(SDL_GetTicks); LOAD(SDL_Delay); #undef LOAD /* Optional symbols — NULL is fine */ + p_SDL_GetModState = dlsym(g_sdl_lib, "SDL_GetModState"); p_SDL_RenderSetClipRect = dlsym(g_sdl_lib, "SDL_RenderSetClipRect"); p_SDL_CreateTexture = dlsym(g_sdl_lib, "SDL_CreateTexture"); p_SDL_DestroyTexture = dlsym(g_sdl_lib, "SDL_DestroyTexture"); @@ -441,9 +443,24 @@ Value* builtin_gfx_present(Value *arg) { return make_null(); } +/* Attach keyboard modifier state as shift/ctrl/alt (0/1) dict fields. + * KMOD_SHIFT = 0x0003, KMOD_CTRL = 0x00C0, KMOD_ALT = 0x0300. + * Key events read the mask from keysym.mod; mouse/wheel events (#568) + * pass SDL_GetModState() — SDL keeps it current at mouse-event time. */ +static void poll_set_mods(Value *d, int mod) { + dict_set_owned(d, "shift", make_num((mod & 0x03) ? 1 : 0)); + dict_set_owned(d, "ctrl", make_num((mod & 0xC0) ? 1 : 0)); + dict_set_owned(d, "alt", make_num((mod & 0x300) ? 1 : 0)); +} + +static int poll_mod_state(void) { + return p_SDL_GetModState ? p_SDL_GetModState() : 0; +} + /* gfx_poll of null — return next event as dict, or null if none. * {"type": "keydown", "key": "up"} / {"type": "quit"} / - * {"type": "mousemove", "x": 100, "y": 200} / etc. */ + * {"type": "mousemove", "x": 100, "y": 200} / etc. + * Key, mouse, and wheel events all carry shift/ctrl/alt (0/1). */ Value* builtin_gfx_poll(Value *arg) { (void)arg; if (!g_window) return make_null(); @@ -459,39 +476,39 @@ Value* builtin_gfx_poll(Value *arg) { dict_set_owned(d, "type", make_str("keydown")); dict_set_owned(d, "key", make_str(scancode_name(ev.key.keysym.scancode))); dict_set_owned(d, "scancode", make_num(ev.key.keysym.scancode)); - dict_set_owned(d, "shift", make_num((ev.key.keysym.mod & 0x03) ? 1 : 0)); - dict_set_owned(d, "ctrl", make_num((ev.key.keysym.mod & 0xC0) ? 1 : 0)); - dict_set_owned(d, "alt", make_num((ev.key.keysym.mod & 0x300) ? 1 : 0)); + poll_set_mods(d, ev.key.keysym.mod); break; case MY_SDL_KEYUP: dict_set_owned(d, "type", make_str("keyup")); dict_set_owned(d, "key", make_str(scancode_name(ev.key.keysym.scancode))); dict_set_owned(d, "scancode", make_num(ev.key.keysym.scancode)); - dict_set_owned(d, "shift", make_num((ev.key.keysym.mod & 0x03) ? 1 : 0)); - dict_set_owned(d, "ctrl", make_num((ev.key.keysym.mod & 0xC0) ? 1 : 0)); - dict_set_owned(d, "alt", make_num((ev.key.keysym.mod & 0x300) ? 1 : 0)); + poll_set_mods(d, ev.key.keysym.mod); break; case MY_SDL_MOUSEMOTION: dict_set_owned(d, "type", make_str("mousemove")); dict_set_owned(d, "x", make_num(ev.motion.x)); dict_set_owned(d, "y", make_num(ev.motion.y)); + poll_set_mods(d, poll_mod_state()); break; case MY_SDL_MOUSEBUTTONDOWN: dict_set_owned(d, "type", make_str("mousedown")); dict_set_owned(d, "button", make_num(ev.button.button)); dict_set_owned(d, "x", make_num(ev.button.x)); dict_set_owned(d, "y", make_num(ev.button.y)); + poll_set_mods(d, poll_mod_state()); break; case MY_SDL_MOUSEBUTTONUP: dict_set_owned(d, "type", make_str("mouseup")); dict_set_owned(d, "button", make_num(ev.button.button)); dict_set_owned(d, "x", make_num(ev.button.x)); dict_set_owned(d, "y", make_num(ev.button.y)); + poll_set_mods(d, poll_mod_state()); break; case MY_SDL_MOUSEWHEEL: dict_set_owned(d, "type", make_str("wheel")); dict_set_owned(d, "x", make_num(ev.wheel.x)); dict_set_owned(d, "y", make_num(ev.wheel.y)); + poll_set_mods(d, poll_mod_state()); break; case MY_SDL_WINDOWEVENT: /* SDL_WINDOWEVENT_RESIZED = 6 */ diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index 0ba51166..1a6ad0e8 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -2044,15 +2044,15 @@ fi echo "" # [63] UI toolkit unit tests (headless, stubs gfx) -echo "[63] UI Toolkit (81 checks)" +echo "[63] UI Toolkit (115 checks)" UI_OUTPUT=$(./eigenscript ../tests/test_ui.eigs 2>&1); UI_OUTPUT_RC=$? if rc_ok "$UI_OUTPUT_RC" "$UI_OUTPUT" && echo "$UI_OUTPUT" | grep -q "All tests passed"; then - TOTAL=$((TOTAL + 81)) - PASS=$((PASS + 81)) - echo " PASS: all 81 UI toolkit checks" + TOTAL=$((TOTAL + 115)) + PASS=$((PASS + 115)) + echo " PASS: all 115 UI toolkit checks" else - TOTAL=$((TOTAL + 81)) - FAIL=$((FAIL + 81)) + TOTAL=$((TOTAL + 115)) + FAIL=$((FAIL + 115)) echo " FAIL: UI toolkit tests" echo "$UI_OUTPUT" | grep -iE "assert|error|FAIL" | head -5 fi diff --git a/tests/test_ui.eigs b/tests/test_ui.eigs index 1be7c21d..f3cf7438 100644 --- a/tests/test_ui.eigs +++ b/tests/test_ui.eigs @@ -367,6 +367,199 @@ _ui.focused_widget is el _handle_focus_key of [kp, {"key": "return", "shift": 0, "ctrl": 0, "alt": 0}] assert_eq of [el.editing, 1, "editable_label enter starts editing"] +# ============================================================ +# 8. Canvas raw-event seam + drag capture (#567) +# ============================================================ +# NOTE: the gfx_ticks stub above returns a constant, so every repeat +# mousedown on the SAME widget id reads as a double-click and skips the +# registry mousedown path — reset _ui.last_click_id between gestures. + +print of "--- Canvas Event Seam Tests (#567) ---" + +cv_seen is [] +define cv_on_mouse(w, ev) as: + append of [cv_seen, ev.type] + if ev.type == "mousedown": + claim_drag of w + +cvroot is panel of ["cvroot", 0, 0, 400, 300] +cv is canvas of ["cv", 10, 10, 300, 200, null, cv_on_mouse] +add_child of [cvroot, cv] +_layout of [cvroot, 0, 0] + +# Hover mousemove (no button) reaches on_mouse +dispatch of [cvroot, {"type": "mousemove", "x": 60, "y": 60}] +assert_eq of [len of cv_seen, 1, "hover mousemove delivered to canvas on_mouse"] +assert_eq of [cv_seen[0], "mousemove", "hover event type is mousemove"] + +# Full press-move-release gesture with capture (the #567 repro) +cv_seen is [] +dispatch of [cvroot, {"type": "mousedown", "x": 50, "y": 50, "button": 1}] +assert_true of [_ui.drag_widget != null, "canvas claimed the pointer via claim_drag"] +dispatch of [cvroot, {"type": "mousemove", "x": 80, "y": 60}] +dispatch of [cvroot, {"type": "mousemove", "x": 120, "y": 70}] +dispatch of [cvroot, {"type": "mouseup", "x": 120, "y": 70, "button": 1}] +assert_eq of [len of cv_seen, 4, "canvas saw all 4 gesture events"] +assert_eq of [cv_seen[0], "mousedown", "gesture event 0 = mousedown"] +assert_eq of [cv_seen[1], "mousemove", "drag mousemove delivered while captured"] +assert_eq of [cv_seen[3], "mouseup", "terminating mouseup delivered to canvas"] +assert_true of [_ui.drag_widget == null, "capture released on mouseup"] + +# Modifier fields ride along on the event dict (#568 contract) +cv_mods is [] +define cv_mod_mouse(w, ev) as: + append of [cv_mods, ev.shift] + append of [cv_mods, ev.ctrl] +cv.on_mouse is cv_mod_mouse +_ui.last_click_id is "" +dispatch of [cvroot, {"type": "mousedown", "x": 50, "y": 50, "button": 1, "shift": 1, "ctrl": 0}] +assert_eq of [cv_mods[0], 1, "ev.shift passes through dispatch to on_mouse"] +assert_eq of [cv_mods[1], 0, "ev.ctrl passes through dispatch to on_mouse"] +_ui.drag_widget is null + +# Sharp edge (#567): a claimant with no drag handler and no on_mouse is +# a silent no-op — this used to throw type_mismatch in the slider updater. +cv0 is canvas of ["cv0", 10, 10, 100, 100, null, null] +claim_drag of cv0 +dispatch of [cvroot, {"type": "mousemove", "x": 100, "y": 60}] +assert_true of [_ui.drag_widget != null, "handler-less claimant keeps capture, no throw"] +release_drag of null +assert_true of [_ui.drag_widget == null, "release_drag clears capture"] + +# ============================================================ +# 9. Registry-driven drag back-compat (built-in draggable types) +# ============================================================ + +print of "--- Drag Registry Back-compat Tests ---" + +dr is panel of ["dr", 0, 0, 400, 300] +sl_changes is 0 +define on_sl_change(w) as: + sl_changes is sl_changes + 1 +dsl is slider of ["dsl", 10, 10, 200, 0, 100, 0, on_sl_change] +dvs is vslider of ["dvs", 10, 50, 100, 0, 100, 0, null] +dkn is knob of ["dkn", 250, 10, 40, 0, 100, 50, null] +dsb is scrollbar of ["dsb", 10, 200, 200, 0, null] +dwv is waveform_view of ["dwv", 10, 220, 200, 60] +dwv.zoom is 2.0 +dwv.offset is 100 +dcp is color_picker of ["dcp", 220, 60, null] +add_child of [dr, dsl] +add_child of [dr, dvs] +add_child of [dr, dkn] +add_child of [dr, dsb] +add_child of [dr, dwv] +add_child of [dr, dcp] +_layout of [dr, 0, 0] + +# slider: press, drag, release +dispatch of [dr, {"type": "mousedown", "x": 60, "y": 18, "button": 1}] +assert_eq of [dsl.value, 25, "slider mousedown sets value"] +dispatch of [dr, {"type": "mousemove", "x": 110, "y": 18}] +assert_eq of [dsl.value, 50, "slider drag updates value via registry on_drag"] +dispatch of [dr, {"type": "mouseup", "x": 110, "y": 18, "button": 1}] +assert_eq of [dsl.dragging, 0, "slider dragging cleared on release"] +assert_true of [_ui.drag_widget == null, "slider capture released"] +assert_eq of [sl_changes, 3, "slider on_change fired on press+drag+release"] + +# vslider +dispatch of [dr, {"type": "mousedown", "x": 18, "y": 100, "button": 1}] +dispatch of [dr, {"type": "mousemove", "x": 18, "y": 75}] +assert_eq of [dvs.value, 75, "vslider drag updates value"] +dispatch of [dr, {"type": "mouseup", "x": 18, "y": 75, "button": 1}] + +# knob (vertical relative drag) +dispatch of [dr, {"type": "mousedown", "x": 260, "y": 30, "button": 1}] +dispatch of [dr, {"type": "mousemove", "x": 260, "y": 10}] +assert_near of [dkn.value, 60, 0.0001, "knob drag updates value"] +dispatch of [dr, {"type": "mouseup", "x": 260, "y": 10, "button": 1}] + +# scrollbar (horizontal) +dispatch of [dr, {"type": "mousedown", "x": 60, "y": 206, "button": 1}] +dispatch of [dr, {"type": "mousemove", "x": 100, "y": 206}] +assert_eq of [dsb.value, 0.4375, "scrollbar drag updates value"] +dispatch of [dr, {"type": "mouseup", "x": 100, "y": 206, "button": 1}] + +# waveform_view (sample-space selection) +dispatch of [dr, {"type": "mousedown", "x": 60, "y": 240, "button": 1}] +assert_eq of [dwv.sel_start, 200, "waveform mousedown sets sel_start"] +dispatch of [dr, {"type": "mousemove", "x": 110, "y": 240}] +assert_eq of [dwv.sel_end, 300, "waveform drag extends sel_end"] +dispatch of [dr, {"type": "mouseup", "x": 110, "y": 240, "button": 1}] + +# color_picker (SV square) +dispatch of [dr, {"type": "mousedown", "x": 270, "y": 90, "button": 1}] +dispatch of [dr, {"type": "mousemove", "x": 295, "y": 120}] +assert_eq of [dcp.sat, 0.5, "color_picker drag updates sat"] +assert_eq of [dcp.val, 0.5, "color_picker drag updates val"] +dispatch of [dr, {"type": "mouseup", "x": 295, "y": 120, "button": 1}] + +# splitter (own root; hit is the divider bar) +spl_resizes is 0 +define on_spl_resize(w) as: + spl_resizes is spl_resizes + 1 +sroot is panel of ["sroot", 0, 0, 400, 100] +spl is splitter of ["spl", 0, 0, 400, 100, 100, on_spl_resize] +add_child of [sroot, spl] +_layout of [sroot, 0, 0] +dispatch of [sroot, {"type": "mousedown", "x": 102, "y": 50, "button": 1}] +dispatch of [sroot, {"type": "mousemove", "x": 150, "y": 50}] +assert_eq of [spl.split_pos, 150, "splitter drag moves split_pos"] +assert_eq of [spl_resizes, 1, "splitter on_resize fired during drag"] +dispatch of [sroot, {"type": "mouseup", "x": 150, "y": 50, "button": 1}] + +# ============================================================ +# 10. Wheel seam (#569) +# ============================================================ + +print of "--- Wheel Seam Tests (#569) ---" + +wheel_seen is [] +define cv_on_wheel(w, ev) as: + append of [wheel_seen, ev.y] + append of [wheel_seen, ev.ctrl] + +wroot is panel of ["wroot", 0, 0, 400, 300] +wsp is scroll_panel of ["wsp", 0, 0, 200, 100] +wsp.content_h is 300 +wcv is canvas of ["wcv", 0, 0, 200, 300, null, null] +wcv.on_wheel is cv_on_wheel +add_child of [wsp, wcv] +add_child of [wroot, wsp] +_layout of [wroot, 0, 0] + +# Position the cursor over the canvas, then wheel: on_wheel consumes it +dispatch of [wroot, {"type": "mousemove", "x": 50, "y": 50}] +dispatch of [wroot, {"type": "wheel", "x": 0, "y": -3, "ctrl": 1}] +assert_eq of [len of wheel_seen, 2, "canvas on_wheel received the event"] +assert_eq of [wheel_seen[0], -3, "on_wheel sees the y delta"] +assert_eq of [wheel_seen[1], 1, "on_wheel sees ctrl modifier (#568 pairing)"] +assert_eq of [wsp.scroll_y, 0, "consumed wheel does not scroll the enclosing panel"] + +# Without on_wheel the scrollable walk handles it as before +wcv.on_wheel is null +dispatch of [wroot, {"type": "wheel", "x": 0, "y": -3}] +assert_eq of [wsp.scroll_y, 60, "wheel falls through to scroll_panel when unconsumed"] + +# ============================================================ +# 11. scroll_panel child _ax/_ay account for scroll offset (#569) +# ============================================================ + +print of "--- Scroll Offset Cache Tests (#569) ---" + +sp3 is scroll_panel of ["sp3", 0, 0, 200, 100] +sp3.content_h is 300 +sp3.content_w is 400 +cv3 is canvas of ["cv3", 0, 0, 400, 300, null, null] +add_child of [sp3, cv3] +_layout of [sp3, 0, 0] +assert_eq of [cv3._ay, 0, "unscrolled child _ay"] +sp3.scroll_y is 50 +sp3.scroll_x is 30 +_layout of [sp3, 0, 0] +assert_eq of [cv3._ay, 0 - 50, "child _ay accounts for scroll_y"] +assert_eq of [cv3._ax, 0 - 30, "child _ax accounts for scroll_x"] + # ============================================================ # Summary # ============================================================