Files
sporel-lib-core.crafting-di…/init.lua
Calic eb8f72bcbd feat: lib-core.crafting-display v0.1.0 — Recipe Panel-Widget
UI layer for recipe-based crafting. Reads recipes from
lib-core.crafting, renders the known-subset as a vertical row-list
inside a panel managed by lib-core.panel, and dispatches right-click
context-menu actions registered by the consuming module.

Surface:
  create(container_entity, opts) -> widget_def
  register_action(widget, label, callback)
  unregister_action(widget, label)
  set_icon_resolver(widget, fn)
  set_label_resolver(widget, fn)
  set_summary_resolver(widget, fn)

Row-build is rebuilt per render frame from crafting.list_recipes():
  - Visibility gate: recipe.is_known(ctx); errors hide row + emit [WARN]
  - Availability gate: crafting.can_craft(id, container, ctx).ok
    drives row colour (full vs dim text_color).

ctx comes from opts.ctx_factory() — default returns {}; override to
feed actor/skill/faction state into both predicates. label_resolver
defaults to recipe.name or recipe.id; summary_resolver joins input
template-ids with " + " and suffixes "xN" for count>1.

Right-click hit-tests row-rects captured during render, then opens
the menu via panel.show_context_menu(x, y, actions). Action
callbacks receive (recipe_id, {container, close_menu, refresh}).
Registration order is preserved (array-based, not pairs-based) so
context-menu rendering matches registration sequence.

Depends on lib-core.crafting 0.1.0, lib-core.panel 0.1.1,
lib-core.inventory-list 0.1.0, lib-core.composition 0.3.0. The last
two are not called directly but are required as direct deps so
modules consuming this widget satisfy crafting's transitive needs
(engine resolver is per-module non-transitive).

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

277 lines
10 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:
-- widget_def.render(theme, x, y, w, h)
-- widget_def.handle_input(input_state, theme, x, y, w, h)
-- widget_def.title (string)
--
-- DEFERRED (v0.1 non-goals):
-- - Row scrolling
-- - Custom row layouts (icon column, summary column, etc.)
-- - Tooltip / hover-detail
-- - Stack-count display
-- =====================================================================
local crafting = require("lib-core.crafting")
local panel = require("lib-core.panel")
local inventory = require("lib-core.inventory-list")
local composition = require("lib-core.composition")
-- inventory + composition are required for transitive completeness (engine
-- resolver is per-module non-transitive). The lib does not call into them
-- directly; crafting handles container access internally.
local _ = inventory
local _ = 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(theme, x, y, w, h)
M._render_widget(widget, theme, x, y, w, h)
end
function widget.handle_input(input_state, theme, x, y, w, h)
return M._handle_input_widget(widget, input_state, theme, x, y, w, h)
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
-- ---------------------------------------------------------------------
function M._render_widget(widget, theme, x, y, w, h)
local rows = build_rows(widget)
local row_h = (theme and theme.row_height) or 28
local pad = (theme and theme.padding) or 8
local txt_col_full = (theme and theme.text_color) or {1.0, 1.0, 1.0, 1.0}
local txt_col_dim = (theme and theme.text_color_dim) or {0.6, 0.6, 0.6, 1.0}
local cy = 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, x + pad, cy, color)
engine.render.draw_text(summary, x + w - pad - 100, cy, color)
end
widget._render_rows[i] = {
recipe_id = row.recipe.id,
x = x, y = cy, w = w, h = row_h,
}
cy = cy + row_h
end
end
-- ---------------------------------------------------------------------
-- Input
-- ---------------------------------------------------------------------
function M._handle_input_widget(widget, input_state, _theme, _x, _y, _w, _h)
if not widget._render_rows then return false end
local mx, my = input_state.mouse_x, input_state.mouse_y
if not (mx and my) then return false end
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
if input_state.right_clicked then
M._invoke_context_menu(widget, r.recipe_id, mx, my)
return true
end
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 or 0, my or 0, 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