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>
306 lines
11 KiB
Lua
306 lines
11 KiB
Lua
-- =====================================================================
|
||
-- lib-core.crafting-display v0.1.0 — Recipe Panel-Widget
|
||
--
|
||
-- Sits on top of lib-core.panel and reads lib-core.crafting +
|
||
-- lib-core.inventory-list + lib-core.composition. Provides a ready-to-
|
||
-- register panel widget that lists recipes known to the actor and shows
|
||
-- per-row availability based on container contents.
|
||
--
|
||
-- Right-clicking a row opens a context-menu populated from actions
|
||
-- registered via M.register_action.
|
||
--
|
||
-- Public API:
|
||
-- display.create(container_entity, opts) -> widget_def
|
||
-- display.register_action(widget_def, label, callback)
|
||
-- display.unregister_action(widget_def, label)
|
||
-- display.set_icon_resolver(widget_def, fn)
|
||
-- display.set_label_resolver(widget_def, fn)
|
||
-- display.set_summary_resolver(widget_def, fn)
|
||
--
|
||
-- 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")
|
||
|
||
-- 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 = {}
|
||
|
||
-- ---------------------------------------------------------------------
|
||
-- Default resolvers
|
||
-- ---------------------------------------------------------------------
|
||
|
||
local function default_label(recipe)
|
||
return recipe.name or recipe.id
|
||
end
|
||
|
||
local function default_summary(recipe)
|
||
local parts = {}
|
||
for _, inp in ipairs(recipe.inputs) do
|
||
if inp.count == 1 then
|
||
parts[#parts + 1] = inp.template
|
||
else
|
||
parts[#parts + 1] = inp.template .. "\xc3\x97" .. inp.count -- UTF-8 "×"
|
||
end
|
||
end
|
||
return table.concat(parts, " + ")
|
||
end
|
||
|
||
local function default_icon(_recipe)
|
||
return nil -- v0.1: module must override icon_resolver for sprites
|
||
end
|
||
|
||
local function default_ctx_factory()
|
||
return {}
|
||
end
|
||
|
||
-- ---------------------------------------------------------------------
|
||
-- Internal: build row-list per frame
|
||
-- ---------------------------------------------------------------------
|
||
|
||
local function build_rows(widget)
|
||
local ctx = widget._ctx_factory()
|
||
if type(ctx) ~= "table" then ctx = {} end
|
||
local out = {}
|
||
for _, recipe in ipairs(crafting.list_recipes()) do
|
||
local known_ok, known = pcall(recipe.is_known, ctx)
|
||
if not known_ok then
|
||
if engine and engine.print then
|
||
engine.print(string.format(
|
||
"[WARN] crafting-display: is_known('%s') errored: %s",
|
||
recipe.id, tostring(known)))
|
||
end
|
||
elseif known == true then
|
||
local match = crafting.can_craft(recipe.id, widget._container, ctx)
|
||
out[#out + 1] = {
|
||
recipe = recipe,
|
||
available = match.ok == true,
|
||
}
|
||
end
|
||
end
|
||
return out
|
||
end
|
||
|
||
-- ---------------------------------------------------------------------
|
||
-- Public API: create
|
||
-- ---------------------------------------------------------------------
|
||
|
||
--- M.create(container_entity, opts) -> widget_def
|
||
--- Creates a crafting-recipe widget bound to `container_entity` (used as
|
||
--- the input source for can_craft availability checks).
|
||
--- opts = {
|
||
--- title = string, default "Crafting"
|
||
--- widget_id = string, default "crafting"
|
||
--- pause_on_open = bool, default false
|
||
--- ctx_factory = function() -> table, default returns {}
|
||
--- icon_resolver = function(recipe) -> any|nil
|
||
--- label_resolver = function(recipe) -> string
|
||
--- summary_resolver = function(recipe) -> string
|
||
--- }
|
||
--- Loud-error if container_entity is nil.
|
||
function M.create(container_entity, opts)
|
||
if container_entity == nil then
|
||
error("crafting-display.create: container must not be nil", 2)
|
||
end
|
||
opts = opts or {}
|
||
local widget = {
|
||
_container = container_entity,
|
||
_opts = opts,
|
||
_actions = {},
|
||
_ctx_factory = opts.ctx_factory or default_ctx_factory,
|
||
_icon_resolver = opts.icon_resolver or default_icon,
|
||
_label_resolver = opts.label_resolver or default_label,
|
||
_summary_resolver = opts.summary_resolver or default_summary,
|
||
title = opts.title or "Crafting",
|
||
widget_id = opts.widget_id or "crafting",
|
||
pause_on_open = opts.pause_on_open == true,
|
||
}
|
||
function widget.render(ctx)
|
||
M._render_widget(widget, ctx)
|
||
end
|
||
function widget.handle_input(ctx, event)
|
||
return M._handle_input_widget(widget, ctx, event)
|
||
end
|
||
return widget
|
||
end
|
||
|
||
-- ---------------------------------------------------------------------
|
||
-- Public API: actions
|
||
-- ---------------------------------------------------------------------
|
||
|
||
--- M.register_action(widget, label, callback)
|
||
--- Registers a context-menu action shown on right-click of any row.
|
||
--- callback(recipe_id, context) where context = { container, close_menu, refresh }.
|
||
--- Loud-error on duplicate label or non-function callback.
|
||
function M.register_action(widget, label, callback)
|
||
if type(label) ~= "string" or label == "" then
|
||
error("crafting-display.register_action: label must be non-empty string", 2)
|
||
end
|
||
if type(callback) ~= "function" then
|
||
error("crafting-display.register_action: callback must be function", 2)
|
||
end
|
||
for _, a in ipairs(widget._actions) do
|
||
if a.label == label then
|
||
error(string.format(
|
||
"crafting-display.register_action: duplicate label '%s'", label), 2)
|
||
end
|
||
end
|
||
widget._actions[#widget._actions + 1] = { label = label, callback = callback }
|
||
end
|
||
|
||
--- M.unregister_action(widget, label)
|
||
--- Removes a previously-registered context-menu action.
|
||
--- Idempotent: no error if `label` was never registered.
|
||
function M.unregister_action(widget, label)
|
||
for i, a in ipairs(widget._actions) do
|
||
if a.label == label then
|
||
table.remove(widget._actions, i)
|
||
return
|
||
end
|
||
end
|
||
end
|
||
|
||
-- ---------------------------------------------------------------------
|
||
-- Public API: resolver overrides
|
||
-- ---------------------------------------------------------------------
|
||
|
||
function M.set_icon_resolver(widget, fn) widget._icon_resolver = fn end
|
||
function M.set_label_resolver(widget, fn) widget._label_resolver = fn end
|
||
function M.set_summary_resolver(widget, fn) widget._summary_resolver = fn end
|
||
|
||
-- ---------------------------------------------------------------------
|
||
-- Render
|
||
-- ---------------------------------------------------------------------
|
||
|
||
-- 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 = 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,
|
||
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 = bounds.x, y = cy, w = bounds.w, h = row_h,
|
||
}
|
||
cy = cy + row_h
|
||
end
|
||
end
|
||
|
||
-- ---------------------------------------------------------------------
|
||
-- Input
|
||
-- ---------------------------------------------------------------------
|
||
|
||
-- 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 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
|
||
M._invoke_context_menu(widget, r.recipe_id, mx, my)
|
||
return true
|
||
end
|
||
end
|
||
return false
|
||
end
|
||
|
||
function M._invoke_context_menu(widget, recipe_id, mx, my)
|
||
local entries = {}
|
||
for _, a in ipairs(widget._actions) do
|
||
local cb = a.callback -- capture for closure
|
||
entries[#entries + 1] = {
|
||
label = a.label,
|
||
callback = function(menu_ctx)
|
||
cb(recipe_id, {
|
||
container = widget._container,
|
||
close_menu = menu_ctx.close_menu,
|
||
refresh = function() end, -- v0.1: free (next frame re-reads)
|
||
})
|
||
end,
|
||
}
|
||
end
|
||
if #entries == 0 then return end
|
||
if panel.show_context_menu then
|
||
panel.show_context_menu(mx, my, entries)
|
||
end
|
||
end
|
||
|
||
-- ---------------------------------------------------------------------
|
||
-- Test-backdoors
|
||
-- ---------------------------------------------------------------------
|
||
|
||
function M._test_get_rows(widget)
|
||
return build_rows(widget)
|
||
end
|
||
|
||
function M._test_resolve_icon(widget, recipe)
|
||
return widget._icon_resolver(recipe)
|
||
end
|
||
|
||
function M._test_resolve_label(widget, recipe)
|
||
return widget._label_resolver(recipe)
|
||
end
|
||
|
||
function M._test_resolve_summary(widget, recipe)
|
||
return widget._summary_resolver(recipe)
|
||
end
|
||
|
||
return M
|