-- ===================================================================== -- lib-core.inventory-list-display v0.1.1 — Inventory vertical-list widget -- -- Sits on top of lib-core.panel and reads lib-core.inventory-list + -- lib-core.composition. Provides a ready-to-register panel widget that -- renders the contents of a composition list-container as a vertical -- scrollable list of icon + label + count rows. -- -- Data-libs (inventory-list, composition) remain headless; all UI -- render/input logic lives here. -- -- Public API: -- display.create(container_entity, opts) -> widget_def -- display.register_action(widget_def, label, callback) -- display.unregister_action(widget_def, label) -- display.set_label_resolver(widget_def, fn) -- display.set_icon_resolver(widget_def, fn) -- -- widget_def conforms to the panel widget contract: -- widget_def.render(ctx) -- widget_def.handle_input(ctx, event) -- widget_def.title (string) -- -- Engine surfaces used (render-phase only; not called headless): -- engine.render.draw_rect(x, y, w, h, color) -- engine.render.draw_text(text, x, y, size, color) -- engine.render.measure_text(text, size) -> w, h -- engine.render.draw_sprite_transform(tex, x, y, ..., u, v, uw, vh) -- engine.module.load_texture(module_id, path) -> texture handle -- -- NOTE on draw_texture_uv: the engine exposes draw_sprite_transform (used -- in vagrant-skeleton for Atlas-UV rendering) rather than a dedicated -- draw_texture_uv. v0.1 uses draw_sprite_transform with rot=0, scl=1 for -- icon rendering. If the texture handle is not available at render-time -- (e.g. because the test module does not call M.init), icon rendering falls -- back to a placeholder rect + "?" text. -- -- DEFERRED (v0.1 non-goals): -- - Row scrolling (wheel event silently ignored) -- - Stack-count display (stack_mode="stack" not supported) -- - Pretty item names (no composition.template_of accessor in v0.1; -- overridable via set_label_resolver) -- - Row selection highlight (selection_color unused) -- ===================================================================== local panel = require("lib-core.panel") local composition = require("lib-core.composition") local inventory = require("lib-core.inventory-list") local M = {} -- ----------------------------------------------------------------------- -- Default resolvers -- ----------------------------------------------------------------------- -- default_icon_resolver(item) -> icon_table or nil -- v0.2.0: per ADR-0054 §3 — additive UI-icon override on top of the -- world-sprite. If the item declares `icon_atlas` (string) + `icon_uv.*` -- (numbers), use those (e.g. for dedicated low-res UI icons baked -- separately). Otherwise fall back to `sprite_atlas` + `sprite_uv.*` -- (the world-render sprite, which today doubles as the UI icon for -- TC_Basics-style item-shaped sprites). -- Returns nil if neither chain yields a sprite (no icon for this item). local function default_icon_resolver(item) local icon_atlas = item:get_property("icon_atlas") if icon_atlas and icon_atlas ~= "" then return { atlas = icon_atlas, uv = { x = item:get_property("icon_uv.x"), y = item:get_property("icon_uv.y"), w = item:get_property("icon_uv.w"), h = item:get_property("icon_uv.h"), }, } end local atlas = item:get_property("sprite_atlas") if not atlas or atlas == "" then return nil end return { atlas = atlas, uv = { x = item:get_property("sprite_uv.x"), y = item:get_property("sprite_uv.y"), w = item:get_property("sprite_uv.w"), h = item:get_property("sprite_uv.h"), }, } end -- default_label_resolver(item) -> string -- Reads the player-facing `name` property from the item-template. Modules -- that declare `name` on their item-templates get pretty names automatically; -- modules without `name` get the generic "Item" fallback. -- Override via set_label_resolver for custom mappings (template-id, lookup -- table, localization, …). local function default_label_resolver(item) local name = item:get_property("name") if name and name ~= "" then return name end return "Item" end -- ----------------------------------------------------------------------- -- Module-local render helper -- ----------------------------------------------------------------------- -- render_widget(widget_def, ctx) -- Called each render frame while the widget is active (delegated from -- widget_def.render). Reads inventory contents and draws one row per item. -- ctx = { bounds = {x,y,w,h}, theme = table, is_focused = bool } local function render_widget(widget_def, ctx) local items = inventory.contents(widget_def._container) widget_def._row_rects = {} -- clear last-frame hit-test state for i, item in ipairs(items) do local row_y = ctx.bounds.y + (i - 1) * ctx.theme.row_height -- Resolve icon local icon = widget_def._icon_resolver(item) if icon ~= nil then -- Atlas-UV icon rendering via draw_sprite_transform. -- engine.module.load_texture loads the texture lazily; result is -- a handle or nil if the engine is not in a context that allows it. local ok, tex = pcall(engine.module.load_texture, widget_def.widget_id, icon.atlas) if ok and tex then local uv = icon.uv -- v0.2.0: fit-to-bounds. Compute uniform scale so the -- larger UV dimension matches the icon box (row_height -- minus a small margin), then center-anchor inside the -- box. Lets world-sprites (e.g. TC_Basics 300×300 native) -- and pre-baked icons share the same row layout without -- per-item magic numbers. local box = ctx.theme.row_height - 4 local max_uv = (uv.w > uv.h) and uv.w or uv.h local s = box / max_uv local draw_x = ctx.bounds.x + (box - uv.w * s) / 2 local draw_y = row_y + 2 + (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) else -- draw_sprite_transform not available (headless / test context) -- or texture load failed: fall through to placeholder rect. engine.render.draw_rect(ctx.bounds.x, row_y, 32, 32, ctx.theme.text_color_dim) engine.render.draw_text("?", ctx.bounds.x + 12, row_y + 8, ctx.theme.font_size_body, ctx.theme.text_color) end else -- No icon: draw 32×32 placeholder rect + "?" glyph engine.render.draw_rect(ctx.bounds.x, row_y, 32, 32, ctx.theme.text_color_dim) engine.render.draw_text("?", ctx.bounds.x + 12, row_y + 8, ctx.theme.font_size_body, ctx.theme.text_color) end -- Resolve label local label = widget_def._label_resolver(item) engine.render.draw_text(label, ctx.bounds.x + 36, row_y + 4, ctx.theme.font_size_body, ctx.theme.text_color) -- Count ("×1" — stack_mode="individual" only in v0.1) local count_str = "\xc3\x971" -- UTF-8 "×1" local text_w, _ = engine.render.measure_text(count_str, ctx.theme.font_size_body) local count_x = ctx.bounds.x + ctx.bounds.w - text_w - ctx.theme.padding engine.render.draw_text(count_str, count_x, row_y + 4, ctx.theme.font_size_body, ctx.theme.text_color) -- Store row rect for right-click hit-test in handle_widget_input widget_def._row_rects[i] = { x = ctx.bounds.x, y = row_y, w = ctx.bounds.w, h = ctx.theme.row_height, item = item, } end end -- ----------------------------------------------------------------------- -- Module-local context-menu helper -- ----------------------------------------------------------------------- -- open_context_menu(widget_def, item, x, y) -- Builds actions array from widget_def._actions and calls panel.show_context_menu. -- Skips if no actions are registered (would open an empty menu). local function open_context_menu(widget_def, item, x, y) local actions = {} -- pairs() iteration order is non-deterministic; for v0.2 consider -- preserving registration order via an internal action-list-array -- alongside _actions (when more actions per widget become common). for label, callback in pairs(widget_def._actions) do local cb = callback -- capture for closure table.insert(actions, { label = label, callback = function(menu_ctx) cb(item, { container = widget_def._container, close_menu = menu_ctx.close_menu, refresh = function() end, -- v0.1: free (next frame re-reads contents) }) end, }) end if #actions == 0 then return end panel.show_context_menu(x, y, actions) end -- ----------------------------------------------------------------------- -- Module-local input helper -- ----------------------------------------------------------------------- -- handle_widget_input(widget_def, 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). local function handle_widget_input(widget_def, ctx, event) if event.kind == "click" and event.button == "right" then for _, rect in ipairs(widget_def._row_rects) do if event.x >= rect.x and event.x < rect.x + rect.w and event.y >= rect.y and event.y < rect.y + rect.h then open_context_menu(widget_def, rect.item, event.x, event.y) return end end end -- Other event kinds (wheel, key, left-click): silent ignore in v0.1. -- Wheel scroll deferred to v0.2 (list-Inv typically fits on one screen). end -- ----------------------------------------------------------------------- -- Public API -- ----------------------------------------------------------------------- --- M.create(container_entity, opts) -> widget_def --- Creates an inventory-list widget bound to `container_entity`. --- container_entity must be a composition-entity with container.kind="list". --- opts = { --- title = string (default "Inventory"), --- widget_id = string (default "inventory"), --- pause_on_open = bool (default false), --- icon_resolver = function(item)->table|nil, --- label_resolver= function(item)->string, --- } --- Returns a widget_def table conforming to the panel widget contract. --- Loud-error if container_entity is nil or has no container block. function M.create(container_entity, opts) if container_entity == nil then error("inventory-list-display.create: container_entity must not be nil") end local block = composition.get_container(container_entity) if block == nil then error("inventory-list-display.create: container has no container block" .. " (template must declare container={kind='list'})") end -- Defensive double-belt: composition currently rejects non-list kinds at -- template-load, but guard here in case that constraint is relaxed later. if block.kind ~= "list" then error(string.format( "inventory-list-display.create: container.kind must be 'list' (got '%s')", tostring(block.kind))) end opts = opts or {} local widget_def = { title = opts.title or "Inventory", widget_id = opts.widget_id or "inventory", pause_on_open = opts.pause_on_open or false, _container = container_entity, _actions = {}, -- label -> callback (sparse; default empty) _icon_resolver = opts.icon_resolver or default_icon_resolver, _label_resolver = opts.label_resolver or default_label_resolver, _row_rects = {}, -- populated each render frame for hit-testing } widget_def.render = function(ctx) render_widget(widget_def, ctx) end widget_def.handle_input = function(ctx, event) handle_widget_input(widget_def, ctx, event) end return widget_def end --- M.register_action(widget_def, label, callback) --- Registers a context-menu action for right-click on a row. --- callback(item, context) where context = { container, close_menu, refresh }. --- Loud-error on duplicate label or non-function callback. function M.register_action(widget_def, label, callback) if type(label) ~= "string" or label == "" then error("inventory-list-display.register_action: label must be a non-empty string") end if type(callback) ~= "function" then error("inventory-list-display.register_action: callback must be a function") end if widget_def._actions[label] ~= nil then error("inventory-list-display.register_action: action '" .. label .. "' already registered") end widget_def._actions[label] = callback end --- M.unregister_action(widget_def, label) --- Removes a previously-registered context-menu action. --- Idempotent: no error if the label was never registered. function M.unregister_action(widget_def, label) widget_def._actions[label] = nil end --- M.set_label_resolver(widget_def, fn) --- Replaces the label resolver for this widget. --- fn(item) -> string (called each render frame per row) --- Loud-error if fn is not a function. function M.set_label_resolver(widget_def, fn) if type(fn) ~= "function" then error("inventory-list-display.set_label_resolver: fn must be a function") end widget_def._label_resolver = fn end --- M.set_icon_resolver(widget_def, fn) --- Replaces the icon resolver for this widget. --- fn(item) -> {atlas=string, uv={x,y,w,h}} or nil --- Loud-error if fn is not a function. function M.set_icon_resolver(widget_def, fn) if type(fn) ~= "function" then error("inventory-list-display.set_icon_resolver: fn must be a function") end widget_def._icon_resolver = fn end -- ----------------------------------------------------------------------- -- Test backdoors -- ----------------------------------------------------------------------- --- M._test_get_rows(widget_def) -> array of items (for tests only) --- Returns the current contents of the widget's container via inventory.contents. function M._test_get_rows(widget_def) return inventory.contents(widget_def._container) end --- M._test_resolve_icon(widget_def, item) -> icon_table or nil (for tests only) --- Invokes the widget's current icon resolver for `item`. function M._test_resolve_icon(widget_def, item) return widget_def._icon_resolver(item) end return M