M.create now accepts either a function (locale_factory, called per frame to resolve the current crafting locale) or a bare entity-handle (wrapped internally as a constant factory for v0.1 bw-compat). The factory's return value is forwarded to crafting.can_craft as the new multi-source / single-sink locale, enabling dynamic topologies (adjacent-container sets that change as the actor moves) without recreating the widget. Action callbacks now receive ctx_inner with both `locale` (the currently-resolved locale, either the Form-2 table or the Form-1 bare handle) and `container` as a bw-compat alias pointing at locale.sink (Form-2) or the handle itself (Form-1). Existing v0.1 callers using ctx.container keep working unchanged. New backdoor _test_get_locale exposes the locale_factory's current return value. README documents both Form-1 and Form-2 with examples plus the updated callback contract. Depends on lib-core.crafting v0.2.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
353 lines
13 KiB
Lua
353 lines
13 KiB
Lua
-- =====================================================================
|
||
-- 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()
|
||
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
|