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

@@ -17,28 +17,40 @@
-- display.set_label_resolver(widget_def, fn)
-- display.set_summary_resolver(widget_def, fn)
--
-- widget_def conforms to the panel widget contract:
-- widget_def.render(theme, x, y, w, h)
-- widget_def.handle_input(input_state, theme, x, y, w, h)
-- widget_def conforms to the panel widget contract (see lib-core.panel
-- README §Widget-Lifecycle-Contract):
-- widget_def.render(ctx)
-- widget_def.handle_input(ctx, event)
-- 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):
-- - Row scrolling
-- - Custom row layouts (icon column, summary column, etc.)
-- - Tooltip / hover-detail
-- - 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 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
-- resolver is per-module non-transitive). The lib does not call into them
-- directly; crafting handles container access internally.
local _ = inventory
local _ = composition
-- inventory-list + composition are required for transitive completeness:
-- the engine's per-module resolver is non-transitive, so consumers of this
-- lib must satisfy crafting's indirect deps here even though this lib does
-- not call into them directly.
require("lib-core.inventory-list")
require("lib-core.composition")
local M = {}
@@ -131,11 +143,11 @@ function M.create(container_entity, opts)
widget_id = opts.widget_id or "crafting",
pause_on_open = opts.pause_on_open == true,
}
function widget.render(theme, x, y, w, h)
M._render_widget(widget, theme, x, y, w, h)
function widget.render(ctx)
M._render_widget(widget, ctx)
end
function widget.handle_input(input_state, theme, x, y, w, h)
return M._handle_input_widget(widget, input_state, theme, x, y, w, h)
function widget.handle_input(ctx, event)
return M._handle_input_widget(widget, ctx, event)
end
return widget
end
@@ -188,26 +200,38 @@ function M.set_summary_resolver(widget, fn) widget._summary_resolver = fn end
-- Render
-- ---------------------------------------------------------------------
function M._render_widget(widget, theme, x, y, w, h)
local rows = build_rows(widget)
local row_h = (theme and theme.row_height) or 28
local pad = (theme and theme.padding) or 8
local txt_col_full = (theme and theme.text_color) or {1.0, 1.0, 1.0, 1.0}
local txt_col_dim = (theme and theme.text_color_dim) or {0.6, 0.6, 0.6, 1.0}
-- 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 bounds = ctx.bounds
local theme = ctx.theme
local row_h = theme.row_height
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 = {}
for i, row in ipairs(rows) do
local color = row.available and txt_col_full or txt_col_dim
local label = widget._label_resolver(row.recipe)
local summary = widget._summary_resolver(row.recipe)
if engine and engine.render and engine.render.draw_text then
engine.render.draw_text(label, x + pad, cy, color)
engine.render.draw_text(summary, x + w - pad - 100, cy, color)
engine.render.draw_text(label,
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
widget._render_rows[i] = {
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
end
@@ -217,16 +241,21 @@ end
-- 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
local mx, my = input_state.mouse_x, input_state.mouse_y
if not (mx and my) then return false end
if event.kind ~= "click" or event.button ~= "right" then
return false
end
local mx, my = event.x, event.y
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 input_state.right_clicked then
M._invoke_context_menu(widget, r.recipe_id, mx, my)
return true
end
if mx >= r.x and mx <= r.x + r.w
and my >= r.y and my <= r.y + r.h then
M._invoke_context_menu(widget, r.recipe_id, mx, my)
return true
end
end
return false
@@ -249,7 +278,7 @@ function M._invoke_context_menu(widget, recipe_id, mx, my)
end
if #entries == 0 then return end
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