-- ===================================================================== -- 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 (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 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(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, 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 -- --------------------------------------------------------------------- -- 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 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, my, 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