Files
calic dda7306188 feat(crafting-display): icon column with fit-to-bounds rendering
Recipe rows gain an icon column on the left (row_height-4 box,
fit-to-bounds, centered). The renderer calls widget._icon_resolver(
recipe) and renders the returned {atlas, uv} via
draw_sprite_transform when non-nil. Default resolver returns nil →
text-only layout preserved for modules that don't wire icons; modules
opt in by passing opts.icon_resolver to create() or via
set_icon_resolver after.

Module-side mapping (recipe.output.template → {atlas, uv}) is the
caller's responsibility — composition has no template-property
getter today (ADR-0001 keeps the read-side domain-free), so the
domain owner is the right place to hold the lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-21 11:44:17 +02:00

381 lines
14 KiB
Lua
Raw Permalink 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 = {}
-- v0.3.0: icon column. Reserve row_h px on the left for the icon
-- box (fit-to-bounds rendering); shift label by the same amount.
-- Resolver returning nil collapses the icon box to a placeholder
-- (text-only layout preserved for non-icon resolvers).
local icon_box = row_h - 4
local label_x = bounds.x + pad + icon_box + pad
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)
local icon = widget._icon_resolver(row.recipe)
if icon ~= nil and engine and engine.render and engine.render.draw_sprite_transform then
local ok, tex = pcall(engine.module.load_texture,
widget.widget_id, icon.atlas)
if ok and tex and icon.uv then
local uv = icon.uv
local max_uv = (uv.w > uv.h) and uv.w or uv.h
local s = icon_box / max_uv
local draw_x = bounds.x + pad + (icon_box - uv.w * s) / 2
local draw_y = cy + 2 + (icon_box - uv.h * s) / 2
engine.render.draw_sprite_transform(
tex,
draw_x, draw_y,
0, s, s,
0, 0,
0xFFFFFFFF,
uv.x, uv.y, uv.w, uv.h)
end
end
if engine and engine.render and engine.render.draw_text then
engine.render.draw_text(label,
label_x, 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