Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/BUILTINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
20 changes: 18 additions & 2 deletions docs/STDLIB.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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` —
Expand Down
17 changes: 16 additions & 1 deletion lib/ui.eigs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
52 changes: 52 additions & 0 deletions lib/ui_draw.eigs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions lib/ui_w_container.eigs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 13 additions & 8 deletions lib/ui_w_data.eigs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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):
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions lib/ui_w_dialog.eigs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions lib/ui_w_dock.eigs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]:
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/ui_w_input.eigs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions lib/ui_w_menu.eigs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions lib/ui_w_special.eigs
Original file line number Diff line number Diff line change
Expand Up @@ -90,19 +90,19 @@ 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:
dbg is _theme.accent
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
Expand Down
12 changes: 6 additions & 6 deletions lib/ui_w_viz.eigs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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 []
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading