-- ===================================================================== -- 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