Files
sporel-lib-core.crafting-di…/init.lua
Calic b42b707a8e 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>
2026-06-14 13:36:29 +02:00

306 lines
11 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- =====================================================================
-- 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