Files
sporel-lib-core.crafting-di…/init.lua
Calic c725ffd3bc fix: nil-guard in _invoke_context_menu + README English
- _invoke_context_menu now early-returns if locale_factory()
  returns nil mid-frame, symmetric with build_rows. Prevents the
  race where a workbench destroyed between render and right-click
  would propagate nil locale into ctx_inner and crash craft.
- README v0.2.0 sections (M.create + action-callback) translated
  from Denglisch to English for consistency with the rest of the
  file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-14 19:53:00 +02:00

354 lines
13 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.2.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(arg, opts) -> widget_def
-- `arg` is either:
-- - a bare entity-handle (Form-1, v0.1 bw-compat) — wrapped as
-- a constant locale_factory internally; or
-- - a `function() -> locale` (Form-2, v0.2) — invoked per frame
-- to obtain the currently-resolved locale, which itself may be
-- a bare entity-handle or a `{sources={...}, sink=...}` table
-- as understood by `lib-core.crafting`.
-- 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 locale = widget._locale_factory()
if locale == nil then
return {}
end
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, locale, ctx)
out[#out + 1] = {
recipe = recipe,
available = match.ok == true,
}
end
end
return out
end
-- ---------------------------------------------------------------------
-- Public API: create
-- ---------------------------------------------------------------------
--- M.create(arg, opts) -> widget_def
--- Creates a crafting-recipe widget. `arg` is either:
--- - a bare entity-handle (Form-1, bw-compat) used as the input source
--- for can_craft availability; internally wrapped as a constant
--- locale_factory `function() return arg end`; or
--- - a `function() -> locale` (Form-2) — called per frame to obtain
--- the current locale. The returned locale may itself be a bare
--- handle (Form-1 sub-semantics) or a `{sources={...}, sink=...}`
--- table as accepted by `lib-core.crafting`.
--- 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 `arg` is nil.
function M.create(arg, opts)
opts = opts or {}
if arg == nil then
error("crafting-display.create: first arg (locale or "
.. "container) must not be nil", 2)
end
local locale_factory
if type(arg) == "function" then
locale_factory = arg
else
-- Bw-compat: bare entity-handle (userdata in Sporel) wrapped
-- as constant factory.
locale_factory = function() return arg end
end
local widget = {
_locale_factory = locale_factory,
_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,
}
-- widget.render(ctx) + widget.handle_input(ctx, event) — panel
-- contract per ADR-0049 / panel/README.md
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, ctx_inner) where
--- ctx_inner = { locale, container, close_menu, refresh }
--- `locale` is the currently-resolved locale (Form-2 table or Form-1
--- bare handle, depending on what the locale_factory returns).
--- `container` is a bw-compat alias pointing at `locale.sink` (Form-2)
--- or the handle itself (Form-1).
--- 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 locale = widget._locale_factory()
if locale == nil then return end -- symmetric with build_rows
local sink_alias
if type(locale) == "table" then
sink_alias = locale.sink
else
-- Form-1: bare handle is both source + sink.
sink_alias = locale
end
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, {
locale = locale,
container = sink_alias, -- bw-compat alias
close_menu = (menu_ctx and menu_ctx.close_menu)
or function() end,
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_get_locale(widget)
return widget._locale_factory()
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