fix: align widget contract with lib-core.panel API

The widget contract was implemented as render(theme, x, y, w, h) and
handle_input(input_state, theme, x, y, w, h), but lib-core.panel
dispatches widgets as render(ctx) and handle_input(ctx, event) where
ctx = {bounds = {x,y,w,h}, theme, is_focused} and event carries
{kind, x, y, button} for clicks or {kind, dy} for wheel.

The mismatch would have surfaced as a crash on the first render frame
(theme.row_height read on a nil first arg) and as a permanently dead
right-click (no field matched input_state.right_clicked because the
real signature passes an event table). Both bugs were masked by the
existing tests, which exercise the public registration surface but
never drove render or handle_input headless.

Changes:
- widget.render and widget.handle_input now match panel's contract.
- _render_widget consumes ctx.bounds + ctx.theme; reads packed-RGBA
  text colours directly instead of falling back to synthetic float
  arrays (panel theme stores 0xRRGGBBAA integers).
- _handle_input_widget dispatches on event.kind == "click" and
  event.button == "right", iterating _render_rows for hit-testing.
- draw_text now passes theme.font_size_body so the engine receives
  the full (text, x, y, size, color) signature.
- Side-effect requires for lib-core.inventory-list and
  lib-core.composition replace the unused-local sentinels, dropping
  the underscore-shadowing.
- _invoke_context_menu trusts mx/my as preconditions and no longer
  defends with `or 0` defaults — the entry-point guards nil.

README documents the widget contract explicitly and captures four
v0.2 hardening notes (is_known cache, WARN rate-limit, empty-action
diagnostic, defensive nil-guard) so the deferral is traceable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Calic
2026-06-14 13:36:29 +02:00
parent eb8f72bcbd
commit b42b707a8e
2 changed files with 89 additions and 33 deletions

View File

@@ -51,6 +51,33 @@ v0.1 ships a minimal recipe-list widget:
- Tooltip / hover-detail - Tooltip / hover-detail
- Stack-count display - Stack-count display
**v0.2 hardening notes (deferred):**
- `is_known(ctx)` is called per recipe per frame; no per-frame memoization.
Large recipe-registries may want a cache layer.
- `is_known(ctx)` errors emit one `[WARN]` per failure per frame; no rate-
limit (spammy if a recipe is permanently broken).
- Right-click on a row with an empty action-set is silently dropped; a
diagnostic warning would help modders catch missing `register_action`
calls.
## Widget Contract
The widget conforms to `lib-core.panel`'s widget-lifecycle contract (see
`lib-core/panel/README.md` §Widget-Lifecycle-Contract):
```lua
widget.render(ctx)
widget.handle_input(ctx, event)
```
where `ctx = {bounds = {x,y,w,h}, theme = table, is_focused = bool}` and
the panel dispatches input events as `{kind = "click", x, y, button}` or
`{kind = "wheel", dy}`. Theme colours are packed-RGBA integers (e.g.
`0xE0E0E0FF`), matching the `panel.DEFAULT_THEME` schema.
Right-click on a row invokes `panel.show_context_menu(x, y, actions)`
with the registered actions; left-click and wheel are ignored in v0.1.
## Row Visibility + Availability rules ## Row Visibility + Availability rules
For every frame, the widget rebuilds its row-list by walking For every frame, the widget rebuilds its row-list by walking

View File

