diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a26d627..8cd18293 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,40 @@ All notable changes to EigenScript are documented here. ## [Unreleased] +### Added + +- **`gfx_read` — pixel readback, the render-decode oracle primitive + (#823).** `gfx_read of [x, y]` returns the back-buffer pixel as + `[r, g, b]` (call after drawing, before `gfx_present`). Containment + and rendering claims can now be proved on real pixels in-process — + the stubbed suite cannot crop, and #599/F-DYN-12 both showed a green + stubbed suite over a broken real render. Works under + `SDL_VIDEODRIVER=dummy`, so suite section [132] runs the real SDL + software renderer in CI with no display. Renderer pixels are a + nondeterministic input (font raster, driver), so it records/replays + on the trace tape like `audio_stream_queued`. +- **`ui_clip_push` / `ui_clip_pop` (lib/ui_draw.eigs)** — a nesting + clip stack over `gfx_clip`: push INTERSECTS with the current clip, + pop restores the parent clip instead of clearing it. Exported for + custom paint routines; every internal lib/ui clip site migrated off + raw `gfx_clip` set/clear pairs (a child's clear used to wipe its + parent's clip). + +### Fixed + +- **Widget drawing is contained (#823).** `render` now wraps every + widget's draw in a clip of its own rect intersected with its + ancestors' — `canvas` `on_paint` included, so a paint callback can no + longer draw over surrounding chrome (dynamics F-DYN-12 clipped + manually; no consumer has to), and label overflow is defined: a label + wider than its column crops at the column edge instead of rendering + past the panel and off the window (measure with `text_width` to size + deliberately). Widgets whose render legitimately leaves the rect — + `dropdown`/`combobox` open lists, `menu`, `dialog`'s dim overlay, + `grid`'s row-label gutter — opt out via their registry entry + (`"clip": 0`). Proved twice: on recorded clip state in the stubbed + suite and on real pixels in section [132], each with a planted fault. + ### Changed - **import resolution is project-first, and a stdlib collision warns diff --git a/docs/BUILTINS.md b/docs/BUILTINS.md index 2f88c38f..30bb2f7e 100644 --- a/docs/BUILTINS.md +++ b/docs/BUILTINS.md @@ -648,6 +648,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_rrect` | `gfx_rrect of [x, y, w, h, radius, r, g, b]` or `[..., a]` | Filled rounded rectangle (scanline corner fill); radius clamps to half the smaller dimension, `radius 0` = plain rect | | `gfx_clip` | `gfx_clip of [x, y, w, h]` / `gfx_clip of null` | Set / clear the render clip rectangle | +| `gfx_read` | `gfx_read of [x, y]` | Read back one rendered pixel as `[r, g, b]` — the render-decode oracle primitive (#823). Reads the current back buffer: call after drawing, **before** `gfx_present`. Null with no window or a failed read. Nondeterministic input (font raster, driver), so it records/replays on the trace tape | | `gfx_text` | `gfx_text of [x, y, text, r, g, b]` or `[..., scale]` | Text. Proportional antialiased TTF when libSDL2_ttf + a font are available (#593); the 5x7 bitmap font otherwise — see the font note below the table | | `gfx_text_width` | `gfx_text_width of [text, scale?]` or `of "text"` | Pixel width of `text` under the active text renderer: TTF metrics when active, `len * 6 * scale` in bitmap mode. Works before `gfx_open` | | `gfx_text_height` | `gfx_text_height of scale?` | Pixel line height under the active text renderer: TTF font height when active, `7 * scale` in bitmap mode | diff --git a/docs/STDLIB.md b/docs/STDLIB.md index 06596e48..fd0635a5 100644 --- a/docs/STDLIB.md +++ b/docs/STDLIB.md @@ -491,7 +491,19 @@ widget signatures. Theme (`lib/ui_theme.eigs`): `theme`, `set_theme`, `theme_dark`/`theme_light`/`theme_high_contrast`. Animation (`lib/ui_anim.eigs`): `tween`, `cancel_tweens`. Text metrics -(`lib/ui_draw.eigs`): `text_width`, `text_height`. +(`lib/ui_draw.eigs`): `text_width`, `text_height`. Clip stack +(`lib/ui_draw.eigs`, #823): `ui_clip_push of [x, y, w, h]` / +`ui_clip_pop` — a nesting clip over `gfx_clip` where each push +INTERSECTS with the current clip and pop restores the parent clip +instead of clearing it. `render` wraps every widget's draw in a push of +its own rect, so **widget drawing is contained**: a `canvas` `on_paint` +cannot spill over surrounding chrome, and a child wider than its parent +(the classic overflowing side-panel label) crops at the parent's edge. +A custom paint routine that needs a tighter clip pushes its own — it +composes with the widget clip automatically. Widgets whose render +legitimately leaves the rect (`dropdown`/`combobox` open lists, `menu`, +`dialog`'s dim overlay, `grid`'s row-label gutter) opt out via their +registry entry (`"clip": 0`). **Widget constructors, by family** (each returns a plain dict; see the module header for the full argument list): @@ -664,7 +676,11 @@ Notes on widget state, where the toolkit could otherwise shadow yours: sized rect (an invisible full-window click-catcher backing an overlay, since containers are hit-transparent where no child sits). The constructor measures once either way, so a `null` size never reaches - the layout engine. + the layout engine. **Overflow is defined (#823)**: text is clipped to + the label's rect intersected with its ancestors', so a label wider + than its column crops at the column edge instead of drawing over the + neighbouring chrome. To size deliberately rather than discover + truncation in a screenshot, measure first with `text_width`. - **`grid.owns_cells`** (default 1) decides who owns the pattern. Leave it 1 and the widget flips `cells[r][c]` itself, then calls `on_cell`. Set it 0 and mouse/keyboard report `(row, col)` without touching `cells` — diff --git a/lib/ui.eigs b/lib/ui.eigs index 29fff9db..0c601de6 100644 --- a/lib/ui.eigs +++ b/lib/ui.eigs @@ -191,7 +191,22 @@ define render(widget, ox, oy) as: ay is oy + widget.y entry is _widget_registry[widget.type] if entry != null and entry.render != null: - entry.render of [widget, ax, ay] + # Containment (#823): every widget draws under a clip of its own + # rect, intersected with its ancestors' via the ui_draw clip + # stack — canvas on_paint included, so a paint callback cannot + # spill over surrounding chrome, and a child wider than its + # parent (the dynamics side-panel label) crops at the parent + # edge. A registry entry opts out with "clip": 0 — reserved for + # widgets whose render legitimately leaves the rect (dropdown / + # combobox open lists, the floating menu, dialog's full-screen + # dim). Their escape from ANCESTOR clips still ends at whatever + # clip is active when they render. + if entry.clip != null and entry.clip == 0: + entry.render of [widget, ax, ay] + else: + ui_clip_push of [ax, ay, widget.w, widget.h] + entry.render of [widget, ax, ay] + ui_clip_pop of null # Disabled overlay if widget.enabled != null and widget.enabled == 0: _draw_disabled_overlay of [ax, ay, widget.w, widget.h] diff --git a/lib/ui_draw.eigs b/lib/ui_draw.eigs index 9561783d..b9a02fb8 100644 --- a/lib/ui_draw.eigs +++ b/lib/ui_draw.eigs @@ -48,6 +48,58 @@ define text_height(scale) as: return gfx_text_height of scale return 7 * scale +# ---- Clip stack (#823) ---- +# Widget drawing is contained: render() pushes every widget's rect around +# its render function, so a child's clip is always INTERSECTED with its +# ancestors' and popping restores the parent clip instead of clearing it +# (a raw `gfx_clip of null` inside a clipped parent wipes the parent's +# clip — the nesting bug this stack exists to end). gfx_clip is the +# backend primitive; these are the only callers in lib/ui. State lives in +# a dict because module functions cannot rebind loader globals (#373) — +# field writes cross the boundary, bare assignment does not. The stack +# list is slot-reused via depth (no pop builtin; append grows it to the +# deepest nesting seen and it stays there). + +_clip is {"depth": 0, "stack": []} + +define ui_clip_push(x, y, w, h) as: + local x2 is x + w + local y2 is y + h + if _clip.depth > 0: + local top is _clip.stack[_clip.depth - 1] + if top[0] > x: + x is top[0] + if top[1] > y: + y is top[1] + if top[0] + top[2] < x2: + x2 is top[0] + top[2] + if top[1] + top[3] < y2: + y2 is top[1] + top[3] + local cw is x2 - x + local ch is y2 - y + if cw < 0: + cw is 0 + if ch < 0: + ch is 0 + if _clip.depth < (len of _clip.stack): + _clip.stack[_clip.depth] is [x, y, cw, ch] + else: + append of [_clip.stack, [x, y, cw, ch]] + _clip.depth is _clip.depth + 1 + gfx_clip of [x, y, cw, ch] + return null + +define ui_clip_pop() as: + if _clip.depth == 0: + return null + _clip.depth is _clip.depth - 1 + if _clip.depth > 0: + local top is _clip.stack[_clip.depth - 1] + gfx_clip of [top[0], top[1], top[2], top[3]] + else: + gfx_clip of null + return null + # ---- Drawing primitives ---- define _draw_box(x, y, w, h, bg, border) as: diff --git a/lib/ui_w_container.eigs b/lib/ui_w_container.eigs index 4b8d7523..3a7ba6ea 100644 --- a/lib/ui_w_container.eigs +++ b/lib/ui_w_container.eigs @@ -138,10 +138,10 @@ define _render_statusbar(widget, ax, ay) as: define _render_scroll_panel(widget, ax, ay) as: _draw_rbox of [ax, ay, widget.w, widget.h, _theme.radius, widget.bg, widget.border] - gfx_clip of [ax + 1, ay + 1, widget.w - 2, widget.h - 2] + ui_clip_push of [ax + 1, ay + 1, widget.w - 2, widget.h - 2] for i in range of (len of widget.children): render of [widget.children[i], ax - widget.scroll_x, ay - widget.scroll_y] - gfx_clip of null + ui_clip_pop of null # Vertical scrollbar if widget.content_h > widget.h: sb_h is floor of (widget.h * widget.h / widget.content_h) diff --git a/lib/ui_w_data.eigs b/lib/ui_w_data.eigs index 56ae586a..362218dd 100644 --- a/lib/ui_w_data.eigs +++ b/lib/ui_w_data.eigs @@ -138,7 +138,7 @@ define _render_table(widget, ax, ay) as: # Rows body_y is ay + widget.header_h body_h is widget.h - widget.header_h - gfx_clip of [ax + 1, body_y, widget.w - 2, body_h] + ui_clip_push of [ax + 1, body_y, widget.w - 2, body_h] for ri in range of (len of widget.rows): ry is body_y - widget.scroll_y + ri * widget.row_h if ry + widget.row_h > body_y and ry < body_y + body_h: @@ -163,7 +163,7 @@ define _render_table(widget, ax, ay) as: cell_text is f"{row[ci]}" _draw_text_clipped of [cx + 6, ry + 4, col.width - 12, cell_text, row_tc, _theme.font_scale] cx is cx + col.width - gfx_clip of null + ui_clip_pop of null # Scrollbar total_h is (len of widget.rows) * widget.row_h if total_h > body_h: @@ -176,7 +176,7 @@ define _render_table(widget, ax, ay) as: define _render_tree(widget, ax, ay) as: # Background _draw_rbox of [ax, ay, widget.w, widget.h, _theme.radius_sm, _theme.panel_bg, _theme.panel_border] - gfx_clip of [ax + 1, ay + 1, widget.w - 2, widget.h - 2] + ui_clip_push of [ax + 1, ay + 1, widget.w - 2, widget.h - 2] flat is [] _flatten_tree of [widget.nodes, 0, flat] for fi in range of (len of flat): @@ -201,7 +201,7 @@ define _render_tree(widget, ax, ay) as: gfx_text of [ax + indent - 12, iy + 4, ">", _theme.text_dim[0], _theme.text_dim[1], _theme.text_dim[2], _theme.font_scale] # Node text _draw_text_clipped of [ax + indent, iy + 4, widget.w - indent - 8, fe.node.text, ntc, _theme.font_scale] - gfx_clip of null + ui_clip_pop of null # Scrollbar total_h is (len of flat) * widget.item_h if total_h > widget.h: @@ -214,7 +214,7 @@ define _render_tree(widget, ax, ay) as: define _render_item_list(widget, ax, ay) as: # Background _draw_rbox of [ax, ay, widget.w, widget.h, _theme.radius_sm, _theme.panel_bg, _theme.panel_border] - gfx_clip of [ax + 1, ay + 1, widget.w - 2, widget.h - 2] + ui_clip_push of [ax + 1, ay + 1, widget.w - 2, widget.h - 2] for i in range of (len of widget.items): iy is ay - widget.scroll_y + i * widget.item_h if iy + widget.item_h > ay and iy < ay + widget.h: @@ -230,7 +230,7 @@ define _render_item_list(widget, ax, ay) as: if widget.reorder_insert >= 0: ri_y is ay - widget.scroll_y + widget.reorder_insert * widget.item_h gfx_rect of [ax + 2, ri_y - 1, widget.w - 4, 2, _theme.accent[0], _theme.accent[1], _theme.accent[2]] - gfx_clip of null + ui_clip_pop of null # Scrollbar total_h is (len of widget.items) * widget.item_h if total_h > widget.h: @@ -484,6 +484,11 @@ _register_widget of ["item_list", { }] _register_widget of ["grid", { + # clip 0: the row-label gutter is documented to draw LEFT of the + # grid's x, outside its own bounds (docs/STDLIB.md) — the #823 + # containment clip would erase it. Fold the gutter into the rect + # before removing this opt-out. + "clip": 0, "render": _render_grid, "hit_test": null, "on_mousedown": _mousedown_grid, @@ -597,7 +602,7 @@ define _render_hex_view(widget, ax, ay) as: local ccol is _theme_color of ["hex_cursor", _theme.accent] local bcol is _theme_color of ["dock_border", (_theme_color of ["panel_border", [60, 60, 80]])] gfx_rect of [ax, ay, widget.w, widget.h, bg[0], bg[1], bg[2]] - gfx_clip of [ax + 1, ay + 1, widget.w - 2, widget.h - 2] + ui_clip_push of [ax + 1, ay + 1, widget.w - 2, widget.h - 2] local m is hex_view_metrics of widget local bpr is m.bpr local half is floor of (bpr / 2) @@ -644,7 +649,7 @@ define _render_hex_view(widget, ax, ay) as: if b >= 32 and b <= 126: ch is chr of b _draw_text_clipped of [axx, ry, m.cw, ch, ascol, 1] - gfx_clip of null + ui_clip_pop of null gfx_rrect of [ax, ay, widget.w, 1, 0, bcol[0], bcol[1], bcol[2]] gfx_rrect of [ax, ay + widget.h - 1, widget.w, 1, 0, bcol[0], bcol[1], bcol[2]] # Scrollbar (the table idiom) diff --git a/lib/ui_w_dialog.eigs b/lib/ui_w_dialog.eigs index 62e35da1..1c459870 100644 --- a/lib/ui_w_dialog.eigs +++ b/lib/ui_w_dialog.eigs @@ -316,6 +316,7 @@ define _clear_hover_dialog(widget) as: # ---- Registration ---- _register_widget of ["dialog", { + "clip": 0, "render": _render_dialog, "hit_test": _hit_test_dialog, "on_mousedown": _mousedown_dialog, diff --git a/lib/ui_w_dock.eigs b/lib/ui_w_dock.eigs index d259679f..595a59e7 100644 --- a/lib/ui_w_dock.eigs +++ b/lib/ui_w_dock.eigs @@ -240,10 +240,10 @@ define _render_dock(widget, ax, ay) as: # Center if widget.center != null and widget.center.visible == 1: local cr is v.center - gfx_clip of [cr[0], cr[1], cr[2], cr[3]] + ui_clip_push of [cr[0], cr[1], cr[2], cr[3]] # render() adds the child's own x/y — pass the DOCK origin. render of [widget.center, ax, ay] - gfx_clip of null + ui_clip_pop of null # Panels: title bar (collapse marker + clipped title), then the # hosted widget under its body clip. for reg in ["west", "east", "south"]: @@ -263,9 +263,9 @@ define _render_dock(widget, ax, ay) as: local hosted is pe.p.widget if hosted.visible == 1: local br is pe.body - gfx_clip of [br[0], br[1], br[2], br[3]] + ui_clip_push of [br[0], br[1], br[2], br[3]] render of [hosted, ax, ay] - gfx_clip of null + ui_clip_pop of null # Handles (accented while THIS dock holds the pointer), then the # border. local dragging is 0 diff --git a/lib/ui_w_input.eigs b/lib/ui_w_input.eigs index 47356569..2940bd83 100644 --- a/lib/ui_w_input.eigs +++ b/lib/ui_w_input.eigs @@ -579,6 +579,7 @@ _register_widget of ["spinbox", { }] _register_widget of ["combobox", { + "clip": 0, "render": _render_combobox, "hit_test": _hit_test_combobox, "on_mousedown": _mousedown_combobox, diff --git a/lib/ui_w_menu.eigs b/lib/ui_w_menu.eigs index 75a10da5..f86f7580 100644 --- a/lib/ui_w_menu.eigs +++ b/lib/ui_w_menu.eigs @@ -609,6 +609,7 @@ define _close_menus(widget) as: # ---- Registration ---- _register_widget of ["dropdown", { + "clip": 0, "render": _render_dropdown, "hit_test": _hit_test_dropdown, "on_mousedown": _mousedown_dropdown, @@ -621,6 +622,7 @@ _register_widget of ["dropdown", { }] _register_widget of ["menu", { + "clip": 0, "render": _render_menu, "hit_test": _hit_test_menu, "on_mousedown": _mousedown_menu, diff --git a/lib/ui_w_special.eigs b/lib/ui_w_special.eigs index b15a5ddc..bc12bd46 100644 --- a/lib/ui_w_special.eigs +++ b/lib/ui_w_special.eigs @@ -90,9 +90,9 @@ define _render_splitter(widget, ax, ay) as: bw is widget.bar_width # Panel A (left) if widget.panel_a != null: - gfx_clip of [ax, ay, sp, widget.h] + ui_clip_push of [ax, ay, sp, widget.h] render of [widget.panel_a, ax, ay] - gfx_clip of null + ui_clip_pop of null # Divider bar dbg is _theme.panel_border if widget.hover == 1 or widget.dragging == 1: @@ -100,9 +100,9 @@ define _render_splitter(widget, ax, ay) as: gfx_rect of [ax + sp, ay, bw, widget.h, dbg[0], dbg[1], dbg[2]] # Panel B (right) if widget.panel_b != null: - gfx_clip of [ax + sp + bw, ay, widget.w - sp - bw, widget.h] + ui_clip_push of [ax + sp + bw, ay, widget.w - sp - bw, widget.h] render of [widget.panel_b, ax + sp + bw, ay] - gfx_clip of null + ui_clip_pop of null define _render_piano_kb(widget, ax, ay) as: kw is widget.key_w diff --git a/lib/ui_w_viz.eigs b/lib/ui_w_viz.eigs index fd641130..396cd46a 100644 --- a/lib/ui_w_viz.eigs +++ b/lib/ui_w_viz.eigs @@ -674,7 +674,7 @@ define _render_chart(widget, ax, ay) as: _chart_line of [v.px, zp[1], v.px + v.pw, zp[1], r, acol] # Series + markers: software-clipped per primitive, and gfx_clip'd as # well so the real renderer enforces it independently. - gfx_clip of [v.px, v.py, v.pw, v.ph] + ui_clip_push of [v.px, v.py, v.pw, v.ph] for si in range of (len of widget.series): local s is widget.series[si] local c is _chart_series_color of [widget, si] @@ -745,7 +745,7 @@ define _render_chart(widget, ax, ay) as: _chart_dot of [lgx + 4, v.py + 6, 3, r, c] _chart_text of [lgx + 12, v.py + 2, lab, txt, 1, r] lgx is lgx + (text_width of [lab, 1]) + 20 - gfx_clip of null + ui_clip_pop of null # Axis captions if (len of widget.x_label) > 0: local xw is text_width of [widget.x_label, 1] @@ -939,7 +939,7 @@ define _render_waveform_view(widget, ax, ay) as: define _render_code_view(widget, ax, ay) as: # Background gfx_rect of [ax, ay, widget.w, widget.h, 20, 20, 30] - gfx_clip of [ax + 1, ay + 1, widget.w - 2, widget.h - 2] + ui_clip_push of [ax + 1, ay + 1, widget.w - 2, widget.h - 2] # Split text into lines by scanning for newline; line_offs keeps each # line's absolute start offset so span offsets (#838) map to columns. lines is [] @@ -1012,7 +1012,7 @@ define _render_code_view(widget, ax, ay) as: local sx is tx + (text_width of [pre, 1]) local sxw is text_width of [pre, 1] _draw_text_clipped of [sx, ly, tmaxw - sxw, seg, fgc, 1] - gfx_clip of null + ui_clip_pop of null # Border gfx_rrect of [ax, ay, widget.w, 1, 0, 35, 35, 45] gfx_rrect of [ax, ay + widget.h - 1, widget.w, 1, 0, 35, 35, 45] @@ -1548,7 +1548,7 @@ define _render_timeline(widget, ax, ay) as: _chart_text of [(floor of tx) + 2, v.ry + 2, (_chart_fmt of [ticks[ti], tstep]), dim, 1, [v.rx, v.ry, v.rw, v.rh]] # Items: gfx_clip for the real renderer, software intersect per # primitive for the containment property itself. - gfx_clip of [v.px, v.py, v.pw, v.ph] + ui_clip_push of [v.px, v.py, v.pw, v.ph] local bx1 is v.px + v.pw for si in range of (len of widget.spans): local s is widget.spans[si] @@ -1603,7 +1603,7 @@ define _render_timeline(widget, ax, ay) as: if imx + imw > bx1: imw is bx1 - imx gfx_rect of [imx, imy, imw, imh, mc[0], mc[1], mc[2]] - gfx_clip of null + ui_clip_pop of null # Cursor/scrubber: a rule across the body plus a handle in the ruler. if widget.cursor != null: local cx is v.px + (widget.cursor - v.t0) * xscale diff --git a/src/ext_gfx.c b/src/ext_gfx.c index 102556cd..9870d5d7 100644 --- a/src/ext_gfx.c +++ b/src/ext_gfx.c @@ -100,6 +100,7 @@ static Uint32 (*p_SDL_GetMouseState)(int*, int*); static Uint32 (*p_SDL_GetTicks)(void); static void (*p_SDL_Delay)(Uint32); static int (*p_SDL_RenderSetClipRect)(SDL_Renderer*, const SDL_Rect*); +static int (*p_SDL_RenderReadPixels)(SDL_Renderer*, const SDL_Rect*, Uint32, void*, int); /* Texture function pointers (for framebuffer blit) */ typedef void SDL_Texture; @@ -161,6 +162,7 @@ static int load_sdl2(void) { p_SDL_GetModState = dlsym(g_sdl_lib, "SDL_GetModState"); p_SDL_GetMouseState = dlsym(g_sdl_lib, "SDL_GetMouseState"); p_SDL_RenderSetClipRect = dlsym(g_sdl_lib, "SDL_RenderSetClipRect"); + p_SDL_RenderReadPixels = dlsym(g_sdl_lib, "SDL_RenderReadPixels"); p_SDL_CreateTexture = dlsym(g_sdl_lib, "SDL_CreateTexture"); p_SDL_DestroyTexture = dlsym(g_sdl_lib, "SDL_DestroyTexture"); p_SDL_UpdateTexture = dlsym(g_sdl_lib, "SDL_UpdateTexture"); @@ -574,6 +576,36 @@ Value* builtin_gfx_clip(Value *arg) { return make_null(); } +/* gfx_read of [x, y] — read back one rendered pixel as [r, g, b]. + * Reads the CURRENT back buffer: call after drawing, before gfx_present + * (post-present back-buffer contents are undefined under SDL_Renderer). + * This is the render-decode oracle primitive (#823): a containment claim + * is proved on real pixels, not on recorded draw calls — the #599 lesson + * that a stubbed-gfx suite cannot see a C-side defect. Renderer pixels + * are a nondeterministic input (font rasterisation, driver, backend), so + * this takes the TAKE/RECORD tape pair like audio_stream_queued. + * Returns null with no window, no SDL symbol, or a failed read. */ +Value* builtin_gfx_read(Value *arg) { + TRACE_NONDET_TAKE("gfx_read"); + if (!g_renderer || !p_SDL_RenderReadPixels || !arg || + arg->type != VAL_LIST || arg->data.list.count < 2) + TRACE_NONDET_RECORD("gfx_read", make_null()); + SDL_Rect r; + r.x = (int)arg->data.list.items[0]->data.num; + r.y = (int)arg->data.list.items[1]->data.num; + r.w = 1; + r.h = 1; + Uint32 px = 0; + if (p_SDL_RenderReadPixels(g_renderer, &r, MY_SDL_PIXELFORMAT_ARGB8888, + &px, (int)sizeof(Uint32)) != 0) + TRACE_NONDET_RECORD("gfx_read", make_null()); + Value *out = make_list(3); + list_append_owned(out, make_num((double)((px >> 16) & 0xFF))); + list_append_owned(out, make_num((double)((px >> 8) & 0xFF))); + list_append_owned(out, make_num((double)(px & 0xFF))); + TRACE_NONDET_RECORD("gfx_read", out); +} + /* gfx_present of null — flip buffer to screen */ Value* builtin_gfx_present(Value *arg) { (void)arg; diff --git a/src/ext_names.h b/src/ext_names.h index dc63b577..8cf488b4 100644 --- a/src/ext_names.h +++ b/src/ext_names.h @@ -34,6 +34,7 @@ X(gfx_circle, builtin_gfx_circle) \ X(gfx_rrect, builtin_gfx_rrect) \ X(gfx_clip, builtin_gfx_clip) \ + X(gfx_read, builtin_gfx_read) \ X(gfx_present, builtin_gfx_present) \ X(gfx_poll, builtin_gfx_poll) \ X(gfx_ticks, builtin_gfx_ticks) \ diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index 6d5e3b4e..ef87cd19 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -2545,6 +2545,40 @@ else echo "" fi +# [132] UI containment render-decode oracle (#823 — probe-gated: needs a +# gfx build). The stubbed [63] suite proves containment on RECORDED clip +# state; this section proves it on real pixels: the actual SDL software +# renderer (dummy video driver) draws an escaping canvas on_paint and an +# overflowing label, and gfx_read decodes the back buffer. Includes its +# own planted fault (registry clip opt-out must turn the probe red). +UC_PROBE_FILE=$(mktemp /tmp/eigs_uc_probe_XXXXXX.eigs) +cat > "$UC_PROBE_FILE" <<'PROBE' +print of (gfx_text_width of ["m", 1]) +PROBE +UC_PROBE_OUT=$(./eigenscript "$UC_PROBE_FILE" 2>&1) +rm -f "$UC_PROBE_FILE" + +if ! echo "$UC_PROBE_OUT" | grep -q "undefined variable"; then + echo "[132] UI Containment Render-Decode Oracle (9 checks)" + UC_OUTPUT=$(SDL_VIDEODRIVER=dummy ./eigenscript ../tests/test_ui_containment_gfx.eigs 2>&1); UC_RC=$? + if rc_ok "$UC_RC" "$UC_OUTPUT" && echo "$UC_OUTPUT" | grep -q "All tests passed"; then + TOTAL=$((TOTAL + 9)) + PASS=$((PASS + 9)) + echo " PASS: real-pixel containment + planted fault" + elif echo "$UC_OUTPUT" | grep -q "^SKIP:"; then + echo " SKIP: $(echo "$UC_OUTPUT" | grep "^SKIP:" | head -1)" + else + TOTAL=$((TOTAL + 9)) + FAIL=$((FAIL + 9)) + echo " FAIL: ui containment oracle" + echo "$UC_OUTPUT" | grep -iE "assert|error|FAIL" | head -5 + fi + echo "" +else + echo "[132] UI containment oracle SKIPPED (binary built without EIGENSCRIPT_EXT_GFX)" + echo "" +fi + # [64] list_truncate builtin echo "[64] List Truncate (9 checks)" LT_OUTPUT=$(./eigenscript ../tests/test_list_truncate.eigs 2>&1); LT_OUTPUT_RC=$? diff --git a/tests/test_ui.eigs b/tests/test_ui.eigs index 20b11165..dd9030e1 100644 --- a/tests/test_ui.eigs +++ b/tests/test_ui.eigs @@ -2561,6 +2561,135 @@ dispatch of [dcroot, {"type": "mousedown", "x": 15, "y": 352, "button": 1}] dispatch of [dcroot, {"type": "mousedown", "x": 15, "y": 352, "button": 1}] assert_eq of [dcel.editing, 1, "#847 editable_label double-click still starts editing"] +# ============================================================ +# #823 — widget drawing is contained (clip stack + render() clip) +# ============================================================ +# A stubbed gfx layer cannot crop, so containment is proved the same way +# as the chart's (#819): on the RECORDED clip state. The claim here is +# stronger than "a clip was set" — it is that the clip ACTIVE while a +# widget's primitives record equals the widget rect intersected with +# every ancestor's, and that popping restores the parent clip instead of +# clearing it. +print of "--- #823 containment: clip stack + render() widget clip ---" + +# Active clip when the rect marked by this rgb was recorded. +# Returns the clip rect, null (no clip active), or "no-marker". +define _clip823_at(mr, mg, mb) as: + local act is null + for i in range of (len of _gfx_log): + local e is _gfx_log[i] + if e[0] == "clip": + act is e[1] + elif e[0] == "rect": + local a is e[1] + if a[4] == mr and a[5] == mg and a[6] == mb: + return act + return "no-marker" + +define _rect823_eq(r, x, y, w, h) as: + if r == null: + return 0 + if r[0] == x and r[1] == y and r[2] == w and r[3] == h: + return 1 + return 0 + +# ---- Clip stack unit: intersection + parent restore ---- +_gfx_log is [] +_gfx_rec is 1 +ui_clip_push of [10, 10, 100, 100] +ui_clip_push of [50, 50, 200, 100] +ui_clip_pop of null +ui_clip_pop of null +_gfx_rec is 0 +assert_true of [(_rect823_eq of [_gfx_log[0][1], 10, 10, 100, 100]) == 1, "#823 first push sets its own rect"] +assert_true of [(_rect823_eq of [_gfx_log[1][1], 50, 50, 60, 60]) == 1, "#823 nested push INTERSECTS with the parent clip"] +assert_true of [(_rect823_eq of [_gfx_log[2][1], 10, 10, 100, 100]) == 1, "#823 pop RESTORES the parent clip, not null"] +assert_eq of [_gfx_log[3][1], null, "#823 final pop clears the clip"] + +# Disjoint rects intersect to a degenerate (empty) clip, never negative. +_gfx_log is [] +_gfx_rec is 1 +ui_clip_push of [0, 0, 50, 50] +ui_clip_push of [200, 200, 50, 50] +ui_clip_pop of null +ui_clip_pop of null +_gfx_rec is 0 +assert_true of [_gfx_log[1][1][2] == 0 and _gfx_log[1][1][3] == 0, "#823 disjoint nested clip degenerates to zero size"] + +# ---- canvas on_paint runs under a clip of the widget rect ---- +define _p823_paint(w, ax, ay) as: + gfx_rect of [ax - 40, ay - 40, 400, 400, 7, 8, 9] + +cc823 is canvas of ["cc823", 20, 30, 100, 80, _p823_paint, null] +_gfx_log is [] +_gfx_rec is 1 +render of [cc823, 0, 0] +_gfx_rec is 0 +assert_true of [(_rect823_eq of [(_clip823_at of [7, 8, 9]), 20, 30, 100, 80]) == 1, "#823 canvas on_paint records under a clip of exactly the canvas rect"] +clip823_last is "unset" +for c8i in range of (len of _gfx_log): + if _gfx_log[c8i][0] == "clip": + clip823_last is _gfx_log[c8i][1] +assert_eq of [clip823_last, null, "#823 render() restores a clear clip after the widget"] + +# ---- nesting: a child crops at its parent's edge ---- +pn823 is panel of ["pn823", 10, 10, 100, 100] +cc823b is canvas of ["cc823b", 50, 50, 100, 80, _p823_paint, null] +add_child of [pn823, cc823b] +_gfx_log is [] +_gfx_rec is 1 +render of [pn823, 0, 0] +_gfx_rec is 0 +assert_true of [(_rect823_eq of [(_clip823_at of [7, 8, 9]), 60, 60, 50, 50]) == 1, "#823 a child's clip is its rect INTERSECTED with the parent panel"] + +# ---- label overflow is DEFINED: cropped at the container edge ---- +# The dynamics side-panel defect: an auto-sized label wider than its +# column drew past the panel. Under render() the label's clip intersects +# the panel rect, so the text crops at the panel edge. +lp823 is panel of ["lp823", 10, 10, 80, 40] +lb823 is label of ["lb823", 2, 2, "a caption far wider than an 80px column"] +add_child of [lp823, lb823] +_gfx_log is [] +_gfx_rec is 1 +render of [lp823, 0, 0] +_gfx_rec is 0 +lb823_clip is "unset" +for l8i in range of (len of _gfx_log): + if _gfx_log[l8i][0] == "clip": + lb823_clip is _gfx_log[l8i][1] + elif _gfx_log[l8i][0] == "text": + if _gfx_log[l8i][1][2] == "a caption far wider than an 80px column": + break +assert_true of [lb823.w > 80, "#823 the label really is wider than its column"] +assert_true of [lb823_clip != "unset" and lb823_clip != null, "#823 label text records under an active clip"] +assert_eq of [lb823_clip[0] + lb823_clip[2], 90, "#823 the label clip ends at the panel's right edge"] + +# ---- planted fault: the probe DOES see a missing clip ---- +# Re-register canvas with the clip opt-out; the same render must record +# the paint marker with NO active clip. If this assertion could not go +# red, every containment claim above would be vacuous. +_widget_registry["canvas"].clip is 0 +_gfx_log is [] +_gfx_rec is 1 +render of [cc823, 0, 0] +_gfx_rec is 0 +assert_eq of [_clip823_at of [7, 8, 9], null, "#823 with the clip disabled the probe reports NO active clip (planted fault)"] +_widget_registry["canvas"].clip is null +_gfx_log is [] +_gfx_rec is 1 +render of [cc823, 0, 0] +_gfx_rec is 0 +assert_true of [(_rect823_eq of [(_clip823_at of [7, 8, 9]), 20, 30, 100, 80]) == 1, "#823 restoring the registration restores containment"] + +# ---- opt-outs stay opt-outs: dropdown's open list is unclipped ---- +dd823 is dropdown of ["dd823", 5, 5, 120, ["alpha", "beta"], 0, null] +dd823.open is 1 +_gfx_log is [] +_gfx_rec is 1 +render of [dd823, 0, 0] +_gfx_rec is 0 +assert_eq of [_count_kind of "clip", 0, "#823 dropdown renders its open list without a widget clip (registry opt-out)"] + # ============================================================ # Summary # ============================================================ diff --git a/tests/test_ui_containment_gfx.eigs b/tests/test_ui_containment_gfx.eigs new file mode 100644 index 00000000..ae416977 --- /dev/null +++ b/tests/test_ui_containment_gfx.eigs @@ -0,0 +1,94 @@ +# ============================================================ +# #823 containment — render-decode oracle against the REAL renderer +# ============================================================ +# The stubbed suite proves containment on recorded clip state; a stub +# cannot crop, so that claim is about the render code, not the renderer +# (the #599 lesson). This driver is the other half: the real ext_gfx.c +# SDL pipeline draws a canvas whose on_paint deliberately escapes its +# rect and a label wider than its column, then gfx_read decodes the +# actual back-buffer pixels — escape pixels must not exist. Runs under +# SDL_VIDEODRIVER=dummy (the software renderer rasterises and reads +# back without a display), probe-gated in the runner like [120]. +# +# Planted fault: the same scene re-rendered with canvas's registry clip +# opted out MUST turn the escape probe red — an oracle that has never +# failed on a planted fault is not evidence. + +load_file of "lib/test.eigs" + +opened is gfx_open of [400, 300, "containment-oracle-823"] +if opened == 0: + print of "SKIP: gfx_open failed (no SDL2)" + exit of 0 + +load_file of "lib/ui.eigs" + +define _is_rogue(px) as: + if px == null: + return 0 + if px[0] == 200 and px[1] == 30 and px[2] == 30: + return 1 + return 0 + +# Bright label text: count "yellowish" so TTF antialiasing (blended +# glyph edges) and the bitmap font both register. +define _is_yellowish(px) as: + if px == null: + return 0 + if px[0] > 128 and px[1] > 128 and px[2] < 100: + return 1 + return 0 + +define _count_yellow(x0, y0, x1, y1) as: + local n is 0 + local yy is y0 + loop while yy < y1: + local xx is x0 + loop while xx < x1: + if (_is_yellowish of (gfx_read of [xx, yy])) == 1: + n is n + 1 + xx is xx + 2 + yy is yy + 2 + return n + +# on_paint deliberately escapes: fills far beyond the widget rect. +define _rogue_paint(w, ax, ay) as: + gfx_rect of [ax - 40, ay - 40, 300, 250, 200, 30, 30] + +cv is canvas of ["cv", 50, 50, 100, 80, _rogue_paint, null] + +pn is panel of ["pn", 10, 150, 120, 60] +lb is label of ["lb", 4, 8, "WWWWWWWWWWWWWWWWWWWWWWWWWWWWWW"] +lb.color is [255, 255, 0] +add_child of [pn, lb] + +define _draw_scene() as: + gfx_clear of [9, 9, 9] + render of [cv, 0, 0] + render of [pn, 0, 0] + +# ---- Contained render: escape pixels must not exist ---- +_draw_scene of null +assert_true of [(_is_rogue of (gfx_read of [60, 60])) == 1, "#823 rogue paint IS visible inside the canvas rect"] +assert_true of [(_is_rogue of (gfx_read of [30, 30])) == 0, "#823 no rogue pixel above-left of the canvas rect"] +assert_true of [(_is_rogue of (gfx_read of [200, 60])) == 0, "#823 no rogue pixel right of the canvas rect"] +assert_true of [(_is_rogue of (gfx_read of [60, 145])) == 0, "#823 no rogue pixel below the canvas rect"] + +# The overflow claim is not vacuous: the label must actually be wider +# than the panel that crops it. +assert_true of [lb.w > 116, "#823 the label really is wider than its panel"] +# Label really drew inside its panel... +assert_true of [(_count_yellow of [14, 152, 128, 208]) > 0, "#823 label text IS visible inside its panel"] +# ...and stops at the panel's right edge (x = 130). +assert_eq of [_count_yellow of [132, 152, 200, 208], 0, "#823 no label pixel past the panel's right edge"] + +# ---- Planted fault: opting canvas out of the clip must go red ---- +_widget_registry["canvas"].clip is 0 +_draw_scene of null +assert_true of [(_is_rogue of (gfx_read of [30, 30])) == 1, "#823 planted fault: with the clip disabled the escape probe DOES see rogue pixels"] +_widget_registry["canvas"].clip is null +_draw_scene of null +assert_true of [(_is_rogue of (gfx_read of [30, 30])) == 0, "#823 restoring the registration restores containment"] + +gfx_close of null +test_summary of null