Engine resolver does exact strcmp on dep versions, so consumer manifests must pin the new lib-core.panel 0.4.0 to keep load-time resolution green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lib-core.crafting-display
Recipe panel-widget. Reads recipes via lib-core.crafting and renders
the known-subset as a vertical row-list inside a panel managed by
lib-core.panel. Each row shows label + summary; row colour reflects
per-row availability (full-colour when craftable, dim when blocked).
Right-clicking a row opens a context-menu populated from actions
registered via register_action.
Version: 0.2.0 Lib-ID: lib-core.crafting-display Requires: lib-core.crafting v0.2.0, lib-core.panel v0.1.1, lib-core.inventory-list v0.1.0, lib-core.composition v0.3.0 Tags: crafting, ui, panel, widget, recipes
Topology
graph LR
this["lib-core.crafting-display"]
crafting["lib-core.crafting"]
panel["lib-core.panel"]
inv["lib-core.inventory-list"]
comp["lib-core.composition"]
this --> crafting
this --> panel
this --> inv
this --> comp
inventory-list and composition are declared as direct deps for the
engine's per-module non-transitive resolver: this lib does not call
them directly, but crafting does, and modules consuming this widget
must satisfy them.
Scope (v0.1.0)
v0.1 ships a minimal recipe-list widget:
create(container_entity, opts)— builds a panel-compatible widget_defregister_action/unregister_action— context-menu actions (right-click on row)set_icon_resolver/set_label_resolver/set_summary_resolver— override default row content- Render: label (left-aligned) + summary (right-aligned)
- Row colour: full when
can_craft.ok==true, dim otherwise - Input: right-click row opens context-menu via
panel.show_context_menu
Intentional non-goals (deferred):
- Row scrolling
- Icon-column rendering (resolver returns nil by default)
- Tooltip / hover-detail
- Stack-count display
v0.2 hardening notes (deferred):
is_known(ctx)is called per recipe per frame; no per-frame memoization. Large recipe-registries may want a cache layer.is_known(ctx)errors emit one[WARN]per failure per frame; no rate- limit (spammy if a recipe is permanently broken).- Right-click on a row with an empty action-set is silently dropped; a
diagnostic warning would help modders catch missing
register_actioncalls.
Widget Contract
The widget conforms to lib-core.panel's widget-lifecycle contract (see
lib-core/panel/README.md §Widget-Lifecycle-Contract):
widget.render(ctx)
widget.handle_input(ctx, event)
where ctx = {bounds = {x,y,w,h}, theme = table, is_focused = bool} and
the panel dispatches input events as {kind = "click", x, y, button} or
{kind = "wheel", dy}. Theme colours are packed-RGBA integers (e.g.
0xE0E0E0FF), matching the panel.DEFAULT_THEME schema.
Right-click on a row invokes panel.show_context_menu(x, y, actions)
with the registered actions; left-click and wheel are ignored in v0.1.
Row Visibility + Availability rules
For every frame, the widget rebuilds its row-list by walking
crafting.list_recipes() and applying two independent gates:
| Gate | Source | Effect on row |
|---|---|---|
| Visibility | recipe.is_known(ctx) |
false → row is hidden entirely |
| Availability | crafting.can_craft(id, locale, ctx) |
ok=false → row visible but dim colour |
The locale here is whatever the widget's locale-factory returns for
the current frame (v0.2: per-frame fresh; v0.1 bw-compat: constant
single-container).
ctx is built from opts.ctx_factory() (default function() return {} end).
Override ctx_factory to wire in actor-state, skill-level, faction-membership,
etc. — whatever the consuming module needs is_known and can_craft to see.
If recipe.is_known(ctx) raises, the row is hidden and a [WARN]
message is emitted via engine.print (defensive — visibility predicate
must not crash UI). No equivalent guard around can_craft: errors
there propagate (consistent with rest of the lib stack).
API
display.create(arg, opts) -> widget_def
Creates a crafting widget. arg is one of:
- Form 1 (bw-compat from v0.1): bare container-entity-handle. Wrapped
internally as a constant locale_factory (
function() return arg end). - Form 2 (NEW in v0.2):
function() -> locale— invoked per frame. Locale is either an entity-handle (Form-1 sub-semantics) or a table{sources={...}, sink=...}. Forward-compatible with dynamic topologies (player moves → adjacent-container set changes, no widget recreate required).
Syntax: display.create(arg: entity | function() -> locale, opts: table|nil) -> widget_def
Example (Form 1, bw-compat):
local display = require("lib-core.crafting-display")
local panel = require("lib-core.panel")
local widget = display.create(workbench_entity, {
title = "Workbench",
widget_id = "crafting",
pause_on_open = true,
ctx_factory = function()
return { actor = current_actor() }
end,
})
panel.register(widget.widget_id, widget)
panel.bind_default_trigger("c", widget.widget_id)
Example (Form 2, locale-factory):
local widget = display.create(function()
return {
sources = adjacent_containers(player),
sink = player_backpack,
}
end, { title = "Workbench" })
opts keys:
title(string, default"Crafting") — panel title bar textwidget_id(string, default"crafting") — key forpanel.registerpause_on_open(bool, defaultfalse) — passed to panel foris_pausing()ctx_factory(function, defaultfunction() return {} end) — per-frame ctx buildericon_resolver(function) — overrides default icon resolver at creation timelabel_resolver(function) — overrides default label resolver at creation timesummary_resolver(function) — overrides default summary resolver at creation time
Loud-error if arg is nil.
Action-Callback Signature (v0.2.0 Update)
Callbacks receive (recipe_id, ctx_inner) where ctx_inner is:
{
locale = <currently-resolved locale>, -- NEW in v0.2
container = <sink (Form-2) or handle (Form-1)>, -- bw-compat alias
close_menu = function() ... end,
refresh = function() ... end,
}
Module action typically calls crafting.craft(recipe_id, ctx_inner.locale, ctx). Existing v0.1 callers that use
ctx_inner.container continue to work via the Form-1 bw-compat shim
(sink-handle is treated internally as a single-container).
display.register_action(widget_def, label, callback)
Syntax: display.register_action(widget_def: table, label: string, callback: function) -> void
Example:
display.register_action(widget, "Craft", function(recipe_id, ctx)
-- v0.2: prefer ctx.locale; ctx.container is a bw-compat alias.
crafting.craft(recipe_id, ctx.locale, {})
ctx.close_menu()
end)
Registers a context-menu action shown on right-click of any row. callback
receives (recipe_id, ctx_inner) where
ctx_inner = { locale, container, close_menu, refresh }.
locale carries the currently-resolved locale (table for Form-2, bare
handle for Form-1). container is a bw-compat alias pointing at
locale.sink (Form-2) or the handle itself (Form-1).
Loud-error on duplicate label or if callback is not a function.
The default action-set is empty — all actions must be registered explicitly.
Registration order is preserved (entries are kept in an array). Identical order is reflected in the context-menu rendering.
display.unregister_action(widget_def, label)
Syntax: display.unregister_action(widget_def: table, label: string) -> void
Removes a context-menu action. Idempotent: no error if label was never registered.
display.set_icon_resolver(widget_def, fn)
Syntax: display.set_icon_resolver(widget_def: table, fn: function) -> void
Replaces the icon resolver. fn(recipe) -> any|nil — return value is
opaque to v0.1 (default icon column not yet rendered; resolver is wired
in for v0.2 atlas-icon support and for tests).
display.set_label_resolver(widget_def, fn)
Syntax: display.set_label_resolver(widget_def: table, fn: function) -> void
Example:
display.set_label_resolver(widget, function(recipe)
return localize("recipe." .. recipe.id) or recipe.name or recipe.id
end)
Replaces the label resolver. fn(recipe) -> string. Called each render frame per row.
Default returns recipe.name or recipe.id.
display.set_summary_resolver(widget_def, fn)
Syntax: display.set_summary_resolver(widget_def: table, fn: function) -> void
Example:
display.set_summary_resolver(widget, function(recipe)
return string.format("%d ingredients", #recipe.inputs)
end)
Replaces the summary resolver. fn(recipe) -> string. Called each render
frame per row. Default joins recipe.inputs template-ids with " + ",
suffixing "×N" when count>1.
Default Resolvers
| Resolver | Default behavior |
|---|---|
label_resolver |
recipe.name or recipe.id |
summary_resolver |
inputs[*].template joined with " + ", "×N" when count>1 |
icon_resolver |
returns nil (no icon in v0.1) |
ctx_factory |
returns {} (empty table; replace to feed actor/skill ctx) |
Test backdoors
display._test_get_rows(widget_def)— returns the per-frame row list ({recipe, available}pairs after is_known filtering)display._test_get_locale(widget_def)— invokes the configured locale_factory and returns whatever it produces (v0.2)display._test_resolve_icon(widget_def, recipe)— invokes current icon resolverdisplay._test_resolve_label(widget_def, recipe)— invokes current label resolverdisplay._test_resolve_summary(widget_def, recipe)— invokes current summary resolver
All five are for test modules only and should not be called in production code.
Glue-Pattern
Minimal module setup:
local display = require("lib-core.crafting-display")
local crafting = require("lib-core.crafting")
local panel = require("lib-core.panel")
-- 1. Define recipes
crafting.define_recipe{
id = "rock_pick",
inputs = {
{ template = "items.rock", count = 1 },
{ template = "items.stick", count = 1 },
},
output = { template = "items.rock_pick", count = 1 },
name = "Rock Pick",
}
-- 2. Create widget (workbench_entity is a list-container)
local widget = display.create(workbench_entity, {
title = "Workbench",
widget_id = "crafting",
pause_on_open = true,
})
-- 3. Register actions
display.register_action(widget, "Craft", function(recipe_id, ctx)
-- v0.2: prefer ctx.locale (ctx.container is a bw-compat alias).
crafting.craft(recipe_id, ctx.locale, {})
ctx.close_menu()
end)
-- 4. Register with panel + bind toggle key
panel.register(widget.widget_id, widget)
panel.bind_default_trigger("c", widget.widget_id)
-- 5. Wire into update + render
function M.update(dt) panel.update(dt) end
function M.render() panel.render() end