@@ -17,28 +17,40 @@
-- display.set_label_resolver(widget_def, fn) -- display.set_label_resolver(widget_def, fn)
-- display.set_summary_resolver(widget_def, fn) -- display.set_summary_resolver(widget_def, fn)
-- --
-- widget_def conforms to the panel widget contract: -- widget_def conforms to the panel widget contract (see lib-core.panel
-- widget_def.render(theme, x, y, w, h) -- README §Widget-Lifecycle-Contract):
-- widget_def.handle_input(input_state, theme, x, y, w, h) -- widget_def.render(ctx)
-- widget_def.handle_input(ctx, event)
-- widget_def.title (string) -- widget_def.title (string)
-- --
-- ctx = { bounds = {x,y,w,h}, theme = table, is_focused = bool }
-- event = { kind = "click", x, y, button = "left"|"right" }
-- | { kind = "wheel", dy = number }
--
-- DEFERRED (v0.1 non-goals): -- DEFERRED (v0.1 non-goals):
-- - Row scrolling -- - Row scrolling
-- - Custom row layouts (icon column, summary column, etc.) -- - Custom row layouts (icon column, summary column, etc.)
-- - Tooltip / hover-detail -- - Tooltip / hover-detail
-- - Stack-count display -- - Stack-count display
--
-- v0.2 hardening notes (deferred):
-- - is_known(ctx) is called per recipe per frame; result is not cached.
-- Hot recipe-registries may want a per-frame memoization layer.
-- - is_known(ctx) errors emit one [WARN] per failure per frame; no rate-
-- limit (spammy on a permanently-broken recipe).
-- - Empty action-set + right-click is silently dropped; consider a
-- diagnostic warning to help modders detect missing register_action calls.
-- ===================================================================== -- =====================================================================
local crafting = require("lib-core.crafting") local crafting = require("lib-core.crafting")
local panel = require("lib-core.panel") local panel = require("lib-core.panel")
local inventory = require("lib-core.inventory-list")
local composition = require("lib-core.composition")
-- inventory + composition are required for transitive completeness (engine -- inventory-list + composition are required for transitive completeness:
-- resolver is per-module non-transitive). The lib does not call into them -- the engine's per-module resolver is non-transitive, so consumers of this
-- directly; crafting handles container access internally. -- lib must satisfy crafting's indirect deps here even though this lib does
local _ = inventory -- not call into them directly.
local _ = composition require("lib-core.inventory-list")
require("lib-core.composition")
local M = {} local M = {}
@@ -131,11 +143,11 @@ function M.create(container_entity, opts)
widget_id = opts.widget_id or "crafting", widget_id = opts.widget_id or "crafting",
pause_on_open = opts.pause_on_open == true, pause_on_open = opts.pause_on_open == true,
} }
function widget.render(theme, x, y, w, h) function widget.render(ctx)
M._render_widget(widget, theme, x, y, w, h) M._render_widget(widget, ctx)
end end
function widget.handle_input(input_state, theme, x, y, w, h) function widget.handle_input(ctx, event)
return M._handle_input_widget(widget, input_state, theme, x, y, w, h) return M._handle_input_widget(widget, ctx, event)
end end
return widget return widget
end end
@@ -188,26 +200,38 @@ function M.set_summary_resolver(widget, fn) widget._summary_resolver = fn end
-- Render -- Render
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
function M._render_widget(widget, theme, x, y, w, h) -- M._render_widget(widget, ctx)
-- Called each render frame by panel via widget.render. Lays out one row
-- per known recipe, dim-colored when can_craft returns ok=false.
-- ctx = { bounds = {x,y,w,h}, theme = table, is_focused = bool }
-- theme keys consumed: row_height, padding, text_color, text_color_dim
-- (all defined in lib-core.panel DEFAULT_THEME — see panel/README.md).
function M._render_widget(widget, ctx)
local rows = build_rows(widget) local rows = build_rows(widget)
local row_h = (theme and theme.row_height) or 28 local bounds = ctx.bounds
local pad = (theme and theme.padding) or 8 local theme = ctx.theme
local txt_col_full = (theme and theme.text_color) or {1.0, 1.0, 1.0, 1.0} local row_h = theme.row_height
local txt_col_dim = (theme and theme.text_color_dim) or {0.6, 0.6, 0.6, 1.0} local pad = theme.padding
local txt_col_full = theme.text_color
local txt_col_dim = theme.text_color_dim
local cy = y + pad local cy = bounds.y + pad
widget._render_rows = {} widget._render_rows = {}
for i, row in ipairs(rows) do for i, row in ipairs(rows) do
local color = row.available and txt_col_full or txt_col_dim local color = row.available and txt_col_full or txt_col_dim
local label = widget._label_resolver(row.recipe) local label = widget._label_resolver(row.recipe)
local summary = widget._summary_resolver(row.recipe) local summary = widget._summary_resolver(row.recipe)
if engine and engine.render and engine.render.draw_text then if engine and engine.render and engine.render.draw_text then
engine.render.draw_text(label, x + pad, cy, color) engine.render.draw_text(label,
engine.render.draw_text(summary, x + w - pad - 100, cy, color) bounds.x + pad, cy,
theme.font_size_body, color)
engine.render.draw_text(summary,
bounds.x + bounds.w - pad - 100, cy,
theme.font_size_body, color)
end end
widget._render_rows[i] = { widget._render_rows[i] = {
recipe_id = row.recipe.id, recipe_id = row.recipe.id,
x = x, y = cy, w = w, h = row_h, x = bounds.x, y = cy, w = bounds.w, h = row_h,
} }
cy = cy + row_h cy = cy + row_h
end end
@@ -217,18 +241,23 @@ end
-- Input -- Input
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
function M._handle_input_widget(widget, input_state, _theme, _x, _y, _w, _h) -- M._handle_input_widget(widget, ctx, event)
-- Dispatched by panel for each input event while widget is active.
-- Right-click on a row opens the context menu.
-- Wheel events are silently ignored (scroll deferred to v0.2).
function M._handle_input_widget(widget, _ctx, event)
if not widget._render_rows then return false end if not widget._render_rows then return false end
local mx, my = input_state.mouse_x, input_state.mouse_y if event.kind ~= "click" or event.button ~= "right" then
if not (mx and my) then return false end return false
end
local mx, my = event.x, event.y
for _, r in ipairs(widget._render_rows) do for _, r in ipairs(widget._render_rows) do
if mx >= r.x and mx <= r.x + r.w and my >= r.y and my <= r.y + r.h then if mx >= r.x and mx <= r.x + r.w
if input_state.right_clicked then and my >= r.y and my <= r.y + r.h then
M._invoke_context_menu(widget, r.recipe_id, mx, my) M._invoke_context_menu(widget, r.recipe_id, mx, my)
return true return true
end end
end end
end
return false return false
end end
@@ -249,7 +278,7 @@ function M._invoke_context_menu(widget, recipe_id, mx, my)
end end
if #entries == 0 then return end if #entries == 0 then return end
if panel.show_context_menu then if panel.show_context_menu then
panel.show_context_menu(mx or 0, my or 0, entries) panel.show_context_menu(mx, my, entries)
end end
end end